From 0ab3d5ab8859f12b9b41f28852ced2ec84d09b3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 11 Mar 2026 17:49:16 +0900 Subject: [PATCH] =?UTF-8?q?R2=20=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Console/Commands/CleanupTempFiles.php | 4 +- app/Console/Commands/CleanupTrash.php | 4 +- app/Console/Commands/UploadLocalFilesToR2.php | 264 ++++ .../Api/V1/FileStorageController.php | 15 +- .../Api/V1/ItemsFileController.php | 2 +- app/Models/Commons/File.php | 42 +- app/Services/FileStorageService.php | 12 +- composer.json | 1 + composer.lock | 346 ++++- config/filesystems.php | 12 + routes/api/v1/files.php | 1 + storage/api-docs/api-docs-v1.json | 1287 ++++++----------- 12 files changed, 1145 insertions(+), 845 deletions(-) create mode 100644 app/Console/Commands/UploadLocalFilesToR2.php diff --git a/app/Console/Commands/CleanupTempFiles.php b/app/Console/Commands/CleanupTempFiles.php index ae40410..ad73bed 100644 --- a/app/Console/Commands/CleanupTempFiles.php +++ b/app/Console/Commands/CleanupTempFiles.php @@ -40,8 +40,8 @@ public function handle(): int foreach ($files as $file) { try { // Delete physical file - if (Storage::disk('tenant')->exists($file->file_path)) { - Storage::disk('tenant')->delete($file->file_path); + if (Storage::disk('r2')->exists($file->file_path)) { + Storage::disk('r2')->delete($file->file_path); } // Force delete from DB diff --git a/app/Console/Commands/CleanupTrash.php b/app/Console/Commands/CleanupTrash.php index 8ed9e58..66c40d7 100644 --- a/app/Console/Commands/CleanupTrash.php +++ b/app/Console/Commands/CleanupTrash.php @@ -60,8 +60,8 @@ private function permanentDelete(File $file): void { DB::transaction(function () use ($file) { // Delete physical file - if (Storage::disk('tenant')->exists($file->file_path)) { - Storage::disk('tenant')->delete($file->file_path); + if (Storage::disk('r2')->exists($file->file_path)) { + Storage::disk('r2')->delete($file->file_path); } // Update tenant storage usage diff --git a/app/Console/Commands/UploadLocalFilesToR2.php b/app/Console/Commands/UploadLocalFilesToR2.php new file mode 100644 index 0000000..d99c8a2 --- /dev/null +++ b/app/Console/Commands/UploadLocalFilesToR2.php @@ -0,0 +1,264 @@ +option('count'); + $source = $this->option('source'); + $dryRun = $this->option('dry-run'); + + $this->info("=== R2 Upload Tool ==="); + $this->info("Source: {$source} | Count: {$count}"); + + return $source === 'db' + ? $this->uploadFromDb($count, $dryRun) + : $this->uploadFromDisk($count, $dryRun); + } + + /** + * Upload files based on DB records (latest by ID desc) + */ + private function uploadFromDb(int $count, bool $dryRun): int + { + $files = File::orderByDesc('id')->limit($count)->get(); + + if ($files->isEmpty()) { + $this->warn('No files in DB.'); + return 0; + } + + $this->newLine(); + $headers = ['ID', 'Display Name', 'R2 Path', 'R2 Exists', 'Local Exists', 'Size']; + $rows = []; + + foreach ($files as $f) { + $localPath = storage_path("app/tenants/{$f->file_path}"); + $r2Exists = Storage::disk('r2')->exists($f->file_path); + $localExists = file_exists($localPath); + + $rows[] = [ + $f->id, + mb_strimwidth($f->display_name ?? '', 0, 25, '...'), + $f->file_path, + $r2Exists ? '✓ YES' : '✗ NO', + $localExists ? '✓ YES' : '✗ NO', + $f->file_size ? $this->formatSize($f->file_size) : '-', + ]; + } + + $this->table($headers, $rows); + + // Filter: local exists but R2 doesn't + $toUpload = $files->filter(function ($f) { + $localPath = storage_path("app/tenants/{$f->file_path}"); + return file_exists($localPath) && !Storage::disk('r2')->exists($f->file_path); + }); + + $alreadyInR2 = $files->filter(function ($f) { + return Storage::disk('r2')->exists($f->file_path); + }); + + if ($alreadyInR2->isNotEmpty()) { + $this->info("Already in R2: {$alreadyInR2->count()} files (skipped)"); + } + + if ($toUpload->isEmpty()) { + $missingBoth = $files->filter(function ($f) { + $localPath = storage_path("app/tenants/{$f->file_path}"); + return !file_exists($localPath) && !Storage::disk('r2')->exists($f->file_path); + }); + + if ($missingBoth->isNotEmpty()) { + $this->warn("Missing both locally and in R2: {$missingBoth->count()} files"); + $this->warn("These files may exist on the dev server only."); + } + + $this->info('Nothing to upload.'); + return 0; + } + + if ($dryRun) { + $this->warn("[DRY RUN] Would upload {$toUpload->count()} files."); + return 0; + } + + // Test R2 connection + $this->info('Testing R2 connection...'); + try { + Storage::disk('r2')->directories('/'); + $this->info('✓ R2 connection OK'); + } catch (\Exception $e) { + $this->error('✗ R2 connection failed: ' . $e->getMessage()); + return 1; + } + + // Upload + $bar = $this->output->createProgressBar($toUpload->count()); + $bar->start(); + $success = 0; + $failed = 0; + + foreach ($toUpload as $f) { + $localPath = storage_path("app/tenants/{$f->file_path}"); + try { + $content = FileFacade::get($localPath); + $mimeType = $f->mime_type ?: FileFacade::mimeType($localPath); + + Storage::disk('r2')->put($f->file_path, $content, [ + 'ContentType' => $mimeType, + ]); + $success++; + } catch (\Exception $e) { + $failed++; + $this->newLine(); + $this->error(" ✗ ID {$f->id}: {$e->getMessage()}"); + } + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + $this->info("=== Upload Complete ==="); + $this->info("✓ Success: {$success}"); + if ($failed > 0) { + $this->error("✗ Failed: {$failed}"); + return 1; + } + return 0; + } + + /** + * Upload files based on local disk (newest files by mtime) + */ + private function uploadFromDisk(int $count, bool $dryRun): int + { + $storagePath = storage_path('app/tenants'); + + if (!is_dir($storagePath)) { + $this->error("Path not found: {$storagePath}"); + return 1; + } + + $allFiles = $this->collectFiles($storagePath); + if (empty($allFiles)) { + $this->warn('No files found.'); + return 0; + } + + usort($allFiles, fn($a, $b) => filemtime($b) - filemtime($a)); + $filesToUpload = array_slice($allFiles, 0, $count); + + $this->info("Found " . count($allFiles) . " total files, uploading {$count} most recent:"); + $this->newLine(); + + $headers = ['#', 'File', 'Size', 'Modified', 'R2 Path']; + $rows = []; + + foreach ($filesToUpload as $i => $filePath) { + $r2Path = $this->toR2Path($filePath); + $rows[] = [$i + 1, basename($filePath), $this->formatSize(filesize($filePath)), date('Y-m-d H:i:s', filemtime($filePath)), $r2Path]; + } + + $this->table($headers, $rows); + + if ($dryRun) { + $this->warn('[DRY RUN] No files uploaded.'); + return 0; + } + + $this->info('Testing R2 connection...'); + try { + Storage::disk('r2')->directories('/'); + $this->info('✓ R2 connection OK'); + } catch (\Exception $e) { + $this->error('✗ R2 connection failed: ' . $e->getMessage()); + return 1; + } + + $bar = $this->output->createProgressBar(count($filesToUpload)); + $bar->start(); + $success = 0; + $failed = 0; + $fix = $this->option('fix'); + + foreach ($filesToUpload as $filePath) { + $r2Path = $this->toR2Path($filePath); + try { + if ($fix) { + $wrongPath = $this->toRelativePath($filePath); + if ($wrongPath !== $r2Path && Storage::disk('r2')->exists($wrongPath)) { + Storage::disk('r2')->delete($wrongPath); + } + } + + $content = FileFacade::get($filePath); + $mimeType = FileFacade::mimeType($filePath); + + Storage::disk('r2')->put($r2Path, $content, ['ContentType' => $mimeType]); + $success++; + } catch (\Exception $e) { + $failed++; + $this->newLine(); + $this->error(" ✗ Failed: {$r2Path} - {$e->getMessage()}"); + } + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + $this->info("=== Upload Complete ==="); + $this->info("✓ Success: {$success}"); + if ($failed > 0) { + $this->error("✗ Failed: {$failed}"); + return 1; + } + return 0; + } + + private function toR2Path(string $filePath): string + { + $relative = $this->toRelativePath($filePath); + return str_starts_with($relative, 'tenants/') ? substr($relative, strlen('tenants/')) : $relative; + } + + private function toRelativePath(string $filePath): string + { + return str_replace(str_replace('\\', '/', storage_path('app/')), '', str_replace('\\', '/', $filePath)); + } + + private function collectFiles(string $dir): array + { + $files = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS) + ); + foreach ($iterator as $file) { + if ($file->isFile() && $file->getFilename() !== '.gitignore') { + $files[] = $file->getPathname(); + } + } + return $files; + } + + private function formatSize(int $bytes): string + { + if ($bytes >= 1048576) return round($bytes / 1048576, 1) . ' MB'; + return round($bytes / 1024, 1) . ' KB'; + } +} diff --git a/app/Http/Controllers/Api/V1/FileStorageController.php b/app/Http/Controllers/Api/V1/FileStorageController.php index 438dabd..1e26fac 100644 --- a/app/Http/Controllers/Api/V1/FileStorageController.php +++ b/app/Http/Controllers/Api/V1/FileStorageController.php @@ -83,14 +83,25 @@ public function trash() } /** - * Download file + * Download file (attachment) */ public function download(int $id) { $service = new FileStorageService; $file = $service->getFile($id); - return $file->download(); + return $file->download(inline: false); + } + + /** + * View file inline (이미지/PDF 브라우저에서 바로 표시) + */ + public function view(int $id) + { + $service = new FileStorageService; + $file = $service->getFile($id); + + return $file->download(inline: true); } /** diff --git a/app/Http/Controllers/Api/V1/ItemsFileController.php b/app/Http/Controllers/Api/V1/ItemsFileController.php index 0527814..7fd8f72 100644 --- a/app/Http/Controllers/Api/V1/ItemsFileController.php +++ b/app/Http/Controllers/Api/V1/ItemsFileController.php @@ -109,7 +109,7 @@ public function upload(int $id, ItemFileUploadRequest $request) $filePath = $directory.'/'.$storedName; // 파일 저장 (tenant 디스크) - Storage::disk('tenant')->putFileAs($directory, $uploadedFile, $storedName); + Storage::disk('r2')->putFileAs($directory, $uploadedFile, $storedName); // file_type 자동 분류 (MIME 타입 기반) $mimeType = $uploadedFile->getMimeType(); diff --git a/app/Models/Commons/File.php b/app/Models/Commons/File.php index 697b493..9169bc6 100644 --- a/app/Models/Commons/File.php +++ b/app/Models/Commons/File.php @@ -103,7 +103,7 @@ public function fileable() */ public function getStoragePath(): string { - return Storage::disk('tenant')->path($this->file_path); + return $this->file_path; } /** @@ -111,22 +111,38 @@ public function getStoragePath(): string */ public function exists(): bool { - return Storage::disk('tenant')->exists($this->file_path); + return Storage::disk('r2')->exists($this->file_path); } /** - * Get download response + * Get download response (streaming from R2) + * + * @param bool $inline true = 브라우저에서 바로 표시 (이미지/PDF), false = 다운로드 */ - public function download() + public function download(bool $inline = false) { if (! $this->exists()) { abort(404, 'File not found in storage'); } - return response()->download( - $this->getStoragePath(), - $this->display_name ?? $this->original_name - ); + $fileName = $this->display_name ?? $this->original_name; + $mimeType = $this->mime_type ?? 'application/octet-stream'; + $disposition = $inline ? 'inline' : 'attachment'; + + // Stream from R2 (메모리에 전체 로드하지 않음) + $stream = Storage::disk('r2')->readStream($this->file_path); + + return response()->stream(function () use ($stream) { + fpassthru($stream); + if (is_resource($stream)) { + fclose($stream); + } + }, 200, [ + 'Content-Type' => $mimeType, + 'Content-Disposition' => $disposition . '; filename="' . $fileName . '"', + 'Content-Length' => $this->file_size, + 'Cache-Control' => 'private, max-age=3600', + ]); } /** @@ -149,9 +165,9 @@ public function moveToFolder(Folder $folder): bool $this->stored_name ?? $this->file_name ); - // Move physical file - if (Storage::disk('tenant')->exists($this->file_path)) { - Storage::disk('tenant')->move($this->file_path, $newPath); + // Move physical file in R2 + if (Storage::disk('r2')->exists($this->file_path)) { + Storage::disk('r2')->move($this->file_path, $newPath); } // Update DB @@ -182,9 +198,9 @@ public function softDeleteFile(int $userId): void */ public function permanentDelete(): void { - // Delete physical file + // Delete physical file from R2 if ($this->exists()) { - Storage::disk('tenant')->delete($this->file_path); + Storage::disk('r2')->delete($this->file_path); } // Decrement tenant storage diff --git a/app/Services/FileStorageService.php b/app/Services/FileStorageService.php index 73df813..3601e5a 100644 --- a/app/Services/FileStorageService.php +++ b/app/Services/FileStorageService.php @@ -54,18 +54,16 @@ public function upload(UploadedFile $file, ?string $description = null): File $storedName ); - // Store file - Storage::disk('tenant')->putFileAs( - dirname($tempPath), - $file, - basename($tempPath) - ); + // Store file to R2 (Cloudflare R2, S3 compatible) + Storage::disk('r2')->put($tempPath, file_get_contents($file->getRealPath()), [ + 'ContentType' => $file->getMimeType(), + ]); // Determine file type $mimeType = $file->getMimeType(); $fileType = $this->determineFileType($mimeType); - // Create DB record + // Create DB record (file_path = R2 key path) $fileRecord = File::create([ 'tenant_id' => $tenantId, 'display_name' => $file->getClientOriginalName(), diff --git a/composer.json b/composer.json index e45077d..01007a9 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "laravel/mcp": "^0.1.1", "laravel/sanctum": "^4.0", "laravel/tinker": "^2.10.1", + "league/flysystem-aws-s3-v3": "^3.32", "livewire/livewire": "^3.0", "maatwebsite/excel": "^3.1", "spatie/laravel-permission": "^6.21" diff --git a/composer.lock b/composer.lock index 19a010a..019af7b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,159 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "340d586f1c4e3f7bd0728229300967da", + "content-hash": "f39a7807cc0a6aa991e31a6acffc9508", "packages": [ + { + "name": "aws/aws-crt-php", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.372.3", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "d207d2ca972c9b10674e535dacd4a5d956a80bad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d207d2ca972c9b10674e535dacd4a5d956a80bad", + "reference": "d207d2ca972c9b10674e535dacd4a5d956a80bad", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/promises": "^2.0", + "guzzlehttp/psr7": "^2.4.5", + "mtdowling/jmespath.php": "^2.8.0", + "php": ">=8.1", + "psr/http-message": "^1.0 || ^2.0", + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^2.7.8", + "dms/phpunit-arraysubset-asserts": "^0.4.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-sockets": "*", + "phpunit/phpunit": "^9.6", + "psr/cache": "^2.0 || ^3.0", + "psr/simple-cache": "^2.0 || ^3.0", + "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-pcntl": "To use client-side monitoring", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + }, + "exclude-from-classmap": [ + "src/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "https://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "https://aws.amazon.com/sdk-for-php", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://github.com/aws/aws-sdk-php/discussions", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.372.3" + }, + "time": "2026-03-10T18:07:21+00:00" + }, { "name": "brick/math", "version": "0.13.1", @@ -2512,6 +2663,61 @@ }, "time": "2025-06-25T13:29:59+00:00" }, + { + "name": "league/flysystem-aws-s3-v3", + "version": "3.32.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0", + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0", + "shasum": "" + }, + "require": { + "aws/aws-sdk-php": "^3.295.10", + "league/flysystem": "^3.10.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\AwsS3V3\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "AWS S3 filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "aws", + "file", + "files", + "filesystem", + "s3", + "storage" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0" + }, + "time": "2026-02-25T16:46:44+00:00" + }, { "name": "league/flysystem-local", "version": "3.30.0", @@ -3236,6 +3442,72 @@ ], "time": "2025-03-24T10:02:05+00:00" }, + { + "name": "mtdowling/jmespath.php", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.33" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.8.0" + }, + "time": "2024-09-04T18:46:31+00:00" + }, { "name": "nesbot/carbon", "version": "3.10.1", @@ -5230,6 +5502,76 @@ ], "time": "2024-09-25T14:21:43+00:00" }, + { + "name": "symfony/filesystem", + "version": "v8.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770", + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.0.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-25T16:59:43+00:00" + }, { "name": "symfony/finder", "version": "v7.3.0", @@ -10773,5 +11115,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/filesystems.php b/config/filesystems.php index 199fd26..9fd9a30 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -76,6 +76,18 @@ 'report' => false, ], + 'r2' => [ + 'driver' => 's3', + 'key' => env('R2_ACCESS_KEY_ID'), + 'secret' => env('R2_SECRET_ACCESS_KEY'), + 'region' => env('R2_REGION', 'auto'), + 'bucket' => env('R2_BUCKET'), + 'endpoint' => env('R2_ENDPOINT'), + 'use_path_style_endpoint' => true, + 'throw' => false, + 'report' => false, + ], + ], /* diff --git a/routes/api/v1/files.php b/routes/api/v1/files.php index 1f0ca39..4f48bc9 100644 --- a/routes/api/v1/files.php +++ b/routes/api/v1/files.php @@ -21,6 +21,7 @@ Route::get('/trash', [FileStorageController::class, 'trash'])->name('v1.files.trash'); // 휴지통 목록 Route::get('/{id}', [FileStorageController::class, 'show'])->name('v1.files.show'); // 파일 상세 Route::get('/{id}/download', [FileStorageController::class, 'download'])->name('v1.files.download'); // 파일 다운로드 + Route::get('/{id}/view', [FileStorageController::class, 'view'])->name('v1.files.view'); // 파일 인라인 보기 (이미지/PDF) Route::delete('/{id}', [FileStorageController::class, 'destroy'])->name('v1.files.destroy'); // 파일 삭제 (soft) Route::post('/{id}/restore', [FileStorageController::class, 'restore'])->name('v1.files.restore'); // 파일 복구 Route::delete('/{id}/permanent', [FileStorageController::class, 'permanentDelete'])->name('v1.files.permanent'); // 파일 영구 삭제 diff --git a/storage/api-docs/api-docs-v1.json b/storage/api-docs/api-docs-v1.json index a12284d..92d16ed 100755 --- a/storage/api-docs/api-docs-v1.json +++ b/storage/api-docs/api-docs-v1.json @@ -11,7 +11,7 @@ "servers": [ { "url": "https://api.sam.kr/", - "description": "SAM관리시스템 API 서버" + "description": "SAM API 서버" } ], "paths": { @@ -42517,6 +42517,231 @@ ] } }, + "/api/v1/production-orders": { + "get": { + "tags": [ + "ProductionOrders" + ], + "summary": "생산지시 목록 조회", + "operationId": "8564d6d5af05027a941d784917a6e7b6", + "parameters": [ + { + "name": "search", + "in": "query", + "description": "검색어 (수주번호, 거래처명, 현장명)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "production_status", + "in": "query", + "description": "생산 상태 필터", + "required": false, + "schema": { + "type": "string", + "enum": [ + "waiting", + "in_production", + "completed" + ] + } + }, + { + "name": "sort_by", + "in": "query", + "description": "정렬 기준", + "required": false, + "schema": { + "type": "string", + "enum": [ + "created_at", + "delivery_date", + "order_no" + ] + } + }, + { + "name": "sort_dir", + "in": "query", + "description": "정렬 방향", + "required": false, + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "성공", + "content": { + "application/json": { + "schema": { + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string" + }, + "data": { + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProductionOrderListItem" + } + }, + "current_page": { + "type": "integer" + }, + "last_page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, + "/api/v1/production-orders/stats": { + "get": { + "tags": [ + "ProductionOrders" + ], + "summary": "생산지시 상태별 통계", + "operationId": "7ab51cb2b0394b4cf098d1d684ed7cc3", + "responses": { + "200": { + "description": "성공", + "content": { + "application/json": { + "schema": { + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/ProductionOrderStats" + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, + "/api/v1/production-orders/{orderId}": { + "get": { + "tags": [ + "ProductionOrders" + ], + "summary": "생산지시 상세 조회", + "operationId": "33057f259db03f1b7d5f06afc15d019e", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "수주 ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "성공", + "content": { + "application/json": { + "schema": { + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/ProductionOrderDetail" + } + }, + "type": "object" + } + } + } + }, + "404": { + "description": "생산지시를 찾을 수 없음" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, "/api/v1/purchases": { "get": { "tags": [ @@ -46553,7 +46778,7 @@ "Role" ], "summary": "역할 목록 조회", - "description": "테넌트 범위 내 역할 목록을 페이징으로 반환합니다. (q로 이름/설명 검색, is_hidden으로 필터)", + "description": "테넌트 범위 내 역할 목록을 페이징으로 반환합니다. (q로 이름/설명 검색)", "operationId": "2fe3440eb56182754caf817600b13375", "parameters": [ { @@ -46582,16 +46807,6 @@ "type": "string", "example": "read" } - }, - { - "name": "is_hidden", - "in": "query", - "description": "숨김 상태 필터", - "required": false, - "schema": { - "type": "boolean", - "example": false - } } ], "responses": { @@ -47048,148 +47263,6 @@ ] } }, - "/api/v1/roles/stats": { - "get": { - "tags": [ - "Role" - ], - "summary": "역할 통계 조회", - "description": "테넌트 범위 내 역할 통계(전체/공개/숨김/사용자 보유)를 반환합니다.", - "operationId": "419d5a08537494bf256b10661e221944", - "responses": { - "200": { - "description": "통계 조회 성공", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ApiResponse" - }, - { - "properties": { - "data": { - "$ref": "#/components/schemas/RoleStats" - } - }, - "type": "object" - } - ] - } - } - } - }, - "401": { - "description": "인증 실패", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "서버 에러", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "BearerAuth": [] - } - ] - } - }, - "/api/v1/roles/active": { - "get": { - "tags": [ - "Role" - ], - "summary": "활성 역할 목록 (드롭다운용)", - "description": "숨겨지지 않은 활성 역할 목록을 이름순으로 반환합니다. (id, name, description만 포함)", - "operationId": "8663eac59de3903354a3d5dd4502a5bf", - "responses": { - "200": { - "description": "목록 조회 성공", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ApiResponse" - }, - { - "properties": { - "data": { - "type": "array", - "items": { - "properties": { - "id": { - "type": "integer", - "example": 1 - }, - "name": { - "type": "string", - "example": "admin" - }, - "description": { - "type": "string", - "example": "관리자", - "nullable": true - } - }, - "type": "object" - } - } - }, - "type": "object" - } - ] - } - } - } - }, - "401": { - "description": "인증 실패", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "서버 에러", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "BearerAuth": [] - } - ] - } - }, "/api/v1/roles/{id}/permissions": { "get": { "tags": [ @@ -47582,467 +47655,6 @@ ] } }, - "/api/v1/role-permissions/menus": { - "get": { - "tags": [ - "RolePermission" - ], - "summary": "권한 매트릭스용 메뉴 트리 조회", - "description": "활성 메뉴를 플랫 배열(depth 포함)로 반환하고, 사용 가능한 권한 유형 목록을 함께 반환합니다.", - "operationId": "1eea6074af7fe23108049fc436ae4b8f", - "responses": { - "200": { - "description": "조회 성공", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ApiResponse" - }, - { - "properties": { - "data": { - "$ref": "#/components/schemas/PermissionMenuTree" - } - }, - "type": "object" - } - ] - } - } - } - }, - "401": { - "description": "인증 실패", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "서버 에러", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "BearerAuth": [] - } - ] - } - }, - "/api/v1/roles/{id}/permissions/matrix": { - "get": { - "tags": [ - "RolePermission" - ], - "summary": "역할의 권한 매트릭스 조회", - "description": "해당 역할에 부여된 메뉴별 권한 매트릭스를 반환합니다.", - "operationId": "18e9a32f62613b9cd3d41e79f500d122", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "example": 1 - } - ], - "responses": { - "200": { - "description": "조회 성공", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ApiResponse" - }, - { - "properties": { - "data": { - "$ref": "#/components/schemas/RolePermissionMatrix" - } - }, - "type": "object" - } - ] - } - } - } - }, - "404": { - "description": "역할 없음", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "인증 실패", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "서버 에러", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "BearerAuth": [] - } - ] - } - }, - "/api/v1/roles/{id}/permissions/toggle": { - "post": { - "tags": [ - "RolePermission" - ], - "summary": "특정 메뉴의 특정 권한 토글", - "description": "지정한 메뉴+권한 유형의 부여 상태를 반전합니다. 하위 메뉴에 재귀적으로 전파합니다.", - "operationId": "cd6302edade7b8f79c39a85f8c369638", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "example": 1 - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RolePermissionToggleRequest" - } - } - } - }, - "responses": { - "200": { - "description": "토글 성공", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ApiResponse" - }, - { - "properties": { - "data": { - "$ref": "#/components/schemas/RolePermissionToggleResponse" - } - }, - "type": "object" - } - ] - } - } - } - }, - "404": { - "description": "역할 없음", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "검증 실패", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "인증 실패", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "서버 에러", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "BearerAuth": [] - } - ] - } - }, - "/api/v1/roles/{id}/permissions/allow-all": { - "post": { - "tags": [ - "RolePermission" - ], - "summary": "모든 권한 허용", - "description": "해당 역할에 모든 활성 메뉴의 모든 권한 유형을 일괄 부여합니다.", - "operationId": "ab526a580d6926ef0971582b9aeb1d58", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "example": 1 - } - ], - "responses": { - "200": { - "description": "성공", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse" - } - } - } - }, - "404": { - "description": "역할 없음", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "인증 실패", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "서버 에러", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "BearerAuth": [] - } - ] - } - }, - "/api/v1/roles/{id}/permissions/deny-all": { - "post": { - "tags": [ - "RolePermission" - ], - "summary": "모든 권한 거부", - "description": "해당 역할의 모든 메뉴 권한을 일괄 제거합니다.", - "operationId": "f0120556f6104f5778f13349a5eec469", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "example": 1 - } - ], - "responses": { - "200": { - "description": "성공", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse" - } - } - } - }, - "404": { - "description": "역할 없음", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "인증 실패", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "서버 에러", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "BearerAuth": [] - } - ] - } - }, - "/api/v1/roles/{id}/permissions/reset": { - "post": { - "tags": [ - "RolePermission" - ], - "summary": "기본 권한으로 초기화 (view만 허용)", - "description": "해당 역할의 모든 권한을 제거한 후, 모든 활성 메뉴에 view 권한만 부여합니다.", - "operationId": "7d0ce4d8a4116908a9639c70dc7dba61", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "example": 1 - } - ], - "responses": { - "200": { - "description": "성공", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse" - } - } - } - }, - "404": { - "description": "역할 없음", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "인증 실패", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "서버 에러", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "BearerAuth": [] - } - ] - } - }, "/api/v1/sales": { "get": { "tags": [ @@ -83244,6 +82856,246 @@ "type": "object" } }, + "ProductionOrderListItem": { + "description": "생산지시 목록 아이템", + "properties": { + "id": { + "description": "수주 ID", + "type": "integer", + "example": 1 + }, + "order_no": { + "description": "수주번호 (= 생산지시번호)", + "type": "string", + "example": "ORD-20260301-0001" + }, + "site_name": { + "description": "현장명", + "type": "string", + "example": "서울현장", + "nullable": true + }, + "client_name": { + "description": "거래처명", + "type": "string", + "example": "(주)고객사", + "nullable": true + }, + "quantity": { + "description": "부품수량 합계", + "type": "number", + "example": 232 + }, + "node_count": { + "description": "개소수 (order_nodes 수)", + "type": "integer", + "example": 4 + }, + "delivery_date": { + "description": "납기일", + "type": "string", + "format": "date", + "example": "2026-03-15", + "nullable": true + }, + "production_ordered_at": { + "description": "생산지시일 (첫 WorkOrder 생성일, Y-m-d)", + "type": "string", + "format": "date", + "example": "2026-02-21", + "nullable": true + }, + "production_status": { + "description": "생산 상태", + "type": "string", + "enum": [ + "waiting", + "in_production", + "completed" + ], + "example": "waiting" + }, + "work_orders_count": { + "description": "작업지시 수 (공정별 1건)", + "type": "integer", + "example": 2 + }, + "work_order_progress": { + "properties": { + "total": { + "type": "integer", + "example": 3 + }, + "completed": { + "type": "integer", + "example": 1 + }, + "in_progress": { + "type": "integer", + "example": 1 + } + }, + "type": "object" + }, + "client": { + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "name": { + "type": "string", + "example": "(주)고객사" + } + }, + "type": "object", + "nullable": true + } + }, + "type": "object" + }, + "ProductionOrderStats": { + "description": "생산지시 통계", + "properties": { + "total": { + "description": "전체", + "type": "integer", + "example": 25 + }, + "waiting": { + "description": "생산대기", + "type": "integer", + "example": 10 + }, + "in_production": { + "description": "생산중", + "type": "integer", + "example": 8 + }, + "completed": { + "description": "생산완료", + "type": "integer", + "example": 7 + } + }, + "type": "object" + }, + "ProductionOrderDetail": { + "description": "생산지시 상세", + "properties": { + "order": { + "$ref": "#/components/schemas/ProductionOrderListItem" + }, + "production_ordered_at": { + "type": "string", + "format": "date", + "example": "2026-02-21", + "nullable": true + }, + "production_status": { + "type": "string", + "enum": [ + "waiting", + "in_production", + "completed" + ] + }, + "node_count": { + "description": "개소수", + "type": "integer", + "example": 4 + }, + "work_order_progress": { + "properties": { + "total": { + "type": "integer" + }, + "completed": { + "type": "integer" + }, + "in_progress": { + "type": "integer" + } + }, + "type": "object" + }, + "work_orders": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "work_order_no": { + "type": "string" + }, + "process_name": { + "type": "string" + }, + "quantity": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "assignees": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "type": "object" + } + }, + "bom_process_groups": { + "type": "array", + "items": { + "properties": { + "process_name": { + "type": "string" + }, + "size_spec": { + "type": "string", + "nullable": true + }, + "items": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer", + "nullable": true + }, + "item_code": { + "type": "string" + }, + "item_name": { + "type": "string" + }, + "spec": { + "type": "string" + }, + "lot_no": { + "type": "string" + }, + "required_qty": { + "type": "number" + }, + "qty": { + "type": "number" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + }, + "type": "object" + }, "Purchase": { "description": "매입 정보", "properties": { @@ -86181,18 +86033,6 @@ "type": "string", "example": "api" }, - "is_hidden": { - "type": "boolean", - "example": false - }, - "permissions_count": { - "type": "integer", - "example": 12 - }, - "users_count": { - "type": "integer", - "example": 3 - }, "created_at": { "type": "string", "format": "date-time", @@ -86253,11 +86093,6 @@ "type": "string", "example": "메뉴 관리 역할", "nullable": true - }, - "is_hidden": { - "description": "숨김 여부", - "type": "boolean", - "example": false } }, "type": "object" @@ -86272,32 +86107,6 @@ "type": "string", "example": "설명 변경", "nullable": true - }, - "is_hidden": { - "type": "boolean", - "example": false - } - }, - "type": "object" - }, - "RoleStats": { - "description": "역할 통계", - "properties": { - "total": { - "type": "integer", - "example": 5 - }, - "visible": { - "type": "integer", - "example": 3 - }, - "hidden": { - "type": "integer", - "example": 2 - }, - "with_users": { - "type": "integer", - "example": 4 } }, "type": "object" @@ -86510,164 +86319,6 @@ } ] }, - "PermissionMenuTree": { - "description": "권한 매트릭스용 메뉴 트리", - "properties": { - "menus": { - "type": "array", - "items": { - "properties": { - "id": { - "type": "integer", - "example": 1 - }, - "parent_id": { - "type": "integer", - "example": null, - "nullable": true - }, - "name": { - "type": "string", - "example": "대시보드" - }, - "url": { - "type": "string", - "example": "/dashboard", - "nullable": true - }, - "icon": { - "type": "string", - "example": "dashboard", - "nullable": true - }, - "sort_order": { - "type": "integer", - "example": 1 - }, - "is_active": { - "type": "boolean", - "example": true - }, - "depth": { - "type": "integer", - "example": 0 - }, - "has_children": { - "type": "boolean", - "example": true - } - }, - "type": "object" - } - }, - "permission_types": { - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "view", - "create", - "update", - "delete", - "approve", - "export", - "manage" - ] - } - }, - "type": "object" - }, - "RolePermissionMatrix": { - "description": "역할의 권한 매트릭스", - "properties": { - "role": { - "properties": { - "id": { - "type": "integer", - "example": 1 - }, - "name": { - "type": "string", - "example": "admin" - }, - "description": { - "type": "string", - "example": "관리자", - "nullable": true - } - }, - "type": "object" - }, - "permission_types": { - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "view", - "create", - "update", - "delete", - "approve", - "export", - "manage" - ] - }, - "permissions": { - "description": "메뉴ID를 키로 한 권한 맵", - "type": "object", - "example": { - "101": { - "view": true, - "create": true - }, - "102": { - "view": true - } - }, - "additionalProperties": true - } - }, - "type": "object" - }, - "RolePermissionToggleRequest": { - "required": [ - "menu_id", - "permission_type" - ], - "properties": { - "menu_id": { - "description": "메뉴 ID", - "type": "integer", - "example": 101 - }, - "permission_type": { - "description": "권한 유형 (view, create, update, delete, approve, export, manage)", - "type": "string", - "example": "view" - } - }, - "type": "object" - }, - "RolePermissionToggleResponse": { - "properties": { - "menu_id": { - "type": "integer", - "example": 101 - }, - "permission_type": { - "type": "string", - "example": "view" - }, - "granted": { - "description": "토글 후 권한 부여 상태", - "type": "boolean", - "example": true - } - }, - "type": "object" - }, "Sale": { "description": "매출 정보", "properties": { @@ -94322,6 +93973,10 @@ "name": "Products-BOM", "description": "제품 BOM (제품/자재 혼합) 관리" }, + { + "name": "ProductionOrders", + "description": "생산지시 관리" + }, { "name": "Purchases", "description": "매입 관리"