diff --git a/Jenkinsfile b/Jenkinsfile index cbbe5e7..6f98d9d 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,12 @@ pipeline { agent any + parameters { + choice(name: 'ACTION', choices: ['deploy', 'rollback'], description: '배포 또는 롤백') + choice(name: 'ROLLBACK_TARGET', choices: ['production', 'stage'], description: '롤백 대상 환경') + string(name: 'ROLLBACK_RELEASE', defaultValue: '', description: '롤백할 릴리스 ID (예: 20260310_120000). 비워두면 직전 릴리스로 롤백') + } + options { disableConcurrentBuilds() } @@ -8,10 +14,73 @@ pipeline { environment { DEPLOY_USER = 'hskwon' RELEASE_ID = new Date().format('yyyyMMdd_HHmmss') + PROD_SERVER = '211.117.60.189' } stages { + + // ── 롤백: 릴리스 목록 조회 ── + stage('Rollback: List Releases') { + when { expression { params.ACTION == 'rollback' } } + steps { + script { + def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/api' : '/home/webservice/api-stage' + sshagent(credentials: ['deploy-ssh-key']) { + def releases = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | head -6 | xargs -I{} basename {}'", returnStdout: true).trim() + def current = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'basename \$(readlink -f ${basePath}/current)'", returnStdout: true).trim() + echo "=== ${params.ROLLBACK_TARGET} 릴리스 목록 ===" + echo "현재 활성: ${current}" + echo "사용 가능:\n${releases}" + } + } + } + } + + // ── 롤백: symlink 전환 ── + stage('Rollback: Switch Release') { + when { expression { params.ACTION == 'rollback' } } + steps { + script { + def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/api' : '/home/webservice/api-stage' + + sshagent(credentials: ['deploy-ssh-key']) { + def targetRelease = params.ROLLBACK_RELEASE + if (!targetRelease?.trim()) { + // 비워두면 직전 릴리스로 롤백 + targetRelease = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | sed -n 2p | xargs basename'", returnStdout: true).trim() + } + + // 릴리스 존재 여부 확인 + sh "ssh ${DEPLOY_USER}@${PROD_SERVER} 'test -d ${basePath}/releases/${targetRelease}'" + + slackSend channel: '#deploy_api', color: '#FF9800', tokenCredentialId: 'slack-token', + message: "🔄 *api* ${params.ROLLBACK_TARGET} 롤백 시작 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + + sh """ + ssh ${DEPLOY_USER}@${PROD_SERVER} ' + ln -sfn ${basePath}/releases/${targetRelease} ${basePath}/current && + cd ${basePath}/current && + php artisan config:cache && + php artisan route:cache && + php artisan view:cache && + sudo systemctl reload php8.4-fpm + ' + """ + + if (params.ROLLBACK_TARGET == 'production') { + sh "ssh ${DEPLOY_USER}@${PROD_SERVER} 'sudo supervisorctl restart sam-queue-worker:*'" + } + + slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token', + message: "✅ *api* ${params.ROLLBACK_TARGET} 롤백 완료 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + } + } + } + } + + // ── 일반 배포: Checkout ── stage('Checkout') { + when { expression { params.ACTION == 'deploy' } } steps { checkout scm script { @@ -24,17 +93,22 @@ pipeline { // ── main → 운영서버 Stage 배포 ── stage('Deploy Stage') { - when { branch 'main' } + when { + allOf { + branch 'main' + expression { params.ACTION == 'deploy' } + } + } steps { sshagent(credentials: ['deploy-ssh-key']) { sh """ - ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}' + ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}' rsync -az --delete \ --exclude='.git' --exclude='.env' \ --exclude='storage/app' --exclude='storage/logs' \ --exclude='storage/framework/sessions' --exclude='storage/framework/cache' \ - . ${DEPLOY_USER}@211.117.60.189:/home/webservice/api-stage/releases/${RELEASE_ID}/ - ssh ${DEPLOY_USER}@211.117.60.189 ' + . ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/api-stage/releases/${RELEASE_ID}/ + ssh ${DEPLOY_USER}@${PROD_SERVER} ' cd /home/webservice/api-stage/releases/${RELEASE_ID} && mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs && sudo chown -R www-data:webservice storage bootstrap/cache && @@ -71,17 +145,22 @@ pipeline { // ── main → 운영서버 Production 배포 ── stage('Deploy Production') { - when { branch 'main' } + when { + allOf { + branch 'main' + expression { params.ACTION == 'deploy' } + } + } steps { sshagent(credentials: ['deploy-ssh-key']) { sh """ - ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}' + ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}' rsync -az --delete \ --exclude='.git' --exclude='.env' \ --exclude='storage/app' --exclude='storage/logs' \ --exclude='storage/framework/sessions' --exclude='storage/framework/cache' \ - . ${DEPLOY_USER}@211.117.60.189:/home/webservice/api/releases/${RELEASE_ID}/ - ssh ${DEPLOY_USER}@211.117.60.189 ' + . ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/api/releases/${RELEASE_ID}/ + ssh ${DEPLOY_USER}@${PROD_SERVER} ' cd /home/webservice/api/releases/${RELEASE_ID} && mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs && sudo chown -R www-data:webservice storage bootstrap/cache && @@ -109,23 +188,32 @@ pipeline { post { success { - slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token', - message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + script { + if (params.ACTION == 'deploy') { + slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token', + message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + } + } } failure { - slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token', - message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" script { - if (env.BRANCH_NAME == 'main') { - sshagent(credentials: ['deploy-ssh-key']) { - sh """ - ssh ${DEPLOY_USER}@211.117.60.189 ' - PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) && - [ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current && - sudo systemctl reload php8.4-fpm - ' || true - """ + if (params.ACTION == 'deploy') { + slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token', + message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + if (env.BRANCH_NAME == 'main') { + sshagent(credentials: ['deploy-ssh-key']) { + sh """ + ssh ${DEPLOY_USER}@${PROD_SERVER} ' + PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) && + [ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current && + sudo systemctl reload php8.4-fpm + ' || true + """ + } } + } else { + slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token', + message: "❌ *api* ${params.ROLLBACK_TARGET} 롤백 실패\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" } } } 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/ChecklistTemplateController.php b/app/Http/Controllers/Api/V1/ChecklistTemplateController.php new file mode 100644 index 0000000..a749df7 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ChecklistTemplateController.php @@ -0,0 +1,82 @@ +query('type', 'day1_audit'); + + return $this->service->getByType($type); + }, __('message.fetched')); + } + + /** + * 템플릿 저장 (전체 덮어쓰기) + */ + public function update(SaveChecklistTemplateRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->save($id, $request->validated()); + }, __('message.updated')); + } + + /** + * 항목별 파일 목록 조회 + */ + public function documents(Request $request) + { + return ApiResponse::handle(function () use ($request) { + $templateId = (int) $request->query('template_id'); + $subItemId = $request->query('sub_item_id'); + + return $this->service->getDocuments($templateId, $subItemId); + }, __('message.fetched')); + } + + /** + * 파일 업로드 + */ + public function uploadDocument(Request $request) + { + $request->validate([ + 'template_id' => ['required', 'integer'], + 'sub_item_id' => ['required', 'string', 'max:50'], + 'file' => ['required', 'file', 'max:10240'], // 10MB + ]); + + return ApiResponse::handle(function () use ($request) { + return $this->service->uploadDocument( + (int) $request->input('template_id'), + $request->input('sub_item_id'), + $request->file('file') + ); + }, __('message.created')); + } + + /** + * 파일 삭제 + */ + public function deleteDocument(int $id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + $replace = filter_var($request->query('replace', false), FILTER_VALIDATE_BOOLEAN); + $this->service->deleteDocument($id, $replace); + + return 'success'; + }, __('message.deleted')); + } +} 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/Http/Controllers/Api/V1/ShipmentController.php b/app/Http/Controllers/Api/V1/ShipmentController.php index 6262700..08f4b2b 100644 --- a/app/Http/Controllers/Api/V1/ShipmentController.php +++ b/app/Http/Controllers/Api/V1/ShipmentController.php @@ -8,13 +8,15 @@ use App\Http\Requests\Shipment\ShipmentUpdateRequest; use App\Http\Requests\Shipment\ShipmentUpdateStatusRequest; use App\Services\ShipmentService; +use App\Services\WorkOrderService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class ShipmentController extends Controller { public function __construct( - private readonly ShipmentService $service + private readonly ShipmentService $service, + private readonly WorkOrderService $workOrderService ) {} /** @@ -83,7 +85,7 @@ public function store(ShipmentStoreRequest $request): JsonResponse { $shipment = $this->service->store($request->validated()); - return ApiResponse::success($shipment, __('message.created'), 201); + return ApiResponse::success($shipment, __('message.created'), [], 201); } /** @@ -132,6 +134,22 @@ public function destroy(int $id): JsonResponse } } + /** + * 수주 기반 출하 생성 + */ + public function createFromOrder(int $orderId): JsonResponse + { + try { + $shipment = $this->workOrderService->createShipmentForOrder($orderId); + + return ApiResponse::success($shipment, __('message.created'), [], 201); + } catch (\Symfony\Component\HttpKernel\Exception\BadRequestHttpException $e) { + return ApiResponse::error($e->getMessage(), 400); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return ApiResponse::error(__('error.order.not_found'), 404); + } + } + /** * LOT 옵션 조회 */ diff --git a/app/Http/Middleware/ApiKeyMiddleware.php b/app/Http/Middleware/ApiKeyMiddleware.php index 5a6d769..64717fd 100644 --- a/app/Http/Middleware/ApiKeyMiddleware.php +++ b/app/Http/Middleware/ApiKeyMiddleware.php @@ -117,6 +117,7 @@ public function handle(Request $request, Closure $next) // 화이트리스트(인증 예외 라우트) - Bearer 토큰 없이 접근 가능 $allowWithoutAuth = [ 'api/v1/login', + 'api/v1/token-login', // MNG → SAM 자동 로그인 (API Key만 필요) 'api/v1/signup', 'api/v1/register', 'api/v1/refresh', diff --git a/app/Http/Requests/Quality/SaveChecklistTemplateRequest.php b/app/Http/Requests/Quality/SaveChecklistTemplateRequest.php new file mode 100644 index 0000000..a37c696 --- /dev/null +++ b/app/Http/Requests/Quality/SaveChecklistTemplateRequest.php @@ -0,0 +1,40 @@ + ['nullable', 'string', 'max:255'], + 'categories' => ['required', 'array', 'min:1'], + 'categories.*.id' => ['required', 'string', 'max:50'], + 'categories.*.title' => ['required', 'string', 'max:255'], + 'categories.*.subItems' => ['required', 'array'], + 'categories.*.subItems.*.id' => ['required', 'string', 'max:50'], + 'categories.*.subItems.*.name' => ['required', 'string', 'max:255'], + 'options' => ['nullable', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'categories.required' => __('validation.required', ['attribute' => '카테고리']), + 'categories.min' => __('validation.min.array', ['attribute' => '카테고리', 'min' => 1]), + 'categories.*.id.required' => __('validation.required', ['attribute' => '카테고리 ID']), + 'categories.*.title.required' => __('validation.required', ['attribute' => '카테고리 제목']), + 'categories.*.subItems.required' => __('validation.required', ['attribute' => '점검항목']), + 'categories.*.subItems.*.id.required' => __('validation.required', ['attribute' => '항목 ID']), + 'categories.*.subItems.*.name.required' => __('validation.required', ['attribute' => '항목명']), + ]; + } +} diff --git a/app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php b/app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php index a4daae7..6742b7e 100644 --- a/app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php +++ b/app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php @@ -34,7 +34,7 @@ public function rules(): array 'items.*.productCategory' => 'nullable|string|in:SCREEN,STEEL', 'items.*.guideRailType' => 'nullable|string|in:wall,ceiling,floor,mixed', 'items.*.motorPower' => 'nullable|string|in:single,three', - 'items.*.controller' => 'nullable|string|in:basic,smart,premium', + 'items.*.controller' => 'nullable|string|in:exposed,embedded,embedded_no_box', 'items.*.wingSize' => 'nullable|numeric|min:0|max:500', 'items.*.inspectionFee' => 'nullable|numeric|min:0', @@ -45,7 +45,7 @@ public function rules(): array 'items.*.PC' => 'nullable|string|in:SCREEN,STEEL', 'items.*.GT' => 'nullable|string|in:wall,ceiling,floor,mixed', 'items.*.MP' => 'nullable|string|in:single,three', - 'items.*.CT' => 'nullable|string|in:basic,smart,premium', + 'items.*.CT' => 'nullable|string|in:exposed,embedded,embedded_no_box', 'items.*.WS' => 'nullable|numeric|min:0|max:500', 'items.*.INSP' => 'nullable|numeric|min:0', @@ -128,7 +128,7 @@ private function normalizeInputVariables(array $item): array 'PC' => $item['productCategory'] ?? $item['PC'] ?? 'SCREEN', 'GT' => $item['guideRailType'] ?? $item['GT'] ?? 'wall', 'MP' => $item['motorPower'] ?? $item['MP'] ?? 'single', - 'CT' => $item['controller'] ?? $item['CT'] ?? 'basic', + 'CT' => $item['controller'] ?? $item['CT'] ?? 'exposed', 'WS' => (float) ($item['wingSize'] ?? $item['WS'] ?? 50), 'INSP' => (float) ($item['inspectionFee'] ?? $item['INSP'] ?? 50000), ]; diff --git a/app/Http/Requests/Quote/QuoteBomCalculateRequest.php b/app/Http/Requests/Quote/QuoteBomCalculateRequest.php index 5aa1b99..24790e7 100644 --- a/app/Http/Requests/Quote/QuoteBomCalculateRequest.php +++ b/app/Http/Requests/Quote/QuoteBomCalculateRequest.php @@ -30,7 +30,7 @@ public function rules(): array 'PC' => 'nullable|string|in:SCREEN,STEEL', 'GT' => 'nullable|string|in:wall,ceiling,floor,mixed', 'MP' => 'nullable|string|in:single,three', - 'CT' => 'nullable|string|in:basic,smart,premium', + 'CT' => 'nullable|string|in:exposed,embedded,embedded_no_box', 'WS' => 'nullable|numeric|min:0|max:500', 'INSP' => 'nullable|numeric|min:0', @@ -82,7 +82,7 @@ public function getInputVariables(): array 'PC' => $validated['PC'] ?? 'SCREEN', 'GT' => $validated['GT'] ?? 'wall', 'MP' => $validated['MP'] ?? 'single', - 'CT' => $validated['CT'] ?? 'basic', + 'CT' => $validated['CT'] ?? 'exposed', 'WS' => (float) ($validated['WS'] ?? 50), 'INSP' => (float) ($validated['INSP'] ?? 50000), ]; diff --git a/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php b/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php index 56904de..8b94a5f 100644 --- a/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php +++ b/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php @@ -31,6 +31,7 @@ public function rules(): array 'remark' => ['nullable', 'string', 'max:1000'], 'manufacturer' => ['nullable', 'string', 'max:100'], 'material_no' => ['nullable', 'string', 'max:50'], + 'certificate_file_id' => ['nullable', 'integer', 'exists:files,id'], ]; } diff --git a/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php b/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php index 4273062..b418e2f 100644 --- a/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php +++ b/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php @@ -33,6 +33,7 @@ public function rules(): array 'inspection_result' => ['nullable', 'string', 'max:20'], 'manufacturer' => ['nullable', 'string', 'max:100'], 'material_no' => ['nullable', 'string', 'max:50'], + 'certificate_file_id' => ['nullable', 'integer', 'exists:files,id'], ]; } 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/Models/Qualitys/ChecklistTemplate.php b/app/Models/Qualitys/ChecklistTemplate.php new file mode 100644 index 0000000..3201593 --- /dev/null +++ b/app/Models/Qualitys/ChecklistTemplate.php @@ -0,0 +1,76 @@ + 'array', + 'options' => 'array', + ]; + + public function creator(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'created_by'); + } + + public function updater(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'updated_by'); + } + + /** + * 점검항목별 연결 파일 (files 테이블 polymorphic) + * document_type = 'checklist_template', document_id = this.id + * field_key = sub_item_id (e.g. 'cat-1-1') + */ + public function documents(): MorphMany + { + return $this->morphMany(File::class, 'document', 'document_type', 'document_id'); + } + + /** + * 특정 항목의 파일 조회 + */ + public function documentsForItem(string $subItemId) + { + return $this->documents()->where('field_key', $subItemId); + } + + /** + * categories JSON에서 모든 sub_item_id 추출 + */ + public function getAllSubItemIds(): array + { + $ids = []; + foreach ($this->categories ?? [] as $category) { + foreach ($category['subItems'] ?? [] as $subItem) { + $ids[] = $subItem['id']; + } + } + + return $ids; + } +} diff --git a/app/Models/Tenants/Receiving.php b/app/Models/Tenants/Receiving.php index d5799e4..68cedf5 100644 --- a/app/Models/Tenants/Receiving.php +++ b/app/Models/Tenants/Receiving.php @@ -34,6 +34,7 @@ class Receiving extends Model 'status', 'remark', 'options', + 'certificate_file_id', 'created_by', 'updated_by', 'deleted_by', @@ -47,6 +48,7 @@ class Receiving extends Model 'receiving_qty' => 'decimal:2', 'item_id' => 'integer', 'options' => 'array', + 'certificate_file_id' => 'integer', ]; /** @@ -92,6 +94,14 @@ public function item(): BelongsTo return $this->belongsTo(\App\Models\Items\Item::class); } + /** + * 업체 제공 성적서 파일 관계 + */ + public function certificateFile(): BelongsTo + { + return $this->belongsTo(\App\Models\Commons\File::class, 'certificate_file_id'); + } + /** * 생성자 관계 */ diff --git a/app/Models/Tenants/Shipment.php b/app/Models/Tenants/Shipment.php index df96ff5..90e8f1a 100644 --- a/app/Models/Tenants/Shipment.php +++ b/app/Models/Tenants/Shipment.php @@ -283,6 +283,12 @@ public function getOrderContactAttribute(): ?string */ public function getOrderInfoAttribute(): array { + $orderOptions = $this->order?->options; + if (is_string($orderOptions)) { + $orderOptions = json_decode($orderOptions, true) ?? []; + } + $orderOptions = $orderOptions ?? []; + return [ 'order_id' => $this->order_id, 'order_no' => $this->order?->order_no, @@ -290,10 +296,16 @@ public function getOrderInfoAttribute(): array 'client_id' => $this->order_client_id, 'customer_name' => $this->order_customer_name, 'site_name' => $this->order_site_name, - 'delivery_address' => $this->order_delivery_address, + 'delivery_address' => $orderOptions['shipping_address'] ?? $this->order_delivery_address, + 'delivery_address_detail' => $orderOptions['shipping_address_detail'] ?? null, 'contact' => $this->order_contact, + // 수신자 정보 (수주 options에서) + 'receiver' => $orderOptions['receiver'] ?? null, + 'receiver_contact' => $orderOptions['receiver_contact'] ?? $this->order_contact, // 추가 정보 - 'delivery_date' => $this->order?->delivery_date?->format('Y-m-d'), 'writer_id' => $this->order?->writer_id, + 'delivery_date' => $this->order?->delivery_date?->format('Y-m-d'), + 'delivery_method' => $this->order?->delivery_method_code, + 'writer_id' => $this->order?->writer_id, 'writer_name' => $this->order?->writer?->name, ]; } diff --git a/app/Services/ChecklistTemplateService.php b/app/Services/ChecklistTemplateService.php new file mode 100644 index 0000000..018928f --- /dev/null +++ b/app/Services/ChecklistTemplateService.php @@ -0,0 +1,256 @@ +where('type', $type) + ->first(); + + if (! $template) { + throw new NotFoundHttpException(__('error.not_found')); + } + + // 각 항목별 파일 수 포함 + $fileCounts = File::query() + ->where('document_type', self::DOCUMENT_TYPE) + ->where('document_id', $template->id) + ->whereNull('deleted_at') + ->selectRaw('field_key, COUNT(*) as count') + ->groupBy('field_key') + ->pluck('count', 'field_key') + ->toArray(); + + return [ + 'id' => $template->id, + 'name' => $template->name, + 'type' => $template->type, + 'categories' => $template->categories, + 'options' => $template->options, + 'file_counts' => $fileCounts, + 'updated_at' => $template->updated_at?->toIso8601String(), + 'updated_by' => $template->updater?->name, + ]; + } + + /** + * 템플릿 저장 (전체 덮어쓰기) + */ + public function save(int $id, array $data): array + { + $template = ChecklistTemplate::findOrFail($id); + $before = $template->toArray(); + + // 삭제된 항목의 파일 처리 + $oldSubItemIds = $template->getAllSubItemIds(); + $newSubItemIds = $this->extractSubItemIds($data['categories']); + $removedIds = array_diff($oldSubItemIds, $newSubItemIds); + + DB::transaction(function () use ($template, $data, $removedIds) { + // 템플릿 업데이트 + $template->update([ + 'name' => $data['name'] ?? $template->name, + 'categories' => $data['categories'], + 'options' => $data['options'] ?? $template->options, + 'updated_by' => $this->apiUserId(), + ]); + + // 삭제된 항목의 파일 → soft delete + if (! empty($removedIds)) { + $orphanFiles = File::query() + ->where('document_type', self::DOCUMENT_TYPE) + ->where('document_id', $template->id) + ->whereIn('field_key', $removedIds) + ->get(); + + foreach ($orphanFiles as $file) { + $file->softDeleteFile($this->apiUserId()); + } + } + }); + + $template->refresh(); + + $this->auditLogger->log( + $this->tenantId(), + self::AUDIT_TARGET, + $template->id, + 'updated', + $before, + $template->toArray() + ); + + return $this->getByType($template->type); + } + + /** + * 항목 완료 토글 + */ + public function toggleItem(int $id, string $subItemId): array + { + $template = ChecklistTemplate::findOrFail($id); + $categories = $template->categories; + $toggled = null; + + foreach ($categories as &$category) { + if (empty($category['subItems'])) { + continue; + } + foreach ($category['subItems'] as &$subItem) { + if ($subItem['id'] === $subItemId) { + $subItem['is_completed'] = ! ($subItem['is_completed'] ?? false); + $subItem['completed_at'] = $subItem['is_completed'] ? now()->toIso8601String() : null; + $toggled = $subItem; + break 2; + } + } + unset($subItem); + } + unset($category); + + if (! $toggled) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $template->update([ + 'categories' => $categories, + 'updated_by' => $this->apiUserId(), + ]); + + return [ + 'id' => $toggled['id'], + 'name' => $toggled['name'], + 'is_completed' => $toggled['is_completed'], + 'completed_at' => $toggled['completed_at'], + ]; + } + + /** + * 항목별 파일 목록 조회 + */ + public function getDocuments(int $templateId, ?string $subItemId = null): array + { + $query = File::query() + ->where('document_type', self::DOCUMENT_TYPE) + ->where('document_id', $templateId) + ->with('uploader:id,name'); + + if ($subItemId) { + $query->where('field_key', $subItemId); + } + + $files = $query->orderBy('field_key')->orderByDesc('id')->get(); + + return $files->map(fn (File $file) => [ + 'id' => $file->id, + 'field_key' => $file->field_key, + 'display_name' => $file->display_name ?? $file->original_name, + 'file_size' => $file->file_size, + 'mime_type' => $file->mime_type, + 'uploaded_by' => $file->uploader?->name, + 'created_at' => $file->created_at?->toIso8601String(), + ])->toArray(); + } + + /** + * 파일 업로드 (polymorphic) + */ + public function uploadDocument(int $templateId, string $subItemId, $uploadedFile): array + { + $template = ChecklistTemplate::findOrFail($templateId); + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 저장 경로: {tenant_id}/checklist-templates/{year}/{month}/{stored_name} + $date = now(); + $storedName = bin2hex(random_bytes(16)).'.'.$uploadedFile->getClientOriginalExtension(); + $filePath = sprintf( + '%d/checklist-templates/%s/%s/%s', + $tenantId, + $date->format('Y'), + $date->format('m'), + $storedName + ); + + // 파일 저장 + Storage::disk('r2')->put($filePath, file_get_contents($uploadedFile->getPathname())); + + // DB 레코드 생성 + $file = File::create([ + 'tenant_id' => $tenantId, + 'document_type' => self::DOCUMENT_TYPE, + 'document_id' => $template->id, + 'field_key' => $subItemId, + 'display_name' => $uploadedFile->getClientOriginalName(), + 'stored_name' => $storedName, + 'file_path' => $filePath, + 'file_size' => $uploadedFile->getSize(), + 'mime_type' => $uploadedFile->getClientMimeType(), + 'uploaded_by' => $userId, + 'created_by' => $userId, + ]); + + return [ + 'id' => $file->id, + 'field_key' => $file->field_key, + 'display_name' => $file->display_name, + 'file_size' => $file->file_size, + 'mime_type' => $file->mime_type, + 'created_at' => $file->created_at?->toIso8601String(), + ]; + } + + /** + * 파일 삭제 + * - 교체(replace=true): hard delete (물리 파일 + DB) + * - 일반 삭제: soft delete (휴지통) + */ + public function deleteDocument(int $fileId, bool $replace = false): void + { + $file = File::query() + ->where('document_type', self::DOCUMENT_TYPE) + ->findOrFail($fileId); + + if ($replace) { + $file->permanentDelete(); + } else { + $file->softDeleteFile($this->apiUserId()); + } + } + + /** + * categories JSON에서 sub_item_id 목록 추출 + */ + private function extractSubItemIds(array $categories): array + { + $ids = []; + foreach ($categories as $category) { + foreach ($category['subItems'] ?? [] as $subItem) { + $ids[] = $subItem['id']; + } + } + + return $ids; + } +} 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/app/Services/QmsLotAuditService.php b/app/Services/QmsLotAuditService.php index dbd367c..951be48 100644 --- a/app/Services/QmsLotAuditService.php +++ b/app/Services/QmsLotAuditService.php @@ -23,8 +23,7 @@ class QmsLotAuditService extends Service public function index(array $params): array { $query = QualityDocument::with([ - 'documentOrders.order.nodes' => fn ($q) => $q->whereNull('parent_id'), - 'documentOrders.order.nodes.items.item', + 'documentOrders.order.item', 'locations', 'performanceReport', ]) @@ -89,7 +88,7 @@ public function routeDocuments(int $qualityDocumentOrderId): array { $docOrder = QualityDocumentOrder::with([ 'order.workOrders.process', - 'locations', + 'locations.orderItem', 'qualityDocument', ])->findOrFail($qualityDocumentOrderId); @@ -119,17 +118,20 @@ public function routeDocuments(int $qualityDocumentOrderId): array // 2. 수주서 $documents[] = $this->formatDocument('order', '수주서', collect([$order])); - // 3. 작업일지 (subType: process.process_name 기반) - $documents[] = $this->formatDocumentWithSubType('log', '작업일지', $workOrders); + // 3. 작업일지 (공정별 1개씩 — 같은 공정의 WO는 그룹핑) + $workOrdersByProcess = $workOrders->groupBy('process_id')->map(fn ($group) => $group->first()); + $documents[] = $this->formatDocumentWithSubType('log', '작업일지', $workOrdersByProcess); - // 4. 중간검사 성적서 (PQC) + // 4. 중간검사 성적서 (PQC — 공정별 1개씩) $pqcInspections = Inspection::where('inspection_type', 'PQC') ->whereIn('work_order_id', $workOrders->pluck('id')) - ->where('status', 'completed') ->with('workOrder.process') ->get(); - $documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $pqcInspections, 'workOrder'); + // 공정별 그룹핑 (같은 공정의 PQC는 최신 1개만) + $pqcByProcess = $pqcInspections->groupBy(fn ($insp) => $insp->workOrder?->process_id) + ->map(fn ($group) => $group->sortByDesc('inspection_date')->first()); + $documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $pqcByProcess, 'workOrder'); // 5. 납품확인서 $shipments = $order->shipments()->get(); @@ -138,9 +140,11 @@ public function routeDocuments(int $qualityDocumentOrderId): array // 6. 출고증 $documents[] = $this->formatDocument('shipping', '출고증', $shipments); - // 7. 제품검사 성적서 - $locationsWithDoc = $docOrder->locations->filter(fn ($loc) => $loc->document_id); - $documents[] = $this->formatDocument('product', '제품검사 성적서', $locationsWithDoc); + // 7. 제품검사 성적서 (FQC 문서 또는 inspection_data 완료건) + $locationsWithInspection = $docOrder->locations->filter( + fn ($loc) => $loc->document_id || $loc->inspection_status === 'completed' + ); + $documents[] = $this->formatDocument('product', '제품검사 성적서', $locationsWithInspection); // 8. 품질관리서 $documents[] = $this->formatDocument('quality', '품질관리서', collect([$qualityDoc])); @@ -220,30 +224,14 @@ private function transformReportToFrontend(QualityDocument $doc): array } /** - * BOM 최상위(FG) 제품명 추출 - * Order → root OrderNode(parent_id=null) → 대표 OrderItem → Item(FG).name + * 수주 대표 제품명 추출 + * Order.item_id → Item.name */ private function getFgProductName(QualityDocument $doc): string { - $firstDocOrder = $doc->documentOrders->first(); - if (! $firstDocOrder) { - return ''; - } + $order = $doc->documentOrders->first()?->order; - $order = $firstDocOrder->order; - if (! $order) { - return ''; - } - - // eager loaded with whereNull('parent_id') filter - $rootNode = $order->nodes->first(); - if (! $rootNode) { - return ''; - } - - $representativeItem = $rootNode->items->first(); - - return $representativeItem?->item?->name ?? ''; + return $order?->item?->name ?? ''; } private function transformRouteToFrontend(QualityDocumentOrder $docOrder, QualityDocument $qualityDoc): array @@ -313,11 +301,12 @@ private function formatDocumentWithSubType(string $type, string $title, $collect 'items' => $collection->values()->map(function ($item) use ($type, $workOrderRelation) { $formatted = $this->formatDocumentItem($type, $item); - // subType: process.process_name 기반 + // subType: process.process_name 기반 + work_order_id 전달 $workOrder = $workOrderRelation ? $item->{$workOrderRelation} : $item; if ($workOrder instanceof WorkOrder) { $processName = $workOrder->process?->process_name; $formatted['sub_type'] = $this->mapProcessToSubType($processName); + $formatted['work_order_id'] = $workOrder->id; } return $formatted; @@ -354,7 +343,7 @@ private function formatDocumentItem(string $type, $item): array ], 'product' => [ 'id' => (string) $item->id, - 'title' => '제품검사 성적서', + 'title' => trim(($item->orderItem?->floor_code ?? '').' '.($item->orderItem?->symbol_code ?? '')) ?: '제품검사 성적서', 'date' => $item->updated_at?->toDateString() ?? '', 'code' => '', ], @@ -479,9 +468,16 @@ private function getShipmentDetail(int $id): array private function getLocationDetail(int $id): array { - $location = QualityDocumentLocation::with(['orderItem', 'document'])->findOrFail($id); + $location = QualityDocumentLocation::with([ + 'orderItem', + 'document.template.sections.items', + 'document.template.columns', + 'document.template.approvalLines', + 'document.template.basicFields', + 'document.data', + ])->findOrFail($id); - return [ + $result = [ 'type' => 'product', 'data' => [ 'id' => $location->id, @@ -494,6 +490,86 @@ private function getLocationDetail(int $id): array 'document_id' => $location->document_id, ], ]; + + // FQC 문서가 있으면 template + data 포함 + if ($location->document) { + $doc = $location->document; + $result['data']['fqc_document'] = [ + 'id' => $doc->id, + 'template_id' => $doc->template_id, + 'document_no' => $doc->document_no, + 'title' => $doc->title, + 'status' => $doc->status, + 'created_at' => $doc->created_at?->toIso8601String(), + 'template' => $this->formatFqcTemplate($doc->template), + 'data' => $doc->data->map(fn ($d) => [ + 'section_id' => $d->section_id, + 'column_id' => $d->column_id, + 'row_index' => $d->row_index, + 'field_key' => $d->field_key, + 'field_value' => $d->field_value, + ])->all(), + ]; + } + + return $result; + } + + private function formatFqcTemplate($template): ?array + { + if (! $template) { + return null; + } + + return [ + 'id' => $template->id, + 'name' => $template->name, + 'category' => $template->category, + 'title' => $template->title, + 'approval_lines' => $template->approvalLines->map(fn ($a) => [ + 'id' => $a->id, + 'name' => $a->name, + 'department' => $a->department, + 'sort_order' => $a->sort_order, + ])->all(), + 'basic_fields' => $template->basicFields->map(fn ($f) => [ + 'id' => $f->id, + 'label' => $f->label, + 'field_key' => $f->field_key, + 'field_type' => $f->field_type, + 'default_value' => $f->default_value, + 'is_required' => $f->is_required, + 'sort_order' => $f->sort_order, + ])->all(), + 'sections' => $template->sections->map(fn ($s) => [ + 'id' => $s->id, + 'name' => $s->name, + 'title' => $s->title, + 'description' => $s->description, + 'image_path' => $s->image_path, + 'sort_order' => $s->sort_order, + 'items' => $s->items->map(fn ($i) => [ + 'id' => $i->id, + 'section_id' => $i->section_id, + 'item_name' => $i->item ?? '', + 'standard' => $i->standard, + 'tolerance' => $i->tolerance, + 'measurement_type' => $i->measurement_type, + 'frequency' => $i->frequency, + 'sort_order' => $i->sort_order, + 'category' => $i->category, + 'method' => $i->method, + ])->all(), + ])->all(), + 'columns' => $template->columns->map(fn ($c) => [ + 'id' => $c->id, + 'label' => $c->label, + 'column_type' => $c->column_type, + 'width' => $c->width, + 'group_name' => $c->group_name, + 'sort_order' => $c->sort_order, + ])->all(), + ]; } private function getQualityDocDetail(int $id): array diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php index 3720f3a..0659156 100644 --- a/app/Services/Quote/FormulaEvaluatorService.php +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -633,7 +633,7 @@ public function calculateBomWithDebug( 'PC' => $inputVariables['PC'] ?? '', 'GT' => $inputVariables['GT'] ?? 'wall', 'MP' => $inputVariables['MP'] ?? 'single', - 'CT' => $inputVariables['CT'] ?? 'basic', + 'CT' => $inputVariables['CT'] ?? 'exposed', 'WS' => $inputVariables['WS'] ?? 50, 'INSP' => $inputVariables['INSP'] ?? 50000, 'finished_goods' => $finishedGoodsCode, @@ -1708,6 +1708,22 @@ private function calculateTenantBom( default => '220V', }; + // 제어기 타입: 프론트 CT(exposed/embedded/embedded_no_box) → controller_type(노출형/매립형) 매핑 + // - exposed: 노출형 (뒷박스 불필요) + // - embedded: 매립형 (뒷박스 포함) + // - embedded_no_box: 매립형 (뒷박스 제외 — 업체 자체 보유) + $ctValue = $inputVariables['CT'] ?? 'exposed'; + $controllerType = $inputVariables['controller_type'] ?? match ($ctValue) { + 'embedded', 'embedded_no_box' => '매립형', + 'exposed' => '노출형', + default => '노출형', + }; + // 뒷박스: embedded만 포함, exposed/embedded_no_box는 제외 + $backboxQty = (int) ($inputVariables['backbox_qty'] ?? match ($ctValue) { + 'embedded' => 1, + default => 0, + }); + $calculatedVariables = array_merge($inputVariables, [ 'W0' => $W0, 'H0' => $H0, @@ -1724,6 +1740,8 @@ private function calculateTenantBom( 'finishing_type' => $finishingType, 'installation_type' => $installationType, 'motor_voltage' => $motorVoltage, + 'controller_type' => $controllerType, + 'backbox_qty' => $backboxQty, ]); $this->addDebugStep(3, '변수계산', [ diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index b42cc01..9dc55bb 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -237,7 +237,7 @@ private function calculateBomMaterials(Quote $quote): array 'PC' => $input['productCategory'] ?? 'SCREEN', 'GT' => $input['guideRailType'] ?? 'wall', 'MP' => $input['motorPower'] ?? 'single', - 'CT' => $input['controller'] ?? 'basic', + 'CT' => $input['controller'] ?? 'exposed', 'WS' => (float) ($input['wingSize'] ?? 50), 'INSP' => (float) ($input['inspectionFee'] ?? 50000), ]; diff --git a/app/Services/ReceivingService.php b/app/Services/ReceivingService.php index 6ecf6ff..2b2c78c 100644 --- a/app/Services/ReceivingService.php +++ b/app/Services/ReceivingService.php @@ -16,7 +16,7 @@ public function index(array $params): LengthAwarePaginator $tenantId = $this->tenantId(); $query = Receiving::query() - ->with(['creator:id,name', 'item:id,item_type,code,name']) + ->with(['creator:id,name', 'item:id,item_type,code,name', 'certificateFile:id,display_name,file_path']) ->where('tenant_id', $tenantId); // 검색어 필터 @@ -162,7 +162,7 @@ public function show(int $id): Receiving return Receiving::query() ->where('tenant_id', $tenantId) - ->with(['creator:id,name', 'item:id,item_type,code,name']) + ->with(['creator:id,name', 'item:id,item_type,code,name', 'certificateFile:id,display_name,file_path']) ->findOrFail($id); } @@ -203,6 +203,11 @@ public function store(array $data): Receiving $receiving->status = $data['status'] ?? 'receiving_pending'; $receiving->remark = $data['remark'] ?? null; + // 성적서 파일 ID + if (isset($data['certificate_file_id'])) { + $receiving->certificate_file_id = $data['certificate_file_id']; + } + // options 필드 처리 (제조사, 수입검사 등 확장 필드) $receiving->options = $this->buildOptions($data); @@ -299,6 +304,11 @@ public function update(int $id, array $data): Receiving } } + // 성적서 파일 ID + if (array_key_exists('certificate_file_id', $data)) { + $receiving->certificate_file_id = $data['certificate_file_id']; + } + // options 필드 업데이트 (제조사, 수입검사 등 확장 필드) $receiving->options = $this->mergeOptions($receiving->options, $data); diff --git a/app/Services/ShipmentService.php b/app/Services/ShipmentService.php index df8b35a..3db0bec 100644 --- a/app/Services/ShipmentService.php +++ b/app/Services/ShipmentService.php @@ -20,7 +20,7 @@ public function index(array $params): LengthAwarePaginator $query = Shipment::query() ->where('tenant_id', $tenantId) - ->with(['items', 'vehicleDispatches', 'order.client', 'order.writer', 'workOrder']); + ->with(['items', 'vehicleDispatches', 'order.client', 'order.writer', 'workOrder', 'creator']); // 검색어 필터 if (! empty($params['search'])) { diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index ba39c09..3da8978 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -602,13 +602,9 @@ public function updateStatus(int $id, string $status, ?array $resultData = null) // 연결된 수주(Order) 상태 동기화 $this->syncOrderStatus($workOrder, $tenantId); - // 작업완료 시: 수주 연동 → 출하 생성 / 선생산 → 재고 입고 - if ($status === WorkOrder::STATUS_COMPLETED) { - if ($workOrder->sales_order_id) { - $this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId); - } else { - $this->stockInFromProduction($workOrder); - } + // 작업완료 시: 선생산(수주 없음) → 재고 입고 + if ($status === WorkOrder::STATUS_COMPLETED && ! $workOrder->sales_order_id) { + $this->stockInFromProduction($workOrder); } return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']); @@ -659,11 +655,167 @@ private function shouldStockIn(WorkOrderItem $woItem): bool } /** - * 작업지시 완료 시 자동 출하 생성 + * PRODUCED 수주에 출하가 없으면 재생성 * - * 작업지시가 완료(completed) 상태가 되면 출하(Shipment)를 자동 생성하여 출하관리로 넘깁니다. - * 발주처/배송 정보는 출하에 복사하지 않고, 수주(Order)를 참조합니다. - * (Shipment 모델의 accessor 메서드로 수주 정보 참조) + * syncOrderStatus에서 이미 PRODUCED인데 출하가 삭제된 경우 호출됩니다. + */ + private function ensureShipmentExists(Order $order, $mainWorkOrders, int $tenantId): void + { + $hasShipment = Shipment::where('tenant_id', $tenantId) + ->where('order_id', $order->id) + ->exists(); + + if (! $hasShipment) { + $this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $this->apiUserId()); + } + } + + /** + * 수주 기반 출하 수동 생성 (API 엔드포인트용) + * + * 출하관리 UI에서 수주를 선택하여 출하를 수동 생성할 때 사용합니다. + * PRODUCED 이상 상태의 수주만 가능합니다. + */ + public function createShipmentForOrder(int $orderId): Shipment + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $order = Order::where('tenant_id', $tenantId)->findOrFail($orderId); + + // PRODUCED 또는 SHIPPED 상태만 출하 생성 가능 + $allowedStatuses = [Order::STATUS_PRODUCED, Order::STATUS_SHIPPED]; + if (! in_array($order->status_code, $allowedStatuses)) { + throw new BadRequestHttpException(__('error.shipment.order_not_produced')); + } + + // 메인 작업지시 조회 + $allWorkOrders = WorkOrder::where('tenant_id', $tenantId) + ->where('sales_order_id', $orderId) + ->where('status', '!=', WorkOrder::STATUS_CANCELLED) + ->get(); + + $mainWorkOrders = $allWorkOrders->filter(fn ($wo) => ! $this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null); + + if ($mainWorkOrders->isEmpty()) { + throw new BadRequestHttpException(__('error.shipment.no_work_orders')); + } + + $shipment = $this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $userId); + + if (! $shipment) { + throw new BadRequestHttpException(__('error.shipment.already_exists')); + } + + return $shipment->load('items'); + } + + /** + * 수주 단위 자동 출하 생성 (생산완료 시) + * + * 수주의 모든 메인 작업지시가 완료되면, 전체 WO 품목을 합쳐서 출하 1건을 생성합니다. + * - 이미 수주에 연결된 출하가 있으면 스킵 (중복 방지) + * - 부분 출고는 출하관리 UI에서 수동 생성 + * + * @param \Illuminate\Support\Collection $mainWorkOrders 메인 작업지시 컬렉션 + */ + private function createShipmentFromOrder(Order $order, $mainWorkOrders, int $tenantId, int $userId): ?Shipment + { + // 이미 이 수주에 연결된 출하가 있으면 스킵 + $existingShipment = Shipment::where('tenant_id', $tenantId) + ->where('order_id', $order->id) + ->first(); + + if ($existingShipment) { + return $existingShipment; + } + + $shipmentNo = Shipment::generateShipmentNo($tenantId); + + $shipment = Shipment::create([ + 'tenant_id' => $tenantId, + 'shipment_no' => $shipmentNo, + 'work_order_id' => null, // 수주 단위이므로 개별 WO 연결 안함 + 'order_id' => $order->id, + 'scheduled_date' => $order->delivery_date ?? now()->toDateString(), + 'status' => 'scheduled', + 'priority' => 'normal', + 'delivery_method' => $order->delivery_method_code ?? 'pickup', + 'can_ship' => true, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + // 모든 메인 작업지시의 품목을 출하 품목으로 복사 + $seq = 0; + foreach ($mainWorkOrders as $wo) { + $workOrderItems = $wo->items()->get(); + + foreach ($workOrderItems as $woItem) { + $result = $woItem->options['result'] ?? []; + $lotNo = $result['lot_no'] ?? null; + $floorUnit = $this->getFloorUnitFromOrderItem($woItem->source_order_item_id, $tenantId); + + ShipmentItem::create([ + 'tenant_id' => $tenantId, + 'shipment_id' => $shipment->id, + 'seq' => ++$seq, + 'item_code' => $woItem->item_id ? "ITEM-{$woItem->item_id}" : null, + 'item_name' => $woItem->item_name, + 'floor_unit' => $floorUnit, + 'specification' => $woItem->specification, + 'quantity' => $result['good_qty'] ?? $woItem->quantity, + 'unit' => $woItem->unit, + 'lot_no' => $lotNo, + 'remarks' => null, + ]); + } + + // WO에 품목이 없으면 수주 품목에서 fallback (해당 WO의 공정에 매칭되는 품목) + if ($workOrderItems->isEmpty() && $wo->salesOrder) { + $orderItems = $wo->salesOrder->items()->get(); + foreach ($orderItems as $orderItem) { + $floorUnit = $this->getFloorUnitFromOrderItem($orderItem->id, $tenantId); + ShipmentItem::create([ + 'tenant_id' => $tenantId, + 'shipment_id' => $shipment->id, + 'seq' => ++$seq, + 'item_code' => $orderItem->item_id ? "ITEM-{$orderItem->item_id}" : null, + 'item_name' => $orderItem->item_name, + 'floor_unit' => $floorUnit, + 'specification' => $orderItem->specification, + 'quantity' => $orderItem->quantity, + 'unit' => $orderItem->unit, + 'lot_no' => null, + 'remarks' => null, + ]); + } + } + } + + $this->auditLogger->log( + $tenantId, + 'shipment', + $shipment->id, + 'auto_created_from_order', + null, + [ + 'order_id' => $order->id, + 'order_no' => $order->order_no, + 'shipment_no' => $shipmentNo, + 'work_order_count' => $mainWorkOrders->count(), + 'items_count' => $shipment->items()->count(), + ] + ); + + return $shipment; + } + + /** + * [DEPRECATED] 작업지시 단위 자동 출하 생성 + * + * 수주 단위 출하(createShipmentFromOrder)로 대체됨. + * 부분 출고 등 특수 케이스에서 개별 WO 기반 출하가 필요할 경우를 위해 유지. */ private function createShipmentFromWorkOrder(WorkOrder $workOrder, int $tenantId, int $userId): ?Shipment { @@ -836,17 +988,50 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void return; } - // 작업지시 상태 → 수주 상태 매핑 - $statusMap = [ - WorkOrder::STATUS_IN_PROGRESS => Order::STATUS_IN_PRODUCTION, - WorkOrder::STATUS_COMPLETED => Order::STATUS_PRODUCED, - WorkOrder::STATUS_SHIPPED => Order::STATUS_SHIPPED, - ]; + // 해당 수주의 모든 비보조 작업지시 상태 집계 + $allWorkOrders = WorkOrder::where('tenant_id', $tenantId) + ->where('sales_order_id', $workOrder->sales_order_id) + ->where('status', '!=', WorkOrder::STATUS_CANCELLED) + ->get(); - $newOrderStatus = $statusMap[$workOrder->status] ?? null; + // 보조 공정 및 공정 미지정 작업지시 제외 + $mainWorkOrders = $allWorkOrders->filter(fn ($wo) => ! $this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null); + + if ($mainWorkOrders->isEmpty()) { + return; + } + + $totalCount = $mainWorkOrders->count(); + $statusCounts = $mainWorkOrders->groupBy('status')->map->count(); + + $shippedCount = $statusCounts->get(WorkOrder::STATUS_SHIPPED, 0); + $completedCount = $statusCounts->get(WorkOrder::STATUS_COMPLETED, 0); + $inProgressCount = $statusCounts->get(WorkOrder::STATUS_IN_PROGRESS, 0); + + // 집계 기반 수주 상태 결정 + // 전부 출하 → SHIPPED + // 전부 완료(또는 완료+출하) → PRODUCED + // 하나라도 진행중/완료/출하 → IN_PRODUCTION + $newOrderStatus = null; + if ($shippedCount === $totalCount) { + $newOrderStatus = Order::STATUS_SHIPPED; + } elseif (($completedCount + $shippedCount) === $totalCount) { + $newOrderStatus = Order::STATUS_PRODUCED; + } elseif ($inProgressCount > 0 || $completedCount > 0 || $shippedCount > 0) { + $newOrderStatus = Order::STATUS_IN_PRODUCTION; + } + + // 매핑되는 상태가 없으면 스킵 + if (! $newOrderStatus) { + return; + } + + // 이미 동일한 상태면 상태 변경은 스킵하되, PRODUCED인데 출하 없으면 재생성 + if ($order->status_code === $newOrderStatus) { + if ($newOrderStatus === Order::STATUS_PRODUCED) { + $this->ensureShipmentExists($order, $mainWorkOrders, $tenantId); + } - // 매핑되는 상태가 없거나 이미 동일한 상태면 스킵 - if (! $newOrderStatus || $order->status_code === $newOrderStatus) { return; } @@ -861,9 +1046,19 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void 'order', $order->id, 'status_synced_from_work_order', - ['status_code' => $oldOrderStatus, 'work_order_id' => $workOrder->id], - ['status_code' => $newOrderStatus, 'work_order_id' => $workOrder->id] + ['status_code' => $oldOrderStatus, 'work_order_id' => $workOrder->id, 'aggregated' => true], + ['status_code' => $newOrderStatus, 'work_order_counts' => [ + 'total' => $totalCount, + 'shipped' => $shippedCount, + 'completed' => $completedCount, + 'in_progress' => $inProgressCount, + ]] ); + + // 생산완료(PRODUCED) 전환 시 → 수주 단위 출하 자동 생성 + if ($newOrderStatus === Order::STATUS_PRODUCED) { + $this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $this->apiUserId()); + } } /** @@ -952,7 +1147,7 @@ private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $ */ private function isAuxiliaryWorkOrder(WorkOrder $workOrder): bool { - $options = is_array($workOrder->options) ? $workOrder->options : (json_decode($workOrder->options, true) ?? []); + $options = is_array($workOrder->options) ? $workOrder->options : (json_decode($workOrder->options ?? '{}', true) ?? []); return ! empty($options['is_auxiliary']); } @@ -1846,15 +2041,92 @@ public function toggleStepProgress(int $workOrderId, int $progressId): array $after ); + // 모든 공정 단계 완료 시 → 작업지시 자동 완료 + $workOrderStatusChanged = false; + if ($progress->isCompleted()) { + $workOrderStatusChanged = $this->autoCompleteWorkOrderIfAllStepsDone($workOrder, $tenantId, $userId); + } + return [ 'id' => $progress->id, 'status' => $progress->status, 'is_completed' => $progress->isCompleted(), 'completed_at' => $progress->completed_at?->toDateTimeString(), 'completed_by' => $progress->completed_by, + 'work_order_status_changed' => $workOrderStatusChanged, ]; } + /** + * 모든 공정 단계 완료 시 작업지시를 자동으로 완료 처리 + * + * 트리거: 마지막 공정 단계(포장 등) 완료 체크 시 + * 흐름: 전 단계 완료 → 작업지시 completed → 수주 상태 동기화 → 출하 자동 생성 + */ + private function autoCompleteWorkOrderIfAllStepsDone(WorkOrder $workOrder, int $tenantId, int $userId): bool + { + // 이미 완료/출하 상태면 스킵 + if (in_array($workOrder->status, [WorkOrder::STATUS_COMPLETED, WorkOrder::STATUS_SHIPPED])) { + return false; + } + + // 해당 작업지시의 모든 공정 단계 조회 + $allSteps = WorkOrderStepProgress::where('work_order_id', $workOrder->id)->get(); + + if ($allSteps->isEmpty()) { + return false; + } + + // 미완료 step 자동 보정: 같은 개소(work_order_item)의 다른 step이 모두 완료된 경우 + // 자재투입 등 모달 방식 step이 DB에 waiting으로 남아있을 수 있음 + $incompleteSteps = $allSteps->where('status', '!=', WorkOrderStepProgress::STATUS_COMPLETED); + if ($incompleteSteps->isNotEmpty()) { + $this->autoCompleteOrphanedSteps($allSteps, $incompleteSteps, $userId); + + // 보정 후 다시 확인 + $allSteps = WorkOrderStepProgress::where('work_order_id', $workOrder->id)->get(); + $allCompleted = $allSteps->every(fn ($step) => $step->status === WorkOrderStepProgress::STATUS_COMPLETED); + + if (! $allCompleted) { + return false; + } + } + + // 작업지시 완료 처리 (updateStatus 재사용으로 출하 생성/수주 동기화 모두 트리거) + $this->updateStatus($workOrder->id, WorkOrder::STATUS_COMPLETED); + + return true; + } + + /** + * 같은 개소(work_order_item)의 나머지 step이 모두 완료되었으면 + * 남은 미완료 step(자재투입 등)도 자동 완료 처리 + */ + private function autoCompleteOrphanedSteps($allSteps, $incompleteSteps, int $userId): void + { + // 개소(item)별로 그룹핑 + $stepsByItem = $allSteps->groupBy('work_order_item_id'); + + foreach ($incompleteSteps as $incomplete) { + $itemSteps = $stepsByItem->get($incomplete->work_order_item_id); + if (! $itemSteps) { + continue; + } + + // 이 개소에서 이 step만 미완료인지 확인 + $otherIncomplete = $itemSteps->where('id', '!=', $incomplete->id) + ->where('status', '!=', WorkOrderStepProgress::STATUS_COMPLETED); + + if ($otherIncomplete->isEmpty()) { + // 이 step만 남았으면 자동 완료 + $incomplete->status = WorkOrderStepProgress::STATUS_COMPLETED; + $incomplete->completed_at = now(); + $incomplete->completed_by = $userId; + $incomplete->save(); + } + } + } + /** * 자재 투입 이력 조회 */ 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/database/migrations/2026_03_11_100000_add_certificate_file_id_to_receivings_table.php b/database/migrations/2026_03_11_100000_add_certificate_file_id_to_receivings_table.php new file mode 100644 index 0000000..719942b --- /dev/null +++ b/database/migrations/2026_03_11_100000_add_certificate_file_id_to_receivings_table.php @@ -0,0 +1,31 @@ +unsignedBigInteger('certificate_file_id') + ->nullable() + ->after('options') + ->comment('업체 제공 성적서 파일 ID'); + + $table->foreign('certificate_file_id') + ->references('id') + ->on('files') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('receivings', function (Blueprint $table) { + $table->dropForeign(['certificate_file_id']); + $table->dropColumn('certificate_file_id'); + }); + } +}; diff --git a/database/migrations/2026_03_11_174640_create_checklist_templates_table.php b/database/migrations/2026_03_11_174640_create_checklist_templates_table.php new file mode 100644 index 0000000..881f58d --- /dev/null +++ b/database/migrations/2026_03_11_174640_create_checklist_templates_table.php @@ -0,0 +1,110 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트ID'); + $table->string('name', 255)->default('품질인정심사 점검표')->comment('템플릿명'); + $table->string('type', 50)->default('day1_audit')->comment('심사유형: day1_audit, day2_lot 등'); + $table->json('categories')->comment('카테고리/항목 JSON'); + $table->json('options')->nullable()->comment('확장 속성'); + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'type'], 'uq_checklist_templates_tenant_type'); + $table->index(['tenant_id', 'type'], 'idx_checklist_templates_tenant_type'); + $table->foreign('tenant_id')->references('id')->on('tenants'); + $table->foreign('created_by')->references('id')->on('users')->onDelete('set null'); + $table->foreign('updated_by')->references('id')->on('users')->onDelete('set null'); + }); + + // 기존 테넌트에 기본 템플릿 시딩 + $this->seedDefaultTemplates(); + } + + public function down(): void + { + Schema::dropIfExists('checklist_templates'); + } + + private function seedDefaultTemplates(): void + { + $defaultCategories = json_encode([ + [ + 'id' => 'cat-1', + 'title' => '원재료 품질관리 기준', + 'subItems' => [ + ['id' => 'cat-1-1', 'name' => '수입검사 기준 확인'], + ['id' => 'cat-1-2', 'name' => '불합격품 처리 기준 확인'], + ['id' => 'cat-1-3', 'name' => '자재 보관 기준 확인'], + ], + ], + [ + 'id' => 'cat-2', + 'title' => '제조공정 관리 기준', + 'subItems' => [ + ['id' => 'cat-2-1', 'name' => '작업표준서 확인'], + ['id' => 'cat-2-2', 'name' => '공정검사 기준 확인'], + ['id' => 'cat-2-3', 'name' => '부적합품 처리 기준 확인'], + ], + ], + [ + 'id' => 'cat-3', + 'title' => '제품 품질관리 기준', + 'subItems' => [ + ['id' => 'cat-3-1', 'name' => '제품검사 기준 확인'], + ['id' => 'cat-3-2', 'name' => '출하검사 기준 확인'], + ['id' => 'cat-3-3', 'name' => '클레임 처리 기준 확인'], + ], + ], + [ + 'id' => 'cat-4', + 'title' => '제조설비 관리', + 'subItems' => [ + ['id' => 'cat-4-1', 'name' => '설비관리 기준 확인'], + ['id' => 'cat-4-2', 'name' => '설비점검 이력 확인'], + ], + ], + [ + 'id' => 'cat-5', + 'title' => '검사설비 관리', + 'subItems' => [ + ['id' => 'cat-5-1', 'name' => '검사설비 관리 기준 확인'], + ['id' => 'cat-5-2', 'name' => '교정 이력 확인'], + ], + ], + [ + 'id' => 'cat-6', + 'title' => '문서 및 인증 관리', + 'subItems' => [ + ['id' => 'cat-6-1', 'name' => '문서관리 기준 확인'], + ['id' => 'cat-6-2', 'name' => 'KS/인증 관리 현황 확인'], + ], + ], + ], JSON_UNESCAPED_UNICODE); + + $tenantIds = DB::table('tenants')->pluck('id'); + $now = now(); + + foreach ($tenantIds as $tenantId) { + DB::table('checklist_templates')->insert([ + 'tenant_id' => $tenantId, + 'name' => '품질인정심사 점검표', + 'type' => 'day1_audit', + 'categories' => $defaultCategories, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } +}; diff --git a/lang/ko/error.php b/lang/ko/error.php index a3165cb..b9ba29a 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -99,6 +99,9 @@ 'cannot_delete' => '현재 상태에서는 삭제할 수 없습니다.', 'invalid_status' => '유효하지 않은 상태입니다.', 'cannot_ship' => '출하 가능 상태가 아닙니다.', + 'order_not_produced' => '생산완료 상태의 수주만 출하를 생성할 수 있습니다.', + 'no_work_orders' => '해당 수주에 유효한 작업지시가 없습니다.', + 'already_exists' => '이미 해당 수주에 출하가 존재합니다.', ], // 파일 관리 관련 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/routes/api/v1/inventory.php b/routes/api/v1/inventory.php index 73b66fd..f148d75 100644 --- a/routes/api/v1/inventory.php +++ b/routes/api/v1/inventory.php @@ -119,6 +119,7 @@ Route::get('/options/logistics', [ShipmentController::class, 'logisticsOptions'])->name('v1.shipments.options.logistics'); Route::get('/options/vehicle-tonnage', [ShipmentController::class, 'vehicleTonnageOptions'])->name('v1.shipments.options.vehicle-tonnage'); Route::post('', [ShipmentController::class, 'store'])->name('v1.shipments.store'); + Route::post('/from-order/{orderId}', [ShipmentController::class, 'createFromOrder'])->whereNumber('orderId')->name('v1.shipments.from-order'); Route::get('/{id}', [ShipmentController::class, 'show'])->whereNumber('id')->name('v1.shipments.show'); Route::put('/{id}', [ShipmentController::class, 'update'])->whereNumber('id')->name('v1.shipments.update'); Route::patch('/{id}/status', [ShipmentController::class, 'updateStatus'])->whereNumber('id')->name('v1.shipments.status'); diff --git a/routes/api/v1/quality.php b/routes/api/v1/quality.php index 5edb7ca..b266e15 100644 --- a/routes/api/v1/quality.php +++ b/routes/api/v1/quality.php @@ -8,6 +8,7 @@ */ use App\Http\Controllers\Api\V1\AuditChecklistController; +use App\Http\Controllers\Api\V1\ChecklistTemplateController; use App\Http\Controllers\Api\V1\PerformanceReportController; use App\Http\Controllers\Api\V1\QmsLotAuditController; use App\Http\Controllers\Api\V1\QualityDocumentController; @@ -50,6 +51,19 @@ Route::patch('/units/{id}/confirm', [QmsLotAuditController::class, 'confirm'])->whereNumber('id')->name('v1.qms.lot-audit.units.confirm'); }); +// QMS 점검표 템플릿 관리 +Route::prefix('quality/checklist-templates')->group(function () { + Route::get('', [ChecklistTemplateController::class, 'show'])->name('v1.quality.checklist-templates.show'); + Route::put('/{id}', [ChecklistTemplateController::class, 'update'])->whereNumber('id')->name('v1.quality.checklist-templates.update'); +}); + +// QMS 점검표 문서 (파일) 관리 +Route::prefix('quality/qms-documents')->group(function () { + Route::get('', [ChecklistTemplateController::class, 'documents'])->name('v1.quality.qms-documents.index'); + Route::post('', [ChecklistTemplateController::class, 'uploadDocument'])->name('v1.quality.qms-documents.store'); + Route::delete('/{id}', [ChecklistTemplateController::class, 'deleteDocument'])->whereNumber('id')->name('v1.quality.qms-documents.destroy'); +}); + // QMS 기준/매뉴얼 심사 (1일차) Route::prefix('qms')->group(function () { Route::get('/checklists', [AuditChecklistController::class, 'index'])->name('v1.qms.checklists.index'); 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": "매입 관리"