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/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 0e95e5b..be019cf 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-03-07 02:57:21 +> **자동 생성**: 2026-03-12 13:58:25 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -26,6 +26,68 @@ ### bad_debt_memos - **badDebt()**: belongsTo → `bad_debts` - **creator()**: belongsTo → `users` +### barobill_bank_sync_status +**모델**: `App\Models\Barobill\BarobillBankSyncStatus` + +- **tenant()**: belongsTo → `tenants` + +### barobill_bank_transactions +**모델**: `App\Models\Barobill\BarobillBankTransaction` + +- **tenant()**: belongsTo → `tenants` + +### barobill_bank_transaction_splits +**모델**: `App\Models\Barobill\BarobillBankTransactionSplit` + +- **tenant()**: belongsTo → `tenants` + +### barobill_billing_records +**모델**: `App\Models\Barobill\BarobillBillingRecord` + +- **member()**: belongsTo → `barobill_members` + +### barobill_card_transactions +**모델**: `App\Models\Barobill\BarobillCardTransaction` + +- **tenant()**: belongsTo → `tenants` + +### barobill_card_transaction_amount_logs +**모델**: `App\Models\Barobill\BarobillCardTransactionAmountLog` + +- **cardTransaction()**: belongsTo → `barobill_card_transactions` + +### barobill_card_transaction_splits +**모델**: `App\Models\Barobill\BarobillCardTransactionSplit` + +- **tenant()**: belongsTo → `tenants` + +### barobill_members +**모델**: `App\Models\Barobill\BarobillMember` + +- **tenant()**: belongsTo → `tenants` + +### barobill_monthly_summarys +**모델**: `App\Models\Barobill\BarobillMonthlySummary` + +- **member()**: belongsTo → `barobill_members` + +### barobill_subscriptions +**모델**: `App\Models\Barobill\BarobillSubscription` + +- **member()**: belongsTo → `barobill_members` + +### hometax_invoices +**모델**: `App\Models\Barobill\HometaxInvoice` + +- **tenant()**: belongsTo → `tenants` +- **journals()**: hasMany → `hometax_invoice_journals` + +### hometax_invoice_journals +**모델**: `App\Models\Barobill\HometaxInvoiceJournal` + +- **tenant()**: belongsTo → `tenants` +- **invoice()**: belongsTo → `hometax_invoices` + ### biddings **모델**: `App\Models\Bidding\Bidding` @@ -309,6 +371,43 @@ ### esign_signers - **contract()**: belongsTo → `esign_contracts` - **signFields()**: hasMany → `esign_sign_fields` +### equipments +**모델**: `App\Models\Equipment\Equipment` + +- **inspectionTemplates()**: hasMany → `equipment_inspection_templates` +- **inspections()**: hasMany → `equipment_inspections` +- **repairs()**: hasMany → `equipment_repairs` +- **photos()**: hasMany → `files` +- **processes()**: belongsToMany → `processes` + +### equipment_inspections +**모델**: `App\Models\Equipment\EquipmentInspection` + +- **equipment()**: belongsTo → `equipments` +- **details()**: hasMany → `equipment_inspection_details` + +### equipment_inspection_details +**모델**: `App\Models\Equipment\EquipmentInspectionDetail` + +- **inspection()**: belongsTo → `equipment_inspections` +- **templateItem()**: belongsTo → `equipment_inspection_templates` + +### equipment_inspection_templates +**모델**: `App\Models\Equipment\EquipmentInspectionTemplate` + +- **equipment()**: belongsTo → `equipments` + +### equipment_process +**모델**: `App\Models\Equipment\EquipmentProcess` + +- **equipment()**: belongsTo → `equipments` +- **process()**: belongsTo → `processes` + +### equipment_repairs +**모델**: `App\Models\Equipment\EquipmentRepair` + +- **equipment()**: belongsTo → `equipments` + ### estimates **모델**: `App\Models\Estimate\Estimate` @@ -734,6 +833,36 @@ ### push_notification_settings **모델**: `App\Models\PushNotificationSetting` +### audit_checklists +**모델**: `App\Models\Qualitys\AuditChecklist` + +- **categories()**: hasMany → `audit_checklist_categories` + +### audit_checklist_categorys +**모델**: `App\Models\Qualitys\AuditChecklistCategory` + +- **checklist()**: belongsTo → `audit_checklists` +- **items()**: hasMany → `audit_checklist_items` + +### audit_checklist_items +**모델**: `App\Models\Qualitys\AuditChecklistItem` + +- **category()**: belongsTo → `audit_checklist_categories` +- **standardDocuments()**: hasMany → `audit_standard_documents` + +### audit_standard_documents +**모델**: `App\Models\Qualitys\AuditStandardDocument` + +- **checklistItem()**: belongsTo → `audit_checklist_items` +- **document()**: belongsTo → `documents` + +### checklist_templates +**모델**: `App\Models\Qualitys\ChecklistTemplate` + +- **creator()**: belongsTo → `users` +- **updater()**: belongsTo → `users` +- **documents()**: morphMany → `files` + ### inspections **모델**: `App\Models\Qualitys\Inspection` @@ -838,6 +967,11 @@ ### quote_revisions - **quote()**: belongsTo → `quotes` - **reviser()**: belongsTo → `users` +### account_codes +**모델**: `App\Models\Tenants\AccountCode` + +- **children()**: hasMany → `account_codes` + ### ai_reports **모델**: `App\Models\Tenants\AiReport` @@ -857,14 +991,24 @@ ### approvals **모델**: `App\Models\Tenants\Approval` - **form()**: belongsTo → `approval_forms` +- **line()**: belongsTo → `approval_lines` - **drafter()**: belongsTo → `users` +- **department()**: belongsTo → `departments` +- **parentDocument()**: belongsTo → `approvals` - **creator()**: belongsTo → `users` - **updater()**: belongsTo → `users` +- **childDocuments()**: hasMany → `approvals` - **steps()**: hasMany → `approval_steps` - **approverSteps()**: hasMany → `approval_steps` - **referenceSteps()**: hasMany → `approval_steps` - **linkable()**: morphTo → `(Polymorphic)` +### approval_delegations +**모델**: `App\Models\Tenants\ApprovalDelegation` + +- **delegator()**: belongsTo → `users` +- **delegate()**: belongsTo → `users` + ### approval_forms **모델**: `App\Models\Tenants\ApprovalForm` @@ -883,6 +1027,7 @@ ### approval_steps - **approval()**: belongsTo → `approvals` - **approver()**: belongsTo → `users` +- **actedBy()**: belongsTo → `users` ### attendances **모델**: `App\Models\Tenants\Attendance` @@ -1004,6 +1149,11 @@ ### loans - **creator()**: belongsTo → `users` - **updater()**: belongsTo → `users` +### mail_logs +**모델**: `App\Models\Tenants\MailLog` + +- **tenant()**: belongsTo → `tenants` + ### payments **모델**: `App\Models\Tenants\Payment` @@ -1046,6 +1196,7 @@ ### receivings **모델**: `App\Models\Tenants\Receiving` - **item()**: belongsTo → `items` +- **certificateFile()**: belongsTo → `files` - **creator()**: belongsTo → `users` ### salarys @@ -1167,6 +1318,11 @@ ### tenant_field_settings - **fieldDef()**: belongsTo → `setting_field_defs` - **optionGroup()**: belongsTo → `tenant_option_groups` +### tenant_mail_configs +**모델**: `App\Models\Tenants\TenantMailConfig` + +- **tenant()**: belongsTo → `tenants` + ### tenant_option_groups **모델**: `App\Models\Tenants\TenantOptionGroup` 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/Enums/InspectionCycle.php b/app/Enums/InspectionCycle.php new file mode 100644 index 0000000..d1e42fb --- /dev/null +++ b/app/Enums/InspectionCycle.php @@ -0,0 +1,232 @@ + '일일', + self::WEEKLY => '주간', + self::MONTHLY => '월간', + self::BIMONTHLY => '2개월', + self::QUARTERLY => '분기', + self::SEMIANNUAL => '반년', + ]; + } + + public static function label(string $cycle): string + { + return self::all()[$cycle] ?? $cycle; + } + + public static function periodType(string $cycle): string + { + return $cycle === self::DAILY ? 'month' : 'year'; + } + + public static function columnLabels(string $cycle, ?string $period = null): array + { + return match ($cycle) { + self::DAILY => self::dailyLabels($period), + self::WEEKLY => self::weeklyLabels(), + self::MONTHLY => self::monthlyLabels(), + self::BIMONTHLY => self::bimonthlyLabels(), + self::QUARTERLY => self::quarterlyLabels(), + self::SEMIANNUAL => self::semiannualLabels(), + default => self::dailyLabels($period), + }; + } + + public static function resolveCheckDate(string $cycle, string $period, int $colIndex): string + { + return match ($cycle) { + self::DAILY => self::dailyCheckDate($period, $colIndex), + self::WEEKLY => self::weeklyCheckDate($period, $colIndex), + self::MONTHLY => self::monthlyCheckDate($period, $colIndex), + self::BIMONTHLY => self::bimonthlyCheckDate($period, $colIndex), + self::QUARTERLY => self::quarterlyCheckDate($period, $colIndex), + self::SEMIANNUAL => self::semiannualCheckDate($period, $colIndex), + default => self::dailyCheckDate($period, $colIndex), + }; + } + + public static function resolvePeriod(string $cycle, string $checkDate): string + { + $date = Carbon::parse($checkDate); + + return match ($cycle) { + self::DAILY => $date->format('Y-m'), + self::WEEKLY => (string) $date->isoWeekYear, + default => $date->format('Y'), + }; + } + + public static function columnCount(string $cycle, ?string $period = null): int + { + return count(self::columnLabels($cycle, $period)); + } + + public static function isWeekend(string $period, int $colIndex): bool + { + $date = Carbon::createFromFormat('Y-m', $period)->startOfMonth()->addDays($colIndex - 1); + + return in_array($date->dayOfWeek, [0, 6]); + } + + public static function getHolidayDates(string $cycle, string $period, int $tenantId): array + { + if ($cycle === self::DAILY) { + $start = Carbon::createFromFormat('Y-m', $period)->startOfMonth(); + $end = $start->copy()->endOfMonth(); + } else { + $start = Carbon::create((int) $period, 1, 1); + $end = Carbon::create((int) $period, 12, 31); + } + + $holidays = Holiday::where('tenant_id', $tenantId) + ->where('start_date', '<=', $end->toDateString()) + ->where('end_date', '>=', $start->toDateString()) + ->get(); + + $dates = []; + foreach ($holidays as $holiday) { + $hStart = $holiday->start_date->copy()->max($start); + $hEnd = $holiday->end_date->copy()->min($end); + $current = $hStart->copy(); + while ($current->lte($hEnd)) { + $dates[$current->format('Y-m-d')] = true; + $current->addDay(); + } + } + + return $dates; + } + + public static function isNonWorkingDay(string $checkDate, array $holidayDates = []): bool + { + $date = Carbon::parse($checkDate); + + return $date->isWeekend() || isset($holidayDates[$checkDate]); + } + + // --- Daily --- + private static function dailyLabels(?string $period): array + { + $date = Carbon::createFromFormat('Y-m', $period ?? now()->format('Y-m')); + $days = $date->daysInMonth; + $labels = []; + for ($d = 1; $d <= $days; $d++) { + $labels[$d] = (string) $d; + } + + return $labels; + } + + private static function dailyCheckDate(string $period, int $colIndex): string + { + return Carbon::createFromFormat('Y-m', $period)->startOfMonth()->addDays($colIndex - 1)->format('Y-m-d'); + } + + // --- Weekly --- + private static function weeklyLabels(): array + { + $labels = []; + for ($w = 1; $w <= 52; $w++) { + $labels[$w] = $w.'주'; + } + + return $labels; + } + + private static function weeklyCheckDate(string $year, int $colIndex): string + { + return Carbon::create((int) $year)->setISODate((int) $year, $colIndex, 1)->format('Y-m-d'); + } + + // --- Monthly --- + private static function monthlyLabels(): array + { + $labels = []; + for ($m = 1; $m <= 12; $m++) { + $labels[$m] = $m.'월'; + } + + return $labels; + } + + private static function monthlyCheckDate(string $year, int $colIndex): string + { + return Carbon::create((int) $year, $colIndex, 1)->format('Y-m-d'); + } + + // --- Bimonthly --- + private static function bimonthlyLabels(): array + { + return [ + 1 => '1~2월', + 2 => '3~4월', + 3 => '5~6월', + 4 => '7~8월', + 5 => '9~10월', + 6 => '11~12월', + ]; + } + + private static function bimonthlyCheckDate(string $year, int $colIndex): string + { + $month = ($colIndex - 1) * 2 + 1; + + return Carbon::create((int) $year, $month, 1)->format('Y-m-d'); + } + + // --- Quarterly --- + private static function quarterlyLabels(): array + { + return [ + 1 => '1분기', + 2 => '2분기', + 3 => '3분기', + 4 => '4분기', + ]; + } + + private static function quarterlyCheckDate(string $year, int $colIndex): string + { + $month = ($colIndex - 1) * 3 + 1; + + return Carbon::create((int) $year, $month, 1)->format('Y-m-d'); + } + + // --- Semiannual --- + private static function semiannualLabels(): array + { + return [ + 1 => '상반기', + 2 => '하반기', + ]; + } + + private static function semiannualCheckDate(string $year, int $colIndex): string + { + $month = $colIndex === 1 ? 1 : 7; + + return Carbon::create((int) $year, $month, 1)->format('Y-m-d'); + } +} diff --git a/app/Http/Controllers/Api/V1/BarobillBankTransactionController.php b/app/Http/Controllers/Api/V1/BarobillBankTransactionController.php new file mode 100644 index 0000000..2a8d14e --- /dev/null +++ b/app/Http/Controllers/Api/V1/BarobillBankTransactionController.php @@ -0,0 +1,287 @@ +validate([ + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'bank_account_num' => 'nullable|string|max:50', + 'search' => 'nullable|string|max:100', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]); + + return $this->service->index($params); + }, __('message.fetched')); + } + + /** + * 계좌 목록 (필터용) + */ + public function accounts(): JsonResponse + { + return ApiResponse::handle(function () { + return $this->service->accounts(); + }, __('message.fetched')); + } + + /** + * 잔액 요약 + */ + public function balanceSummary(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $params = $request->validate([ + 'date' => 'nullable|date', + ]); + + return $this->service->balanceSummary($params); + }, __('message.fetched')); + } + + // ========================================================================= + // 분할 (Splits) + // ========================================================================= + + /** + * 거래 분할 조회 + */ + public function getSplits(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'unique_key' => 'required|string|max:500', + ]); + + return $this->service->getSplits($validated['unique_key']); + }, __('message.fetched')); + } + + /** + * 거래 분할 저장 + */ + public function saveSplits(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'unique_key' => 'required|string|max:500', + 'items' => 'required|array|min:1', + 'items.*.split_amount' => 'required|numeric', + 'items.*.account_code' => 'nullable|string|max:20', + 'items.*.account_name' => 'nullable|string|max:100', + 'items.*.deduction_type' => 'nullable|string|max:20', + 'items.*.evidence_name' => 'nullable|string|max:100', + 'items.*.description' => 'nullable|string|max:500', + 'items.*.memo' => 'nullable|string|max:500', + 'items.*.bank_account_num' => 'nullable|string|max:50', + 'items.*.trans_dt' => 'nullable|string|max:20', + 'items.*.trans_date' => 'nullable|date', + 'items.*.original_deposit' => 'nullable|numeric', + 'items.*.original_withdraw' => 'nullable|numeric', + 'items.*.summary' => 'nullable|string|max:500', + ]); + + return $this->service->saveSplits($validated['unique_key'], $validated['items']); + }, __('message.created')); + } + + /** + * 거래 분할 삭제 + */ + public function deleteSplits(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'unique_key' => 'required|string|max:500', + ]); + + return $this->service->deleteSplits($validated['unique_key']); + }, __('message.deleted')); + } + + // ========================================================================= + // 오버라이드 (Override) + // ========================================================================= + + /** + * 적요/분류 오버라이드 저장 + */ + public function saveOverride(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'unique_key' => 'required|string|max:500', + 'modified_summary' => 'nullable|string|max:500', + 'modified_cast' => 'nullable|string|max:100', + ]); + + return $this->service->saveOverride( + $validated['unique_key'], + $validated['modified_summary'] ?? null, + $validated['modified_cast'] ?? null + ); + }, __('message.updated')); + } + + // ========================================================================= + // 수동 입력 (Manual) + // ========================================================================= + + /** + * 수동 은행 거래 등록 + */ + public function storeManual(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'bank_account_num' => 'required|string|max:50', + 'bank_code' => 'nullable|string|max:10', + 'bank_name' => 'nullable|string|max:50', + 'trans_date' => 'required|date', + 'trans_time' => 'nullable|string|max:10', + 'trans_dt' => 'nullable|string|max:20', + 'deposit' => 'nullable|numeric|min:0', + 'withdraw' => 'nullable|numeric|min:0', + 'balance' => 'nullable|numeric', + 'summary' => 'nullable|string|max:500', + 'cast' => 'nullable|string|max:100', + 'memo' => 'nullable|string|max:500', + 'trans_office' => 'nullable|string|max:100', + 'account_code' => 'nullable|string|max:20', + 'account_name' => 'nullable|string|max:100', + 'client_code' => 'nullable|string|max:20', + 'client_name' => 'nullable|string|max:200', + ]); + + return $this->service->storeManual($validated); + }, __('message.created')); + } + + /** + * 수동 은행 거래 수정 + */ + public function updateManual(Request $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + $validated = $request->validate([ + 'deposit' => 'nullable|numeric|min:0', + 'withdraw' => 'nullable|numeric|min:0', + 'balance' => 'nullable|numeric', + 'summary' => 'nullable|string|max:500', + 'cast' => 'nullable|string|max:100', + 'memo' => 'nullable|string|max:500', + 'account_code' => 'nullable|string|max:20', + 'account_name' => 'nullable|string|max:100', + 'client_code' => 'nullable|string|max:20', + 'client_name' => 'nullable|string|max:200', + ]); + + return $this->service->updateManual($id, $validated); + }, __('message.updated')); + } + + /** + * 수동 은행 거래 삭제 + */ + public function destroyManual(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->destroyManual($id); + }, __('message.deleted')); + } + + // ========================================================================= + // 분개 (Journal Entries) + // ========================================================================= + + /** + * 은행 거래 분개 조회 + */ + public function getJournalEntries(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $sourceKey = "barobill_bank_{$id}"; + + return $this->journalSyncService->getForSource( + JournalEntry::SOURCE_BAROBILL_BANK, + $sourceKey + ) ?? ['items' => []]; + }, __('message.fetched')); + } + + /** + * 은행 거래 분개 저장 + */ + public function storeJournalEntries(Request $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + $validated = $request->validate([ + 'items' => 'required|array|min:1', + 'items.*.side' => 'required|in:debit,credit', + 'items.*.account_code' => 'required|string|max:20', + 'items.*.account_name' => 'nullable|string|max:100', + 'items.*.debit_amount' => 'required|integer|min:0', + 'items.*.credit_amount' => 'required|integer|min:0', + 'items.*.vendor_name' => 'nullable|string|max:200', + 'items.*.memo' => 'nullable|string|max:500', + ]); + + $bankTx = \App\Models\Barobill\BarobillBankTransaction::find($id); + if (! $bankTx) { + throw new \Illuminate\Database\Eloquent\ModelNotFoundException; + } + + $entryDate = $bankTx->trans_date ?? now()->format('Y-m-d'); + $sourceKey = "barobill_bank_{$id}"; + + return $this->journalSyncService->saveForSource( + JournalEntry::SOURCE_BAROBILL_BANK, + $sourceKey, + $entryDate, + "바로빌 은행거래 분개 (#{$id})", + $validated['items'], + ); + }, __('message.created')); + } + + /** + * 은행 거래 분개 삭제 + */ + public function deleteJournalEntries(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $sourceKey = "barobill_bank_{$id}"; + + return $this->journalSyncService->deleteForSource( + JournalEntry::SOURCE_BAROBILL_BANK, + $sourceKey + ); + }, __('message.deleted')); + } +} diff --git a/app/Http/Controllers/Api/V1/BarobillCardTransactionController.php b/app/Http/Controllers/Api/V1/BarobillCardTransactionController.php new file mode 100644 index 0000000..e99648c --- /dev/null +++ b/app/Http/Controllers/Api/V1/BarobillCardTransactionController.php @@ -0,0 +1,326 @@ +validate([ + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'card_num' => 'nullable|string|max:50', + 'search' => 'nullable|string|max:100', + 'include_hidden' => 'nullable|boolean', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]); + + return $this->service->index($params); + }, __('message.fetched')); + } + + /** + * 단일 카드 거래 상세 + */ + public function show(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $tx = $this->service->show($id); + if (! $tx) { + throw new \Illuminate\Database\Eloquent\ModelNotFoundException; + } + + return $tx; + }, __('message.fetched')); + } + + /** + * 카드 번호 목록 (필터용) + */ + public function cardNumbers(): JsonResponse + { + return ApiResponse::handle(function () { + return $this->service->cardNumbers(); + }, __('message.fetched')); + } + + // ========================================================================= + // 분할 (Splits) + // ========================================================================= + + /** + * 카드 거래 분할 조회 + */ + public function getSplits(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'unique_key' => 'required|string|max:500', + ]); + + return $this->service->getSplits($validated['unique_key']); + }, __('message.fetched')); + } + + /** + * 카드 거래 분할 저장 + */ + public function saveSplits(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'unique_key' => 'required|string|max:500', + 'items' => 'required|array|min:1', + 'items.*.split_amount' => 'required|numeric', + 'items.*.split_supply_amount' => 'nullable|numeric', + 'items.*.split_tax' => 'nullable|numeric', + 'items.*.account_code' => 'nullable|string|max:20', + 'items.*.account_name' => 'nullable|string|max:100', + 'items.*.deduction_type' => 'nullable|string|max:20', + 'items.*.evidence_name' => 'nullable|string|max:100', + 'items.*.description' => 'nullable|string|max:500', + 'items.*.memo' => 'nullable|string|max:500', + 'items.*.card_num' => 'nullable|string|max:50', + 'items.*.use_dt' => 'nullable|string|max:20', + 'items.*.use_date' => 'nullable|date', + 'items.*.approval_num' => 'nullable|string|max:50', + 'items.*.original_amount' => 'nullable|numeric', + 'items.*.merchant_name' => 'nullable|string|max:200', + ]); + + return $this->service->saveSplits($validated['unique_key'], $validated['items']); + }, __('message.created')); + } + + /** + * 카드 거래 분할 삭제 + */ + public function deleteSplits(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'unique_key' => 'required|string|max:500', + ]); + + return $this->service->deleteSplits($validated['unique_key']); + }, __('message.deleted')); + } + + // ========================================================================= + // 수동 입력 (Manual) + // ========================================================================= + + /** + * 수동 카드 거래 등록 + */ + public function storeManual(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'card_num' => 'required|string|max:50', + 'card_company' => 'nullable|string|max:10', + 'card_company_name' => 'nullable|string|max:50', + 'use_dt' => 'required|string|max:20', + 'use_date' => 'required|date', + 'use_time' => 'nullable|string|max:10', + 'approval_num' => 'nullable|string|max:50', + 'approval_type' => 'nullable|string|max:10', + 'approval_amount' => 'required|numeric', + 'tax' => 'nullable|numeric', + 'service_charge' => 'nullable|numeric', + 'merchant_name' => 'required|string|max:200', + 'merchant_biz_num' => 'nullable|string|max:20', + 'account_code' => 'nullable|string|max:20', + 'account_name' => 'nullable|string|max:100', + 'deduction_type' => 'nullable|string|max:20', + 'evidence_name' => 'nullable|string|max:100', + 'description' => 'nullable|string|max:500', + 'memo' => 'nullable|string|max:500', + ]); + + return $this->service->storeManual($validated); + }, __('message.created')); + } + + /** + * 수동 카드 거래 수정 + */ + public function updateManual(Request $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + $validated = $request->validate([ + 'approval_amount' => 'nullable|numeric', + 'tax' => 'nullable|numeric', + 'merchant_name' => 'nullable|string|max:200', + 'account_code' => 'nullable|string|max:20', + 'account_name' => 'nullable|string|max:100', + 'deduction_type' => 'nullable|string|max:20', + 'description' => 'nullable|string|max:500', + 'memo' => 'nullable|string|max:500', + ]); + + return $this->service->updateManual($id, $validated); + }, __('message.updated')); + } + + /** + * 수동 카드 거래 삭제 + */ + public function destroyManual(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->destroyManual($id); + }, __('message.deleted')); + } + + // ========================================================================= + // 숨김/복원 (Hide/Restore) + // ========================================================================= + + /** + * 카드 거래 숨김 + */ + public function hide(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->hide($id); + }, __('message.updated')); + } + + /** + * 카드 거래 숨김 복원 + */ + public function restore(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->restore($id); + }, __('message.updated')); + } + + /** + * 숨겨진 거래 목록 + */ + public function hiddenList(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $params = $request->validate([ + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + ]); + + return $this->service->hiddenList($params); + }, __('message.fetched')); + } + + // ========================================================================= + // 금액 수정 + // ========================================================================= + + /** + * 공급가액/세액 수정 + */ + public function updateAmount(Request $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + $validated = $request->validate([ + 'supply_amount' => 'required|numeric', + 'tax' => 'required|numeric', + 'modified_by_name' => 'nullable|string|max:50', + ]); + + return $this->service->updateAmount($id, $validated); + }, __('message.updated')); + } + + // ========================================================================= + // 분개 (Journal Entries) + // ========================================================================= + + /** + * 카드 거래 분개 조회 + */ + public function getJournalEntries(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $sourceKey = "barobill_card_{$id}"; + + return $this->journalSyncService->getForSource( + JournalEntry::SOURCE_BAROBILL_CARD, + $sourceKey + ) ?? ['items' => []]; + }, __('message.fetched')); + } + + /** + * 카드 거래 분개 저장 + */ + public function storeJournalEntries(Request $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + $validated = $request->validate([ + 'items' => 'required|array|min:1', + 'items.*.side' => 'required|in:debit,credit', + 'items.*.account_code' => 'required|string|max:20', + 'items.*.account_name' => 'nullable|string|max:100', + 'items.*.debit_amount' => 'required|integer|min:0', + 'items.*.credit_amount' => 'required|integer|min:0', + 'items.*.vendor_name' => 'nullable|string|max:200', + 'items.*.memo' => 'nullable|string|max:500', + ]); + + $tx = $this->service->show($id); + if (! $tx) { + throw new \Illuminate\Database\Eloquent\ModelNotFoundException; + } + + $entryDate = $tx->use_date ?? now()->format('Y-m-d'); + $sourceKey = "barobill_card_{$id}"; + + return $this->journalSyncService->saveForSource( + JournalEntry::SOURCE_BAROBILL_CARD, + $sourceKey, + $entryDate, + "바로빌 카드거래 분개 (#{$id})", + $validated['items'], + ); + }, __('message.created')); + } + + /** + * 카드 거래 분개 삭제 + */ + public function deleteJournalEntries(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $sourceKey = "barobill_card_{$id}"; + + return $this->journalSyncService->deleteForSource( + JournalEntry::SOURCE_BAROBILL_CARD, + $sourceKey + ); + }, __('message.deleted')); + } +} diff --git a/app/Http/Controllers/Api/V1/BarobillController.php b/app/Http/Controllers/Api/V1/BarobillController.php index 9e90b91..a93307c 100644 --- a/app/Http/Controllers/Api/V1/BarobillController.php +++ b/app/Http/Controllers/Api/V1/BarobillController.php @@ -28,7 +28,7 @@ public function status() 'barobill_id' => $setting->barobill_id, 'biz_no' => $setting->corp_num, 'status' => $setting->isVerified() ? 'active' : 'inactive', - 'server_mode' => config('services.barobill.test_mode', true) ? 'test' : 'production', + 'server_mode' => $this->barobillService->isTestMode() ? 'test' : 'production', ] : null, ]; }, __('message.fetched')); @@ -86,17 +86,21 @@ public function signup(Request $request) }, __('message.saved')); } + /** + * 바로빌 서비스 URL 조회 (공통) + */ + private function getServiceUrl(string $path): array + { + return ['url' => $this->barobillService->getBaseUrl().$path]; + } + /** * 은행 빠른조회 서비스 URL 조회 */ - public function bankServiceUrl(Request $request) + public function bankServiceUrl() { return ApiResponse::handle(function () { - $baseUrl = config('services.barobill.test_mode', true) - ? 'https://testws.barobill.co.kr' - : 'https://ws.barobill.co.kr'; - - return ['url' => $baseUrl.'/Bank/BankAccountService']; + return $this->getServiceUrl('/BANKACCOUNT.asmx'); }, __('message.fetched')); } @@ -106,11 +110,7 @@ public function bankServiceUrl(Request $request) public function accountLinkUrl() { return ApiResponse::handle(function () { - $baseUrl = config('services.barobill.test_mode', true) - ? 'https://testws.barobill.co.kr' - : 'https://ws.barobill.co.kr'; - - return ['url' => $baseUrl.'/Bank/AccountLink']; + return $this->getServiceUrl('/BANKACCOUNT.asmx'); }, __('message.fetched')); } @@ -120,11 +120,7 @@ public function accountLinkUrl() public function cardLinkUrl() { return ApiResponse::handle(function () { - $baseUrl = config('services.barobill.test_mode', true) - ? 'https://testws.barobill.co.kr' - : 'https://ws.barobill.co.kr'; - - return ['url' => $baseUrl.'/Card/CardLink']; + return $this->getServiceUrl('/CARD.asmx'); }, __('message.fetched')); } @@ -134,11 +130,7 @@ public function cardLinkUrl() public function certificateUrl() { return ApiResponse::handle(function () { - $baseUrl = config('services.barobill.test_mode', true) - ? 'https://testws.barobill.co.kr' - : 'https://ws.barobill.co.kr'; - - return ['url' => $baseUrl.'/Certificate/Register']; + return $this->getServiceUrl('/CORPSTATE.asmx'); }, __('message.fetched')); } } diff --git a/app/Http/Controllers/Api/V1/ChecklistTemplateController.php b/app/Http/Controllers/Api/V1/ChecklistTemplateController.php new file mode 100644 index 0000000..da1ce14 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ChecklistTemplateController.php @@ -0,0 +1,92 @@ +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 toggleItem(int $id, string $subItemId) + { + return ApiResponse::handle(function () use ($id, $subItemId) { + return $this->service->toggleItem($id, $subItemId); + }, __('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/HometaxInvoiceController.php b/app/Http/Controllers/Api/V1/HometaxInvoiceController.php new file mode 100644 index 0000000..772d6b9 --- /dev/null +++ b/app/Http/Controllers/Api/V1/HometaxInvoiceController.php @@ -0,0 +1,278 @@ +validate([ + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'search' => 'nullable|string|max:100', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]); + + return $this->service->sales($params); + }, __('message.fetched')); + } + + /** + * 매입 세금계산서 목록 + */ + public function purchases(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $params = $request->validate([ + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'search' => 'nullable|string|max:100', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]); + + return $this->service->purchases($params); + }, __('message.fetched')); + } + + /** + * 세금계산서 상세 조회 + */ + public function show(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $invoice = $this->service->show($id); + if (! $invoice) { + throw new \Illuminate\Database\Eloquent\ModelNotFoundException; + } + + return $invoice; + }, __('message.fetched')); + } + + /** + * 요약 통계 (매출/매입 합계) + */ + public function summary(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $params = $request->validate([ + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + ]); + + return $this->service->summary($params); + }, __('message.fetched')); + } + + // ========================================================================= + // 수동 입력 (Manual) + // ========================================================================= + + /** + * 수동 세금계산서 등록 + */ + public function store(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'invoice_type' => 'required|in:sales,purchase', + 'nts_confirm_num' => 'nullable|string|max:50', + 'write_date' => 'required|date', + 'issue_date' => 'nullable|date', + 'invoicer_corp_num' => 'nullable|string|max:20', + 'invoicer_corp_name' => 'nullable|string|max:200', + 'invoicer_ceo_name' => 'nullable|string|max:100', + 'invoicee_corp_num' => 'nullable|string|max:20', + 'invoicee_corp_name' => 'nullable|string|max:200', + 'invoicee_ceo_name' => 'nullable|string|max:100', + 'supply_amount' => 'required|integer', + 'tax_amount' => 'required|integer', + 'total_amount' => 'required|integer', + 'tax_type' => 'nullable|string|max:10', + 'purpose_type' => 'nullable|string|max:10', + 'issue_type' => 'nullable|string|max:10', + 'item_name' => 'nullable|string|max:200', + 'account_code' => 'nullable|string|max:20', + 'account_name' => 'nullable|string|max:100', + 'deduction_type' => 'nullable|string|max:20', + 'remark1' => 'nullable|string|max:500', + ]); + + return $this->service->storeManual($validated); + }, __('message.created')); + } + + /** + * 수동 세금계산서 수정 + */ + public function update(Request $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + $validated = $request->validate([ + 'write_date' => 'nullable|date', + 'issue_date' => 'nullable|date', + 'invoicer_corp_num' => 'nullable|string|max:20', + 'invoicer_corp_name' => 'nullable|string|max:200', + 'invoicee_corp_num' => 'nullable|string|max:20', + 'invoicee_corp_name' => 'nullable|string|max:200', + 'supply_amount' => 'nullable|integer', + 'tax_amount' => 'nullable|integer', + 'total_amount' => 'nullable|integer', + 'item_name' => 'nullable|string|max:200', + 'account_code' => 'nullable|string|max:20', + 'account_name' => 'nullable|string|max:100', + 'deduction_type' => 'nullable|string|max:20', + 'remark1' => 'nullable|string|max:500', + ]); + + return $this->service->updateManual($id, $validated); + }, __('message.updated')); + } + + /** + * 수동 세금계산서 삭제 + */ + public function destroy(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->destroyManual($id); + }, __('message.deleted')); + } + + // ========================================================================= + // 분개 (자체 테이블: hometax_invoice_journals) + // ========================================================================= + + /** + * 분개 조회 + */ + public function getJournals(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->getJournals($id); + }, __('message.fetched')); + } + + /** + * 분개 저장 + */ + public function saveJournals(Request $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + $validated = $request->validate([ + 'items' => 'required|array|min:1', + 'items.*.dc_type' => 'required|in:debit,credit', + 'items.*.account_code' => 'required|string|max:20', + 'items.*.account_name' => 'nullable|string|max:100', + 'items.*.debit_amount' => 'required|integer|min:0', + 'items.*.credit_amount' => 'required|integer|min:0', + 'items.*.description' => 'nullable|string|max:500', + ]); + + return $this->service->saveJournals($id, $validated['items']); + }, __('message.created')); + } + + /** + * 분개 삭제 + */ + public function deleteJournals(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->deleteJournals($id); + }, __('message.deleted')); + } + + // ========================================================================= + // 통합 분개 (JournalSyncService - CEO 대시보드 연동) + // ========================================================================= + + /** + * 통합 분개 조회 + */ + public function getJournalEntries(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $sourceKey = "hometax_{$id}"; + + return $this->journalSyncService->getForSource( + JournalEntry::SOURCE_HOMETAX_INVOICE, + $sourceKey + ) ?? ['items' => []]; + }, __('message.fetched')); + } + + /** + * 통합 분개 저장 + */ + public function storeJournalEntries(Request $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + $validated = $request->validate([ + 'items' => 'required|array|min:1', + 'items.*.side' => 'required|in:debit,credit', + 'items.*.account_code' => 'required|string|max:20', + 'items.*.account_name' => 'nullable|string|max:100', + 'items.*.debit_amount' => 'required|integer|min:0', + 'items.*.credit_amount' => 'required|integer|min:0', + 'items.*.vendor_name' => 'nullable|string|max:200', + 'items.*.memo' => 'nullable|string|max:500', + ]); + + $invoice = $this->service->show($id); + if (! $invoice) { + throw new \Illuminate\Database\Eloquent\ModelNotFoundException; + } + + $entryDate = $invoice->write_date?->format('Y-m-d') ?? now()->format('Y-m-d'); + $sourceKey = "hometax_{$id}"; + + return $this->journalSyncService->saveForSource( + JournalEntry::SOURCE_HOMETAX_INVOICE, + $sourceKey, + $entryDate, + "홈택스 세금계산서 분개 (#{$id})", + $validated['items'], + ); + }, __('message.created')); + } + + /** + * 통합 분개 삭제 + */ + public function deleteJournalEntries(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $sourceKey = "hometax_{$id}"; + + return $this->journalSyncService->deleteForSource( + JournalEntry::SOURCE_HOMETAX_INVOICE, + $sourceKey + ); + }, __('message.deleted')); + } +} 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/PayrollController.php b/app/Http/Controllers/Api/V1/PayrollController.php index 4c8af6f..8a0b4dc 100644 --- a/app/Http/Controllers/Api/V1/PayrollController.php +++ b/app/Http/Controllers/Api/V1/PayrollController.php @@ -4,18 +4,24 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\V1\Payroll\BulkGeneratePayrollRequest; use App\Http\Requests\V1\Payroll\CalculatePayrollRequest; +use App\Http\Requests\V1\Payroll\CopyFromPreviousPayrollRequest; use App\Http\Requests\V1\Payroll\PayPayrollRequest; +use App\Http\Requests\V1\Payroll\StorePayrollJournalRequest; use App\Http\Requests\V1\Payroll\StorePayrollRequest; use App\Http\Requests\V1\Payroll\UpdatePayrollRequest; use App\Http\Requests\V1\Payroll\UpdatePayrollSettingRequest; +use App\Services\ExportService; use App\Services\PayrollService; use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\BinaryFileResponse; class PayrollController extends Controller { public function __construct( - private readonly PayrollService $service + private readonly PayrollService $service, + private readonly ExportService $exportService ) {} /** @@ -28,6 +34,7 @@ public function index(Request $request) 'month', 'user_id', 'status', + 'department_id', 'search', 'sort_by', 'sort_dir', @@ -103,6 +110,16 @@ public function confirm(int $id) return ApiResponse::success($payroll, __('message.payroll.confirmed')); } + /** + * 급여 확정 취소 + */ + public function unconfirm(int $id) + { + $payroll = $this->service->unconfirm($id); + + return ApiResponse::success($payroll, __('message.payroll.unconfirmed')); + } + /** * 급여 지급 처리 */ @@ -113,6 +130,16 @@ public function pay(int $id, PayPayrollRequest $request) return ApiResponse::success($payroll, __('message.payroll.paid')); } + /** + * 급여 지급 취소 (슈퍼관리자) + */ + public function unpay(int $id) + { + $payroll = $this->service->unpay($id); + + return ApiResponse::success($payroll, __('message.payroll.unpaid')); + } + /** * 일괄 확정 */ @@ -127,13 +154,29 @@ public function bulkConfirm(Request $request) } /** - * 급여명세서 조회 + * 재직사원 일괄 생성 */ - public function payslip(int $id) + public function bulkGenerate(BulkGeneratePayrollRequest $request) { - $payslip = $this->service->payslip($id); + $year = (int) $request->input('year'); + $month = (int) $request->input('month'); - return ApiResponse::success($payslip, __('message.fetched')); + $result = $this->service->bulkGenerate($year, $month); + + return ApiResponse::success($result, __('message.payroll.bulk_generated')); + } + + /** + * 전월 급여 복사 + */ + public function copyFromPrevious(CopyFromPreviousPayrollRequest $request) + { + $year = (int) $request->input('year'); + $month = (int) $request->input('month'); + + $result = $this->service->copyFromPreviousMonth($year, $month); + + return ApiResponse::success($result, __('message.payroll.copied')); } /** @@ -150,6 +193,76 @@ public function calculate(CalculatePayrollRequest $request) return ApiResponse::success($payrolls, __('message.payroll.calculated')); } + /** + * 급여 계산 미리보기 + */ + public function calculatePreview(Request $request) + { + $data = $request->only([ + 'user_id', + 'base_salary', + 'overtime_pay', + 'bonus', + 'allowances', + 'deductions', + ]); + + $result = $this->service->calculatePreview($data); + + return ApiResponse::success($result, __('message.calculated')); + } + + /** + * 급여명세서 조회 + */ + public function payslip(int $id) + { + $payslip = $this->service->payslip($id); + + return ApiResponse::success($payslip, __('message.fetched')); + } + + /** + * 급여 엑셀 내보내기 + */ + public function export(Request $request): BinaryFileResponse + { + $params = $request->only([ + 'year', + 'month', + 'status', + 'user_id', + 'department_id', + 'search', + 'sort_by', + 'sort_dir', + ]); + + $exportData = $this->service->getExportData($params); + $filename = '급여현황_'.date('Ymd_His'); + + return $this->exportService->download( + $exportData['data'], + $exportData['headings'], + $filename, + '급여현황' + ); + } + + /** + * 급여 전표 생성 + */ + public function journalEntries(StorePayrollJournalRequest $request) + { + $year = (int) $request->input('year'); + $month = (int) $request->input('month'); + $entryDate = $request->input('entry_date'); + + $entry = $this->service->createJournalEntries($year, $month, $entryDate); + + return ApiResponse::success($entry, __('message.payroll.journal_created')); + } + /** * 급여 설정 조회 */ 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/Controllers/V1/Equipment/EquipmentController.php b/app/Http/Controllers/V1/Equipment/EquipmentController.php new file mode 100644 index 0000000..41c64b7 --- /dev/null +++ b/app/Http/Controllers/V1/Equipment/EquipmentController.php @@ -0,0 +1,91 @@ + $this->service->index($request->only([ + 'search', 'status', 'production_line', 'equipment_type', + 'sort_by', 'sort_direction', 'per_page', + ])), + __('message.fetched') + ); + } + + public function show(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->show($id), + __('message.fetched') + ); + } + + public function store(StoreEquipmentRequest $request): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->store($request->validated()), + __('message.equipment.created') + ); + } + + public function update(UpdateEquipmentRequest $request, int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->update($id, $request->validated()), + __('message.equipment.updated') + ); + } + + public function destroy(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->destroy($id), + __('message.equipment.deleted') + ); + } + + public function restore(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->restore($id), + __('message.equipment.restored') + ); + } + + public function toggleActive(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->toggleActive($id), + __('message.toggled') + ); + } + + public function stats(): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->stats(), + __('message.fetched') + ); + } + + public function options(): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->options(), + __('message.fetched') + ); + } +} diff --git a/app/Http/Controllers/V1/Equipment/EquipmentInspectionController.php b/app/Http/Controllers/V1/Equipment/EquipmentInspectionController.php new file mode 100644 index 0000000..7433efe --- /dev/null +++ b/app/Http/Controllers/V1/Equipment/EquipmentInspectionController.php @@ -0,0 +1,130 @@ + $this->service->getInspections( + $request->input('cycle', 'daily'), + $request->input('period', now()->format('Y-m')), + $request->input('production_line'), + $request->input('equipment_id') ? (int) $request->input('equipment_id') : null + ), + __('message.fetched') + ); + } + + public function toggleDetail(ToggleInspectionDetailRequest $request): JsonResponse + { + $data = $request->validated(); + + return ApiResponse::handle( + fn () => $this->service->toggleDetail( + $data['equipment_id'], + $data['template_item_id'], + $data['check_date'], + $data['cycle'] ?? 'daily' + ), + __('message.equipment.inspection_saved') + ); + } + + public function setResult(Request $request): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->setResult( + (int) $request->input('equipment_id'), + (int) $request->input('template_item_id'), + $request->input('check_date'), + $request->input('cycle', 'daily'), + $request->input('result') + ), + __('message.equipment.inspection_saved') + ); + } + + public function updateNotes(UpdateInspectionNotesRequest $request): JsonResponse + { + $data = $request->validated(); + + return ApiResponse::handle( + fn () => $this->service->updateNotes( + $data['equipment_id'], + $data['year_month'], + collect($data)->only(['overall_judgment', 'inspector_id', 'repair_note', 'issue_note'])->toArray(), + $data['cycle'] ?? 'daily' + ), + __('message.equipment.inspection_saved') + ); + } + + public function resetInspection(Request $request): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->resetInspection( + (int) $request->input('equipment_id'), + $request->input('cycle', 'daily'), + $request->input('period') + ), + __('message.equipment.inspection_reset') + ); + } + + public function templates(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->getActiveCycles($id), + __('message.fetched') + ); + } + + public function storeTemplate(StoreInspectionTemplateRequest $request, int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->saveTemplate($id, $request->validated()), + __('message.equipment.template_created') + ); + } + + public function updateTemplate(Request $request, int $templateId): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->updateTemplate($templateId, $request->all()), + __('message.updated') + ); + } + + public function deleteTemplate(int $templateId): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->deleteTemplate($templateId), + __('message.deleted') + ); + } + + public function copyTemplates(Request $request, int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->copyTemplates( + $id, + $request->input('source_cycle'), + $request->input('target_cycles', []) + ), + __('message.equipment.template_copied') + ); + } +} diff --git a/app/Http/Controllers/V1/Equipment/EquipmentRepairController.php b/app/Http/Controllers/V1/Equipment/EquipmentRepairController.php new file mode 100644 index 0000000..c31399a --- /dev/null +++ b/app/Http/Controllers/V1/Equipment/EquipmentRepairController.php @@ -0,0 +1,49 @@ + $this->service->index($request->only([ + 'equipment_id', 'repair_type', 'date_from', 'date_to', 'search', 'per_page', + ])), + __('message.fetched') + ); + } + + public function store(StoreEquipmentRepairRequest $request): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->store($request->validated()), + __('message.equipment.repair_created') + ); + } + + public function update(StoreEquipmentRepairRequest $request, int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->update($id, $request->validated()), + __('message.updated') + ); + } + + public function destroy(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->destroy($id), + __('message.deleted') + ); + } +} 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/Equipment/StoreEquipmentRepairRequest.php b/app/Http/Requests/V1/Equipment/StoreEquipmentRepairRequest.php new file mode 100644 index 0000000..440e78a --- /dev/null +++ b/app/Http/Requests/V1/Equipment/StoreEquipmentRepairRequest.php @@ -0,0 +1,29 @@ + 'required|integer|exists:equipments,id', + 'repair_date' => 'required|date', + 'repair_type' => 'nullable|string|in:internal,external', + 'repair_hours' => 'nullable|numeric|min:0', + 'description' => 'nullable|string', + 'cost' => 'nullable|numeric|min:0', + 'vendor' => 'nullable|string|max:100', + 'repaired_by' => 'nullable|integer|exists:users,id', + 'memo' => 'nullable|string', + 'options' => 'nullable|array', + ]; + } +} diff --git a/app/Http/Requests/V1/Equipment/StoreEquipmentRequest.php b/app/Http/Requests/V1/Equipment/StoreEquipmentRequest.php new file mode 100644 index 0000000..1990daa --- /dev/null +++ b/app/Http/Requests/V1/Equipment/StoreEquipmentRequest.php @@ -0,0 +1,41 @@ + 'required|string|max:50', + 'name' => 'required|string|max:100', + 'equipment_type' => 'nullable|string|max:50', + 'specification' => 'nullable|string|max:200', + 'manufacturer' => 'nullable|string|max:100', + 'model_name' => 'nullable|string|max:100', + 'serial_no' => 'nullable|string|max:100', + 'location' => 'nullable|string|max:100', + 'production_line' => 'nullable|string|max:50', + 'purchase_date' => 'nullable|date', + 'install_date' => 'nullable|date', + 'purchase_price' => 'nullable|numeric|min:0', + 'useful_life' => 'nullable|integer|min:0', + 'status' => 'nullable|in:active,idle,disposed', + 'disposed_date' => 'nullable|date', + 'manager_id' => 'nullable|integer|exists:users,id', + 'sub_manager_id' => 'nullable|integer|exists:users,id', + 'photo_path' => 'nullable|string|max:500', + 'memo' => 'nullable|string', + 'options' => 'nullable|array', + 'is_active' => 'nullable|boolean', + 'sort_order' => 'nullable|integer|min:0', + ]; + } +} diff --git a/app/Http/Requests/V1/Equipment/StoreInspectionTemplateRequest.php b/app/Http/Requests/V1/Equipment/StoreInspectionTemplateRequest.php new file mode 100644 index 0000000..43d3ce0 --- /dev/null +++ b/app/Http/Requests/V1/Equipment/StoreInspectionTemplateRequest.php @@ -0,0 +1,28 @@ + 'required|string|in:daily,weekly,monthly,bimonthly,quarterly,semiannual', + 'item_no' => 'required|string|max:20', + 'check_point' => 'required|string|max:100', + 'check_item' => 'required|string|max:200', + 'check_timing' => 'nullable|string|max:50', + 'check_frequency' => 'nullable|string|max:50', + 'check_method' => 'nullable|string|max:200', + 'sort_order' => 'nullable|integer|min:0', + 'is_active' => 'nullable|boolean', + ]; + } +} diff --git a/app/Http/Requests/V1/Equipment/ToggleInspectionDetailRequest.php b/app/Http/Requests/V1/Equipment/ToggleInspectionDetailRequest.php new file mode 100644 index 0000000..56b2a98 --- /dev/null +++ b/app/Http/Requests/V1/Equipment/ToggleInspectionDetailRequest.php @@ -0,0 +1,23 @@ + 'required|integer|exists:equipments,id', + 'template_item_id' => 'required|integer|exists:equipment_inspection_templates,id', + 'check_date' => 'required|date', + 'cycle' => 'nullable|string|in:daily,weekly,monthly,bimonthly,quarterly,semiannual', + ]; + } +} diff --git a/app/Http/Requests/V1/Equipment/UpdateEquipmentRequest.php b/app/Http/Requests/V1/Equipment/UpdateEquipmentRequest.php new file mode 100644 index 0000000..922b484 --- /dev/null +++ b/app/Http/Requests/V1/Equipment/UpdateEquipmentRequest.php @@ -0,0 +1,41 @@ + 'sometimes|string|max:50', + 'name' => 'sometimes|string|max:100', + 'equipment_type' => 'nullable|string|max:50', + 'specification' => 'nullable|string|max:200', + 'manufacturer' => 'nullable|string|max:100', + 'model_name' => 'nullable|string|max:100', + 'serial_no' => 'nullable|string|max:100', + 'location' => 'nullable|string|max:100', + 'production_line' => 'nullable|string|max:50', + 'purchase_date' => 'nullable|date', + 'install_date' => 'nullable|date', + 'purchase_price' => 'nullable|numeric|min:0', + 'useful_life' => 'nullable|integer|min:0', + 'status' => 'nullable|in:active,idle,disposed', + 'disposed_date' => 'nullable|date', + 'manager_id' => 'nullable|integer|exists:users,id', + 'sub_manager_id' => 'nullable|integer|exists:users,id', + 'photo_path' => 'nullable|string|max:500', + 'memo' => 'nullable|string', + 'options' => 'nullable|array', + 'is_active' => 'nullable|boolean', + 'sort_order' => 'nullable|integer|min:0', + ]; + } +} diff --git a/app/Http/Requests/V1/Equipment/UpdateInspectionNotesRequest.php b/app/Http/Requests/V1/Equipment/UpdateInspectionNotesRequest.php new file mode 100644 index 0000000..d757053 --- /dev/null +++ b/app/Http/Requests/V1/Equipment/UpdateInspectionNotesRequest.php @@ -0,0 +1,26 @@ + 'required|integer|exists:equipments,id', + 'year_month' => 'required|string', + 'cycle' => 'nullable|string|in:daily,weekly,monthly,bimonthly,quarterly,semiannual', + 'overall_judgment' => 'nullable|string|in:OK,NG', + 'inspector_id' => 'nullable|integer|exists:users,id', + 'repair_note' => 'nullable|string', + 'issue_note' => 'nullable|string', + ]; + } +} diff --git a/app/Http/Requests/V1/Payroll/BulkGeneratePayrollRequest.php b/app/Http/Requests/V1/Payroll/BulkGeneratePayrollRequest.php new file mode 100644 index 0000000..103ca07 --- /dev/null +++ b/app/Http/Requests/V1/Payroll/BulkGeneratePayrollRequest.php @@ -0,0 +1,29 @@ + ['required', 'integer', 'min:2000', 'max:2100'], + 'month' => ['required', 'integer', 'min:1', 'max:12'], + ]; + } + + public function attributes(): array + { + return [ + 'year' => __('validation.attributes.pay_year'), + 'month' => __('validation.attributes.pay_month'), + ]; + } +} diff --git a/app/Http/Requests/V1/Payroll/CopyFromPreviousPayrollRequest.php b/app/Http/Requests/V1/Payroll/CopyFromPreviousPayrollRequest.php new file mode 100644 index 0000000..c88a89e --- /dev/null +++ b/app/Http/Requests/V1/Payroll/CopyFromPreviousPayrollRequest.php @@ -0,0 +1,29 @@ + ['required', 'integer', 'min:2000', 'max:2100'], + 'month' => ['required', 'integer', 'min:1', 'max:12'], + ]; + } + + public function attributes(): array + { + return [ + 'year' => __('validation.attributes.pay_year'), + 'month' => __('validation.attributes.pay_month'), + ]; + } +} diff --git a/app/Http/Requests/V1/Payroll/StorePayrollJournalRequest.php b/app/Http/Requests/V1/Payroll/StorePayrollJournalRequest.php new file mode 100644 index 0000000..b8643cc --- /dev/null +++ b/app/Http/Requests/V1/Payroll/StorePayrollJournalRequest.php @@ -0,0 +1,31 @@ + ['required', 'integer', 'min:2000', 'max:2100'], + 'month' => ['required', 'integer', 'min:1', 'max:12'], + 'entry_date' => ['nullable', 'date_format:Y-m-d'], + ]; + } + + public function attributes(): array + { + return [ + 'year' => __('validation.attributes.pay_year'), + 'month' => __('validation.attributes.pay_month'), + 'entry_date' => __('validation.attributes.entry_date'), + ]; + } +} diff --git a/app/Http/Requests/V1/Payroll/StorePayrollRequest.php b/app/Http/Requests/V1/Payroll/StorePayrollRequest.php index b416dca..35f6e93 100644 --- a/app/Http/Requests/V1/Payroll/StorePayrollRequest.php +++ b/app/Http/Requests/V1/Payroll/StorePayrollRequest.php @@ -31,6 +31,14 @@ public function rules(): array 'deductions' => ['nullable', 'array'], 'deductions.*.name' => ['required_with:deductions', 'string', 'max:50'], 'deductions.*.amount' => ['required_with:deductions', 'numeric', 'min:0'], + 'deduction_overrides' => ['nullable', 'array'], + 'deduction_overrides.income_tax' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.resident_tax' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.health_insurance' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.long_term_care' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.pension' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.employment_insurance' => ['nullable', 'numeric', 'min:0'], + 'family_count' => ['nullable', 'integer', 'min:1', 'max:11'], 'note' => ['nullable', 'string', 'max:1000'], ]; } diff --git a/app/Http/Requests/V1/Payroll/UpdatePayrollRequest.php b/app/Http/Requests/V1/Payroll/UpdatePayrollRequest.php index 6eefc3d..413dfaf 100644 --- a/app/Http/Requests/V1/Payroll/UpdatePayrollRequest.php +++ b/app/Http/Requests/V1/Payroll/UpdatePayrollRequest.php @@ -31,6 +31,14 @@ public function rules(): array 'deductions' => ['nullable', 'array'], 'deductions.*.name' => ['required_with:deductions', 'string', 'max:50'], 'deductions.*.amount' => ['required_with:deductions', 'numeric', 'min:0'], + 'deduction_overrides' => ['nullable', 'array'], + 'deduction_overrides.income_tax' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.resident_tax' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.health_insurance' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.long_term_care' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.pension' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.employment_insurance' => ['nullable', 'numeric', 'min:0'], + '_is_super_admin' => ['nullable', 'boolean'], 'note' => ['nullable', 'string', 'max:1000'], ]; } 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/Barobill/BarobillBankSyncStatus.php b/app/Models/Barobill/BarobillBankSyncStatus.php new file mode 100644 index 0000000..e4431bb --- /dev/null +++ b/app/Models/Barobill/BarobillBankSyncStatus.php @@ -0,0 +1,34 @@ + 'datetime', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } +} diff --git a/app/Models/Barobill/BarobillBankTransaction.php b/app/Models/Barobill/BarobillBankTransaction.php new file mode 100644 index 0000000..3bc7c0e --- /dev/null +++ b/app/Models/Barobill/BarobillBankTransaction.php @@ -0,0 +1,97 @@ + 'decimal:2', + 'withdraw' => 'decimal:2', + 'balance' => 'decimal:2', + 'is_manual' => 'boolean', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + /** + * 거래 고유 키 (계좌번호|거래일시|입금|출금|잔액) + */ + public function getUniqueKeyAttribute(): string + { + return static::generateUniqueKey([ + 'bank_account_num' => $this->bank_account_num, + 'trans_dt' => $this->trans_dt, + 'deposit' => $this->deposit, + 'withdraw' => $this->withdraw, + 'balance' => $this->balance, + ]); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function generateUniqueKey(array $data): string + { + return implode('|', [ + $data['bank_account_num'] ?? '', + $data['trans_dt'] ?? '', + $data['deposit'] ?? '0', + $data['withdraw'] ?? '0', + $data['balance'] ?? '0', + ]); + } + + public static function getByDateRange(int $tenantId, string $startDate, string $endDate, ?string $accountNum = null) + { + $query = static::where('tenant_id', $tenantId) + ->whereBetween('trans_date', [$startDate, $endDate]); + + if ($accountNum) { + $query->where('bank_account_num', $accountNum); + } + + return $query->orderBy('trans_date')->orderBy('trans_dt')->get(); + } +} diff --git a/app/Models/Barobill/BarobillBankTransactionOverride.php b/app/Models/Barobill/BarobillBankTransactionOverride.php new file mode 100644 index 0000000..1d8d78a --- /dev/null +++ b/app/Models/Barobill/BarobillBankTransactionOverride.php @@ -0,0 +1,49 @@ +where('unique_key', $uniqueKey); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getByUniqueKeys(int $tenantId, array $uniqueKeys) + { + return static::where('tenant_id', $tenantId) + ->whereIn('unique_key', $uniqueKeys) + ->get() + ->keyBy('unique_key'); + } + + public static function saveOverride(int $tenantId, string $uniqueKey, ?string $modifiedSummary, ?string $modifiedCast): self + { + return static::updateOrCreate( + ['tenant_id' => $tenantId, 'unique_key' => $uniqueKey], + ['modified_summary' => $modifiedSummary, 'modified_cast' => $modifiedCast] + ); + } +} diff --git a/app/Models/Barobill/BarobillBankTransactionSplit.php b/app/Models/Barobill/BarobillBankTransactionSplit.php new file mode 100644 index 0000000..35e5184 --- /dev/null +++ b/app/Models/Barobill/BarobillBankTransactionSplit.php @@ -0,0 +1,69 @@ + 'decimal:2', + 'original_deposit' => 'decimal:2', + 'original_withdraw' => 'decimal:2', + 'sort_order' => 'integer', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getByDateRange(int $tenantId, string $startDate, string $endDate) + { + return static::where('tenant_id', $tenantId) + ->whereBetween('trans_date', [$startDate, $endDate]) + ->orderBy('original_unique_key') + ->orderBy('sort_order') + ->get() + ->groupBy('original_unique_key'); + } + + public static function getByUniqueKey(int $tenantId, string $uniqueKey) + { + return static::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->orderBy('sort_order') + ->get(); + } +} diff --git a/app/Models/Barobill/BarobillBillingRecord.php b/app/Models/Barobill/BarobillBillingRecord.php new file mode 100644 index 0000000..5a87228 --- /dev/null +++ b/app/Models/Barobill/BarobillBillingRecord.php @@ -0,0 +1,82 @@ + 'integer', + 'unit_price' => 'integer', + 'total_amount' => 'integer', + 'billed_at' => 'date', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function member(): BelongsTo + { + return $this->belongsTo(BarobillMember::class, 'member_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeOfMonth($query, string $billingMonth) + { + return $query->where('billing_month', $billingMonth); + } + + public function scopeSubscription($query) + { + return $query->where('billing_type', 'subscription'); + } + + public function scopeUsage($query) + { + return $query->where('billing_type', 'usage'); + } + + public function scopeOfService($query, string $serviceType) + { + return $query->where('service_type', $serviceType); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + public function getServiceTypeLabelAttribute(): string + { + return match ($this->service_type) { + 'tax_invoice' => '전자세금계산서', + 'bank_account' => '계좌조회', + 'card' => '카드조회', + 'hometax' => '홈택스', + default => $this->service_type, + }; + } +} diff --git a/app/Models/Barobill/BarobillCardTransaction.php b/app/Models/Barobill/BarobillCardTransaction.php new file mode 100644 index 0000000..49611f9 --- /dev/null +++ b/app/Models/Barobill/BarobillCardTransaction.php @@ -0,0 +1,108 @@ + 'decimal:2', + 'tax' => 'decimal:2', + 'service_charge' => 'decimal:2', + 'modified_supply_amount' => 'decimal:2', + 'modified_tax' => 'decimal:2', + 'is_manual' => 'boolean', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + /** + * 거래 고유 키 (cardNum|useDt|approvalNum|approvalAmount) + */ + public function getUniqueKeyAttribute(): string + { + return static::generateUniqueKey([ + 'card_num' => $this->card_num, + 'use_dt' => $this->use_dt, + 'approval_num' => $this->approval_num, + 'approval_amount' => $this->approval_amount, + ]); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function generateUniqueKey(array $data): string + { + return implode('|', [ + $data['card_num'] ?? '', + $data['use_dt'] ?? '', + $data['approval_num'] ?? '', + $data['approval_amount'] ?? '0', + ]); + } + + public static function getByDateRange(int $tenantId, string $startDate, string $endDate, ?string $cardNum = null) + { + $query = static::where('tenant_id', $tenantId) + ->whereBetween('use_date', [$startDate, $endDate]); + + if ($cardNum) { + $query->where('card_num', $cardNum); + } + + return $query->orderBy('use_date')->orderBy('use_dt')->get(); + } +} diff --git a/app/Models/Barobill/BarobillCardTransactionAmountLog.php b/app/Models/Barobill/BarobillCardTransactionAmountLog.php new file mode 100644 index 0000000..0109c11 --- /dev/null +++ b/app/Models/Barobill/BarobillCardTransactionAmountLog.php @@ -0,0 +1,41 @@ + 'decimal:2', + 'before_tax' => 'decimal:2', + 'after_supply_amount' => 'decimal:2', + 'after_tax' => 'decimal:2', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function cardTransaction(): BelongsTo + { + return $this->belongsTo(BarobillCardTransaction::class, 'card_transaction_id'); + } +} diff --git a/app/Models/Barobill/BarobillCardTransactionHide.php b/app/Models/Barobill/BarobillCardTransactionHide.php new file mode 100644 index 0000000..3112665 --- /dev/null +++ b/app/Models/Barobill/BarobillCardTransactionHide.php @@ -0,0 +1,61 @@ + 'decimal:2', + ]; + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getHiddenKeys(int $tenantId, string $startDate, string $endDate) + { + return static::where('tenant_id', $tenantId) + ->whereBetween('use_date', [$startDate, $endDate]) + ->pluck('original_unique_key') + ->toArray(); + } + + public static function hideTransaction(int $tenantId, string $uniqueKey, array $originalData, int $userId): self + { + return static::create([ + 'tenant_id' => $tenantId, + 'original_unique_key' => $uniqueKey, + 'card_num' => $originalData['card_num'] ?? '', + 'use_date' => $originalData['use_date'] ?? '', + 'approval_num' => $originalData['approval_num'] ?? '', + 'original_amount' => $originalData['approval_amount'] ?? 0, + 'merchant_name' => $originalData['merchant_name'] ?? '', + 'hidden_by' => $userId, + ]); + } + + public static function restoreTransaction(int $tenantId, string $uniqueKey): bool + { + return static::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->delete() > 0; + } +} diff --git a/app/Models/Barobill/BarobillCardTransactionSplit.php b/app/Models/Barobill/BarobillCardTransactionSplit.php new file mode 100644 index 0000000..8daf73c --- /dev/null +++ b/app/Models/Barobill/BarobillCardTransactionSplit.php @@ -0,0 +1,74 @@ + 'decimal:2', + 'split_supply_amount' => 'decimal:2', + 'split_tax' => 'decimal:2', + 'original_amount' => 'decimal:2', + 'sort_order' => 'integer', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getByDateRange(int $tenantId, string $startDate, string $endDate) + { + return static::where('tenant_id', $tenantId) + ->whereBetween('use_date', [$startDate, $endDate]) + ->orderBy('original_unique_key') + ->orderBy('sort_order') + ->get() + ->groupBy('original_unique_key'); + } + + public static function getByUniqueKey(int $tenantId, string $uniqueKey) + { + return static::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->orderBy('sort_order') + ->get(); + } +} diff --git a/app/Models/Barobill/BarobillConfig.php b/app/Models/Barobill/BarobillConfig.php new file mode 100644 index 0000000..5962e02 --- /dev/null +++ b/app/Models/Barobill/BarobillConfig.php @@ -0,0 +1,61 @@ + 'boolean', + ]; + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getActiveTest(): ?self + { + return static::where('environment', 'test')->where('is_active', true)->first(); + } + + public static function getActiveProduction(): ?self + { + return static::where('environment', 'production')->where('is_active', true)->first(); + } + + public static function getActive(bool $isTestMode): ?self + { + return $isTestMode ? static::getActiveTest() : static::getActiveProduction(); + } + + public function getEnvironmentLabelAttribute(): string + { + return $this->environment === 'test' ? '테스트' : '운영'; + } + + public function getMaskedCertKeyAttribute(): string + { + $key = $this->cert_key; + if (strlen($key) <= 8) { + return str_repeat('*', strlen($key)); + } + + return substr($key, 0, 4).'****'.substr($key, -4); + } +} diff --git a/app/Models/Barobill/BarobillMember.php b/app/Models/Barobill/BarobillMember.php new file mode 100644 index 0000000..341bae4 --- /dev/null +++ b/app/Models/Barobill/BarobillMember.php @@ -0,0 +1,82 @@ + 'encrypted', + 'last_sales_fetch_at' => 'datetime', + 'last_purchases_fetch_at' => 'datetime', + ]; + + protected $hidden = [ + 'barobill_pwd', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + public function getFormattedBizNoAttribute(): string + { + $num = $this->biz_no; + if (strlen($num) === 10) { + return substr($num, 0, 3).'-'.substr($num, 3, 2).'-'.substr($num, 5); + } + + return $num ?? ''; + } + + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + 'active' => '활성', + 'inactive' => '비활성', + 'pending' => '대기', + default => $this->status, + }; + } + + public function isTestMode(): bool + { + return $this->server_mode === 'test'; + } +} diff --git a/app/Models/Barobill/BarobillMonthlySummary.php b/app/Models/Barobill/BarobillMonthlySummary.php new file mode 100644 index 0000000..0ce3325 --- /dev/null +++ b/app/Models/Barobill/BarobillMonthlySummary.php @@ -0,0 +1,53 @@ + 'integer', + 'card_fee' => 'integer', + 'hometax_fee' => 'integer', + 'subscription_total' => 'integer', + 'tax_invoice_count' => 'integer', + 'tax_invoice_amount' => 'integer', + 'usage_total' => 'integer', + 'grand_total' => 'integer', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function member(): BelongsTo + { + return $this->belongsTo(BarobillMember::class, 'member_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeOfMonth($query, string $billingMonth) + { + return $query->where('billing_month', $billingMonth); + } +} diff --git a/app/Models/Barobill/BarobillPricingPolicy.php b/app/Models/Barobill/BarobillPricingPolicy.php new file mode 100644 index 0000000..f816a35 --- /dev/null +++ b/app/Models/Barobill/BarobillPricingPolicy.php @@ -0,0 +1,82 @@ + 'integer', + 'additional_unit' => 'integer', + 'additional_price' => 'integer', + 'is_active' => 'boolean', + 'sort_order' => 'integer', + ]; + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getByServiceType(string $serviceType): ?self + { + return static::active()->where('service_type', $serviceType)->first(); + } + + public static function getAllActive() + { + return static::active()->orderBy('sort_order')->get(); + } + + public function getServiceTypeLabelAttribute(): string + { + return match ($this->service_type) { + self::TYPE_CARD => '카드조회', + self::TYPE_TAX_INVOICE => '전자세금계산서', + self::TYPE_BANK_ACCOUNT => '계좌조회', + default => $this->service_type, + }; + } + + public function calculateBilling(int $usageCount): int + { + if ($usageCount <= $this->free_quota) { + return 0; + } + + $excess = $usageCount - $this->free_quota; + $units = (int) ceil($excess / max($this->additional_unit, 1)); + + return $units * $this->additional_price; + } +} diff --git a/app/Models/Barobill/BarobillSubscription.php b/app/Models/Barobill/BarobillSubscription.php new file mode 100644 index 0000000..bd6acbe --- /dev/null +++ b/app/Models/Barobill/BarobillSubscription.php @@ -0,0 +1,76 @@ + 10000, + 'card' => 10000, + 'hometax' => 0, + ]; + + protected $fillable = [ + 'member_id', + 'service_type', + 'monthly_fee', + 'started_at', + 'ended_at', + 'is_active', + 'memo', + ]; + + protected $casts = [ + 'monthly_fee' => 'integer', + 'started_at' => 'date', + 'ended_at' => 'date', + 'is_active' => 'boolean', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function member(): BelongsTo + { + return $this->belongsTo(BarobillMember::class, 'member_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeOfService($query, string $serviceType) + { + return $query->where('service_type', $serviceType); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + public function getServiceTypeLabelAttribute(): string + { + return match ($this->service_type) { + 'bank_account' => '계좌조회', + 'card' => '카드조회', + 'hometax' => '홈택스', + default => $this->service_type, + }; + } +} diff --git a/app/Models/Barobill/HometaxInvoice.php b/app/Models/Barobill/HometaxInvoice.php new file mode 100644 index 0000000..f1ffab0 --- /dev/null +++ b/app/Models/Barobill/HometaxInvoice.php @@ -0,0 +1,158 @@ + 'integer', + 'tax_amount' => 'integer', + 'total_amount' => 'integer', + 'item_count' => 'integer', + 'item_unit_price' => 'integer', + 'item_supply_amount' => 'integer', + 'item_tax_amount' => 'integer', + 'is_modified' => 'boolean', + 'write_date' => 'date', + 'issue_date' => 'date', + 'send_date' => 'date', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + public function journals(): HasMany + { + return $this->hasMany(HometaxInvoiceJournal::class, 'hometax_invoice_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeSales($query) + { + return $query->where('invoice_type', 'sales'); + } + + public function scopePurchase($query) + { + return $query->where('invoice_type', 'purchase'); + } + + public function scopePeriod($query, string $startDate, string $endDate) + { + return $query->whereBetween('write_date', [$startDate, $endDate]); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + public function getTaxTypeNameAttribute(): string + { + return match ($this->tax_type) { + self::TAX_TYPE_TAXABLE => '과세', + self::TAX_TYPE_ZERO => '영세', + self::TAX_TYPE_EXEMPT => '면세', + default => $this->tax_type ?? '', + }; + } + + public function getPurposeTypeNameAttribute(): string + { + return match ($this->purpose_type) { + self::PURPOSE_TYPE_RECEIPT => '영수', + self::PURPOSE_TYPE_CLAIM => '청구', + default => $this->purpose_type ?? '', + }; + } + + public function getIssueTypeNameAttribute(): string + { + return match ($this->issue_type) { + self::ISSUE_TYPE_NORMAL => '정발행', + self::ISSUE_TYPE_REVERSE => '역발행', + default => $this->issue_type ?? '', + }; + } +} diff --git a/app/Models/Barobill/HometaxInvoiceJournal.php b/app/Models/Barobill/HometaxInvoiceJournal.php new file mode 100644 index 0000000..c328c89 --- /dev/null +++ b/app/Models/Barobill/HometaxInvoiceJournal.php @@ -0,0 +1,78 @@ + 'integer', + 'credit_amount' => 'integer', + 'sort_order' => 'integer', + 'supply_amount' => 'integer', + 'tax_amount' => 'integer', + 'total_amount' => 'integer', + 'write_date' => 'date', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + public function invoice(): BelongsTo + { + return $this->belongsTo(HometaxInvoice::class, 'hometax_invoice_id'); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getByInvoiceId(int $tenantId, int $invoiceId) + { + return static::where('tenant_id', $tenantId) + ->where('hometax_invoice_id', $invoiceId) + ->orderBy('sort_order') + ->get(); + } + + public static function getJournaledInvoiceIds(int $tenantId, array $invoiceIds): array + { + return static::where('tenant_id', $tenantId) + ->whereIn('hometax_invoice_id', $invoiceIds) + ->distinct() + ->pluck('hometax_invoice_id') + ->toArray(); + } +} 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/Equipment/EquipmentInspection.php b/app/Models/Equipment/EquipmentInspection.php new file mode 100644 index 0000000..b8c7e33 --- /dev/null +++ b/app/Models/Equipment/EquipmentInspection.php @@ -0,0 +1,43 @@ +belongsTo(Equipment::class, 'equipment_id'); + } + + public function inspector(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'inspector_id'); + } + + public function details(): HasMany + { + return $this->hasMany(EquipmentInspectionDetail::class, 'inspection_id'); + } +} diff --git a/app/Models/Equipment/EquipmentInspectionDetail.php b/app/Models/Equipment/EquipmentInspectionDetail.php new file mode 100644 index 0000000..382b23f --- /dev/null +++ b/app/Models/Equipment/EquipmentInspectionDetail.php @@ -0,0 +1,55 @@ + 'date', + ]; + + public function inspection(): BelongsTo + { + return $this->belongsTo(EquipmentInspection::class, 'inspection_id'); + } + + public function templateItem(): BelongsTo + { + return $this->belongsTo(EquipmentInspectionTemplate::class, 'template_item_id'); + } + + public static function getNextResult(?string $current): ?string + { + return match ($current) { + null, '' => 'good', + 'good' => 'bad', + 'bad' => 'repaired', + 'repaired' => null, + default => 'good', + }; + } + + public static function getResultSymbol(?string $result): string + { + return match ($result) { + 'good' => '○', + 'bad' => 'X', + 'repaired' => '△', + default => '', + }; + } +} diff --git a/app/Models/Equipment/EquipmentInspectionTemplate.php b/app/Models/Equipment/EquipmentInspectionTemplate.php new file mode 100644 index 0000000..539c6a6 --- /dev/null +++ b/app/Models/Equipment/EquipmentInspectionTemplate.php @@ -0,0 +1,42 @@ + 'boolean', + ]; + + public function equipment(): BelongsTo + { + return $this->belongsTo(Equipment::class, 'equipment_id'); + } + + public function scopeByCycle($query, string $cycle) + { + return $query->where('inspection_cycle', $cycle); + } +} diff --git a/app/Models/Equipment/EquipmentProcess.php b/app/Models/Equipment/EquipmentProcess.php new file mode 100644 index 0000000..ca8260f --- /dev/null +++ b/app/Models/Equipment/EquipmentProcess.php @@ -0,0 +1,31 @@ + 'boolean', + ]; + + public function equipment(): BelongsTo + { + return $this->belongsTo(Equipment::class, 'equipment_id'); + } + + public function process(): BelongsTo + { + return $this->belongsTo(\App\Models\Process::class, 'process_id'); + } +} diff --git a/app/Models/Equipment/EquipmentRepair.php b/app/Models/Equipment/EquipmentRepair.php new file mode 100644 index 0000000..60733ed --- /dev/null +++ b/app/Models/Equipment/EquipmentRepair.php @@ -0,0 +1,62 @@ + 'date', + 'repair_hours' => 'decimal:1', + 'cost' => 'decimal:2', + 'options' => 'array', + ]; + + public function getOption(string $key, mixed $default = null): mixed + { + return data_get($this->options, $key, $default); + } + + public function setOption(string $key, mixed $value): self + { + $options = $this->options ?? []; + data_set($options, $key, $value); + $this->options = $options; + + return $this; + } + + public function equipment(): BelongsTo + { + return $this->belongsTo(Equipment::class, 'equipment_id'); + } + + public function repairer(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'repaired_by'); + } +} diff --git a/app/Models/Qualitys/ChecklistTemplate.php b/app/Models/Qualitys/ChecklistTemplate.php new file mode 100644 index 0000000..4c4698f --- /dev/null +++ b/app/Models/Qualitys/ChecklistTemplate.php @@ -0,0 +1,76 @@ + 'array', + 'options' => 'array', + ]; + + public function creator(): BelongsTo + { + return $this->belongsTo(\App\Models\Members\User::class, 'created_by'); + } + + public function updater(): BelongsTo + { + return $this->belongsTo(\App\Models\Members\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/BarobillSetting.php b/app/Models/Tenants/BarobillSetting.php index 45ad09e..32e3a6e 100644 --- a/app/Models/Tenants/BarobillSetting.php +++ b/app/Models/Tenants/BarobillSetting.php @@ -2,12 +2,15 @@ namespace App\Models\Tenants; +use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Facades\Crypt; class BarobillSetting extends Model { + use BelongsToTenant; + protected $fillable = [ 'tenant_id', 'corp_num', @@ -23,6 +26,10 @@ class BarobillSetting extends Model 'contact_tel', 'is_active', 'auto_issue', + 'use_tax_invoice', + 'use_bank_account', + 'use_card_usage', + 'use_hometax', 'verified_at', 'created_by', 'updated_by', @@ -31,6 +38,10 @@ class BarobillSetting extends Model protected $casts = [ 'is_active' => 'boolean', 'auto_issue' => 'boolean', + 'use_tax_invoice' => 'boolean', + 'use_bank_account' => 'boolean', + 'use_card_usage' => 'boolean', + 'use_hometax' => 'boolean', 'verified_at' => 'datetime', ]; @@ -129,4 +140,26 @@ public function getFormattedCorpNumAttribute(): string return $num; } + + /** + * 활성화된 서비스 목록 + */ + public function getActiveServicesAttribute(): array + { + $services = []; + if ($this->use_tax_invoice) { + $services[] = 'tax_invoice'; + } + if ($this->use_bank_account) { + $services[] = 'bank_account'; + } + if ($this->use_card_usage) { + $services[] = 'card_usage'; + } + if ($this->use_hometax) { + $services[] = 'hometax'; + } + + return $services; + } } diff --git a/app/Models/Tenants/IncomeTaxBracket.php b/app/Models/Tenants/IncomeTaxBracket.php new file mode 100644 index 0000000..973cd17 --- /dev/null +++ b/app/Models/Tenants/IncomeTaxBracket.php @@ -0,0 +1,64 @@ + 'integer', + 'salary_from' => 'integer', + 'salary_to' => 'integer', + 'family_count' => 'integer', + 'tax_amount' => 'integer', + ]; + + public function scopeForYear(Builder $query, int $year): Builder + { + return $query->where('tax_year', $year); + } + + public function scopeForSalaryRange(Builder $query, int $salaryThousand): Builder + { + return $query->where('salary_from', '<=', $salaryThousand) + ->where(function ($q) use ($salaryThousand) { + $q->where('salary_to', '>', $salaryThousand) + ->orWhere(function ($q2) use ($salaryThousand) { + $q2->whereColumn('salary_from', 'salary_to') + ->where('salary_from', $salaryThousand); + }); + }); + } + + public function scopeForFamilyCount(Builder $query, int $count): Builder + { + return $query->where('family_count', $count); + } + + /** + * 간이세액표에서 세액 조회 + */ + public static function lookupTax(int $year, int $salaryThousand, int $familyCount): int + { + $familyCount = max(1, min(11, $familyCount)); + + $bracket = static::forYear($year) + ->forSalaryRange($salaryThousand) + ->forFamilyCount($familyCount) + ->first(); + + return $bracket ? $bracket->tax_amount : 0; + } +} diff --git a/app/Models/Tenants/JournalEntry.php b/app/Models/Tenants/JournalEntry.php index 6a0970a..ab8c4a9 100644 --- a/app/Models/Tenants/JournalEntry.php +++ b/app/Models/Tenants/JournalEntry.php @@ -34,14 +34,26 @@ class JournalEntry extends Model // Status public const STATUS_DRAFT = 'draft'; + public const STATUS_CONFIRMED = 'confirmed'; // Source type public const SOURCE_MANUAL = 'manual'; + public const SOURCE_BANK_TRANSACTION = 'bank_transaction'; + public const SOURCE_TAX_INVOICE = 'tax_invoice'; + public const SOURCE_CARD_TRANSACTION = 'card_transaction'; + public const SOURCE_BAROBILL_CARD = 'barobill_card'; + + public const SOURCE_BAROBILL_BANK = 'barobill_bank'; + + public const SOURCE_HOMETAX_INVOICE = 'hometax_invoice'; + + public const SOURCE_PAYROLL = 'payroll'; + // Entry type public const TYPE_GENERAL = 'general'; diff --git a/app/Models/Tenants/MailLog.php b/app/Models/Tenants/MailLog.php new file mode 100644 index 0000000..5bdde7f --- /dev/null +++ b/app/Models/Tenants/MailLog.php @@ -0,0 +1,47 @@ + 'datetime', + 'options' => 'array', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function getOption(string $key, mixed $default = null): mixed + { + return data_get($this->options, $key, $default); + } + + public function setOption(string $key, mixed $value): static + { + $options = $this->options ?? []; + data_set($options, $key, $value); + $this->options = $options; + + return $this; + } +} diff --git a/app/Models/Tenants/Payroll.php b/app/Models/Tenants/Payroll.php index da12f95..a3e3682 100644 --- a/app/Models/Tenants/Payroll.php +++ b/app/Models/Tenants/Payroll.php @@ -49,17 +49,19 @@ class Payroll extends Model protected $casts = [ 'allowances' => 'array', 'deductions' => 'array', - 'base_salary' => 'decimal:2', - 'overtime_pay' => 'decimal:2', - 'bonus' => 'decimal:2', - 'gross_salary' => 'decimal:2', - 'income_tax' => 'decimal:2', - 'resident_tax' => 'decimal:2', - 'health_insurance' => 'decimal:2', - 'pension' => 'decimal:2', - 'employment_insurance' => 'decimal:2', - 'total_deductions' => 'decimal:2', - 'net_salary' => 'decimal:2', + 'options' => 'array', + 'base_salary' => 'decimal:0', + 'overtime_pay' => 'decimal:0', + 'bonus' => 'decimal:0', + 'gross_salary' => 'decimal:0', + 'income_tax' => 'decimal:0', + 'resident_tax' => 'decimal:0', + 'health_insurance' => 'decimal:0', + 'long_term_care' => 'decimal:0', + 'pension' => 'decimal:0', + 'employment_insurance' => 'decimal:0', + 'total_deductions' => 'decimal:0', + 'net_salary' => 'decimal:0', 'confirmed_at' => 'datetime', 'paid_at' => 'datetime', 'pay_year' => 'integer', @@ -79,9 +81,11 @@ class Payroll extends Model 'income_tax', 'resident_tax', 'health_insurance', + 'long_term_care', 'pension', 'employment_insurance', 'deductions', + 'options', 'total_deductions', 'net_salary', 'status', @@ -104,6 +108,7 @@ class Payroll extends Model 'income_tax' => 0, 'resident_tax' => 0, 'health_insurance' => 0, + 'long_term_care' => 0, 'pension' => 0, 'employment_insurance' => 0, 'total_deductions' => 0, @@ -227,13 +232,33 @@ public function scopeForUser($query, int $userId) // ========================================================================= /** - * 수정 가능 여부 (작성중 상태만) + * 수정 가능 여부 (작성중, 슈퍼관리자는 모든 상태) */ - public function isEditable(): bool + public function isEditable(bool $isSuperAdmin = false): bool { + if ($isSuperAdmin) { + return true; + } + return $this->status === self::STATUS_DRAFT; } + /** + * 확정 취소 가능 여부 + */ + public function isUnconfirmable(): bool + { + return $this->status === self::STATUS_CONFIRMED; + } + + /** + * 지급 취소 가능 여부 (슈퍼관리자 전용) + */ + public function isUnpayable(): bool + { + return $this->status === self::STATUS_PAID; + } + /** * 확정 가능 여부 */ @@ -322,6 +347,7 @@ public function calculateTotalDeductions(): float return $this->income_tax + $this->resident_tax + $this->health_insurance + + $this->long_term_care + $this->pension + $this->employment_insurance + $this->deductions_total; 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/Models/Tenants/TenantMailConfig.php b/app/Models/Tenants/TenantMailConfig.php new file mode 100644 index 0000000..ec842a3 --- /dev/null +++ b/app/Models/Tenants/TenantMailConfig.php @@ -0,0 +1,103 @@ + 'boolean', + 'daily_limit' => 'integer', + 'is_active' => 'boolean', + 'options' => 'array', + ]; + + // Options 키 상수 + public const OPTION_SMTP = 'smtp'; + + public const OPTION_PRESET = 'preset'; + + public const OPTION_BRANDING = 'branding'; + + public const OPTION_CONNECTION_TEST = 'connection_test'; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function getOption(string $key, mixed $default = null): mixed + { + return data_get($this->options, $key, $default); + } + + public function setOption(string $key, mixed $value): static + { + $options = $this->options ?? []; + data_set($options, $key, $value); + $this->options = $options; + + return $this; + } + + public function getSmtpHost(): ?string + { + return $this->getOption('smtp.host'); + } + + public function getSmtpPort(): int + { + return (int) $this->getOption('smtp.port', 587); + } + + public function getSmtpEncryption(): string + { + return $this->getOption('smtp.encryption', 'tls'); + } + + public function getSmtpUsername(): ?string + { + return $this->getOption('smtp.username'); + } + + public function getSmtpPassword(): ?string + { + $encrypted = $this->getOption('smtp.password'); + if (! $encrypted) { + return null; + } + + try { + return decrypt($encrypted); + } catch (\Exception $e) { + return null; + } + } + + public function getPreset(): ?string + { + return $this->getOption('preset'); + } +} diff --git a/app/Services/BarobillBankTransactionService.php b/app/Services/BarobillBankTransactionService.php new file mode 100644 index 0000000..3af3ca5 --- /dev/null +++ b/app/Services/BarobillBankTransactionService.php @@ -0,0 +1,249 @@ +tenantId(); + $startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d'); + $endDate = $params['end_date'] ?? now()->format('Y-m-d'); + $accountNum = $params['bank_account_num'] ?? null; + $search = $params['search'] ?? null; + $perPage = $params['per_page'] ?? 50; + + $query = BarobillBankTransaction::where('tenant_id', $tenantId) + ->whereBetween('trans_date', [$startDate, $endDate]); + + if ($accountNum) { + $query->where('bank_account_num', $accountNum); + } + + if ($search) { + $query->where(function ($q) use ($search) { + $q->where('summary', 'like', "%{$search}%") + ->orWhere('memo', 'like', "%{$search}%") + ->orWhere('client_name', 'like', "%{$search}%"); + }); + } + + $query->orderByDesc('trans_date')->orderByDesc('trans_dt'); + + $transactions = $query->paginate($perPage); + + // 분할/오버라이드 정보 로드 + $uniqueKeys = $transactions->getCollection()->map->unique_key->toArray(); + + $splits = BarobillBankTransactionSplit::where('tenant_id', $tenantId) + ->whereIn('original_unique_key', $uniqueKeys) + ->orderBy('sort_order') + ->get() + ->groupBy('original_unique_key'); + + $overrides = BarobillBankTransactionOverride::getByUniqueKeys($tenantId, $uniqueKeys); + + $transactions->getCollection()->transform(function ($tx) use ($splits, $overrides) { + $tx->splits = $splits->get($tx->unique_key, collect()); + $tx->has_splits = $tx->splits->isNotEmpty(); + $tx->override = $overrides->get($tx->unique_key); + + return $tx; + }); + + return [ + 'data' => $transactions, + ]; + } + + /** + * 계좌 목록 (필터용) + */ + public function accounts(): array + { + $tenantId = $this->tenantId(); + + $accounts = BarobillBankTransaction::where('tenant_id', $tenantId) + ->select('bank_account_num', 'bank_name') + ->distinct() + ->orderBy('bank_account_num') + ->get(); + + return ['items' => $accounts]; + } + + /** + * 거래 분할 조회 + */ + public function getSplits(string $uniqueKey): array + { + $tenantId = $this->tenantId(); + $splits = BarobillBankTransactionSplit::getByUniqueKey($tenantId, $uniqueKey); + + return ['items' => $splits]; + } + + /** + * 거래 분할 저장 + */ + public function saveSplits(string $uniqueKey, array $items): array + { + $tenantId = $this->tenantId(); + + return DB::transaction(function () use ($tenantId, $uniqueKey, $items) { + BarobillBankTransactionSplit::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->delete(); + + $created = []; + foreach ($items as $index => $item) { + $created[] = BarobillBankTransactionSplit::create([ + 'tenant_id' => $tenantId, + 'original_unique_key' => $uniqueKey, + 'split_amount' => $item['split_amount'], + 'account_code' => $item['account_code'] ?? null, + 'account_name' => $item['account_name'] ?? null, + 'deduction_type' => $item['deduction_type'] ?? null, + 'evidence_name' => $item['evidence_name'] ?? null, + 'description' => $item['description'] ?? null, + 'memo' => $item['memo'] ?? null, + 'sort_order' => $index + 1, + 'bank_account_num' => $item['bank_account_num'] ?? null, + 'trans_dt' => $item['trans_dt'] ?? null, + 'trans_date' => $item['trans_date'] ?? null, + 'original_deposit' => $item['original_deposit'] ?? 0, + 'original_withdraw' => $item['original_withdraw'] ?? 0, + 'summary' => $item['summary'] ?? null, + ]); + } + + return ['items' => $created, 'count' => count($created)]; + }); + } + + /** + * 거래 분할 삭제 + */ + public function deleteSplits(string $uniqueKey): array + { + $tenantId = $this->tenantId(); + $deleted = BarobillBankTransactionSplit::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->delete(); + + return ['deleted_count' => $deleted]; + } + + /** + * 적요/분류 오버라이드 저장 + */ + public function saveOverride(string $uniqueKey, ?string $modifiedSummary, ?string $modifiedCast): BarobillBankTransactionOverride + { + $tenantId = $this->tenantId(); + + return BarobillBankTransactionOverride::saveOverride($tenantId, $uniqueKey, $modifiedSummary, $modifiedCast); + } + + /** + * 수동 은행 거래 등록 + */ + public function storeManual(array $data): BarobillBankTransaction + { + $tenantId = $this->tenantId(); + + return BarobillBankTransaction::create([ + 'tenant_id' => $tenantId, + 'bank_account_num' => $data['bank_account_num'], + 'bank_code' => $data['bank_code'] ?? null, + 'bank_name' => $data['bank_name'] ?? null, + 'trans_date' => $data['trans_date'], + 'trans_time' => $data['trans_time'] ?? null, + 'trans_dt' => $data['trans_dt'] ?? $data['trans_date'].($data['trans_time'] ?? '000000'), + 'deposit' => $data['deposit'] ?? 0, + 'withdraw' => $data['withdraw'] ?? 0, + 'balance' => $data['balance'] ?? 0, + 'summary' => $data['summary'] ?? null, + 'cast' => $data['cast'] ?? null, + 'memo' => $data['memo'] ?? null, + 'trans_office' => $data['trans_office'] ?? null, + 'account_code' => $data['account_code'] ?? null, + 'account_name' => $data['account_name'] ?? null, + 'client_code' => $data['client_code'] ?? null, + 'client_name' => $data['client_name'] ?? null, + 'is_manual' => true, + ]); + } + + /** + * 수동 은행 거래 수정 + */ + public function updateManual(int $id, array $data): BarobillBankTransaction + { + $tx = BarobillBankTransaction::where('tenant_id', $this->tenantId()) + ->where('is_manual', true) + ->findOrFail($id); + + $tx->update($data); + + return $tx->fresh(); + } + + /** + * 수동 은행 거래 삭제 + */ + public function destroyManual(int $id): bool + { + $tx = BarobillBankTransaction::where('tenant_id', $this->tenantId()) + ->where('is_manual', true) + ->findOrFail($id); + + return $tx->delete(); + } + + /** + * 잔액 요약 + */ + public function balanceSummary(array $params): array + { + $tenantId = $this->tenantId(); + $date = $params['date'] ?? now()->format('Y-m-d'); + + $accounts = BarobillBankTransaction::where('tenant_id', $tenantId) + ->select('bank_account_num', 'bank_name') + ->distinct() + ->get(); + + $summary = []; + foreach ($accounts as $account) { + $lastTx = BarobillBankTransaction::where('tenant_id', $tenantId) + ->where('bank_account_num', $account->bank_account_num) + ->where('trans_date', '<=', $date) + ->orderByDesc('trans_date') + ->orderByDesc('trans_dt') + ->first(); + + $summary[] = [ + 'bank_account_num' => $account->bank_account_num, + 'bank_name' => $account->bank_name, + 'balance' => $lastTx ? $lastTx->balance : 0, + 'last_trans_date' => $lastTx?->trans_date, + ]; + } + + return ['items' => $summary]; + } +} diff --git a/app/Services/BarobillCardTransactionService.php b/app/Services/BarobillCardTransactionService.php new file mode 100644 index 0000000..688c433 --- /dev/null +++ b/app/Services/BarobillCardTransactionService.php @@ -0,0 +1,308 @@ +tenantId(); + $startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d'); + $endDate = $params['end_date'] ?? now()->format('Y-m-d'); + $cardNum = $params['card_num'] ?? null; + $search = $params['search'] ?? null; + $includeHidden = $params['include_hidden'] ?? false; + $perPage = $params['per_page'] ?? 50; + + $query = BarobillCardTransaction::where('tenant_id', $tenantId) + ->whereBetween('use_date', [$startDate, $endDate]); + + if ($cardNum) { + $query->where('card_num', $cardNum); + } + + if ($search) { + $query->where(function ($q) use ($search) { + $q->where('merchant_name', 'like', "%{$search}%") + ->orWhere('memo', 'like', "%{$search}%") + ->orWhere('approval_num', 'like', "%{$search}%"); + }); + } + + // 숨김 거래 필터링 + if (! $includeHidden) { + $hiddenKeys = BarobillCardTransactionHide::getHiddenKeys($tenantId, $startDate, $endDate); + if (! empty($hiddenKeys)) { + $query->whereNotIn( + DB::raw("CONCAT(card_num, '|', use_dt, '|', approval_num, '|', approval_amount)"), + $hiddenKeys + ); + } + } + + $query->orderByDesc('use_date')->orderByDesc('use_dt'); + + $transactions = $query->paginate($perPage); + + // 분할 거래 정보 로드 + $uniqueKeys = $transactions->getCollection()->map->unique_key->toArray(); + $splits = BarobillCardTransactionSplit::where('tenant_id', $tenantId) + ->whereIn('original_unique_key', $uniqueKeys) + ->orderBy('sort_order') + ->get() + ->groupBy('original_unique_key'); + + $transactions->getCollection()->transform(function ($tx) use ($splits) { + $tx->splits = $splits->get($tx->unique_key, collect()); + $tx->has_splits = $tx->splits->isNotEmpty(); + + return $tx; + }); + + return [ + 'data' => $transactions, + ]; + } + + /** + * 단일 카드 거래 상세 + */ + public function show(int $id): ?BarobillCardTransaction + { + return BarobillCardTransaction::where('tenant_id', $this->tenantId()) + ->find($id); + } + + /** + * 카드 거래 분할 조회 + */ + public function getSplits(string $uniqueKey): array + { + $tenantId = $this->tenantId(); + $splits = BarobillCardTransactionSplit::getByUniqueKey($tenantId, $uniqueKey); + + return ['items' => $splits]; + } + + /** + * 카드 거래 분할 저장 + */ + public function saveSplits(string $uniqueKey, array $items): array + { + $tenantId = $this->tenantId(); + + return DB::transaction(function () use ($tenantId, $uniqueKey, $items) { + // 기존 분할 삭제 + BarobillCardTransactionSplit::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->delete(); + + $created = []; + foreach ($items as $index => $item) { + $created[] = BarobillCardTransactionSplit::create([ + 'tenant_id' => $tenantId, + 'original_unique_key' => $uniqueKey, + 'split_amount' => $item['split_amount'], + 'split_supply_amount' => $item['split_supply_amount'] ?? 0, + 'split_tax' => $item['split_tax'] ?? 0, + 'account_code' => $item['account_code'] ?? null, + 'account_name' => $item['account_name'] ?? null, + 'deduction_type' => $item['deduction_type'] ?? null, + 'evidence_name' => $item['evidence_name'] ?? null, + 'description' => $item['description'] ?? null, + 'memo' => $item['memo'] ?? null, + 'sort_order' => $index + 1, + 'card_num' => $item['card_num'] ?? null, + 'use_dt' => $item['use_dt'] ?? null, + 'use_date' => $item['use_date'] ?? null, + 'approval_num' => $item['approval_num'] ?? null, + 'original_amount' => $item['original_amount'] ?? 0, + 'merchant_name' => $item['merchant_name'] ?? null, + ]); + } + + return ['items' => $created, 'count' => count($created)]; + }); + } + + /** + * 카드 거래 분할 삭제 + */ + public function deleteSplits(string $uniqueKey): array + { + $tenantId = $this->tenantId(); + $deleted = BarobillCardTransactionSplit::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->delete(); + + return ['deleted_count' => $deleted]; + } + + /** + * 수동 카드 거래 등록 + */ + public function storeManual(array $data): BarobillCardTransaction + { + $tenantId = $this->tenantId(); + + return BarobillCardTransaction::create([ + 'tenant_id' => $tenantId, + 'card_num' => $data['card_num'], + 'card_company' => $data['card_company'] ?? null, + 'card_company_name' => $data['card_company_name'] ?? null, + 'use_dt' => $data['use_dt'], + 'use_date' => $data['use_date'], + 'use_time' => $data['use_time'] ?? null, + 'approval_num' => $data['approval_num'] ?? 'MANUAL-'.now()->format('YmdHis'), + 'approval_type' => $data['approval_type'] ?? '1', + 'approval_amount' => $data['approval_amount'], + 'tax' => $data['tax'] ?? 0, + 'service_charge' => $data['service_charge'] ?? 0, + 'payment_plan' => $data['payment_plan'] ?? null, + 'merchant_name' => $data['merchant_name'], + 'merchant_biz_num' => $data['merchant_biz_num'] ?? null, + 'account_code' => $data['account_code'] ?? null, + 'account_name' => $data['account_name'] ?? null, + 'deduction_type' => $data['deduction_type'] ?? null, + 'evidence_name' => $data['evidence_name'] ?? null, + 'description' => $data['description'] ?? null, + 'memo' => $data['memo'] ?? null, + 'is_manual' => true, + ]); + } + + /** + * 수동 카드 거래 수정 + */ + public function updateManual(int $id, array $data): BarobillCardTransaction + { + $tx = BarobillCardTransaction::where('tenant_id', $this->tenantId()) + ->where('is_manual', true) + ->findOrFail($id); + + $tx->update($data); + + return $tx->fresh(); + } + + /** + * 수동 카드 거래 삭제 + */ + public function destroyManual(int $id): bool + { + $tx = BarobillCardTransaction::where('tenant_id', $this->tenantId()) + ->where('is_manual', true) + ->findOrFail($id); + + return $tx->delete(); + } + + /** + * 카드 거래 숨김 + */ + public function hide(int $id): BarobillCardTransactionHide + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $tx = BarobillCardTransaction::where('tenant_id', $tenantId)->findOrFail($id); + + return BarobillCardTransactionHide::hideTransaction($tenantId, $tx->unique_key, [ + 'card_num' => $tx->card_num, + 'use_date' => $tx->use_date, + 'approval_num' => $tx->approval_num, + 'approval_amount' => $tx->approval_amount, + 'merchant_name' => $tx->merchant_name, + ], $userId); + } + + /** + * 카드 거래 숨김 복원 + */ + public function restore(int $id): bool + { + $tenantId = $this->tenantId(); + $tx = BarobillCardTransaction::where('tenant_id', $tenantId)->findOrFail($id); + + return BarobillCardTransactionHide::restoreTransaction($tenantId, $tx->unique_key); + } + + /** + * 숨겨진 거래 목록 + */ + public function hiddenList(array $params): array + { + $tenantId = $this->tenantId(); + $startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d'); + $endDate = $params['end_date'] ?? now()->format('Y-m-d'); + + $hiddenItems = BarobillCardTransactionHide::where('tenant_id', $tenantId) + ->whereBetween('use_date', [$startDate, $endDate]) + ->orderByDesc('created_at') + ->get(); + + return ['items' => $hiddenItems]; + } + + /** + * 금액 수정 (공급가액/세액 수정) + */ + public function updateAmount(int $id, array $data): BarobillCardTransaction + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $tx = BarobillCardTransaction::where('tenant_id', $tenantId)->findOrFail($id); + + // 변경 이력 기록 + BarobillCardTransactionAmountLog::create([ + 'card_transaction_id' => $tx->id, + 'original_unique_key' => $tx->unique_key, + 'before_supply_amount' => $tx->modified_supply_amount ?? $tx->approval_amount, + 'before_tax' => $tx->modified_tax ?? $tx->tax, + 'after_supply_amount' => $data['supply_amount'], + 'after_tax' => $data['tax'], + 'modified_by' => $userId, + 'modified_by_name' => $data['modified_by_name'] ?? '', + 'ip_address' => request()->ip(), + ]); + + $tx->update([ + 'modified_supply_amount' => $data['supply_amount'], + 'modified_tax' => $data['tax'], + ]); + + return $tx->fresh(); + } + + /** + * 카드 번호 목록 (필터용) + */ + public function cardNumbers(): array + { + $tenantId = $this->tenantId(); + + $cards = BarobillCardTransaction::where('tenant_id', $tenantId) + ->select('card_num', 'card_company_name') + ->distinct() + ->orderBy('card_num') + ->get(); + + return ['items' => $cards]; + } +} diff --git a/app/Services/BarobillService.php b/app/Services/BarobillService.php index 1d8451f..e63420f 100644 --- a/app/Services/BarobillService.php +++ b/app/Services/BarobillService.php @@ -12,18 +12,41 @@ * 바로빌 API 연동 서비스 * * 바로빌 개발자센터: https://dev.barobill.co.kr/ + * SOAP 서비스 URL: https://ws.baroservice.com/ (운영) / https://testws.baroservice.com/ (테스트) */ class BarobillService extends Service { /** - * 바로빌 API 기본 URL + * 바로빌 SOAP 서비스 기본 URL (운영) */ - private const API_BASE_URL = 'https://ws.barobill.co.kr'; + private const API_BASE_URL = 'https://ws.baroservice.com'; /** - * 바로빌 API 테스트 URL + * 바로빌 SOAP 서비스 테스트 URL */ - private const API_TEST_URL = 'https://testws.barobill.co.kr'; + private const API_TEST_URL = 'https://testws.baroservice.com'; + + /** + * API 서비스 경로 매핑 + */ + private const SERVICE_PATHS = [ + 'TI' => '/TI.asmx', // 세금계산서 + 'CORPSTATE' => '/CORPSTATE.asmx', // 회원/사업자 관리 + 'BANKACCOUNT' => '/BANKACCOUNT.asmx', // 계좌 조회 + 'CARD' => '/CARD.asmx', // 카드 조회 + ]; + + /** + * 메서드별 서비스 매핑 + */ + private const METHOD_SERVICE_MAP = [ + 'GetAccessToken' => 'CORPSTATE', + 'CheckCorpNum' => 'CORPSTATE', + 'RegistCorp' => 'CORPSTATE', + 'RegistAndIssueTaxInvoice' => 'TI', + 'CancelTaxInvoice' => 'TI', + 'GetTaxInvoiceState' => 'TI', + ]; /** * 테스트 모드 여부 @@ -32,7 +55,7 @@ class BarobillService extends Service public function __construct() { - $this->testMode = config('services.barobill.test_mode', true); + $this->testMode = (bool) config('services.barobill.test_mode', true); } // ========================================================================= @@ -44,11 +67,7 @@ public function __construct() */ public function getSetting(): ?BarobillSetting { - $tenantId = $this->tenantId(); - - return BarobillSetting::query() - ->where('tenant_id', $tenantId) - ->first(); + return BarobillSetting::query()->first(); } /** @@ -59,9 +78,7 @@ public function saveSetting(array $data): BarobillSetting $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - $setting = BarobillSetting::query() - ->where('tenant_id', $tenantId) - ->first(); + $setting = BarobillSetting::query()->first(); if ($setting) { $setting->fill(array_merge($data, ['updated_by' => $userId])); @@ -89,7 +106,6 @@ public function testConnection(): array } try { - // 바로빌 API 토큰 조회로 연동 테스트 $response = $this->callApi('GetAccessToken', [ 'CERTKEY' => $setting->cert_key, 'CorpNum' => $setting->corp_num, @@ -97,7 +113,6 @@ public function testConnection(): array ]); if (! empty($response['AccessToken'])) { - // 검증 성공 시 verified_at 업데이트 $setting->verified_at = now(); $setting->save(); @@ -108,7 +123,10 @@ public function testConnection(): array ]; } - throw new BadRequestHttpException($response['Message'] ?? __('error.barobill.connection_failed')); + return [ + 'success' => false, + 'message' => $response['Message'] ?? __('error.barobill.connection_failed'), + ]; } catch (\Exception $e) { Log::error('바로빌 연동 테스트 실패', [ 'tenant_id' => $this->tenantId(), @@ -125,19 +143,11 @@ public function testConnection(): array /** * 사업자등록번호 유효성 검사 (휴폐업 조회) - * - * 바로빌 API를 통해 사업자등록번호의 유효성을 검증합니다. - * 바로빌 설정이 없는 경우 기본 형식 검증만 수행합니다. - * - * @param string $businessNumber 사업자등록번호 (10자리, 하이픈 제거) - * @return array{valid: bool, status: string, status_label: string, corp_name: ?string, ceo_name: ?string, message: string} */ public function checkBusinessNumber(string $businessNumber): array { - // 하이픈 제거 및 숫자만 추출 $businessNumber = preg_replace('/[^0-9]/', '', $businessNumber); - // 기본 형식 검증 (10자리) if (strlen($businessNumber) !== 10) { return [ 'valid' => false, @@ -149,7 +159,6 @@ public function checkBusinessNumber(string $businessNumber): array ]; } - // 체크섬 검증 (사업자등록번호 자체 유효성) if (! $this->validateBusinessNumberChecksum($businessNumber)) { return [ 'valid' => false, @@ -161,16 +170,14 @@ public function checkBusinessNumber(string $businessNumber): array ]; } - // 바로빌 API 조회 시도 try { $response = $this->callApi('CheckCorpNum', [ 'CorpNum' => $businessNumber, ]); - // 바로빌 응답 해석 if (isset($response['CorpState'])) { $state = $response['CorpState']; - $isValid = in_array($state, ['01', '02']); // 01: 사업중, 02: 휴업 + $isValid = in_array($state, ['01', '02']); $statusLabel = match ($state) { '01' => '사업중', '02' => '휴업', @@ -190,7 +197,6 @@ public function checkBusinessNumber(string $businessNumber): array ]; } - // 응답 형식이 다른 경우 (결과 코드 방식) if (isset($response['Result'])) { $isValid = $response['Result'] >= 0; @@ -206,7 +212,6 @@ public function checkBusinessNumber(string $businessNumber): array ]; } - // 기본 응답 (체크섬만 통과한 경우) return [ 'valid' => true, 'status' => 'format_valid', @@ -216,7 +221,6 @@ public function checkBusinessNumber(string $businessNumber): array 'message' => __('message.company.business_number_format_valid'), ]; } catch (\Exception $e) { - // API 호출 실패 시 형식 검증 결과만 반환 Log::warning('바로빌 사업자번호 조회 실패', [ 'business_number' => $businessNumber, 'error' => $e->getMessage(), @@ -235,8 +239,6 @@ public function checkBusinessNumber(string $businessNumber): array /** * 사업자등록번호 체크섬 검증 - * - * @param string $businessNumber 10자리 사업자등록번호 */ private function validateBusinessNumberChecksum(string $businessNumber): bool { @@ -252,7 +254,6 @@ private function validateBusinessNumberChecksum(string $businessNumber): bool $sum += intval($digits[$i]) * $multipliers[$i]; } - // 8번째 자리 (인덱스 8)에 대한 추가 처리 $sum += intval(floor(intval($digits[8]) * 5 / 10)); $remainder = $sum % 10; @@ -277,14 +278,11 @@ public function issueTaxInvoice(TaxInvoice $taxInvoice): TaxInvoice } try { - // 바로빌 API 호출을 위한 데이터 구성 $apiData = $this->buildTaxInvoiceData($taxInvoice, $setting); - // 세금계산서 발행 API 호출 $response = $this->callApi('RegistAndIssueTaxInvoice', $apiData); if (! empty($response['InvoiceID'])) { - // 발행 성공 $taxInvoice->barobill_invoice_id = $response['InvoiceID']; $taxInvoice->nts_confirm_num = $response['NTSConfirmNum'] ?? null; $taxInvoice->status = TaxInvoice::STATUS_ISSUED; @@ -301,9 +299,10 @@ public function issueTaxInvoice(TaxInvoice $taxInvoice): TaxInvoice return $taxInvoice->fresh(); } - throw new \Exception($response['Message'] ?? '발행 실패'); + throw new \RuntimeException($response['Message'] ?? '발행 실패'); + } catch (BadRequestHttpException $e) { + throw $e; } catch (\Exception $e) { - // 발행 실패 $taxInvoice->status = TaxInvoice::STATUS_FAILED; $taxInvoice->error_message = $e->getMessage(); $taxInvoice->save(); @@ -334,7 +333,6 @@ public function cancelTaxInvoice(TaxInvoice $taxInvoice, string $reason): TaxInv } try { - // 세금계산서 취소 API 호출 $response = $this->callApi('CancelTaxInvoice', [ 'CERTKEY' => $setting->cert_key, 'CorpNum' => $setting->corp_num, @@ -358,7 +356,9 @@ public function cancelTaxInvoice(TaxInvoice $taxInvoice, string $reason): TaxInv return $taxInvoice->fresh(); } - throw new \Exception($response['Message'] ?? '취소 실패'); + throw new \RuntimeException($response['Message'] ?? '취소 실패'); + } catch (BadRequestHttpException $e) { + throw $e; } catch (\Exception $e) { Log::error('세금계산서 취소 실패', [ 'tenant_id' => $this->tenantId(), @@ -396,7 +396,6 @@ public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice if (! empty($response['State'])) { $taxInvoice->nts_send_status = $response['State']; - // 국세청 전송 완료 시 상태 업데이트 if ($response['State'] === '전송완료' && ! $taxInvoice->sent_at) { $taxInvoice->status = TaxInvoice::STATUS_SENT; $taxInvoice->sent_at = now(); @@ -418,6 +417,26 @@ public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice } } + // ========================================================================= + // URL 헬퍼 + // ========================================================================= + + /** + * 바로빌 API base URL 반환 + */ + public function getBaseUrl(): string + { + return $this->testMode ? self::API_TEST_URL : self::API_BASE_URL; + } + + /** + * 테스트 모드 여부 + */ + public function isTestMode(): bool + { + return $this->testMode; + } + // ========================================================================= // Private 메서드 // ========================================================================= @@ -427,8 +446,10 @@ public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice */ private function callApi(string $method, array $data): array { - $baseUrl = $this->testMode ? self::API_TEST_URL : self::API_BASE_URL; - $url = $baseUrl.'/TI/'.$method; + $baseUrl = $this->getBaseUrl(); + $servicePath = self::METHOD_SERVICE_MAP[$method] ?? 'TI'; + $path = self::SERVICE_PATHS[$servicePath] ?? '/TI.asmx'; + $url = $baseUrl.$path.'/'.$method; $response = Http::timeout(30) ->withHeaders([ @@ -437,7 +458,7 @@ private function callApi(string $method, array $data): array ->post($url, $data); if ($response->failed()) { - throw new \Exception('API 호출 실패: '.$response->status()); + throw new \RuntimeException('API 호출 실패: '.$response->status()); } return $response->json() ?? []; @@ -448,7 +469,6 @@ private function callApi(string $method, array $data): array */ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $setting): array { - // 품목 데이터 구성 $items = []; foreach ($taxInvoice->items ?? [] as $index => $item) { $items[] = [ @@ -463,7 +483,6 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se ]; } - // 품목이 없는 경우 기본 품목 추가 if (empty($items)) { $items[] = [ 'PurchaseDT' => $taxInvoice->issue_date->format('Ymd'), @@ -487,8 +506,6 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se 'TaxType' => '과세', 'PurposeType' => '영수', 'WriteDate' => $taxInvoice->issue_date->format('Ymd'), - - // 공급자 정보 'InvoicerCorpNum' => $taxInvoice->supplier_corp_num, 'InvoicerCorpName' => $taxInvoice->supplier_corp_name, 'InvoicerCEOName' => $taxInvoice->supplier_ceo_name, @@ -496,8 +513,6 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se 'InvoicerBizType' => $taxInvoice->supplier_biz_type, 'InvoicerBizClass' => $taxInvoice->supplier_biz_class, 'InvoicerContactID' => $taxInvoice->supplier_contact_id, - - // 공급받는자 정보 'InvoiceeCorpNum' => $taxInvoice->buyer_corp_num, 'InvoiceeCorpName' => $taxInvoice->buyer_corp_name, 'InvoiceeCEOName' => $taxInvoice->buyer_ceo_name, @@ -505,16 +520,10 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se 'InvoiceeBizType' => $taxInvoice->buyer_biz_type, 'InvoiceeBizClass' => $taxInvoice->buyer_biz_class, 'InvoiceeContactID' => $taxInvoice->buyer_contact_id, - - // 금액 정보 'SupplyCostTotal' => (int) $taxInvoice->supply_amount, 'TaxTotal' => (int) $taxInvoice->tax_amount, 'TotalAmount' => (int) $taxInvoice->total_amount, - - // 품목 정보 'TaxInvoiceTradeLineItems' => $items, - - // 비고 'Remark1' => $taxInvoice->description ?? '', ], ]; 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/Equipment/EquipmentInspectionService.php b/app/Services/Equipment/EquipmentInspectionService.php new file mode 100644 index 0000000..ebc5972 --- /dev/null +++ b/app/Services/Equipment/EquipmentInspectionService.php @@ -0,0 +1,376 @@ +where('status', '!=', 'disposed') + ->with(['manager', 'subManager']); + + if ($productionLine) { + $equipmentQuery->byLine($productionLine); + } + + if ($equipmentId) { + $equipmentQuery->where('id', $equipmentId); + } + + $equipments = $equipmentQuery->orderBy('sort_order')->orderBy('name')->get(); + + $labels = InspectionCycle::columnLabels($cycle, $period); + $userId = $this->apiUserId(); + + $result = []; + + foreach ($equipments as $equipment) { + $templates = EquipmentInspectionTemplate::where('equipment_id', $equipment->id) + ->byCycle($cycle) + ->active() + ->orderBy('sort_order') + ->get(); + + if ($templates->isEmpty()) { + continue; + } + + $inspection = EquipmentInspection::where('equipment_id', $equipment->id) + ->where('inspection_cycle', $cycle) + ->where('year_month', $period) + ->first(); + + $details = []; + if ($inspection) { + $details = EquipmentInspectionDetail::where('inspection_id', $inspection->id) + ->get() + ->groupBy(function ($d) { + return $d->template_item_id.'_'.$d->check_date->format('Y-m-d'); + }); + } + + $result[] = [ + 'equipment' => $equipment, + 'templates' => $templates, + 'inspection' => $inspection, + 'details' => $details, + 'labels' => $labels, + 'can_inspect' => $equipment->canInspect($userId), + ]; + } + + return $result; + } + + public function toggleDetail(int $equipmentId, int $templateItemId, string $checkDate, string $cycle = 'daily'): array + { + return DB::transaction(function () use ($equipmentId, $templateItemId, $checkDate, $cycle) { + $equipment = Equipment::find($equipmentId); + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + $userId = $this->apiUserId(); + if (! $equipment->canInspect($userId)) { + throw new AccessDeniedHttpException(__('error.equipment.no_inspect_permission')); + } + + $period = InspectionCycle::resolvePeriod($cycle, $checkDate); + if ($cycle === InspectionCycle::DAILY) { + $tenantId = $this->tenantId(); + $holidayDates = InspectionCycle::getHolidayDates($cycle, $period, $tenantId); + if (InspectionCycle::isNonWorkingDay($checkDate, $holidayDates)) { + throw new BadRequestHttpException(__('error.equipment.non_working_day')); + } + } + + $tenantId = $this->tenantId(); + + $inspection = EquipmentInspection::firstOrCreate( + [ + 'tenant_id' => $tenantId, + 'equipment_id' => $equipmentId, + 'inspection_cycle' => $cycle, + 'year_month' => $period, + ], + [ + 'created_by' => $userId, + ] + ); + + $detail = EquipmentInspectionDetail::where('inspection_id', $inspection->id) + ->where('template_item_id', $templateItemId) + ->where('check_date', $checkDate) + ->first(); + + if ($detail) { + $nextResult = EquipmentInspectionDetail::getNextResult($detail->result); + if ($nextResult === null) { + $detail->delete(); + + return ['result' => null, 'symbol' => '']; + } + $detail->update(['result' => $nextResult]); + } else { + $detail = EquipmentInspectionDetail::create([ + 'inspection_id' => $inspection->id, + 'template_item_id' => $templateItemId, + 'check_date' => $checkDate, + 'result' => 'good', + ]); + $nextResult = 'good'; + } + + return [ + 'result' => $nextResult, + 'symbol' => EquipmentInspectionDetail::getResultSymbol($nextResult), + ]; + }); + } + + public function setResult(int $equipmentId, int $templateItemId, string $checkDate, string $cycle, ?string $result): array + { + return DB::transaction(function () use ($equipmentId, $templateItemId, $checkDate, $cycle, $result) { + $equipment = Equipment::find($equipmentId); + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + $userId = $this->apiUserId(); + if (! $equipment->canInspect($userId)) { + throw new AccessDeniedHttpException(__('error.equipment.no_inspect_permission')); + } + + $period = InspectionCycle::resolvePeriod($cycle, $checkDate); + if ($cycle === InspectionCycle::DAILY) { + $tenantId = $this->tenantId(); + $holidayDates = InspectionCycle::getHolidayDates($cycle, $period, $tenantId); + if (InspectionCycle::isNonWorkingDay($checkDate, $holidayDates)) { + throw new BadRequestHttpException(__('error.equipment.non_working_day')); + } + } + + $tenantId = $this->tenantId(); + + $inspection = EquipmentInspection::firstOrCreate( + [ + 'tenant_id' => $tenantId, + 'equipment_id' => $equipmentId, + 'inspection_cycle' => $cycle, + 'year_month' => $period, + ], + [ + 'created_by' => $userId, + ] + ); + + $detail = EquipmentInspectionDetail::where('inspection_id', $inspection->id) + ->where('template_item_id', $templateItemId) + ->where('check_date', $checkDate) + ->first(); + + if ($result === null) { + if ($detail) { + $detail->delete(); + } + + return ['result' => null, 'symbol' => '']; + } + + if ($detail) { + $detail->update(['result' => $result]); + } else { + $detail = EquipmentInspectionDetail::create([ + 'inspection_id' => $inspection->id, + 'template_item_id' => $templateItemId, + 'check_date' => $checkDate, + 'result' => $result, + ]); + } + + return [ + 'result' => $result, + 'symbol' => EquipmentInspectionDetail::getResultSymbol($result), + ]; + }); + } + + public function updateNotes(int $equipmentId, string $yearMonth, array $data, string $cycle = 'daily'): EquipmentInspection + { + return DB::transaction(function () use ($equipmentId, $yearMonth, $data, $cycle) { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $inspection = EquipmentInspection::firstOrCreate( + [ + 'tenant_id' => $tenantId, + 'equipment_id' => $equipmentId, + 'inspection_cycle' => $cycle, + 'year_month' => $yearMonth, + ], + [ + 'created_by' => $userId, + ] + ); + + $inspection->update($data); + + return $inspection->fresh(); + }); + } + + public function resetInspection(int $equipmentId, string $cycle, string $period): int + { + return DB::transaction(function () use ($equipmentId, $cycle, $period) { + $equipment = Equipment::find($equipmentId); + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + $userId = $this->apiUserId(); + if (! $equipment->canInspect($userId)) { + throw new AccessDeniedHttpException(__('error.equipment.no_inspect_permission')); + } + + $tenantId = $this->tenantId(); + + $inspection = EquipmentInspection::where('tenant_id', $tenantId) + ->where('equipment_id', $equipmentId) + ->where('inspection_cycle', $cycle) + ->where('year_month', $period) + ->first(); + + if (! $inspection) { + return 0; + } + + $deleted = EquipmentInspectionDetail::where('inspection_id', $inspection->id)->delete(); + $inspection->update([ + 'overall_judgment' => null, + 'repair_note' => null, + 'issue_note' => null, + 'inspector_id' => null, + ]); + + return $deleted; + }); + } + + public function saveTemplate(int $equipmentId, array $data): EquipmentInspectionTemplate + { + return DB::transaction(function () use ($equipmentId, $data) { + $tenantId = $this->tenantId(); + + return EquipmentInspectionTemplate::create(array_merge($data, [ + 'tenant_id' => $tenantId, + 'equipment_id' => $equipmentId, + ])); + }); + } + + public function updateTemplate(int $id, array $data): EquipmentInspectionTemplate + { + return DB::transaction(function () use ($id, $data) { + $template = EquipmentInspectionTemplate::find($id); + + if (! $template) { + throw new NotFoundHttpException(__('error.equipment.template_not_found')); + } + + $template->update($data); + + return $template->fresh(); + }); + } + + public function deleteTemplate(int $id): bool + { + return DB::transaction(function () use ($id) { + $template = EquipmentInspectionTemplate::find($id); + + if (! $template) { + throw new NotFoundHttpException(__('error.equipment.template_not_found')); + } + + return $template->delete(); + }); + } + + public function copyTemplates(int $equipmentId, string $sourceCycle, array $targetCycles): array + { + return DB::transaction(function () use ($equipmentId, $sourceCycle, $targetCycles) { + $tenantId = $this->tenantId(); + + $sourceTemplates = EquipmentInspectionTemplate::where('equipment_id', $equipmentId) + ->byCycle($sourceCycle) + ->active() + ->orderBy('sort_order') + ->get(); + + if ($sourceTemplates->isEmpty()) { + throw new BadRequestHttpException(__('error.equipment.no_source_templates')); + } + + $copiedCount = 0; + $skippedCount = 0; + + foreach ($targetCycles as $targetCycle) { + foreach ($sourceTemplates as $template) { + $exists = EquipmentInspectionTemplate::where('equipment_id', $equipmentId) + ->where('inspection_cycle', $targetCycle) + ->where('item_no', $template->item_no) + ->exists(); + + if ($exists) { + $skippedCount++; + + continue; + } + + EquipmentInspectionTemplate::create([ + 'tenant_id' => $tenantId, + 'equipment_id' => $equipmentId, + 'inspection_cycle' => $targetCycle, + 'item_no' => $template->item_no, + 'check_point' => $template->check_point, + 'check_item' => $template->check_item, + 'check_timing' => $template->check_timing, + 'check_frequency' => $template->check_frequency, + 'check_method' => $template->check_method, + 'sort_order' => $template->sort_order, + 'is_active' => true, + ]); + $copiedCount++; + } + } + + return [ + 'copied' => $copiedCount, + 'skipped' => $skippedCount, + 'source_count' => $sourceTemplates->count(), + 'target_cycles' => $targetCycles, + ]; + }); + } + + public function getActiveCycles(int $equipmentId): array + { + return EquipmentInspectionTemplate::where('equipment_id', $equipmentId) + ->active() + ->distinct() + ->pluck('inspection_cycle') + ->toArray(); + } +} diff --git a/app/Services/Equipment/EquipmentRepairService.php b/app/Services/Equipment/EquipmentRepairService.php new file mode 100644 index 0000000..47826b0 --- /dev/null +++ b/app/Services/Equipment/EquipmentRepairService.php @@ -0,0 +1,102 @@ +with('equipment', 'repairer'); + + if (! empty($filters['equipment_id'])) { + $query->where('equipment_id', $filters['equipment_id']); + } + + if (! empty($filters['repair_type'])) { + $query->where('repair_type', $filters['repair_type']); + } + + if (! empty($filters['date_from'])) { + $query->where('repair_date', '>=', $filters['date_from']); + } + + if (! empty($filters['date_to'])) { + $query->where('repair_date', '<=', $filters['date_to']); + } + + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('description', 'like', "%{$search}%") + ->orWhereHas('equipment', function ($eq) use ($search) { + $eq->where('name', 'like', "%{$search}%") + ->orWhere('equipment_code', 'like', "%{$search}%"); + }); + }); + } + + return $query->orderBy('repair_date', 'desc')->paginate($filters['per_page'] ?? 20); + } + + public function show(int $id): EquipmentRepair + { + $repair = EquipmentRepair::with('equipment', 'repairer')->find($id); + + if (! $repair) { + throw new NotFoundHttpException(__('error.equipment.repair_not_found')); + } + + return $repair; + } + + public function store(array $data): EquipmentRepair + { + return DB::transaction(function () use ($data) { + $data['tenant_id'] = $this->tenantId(); + + return EquipmentRepair::create($data); + }); + } + + public function update(int $id, array $data): EquipmentRepair + { + return DB::transaction(function () use ($id, $data) { + $repair = EquipmentRepair::find($id); + + if (! $repair) { + throw new NotFoundHttpException(__('error.equipment.repair_not_found')); + } + + $repair->update($data); + + return $repair->fresh(); + }); + } + + public function destroy(int $id): bool + { + return DB::transaction(function () use ($id) { + $repair = EquipmentRepair::find($id); + + if (! $repair) { + throw new NotFoundHttpException(__('error.equipment.repair_not_found')); + } + + return $repair->delete(); + }); + } + + public function recentRepairs(int $limit = 5): \Illuminate\Database\Eloquent\Collection + { + return EquipmentRepair::with('equipment') + ->orderBy('repair_date', 'desc') + ->limit($limit) + ->get(); + } +} diff --git a/app/Services/Equipment/EquipmentService.php b/app/Services/Equipment/EquipmentService.php new file mode 100644 index 0000000..7d736d5 --- /dev/null +++ b/app/Services/Equipment/EquipmentService.php @@ -0,0 +1,153 @@ +with(['manager', 'subManager']); + + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('equipment_code', 'like', "%{$search}%") + ->orWhere('name', 'like', "%{$search}%"); + }); + } + + if (! empty($filters['status'])) { + $query->byStatus($filters['status']); + } + + if (! empty($filters['production_line'])) { + $query->byLine($filters['production_line']); + } + + if (! empty($filters['equipment_type'])) { + $query->byType($filters['equipment_type']); + } + + $sortBy = $filters['sort_by'] ?? 'sort_order'; + $sortDir = $filters['sort_direction'] ?? 'asc'; + $query->orderBy($sortBy, $sortDir); + + return $query->paginate($filters['per_page'] ?? 20); + } + + public function show(int $id): Equipment + { + $equipment = Equipment::with(['manager', 'subManager', 'inspectionTemplates', 'repairs', 'processes', 'photos'])->find($id); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + return $equipment; + } + + public function store(array $data): Equipment + { + return DB::transaction(function () use ($data) { + $data['tenant_id'] = $this->tenantId(); + + return Equipment::create($data); + }); + } + + public function update(int $id, array $data): Equipment + { + return DB::transaction(function () use ($id, $data) { + $equipment = Equipment::find($id); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + $equipment->update($data); + + return $equipment->fresh(); + }); + } + + public function destroy(int $id): bool + { + return DB::transaction(function () use ($id) { + $equipment = Equipment::find($id); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + return $equipment->delete(); + }); + } + + public function restore(int $id): Equipment + { + return DB::transaction(function () use ($id) { + $equipment = Equipment::onlyTrashed()->find($id); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + $equipment->restore(); + + return $equipment->fresh(); + }); + } + + public function toggleActive(int $id): Equipment + { + return DB::transaction(function () use ($id) { + $equipment = Equipment::find($id); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + $equipment->update(['is_active' => ! $equipment->is_active]); + + return $equipment->fresh(); + }); + } + + public function stats(): array + { + $total = Equipment::count(); + $active = Equipment::where('status', 'active')->count(); + $idle = Equipment::where('status', 'idle')->count(); + $disposed = Equipment::where('status', 'disposed')->count(); + + return compact('total', 'active', 'idle', 'disposed'); + } + + public function options(): array + { + return [ + 'equipment_types' => Equipment::getEquipmentTypes(), + 'production_lines' => Equipment::getProductionLines(), + 'statuses' => Equipment::getStatuses(), + 'equipment_list' => Equipment::active() + ->orderBy('sort_order') + ->orderBy('name') + ->get(['id', 'equipment_code', 'name', 'equipment_type', 'production_line']), + ]; + } + + public function typeStats(): array + { + return Equipment::where('status', '!=', 'disposed') + ->selectRaw('equipment_type, count(*) as count') + ->groupBy('equipment_type') + ->pluck('count', 'equipment_type') + ->toArray(); + } +} 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/HometaxInvoiceService.php b/app/Services/HometaxInvoiceService.php new file mode 100644 index 0000000..75a7bf9 --- /dev/null +++ b/app/Services/HometaxInvoiceService.php @@ -0,0 +1,222 @@ +listByType('sales', $params); + } + + /** + * 매입 세금계산서 목록 + */ + public function purchases(array $params): array + { + return $this->listByType('purchase', $params); + } + + /** + * 세금계산서 상세 조회 + */ + public function show(int $id): ?HometaxInvoice + { + return HometaxInvoice::where('tenant_id', $this->tenantId()) + ->with('journals') + ->find($id); + } + + /** + * 분개 저장 (홈택스 자체 분개 테이블 사용) + */ + public function saveJournals(int $invoiceId, array $items): array + { + $tenantId = $this->tenantId(); + $invoice = HometaxInvoice::where('tenant_id', $tenantId)->findOrFail($invoiceId); + + return DB::transaction(function () use ($tenantId, $invoice, $items) { + // 기존 분개 삭제 + HometaxInvoiceJournal::where('tenant_id', $tenantId) + ->where('hometax_invoice_id', $invoice->id) + ->delete(); + + $created = []; + foreach ($items as $index => $item) { + $created[] = HometaxInvoiceJournal::create([ + 'tenant_id' => $tenantId, + 'hometax_invoice_id' => $invoice->id, + 'nts_confirm_num' => $invoice->nts_confirm_num, + 'dc_type' => $item['dc_type'], + 'account_code' => $item['account_code'], + 'account_name' => $item['account_name'] ?? null, + 'debit_amount' => $item['debit_amount'] ?? 0, + 'credit_amount' => $item['credit_amount'] ?? 0, + 'description' => $item['description'] ?? null, + 'sort_order' => $index + 1, + 'invoice_type' => $invoice->invoice_type, + 'write_date' => $invoice->write_date, + 'supply_amount' => $invoice->supply_amount, + 'tax_amount' => $invoice->tax_amount, + 'total_amount' => $invoice->total_amount, + 'trading_partner_name' => $invoice->invoice_type === 'sales' + ? $invoice->invoicee_corp_name + : $invoice->invoicer_corp_name, + ]); + } + + return ['items' => $created, 'count' => count($created)]; + }); + } + + /** + * 분개 조회 + */ + public function getJournals(int $invoiceId): array + { + $tenantId = $this->tenantId(); + $journals = HometaxInvoiceJournal::getByInvoiceId($tenantId, $invoiceId); + + return ['items' => $journals]; + } + + /** + * 분개 삭제 + */ + public function deleteJournals(int $invoiceId): array + { + $tenantId = $this->tenantId(); + $deleted = HometaxInvoiceJournal::where('tenant_id', $tenantId) + ->where('hometax_invoice_id', $invoiceId) + ->delete(); + + return ['deleted_count' => $deleted]; + } + + /** + * 수동 세금계산서 등록 + */ + public function storeManual(array $data): HometaxInvoice + { + $tenantId = $this->tenantId(); + + return HometaxInvoice::create(array_merge($data, [ + 'tenant_id' => $tenantId, + ])); + } + + /** + * 수동 세금계산서 수정 + */ + public function updateManual(int $id, array $data): HometaxInvoice + { + $invoice = HometaxInvoice::where('tenant_id', $this->tenantId())->findOrFail($id); + $invoice->update($data); + + return $invoice->fresh(); + } + + /** + * 수동 세금계산서 삭제 (soft delete) + */ + public function destroyManual(int $id): bool + { + $invoice = HometaxInvoice::where('tenant_id', $this->tenantId())->findOrFail($id); + + return $invoice->delete(); + } + + /** + * 요약 통계 + */ + public function summary(array $params): array + { + $tenantId = $this->tenantId(); + $startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d'); + $endDate = $params['end_date'] ?? now()->format('Y-m-d'); + + $salesQuery = HometaxInvoice::where('tenant_id', $tenantId) + ->sales() + ->period($startDate, $endDate); + + $purchaseQuery = HometaxInvoice::where('tenant_id', $tenantId) + ->purchase() + ->period($startDate, $endDate); + + return [ + 'sales' => [ + 'count' => (clone $salesQuery)->count(), + 'supply_amount' => (int) (clone $salesQuery)->sum('supply_amount'), + 'tax_amount' => (int) (clone $salesQuery)->sum('tax_amount'), + 'total_amount' => (int) (clone $salesQuery)->sum('total_amount'), + ], + 'purchase' => [ + 'count' => (clone $purchaseQuery)->count(), + 'supply_amount' => (int) (clone $purchaseQuery)->sum('supply_amount'), + 'tax_amount' => (int) (clone $purchaseQuery)->sum('tax_amount'), + 'total_amount' => (int) (clone $purchaseQuery)->sum('total_amount'), + ], + ]; + } + + /** + * 타입별 목록 조회 (공통) + */ + private function listByType(string $invoiceType, array $params): array + { + $tenantId = $this->tenantId(); + $startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d'); + $endDate = $params['end_date'] ?? now()->format('Y-m-d'); + $search = $params['search'] ?? null; + $perPage = $params['per_page'] ?? 50; + + $query = HometaxInvoice::where('tenant_id', $tenantId) + ->where('invoice_type', $invoiceType) + ->period($startDate, $endDate); + + if ($search) { + $query->where(function ($q) use ($search, $invoiceType) { + if ($invoiceType === 'sales') { + $q->where('invoicee_corp_name', 'like', "%{$search}%") + ->orWhere('invoicee_corp_num', 'like', "%{$search}%"); + } else { + $q->where('invoicer_corp_name', 'like', "%{$search}%") + ->orWhere('invoicer_corp_num', 'like', "%{$search}%"); + } + $q->orWhere('nts_confirm_num', 'like', "%{$search}%") + ->orWhere('item_name', 'like', "%{$search}%"); + }); + } + + $query->orderByDesc('write_date')->orderByDesc('issue_date'); + + $invoices = $query->paginate($perPage); + + // 분개 존재 여부 로드 + $invoiceIds = $invoices->getCollection()->pluck('id')->toArray(); + $journaledIds = HometaxInvoiceJournal::getJournaledInvoiceIds($tenantId, $invoiceIds); + + $invoices->getCollection()->transform(function ($invoice) use ($journaledIds) { + $invoice->has_journal = in_array($invoice->id, $journaledIds); + + return $invoice; + }); + + return [ + 'data' => $invoices, + ]; + } +} diff --git a/app/Services/PayrollService.php b/app/Services/PayrollService.php index ca49c9d..2413047 100644 --- a/app/Services/PayrollService.php +++ b/app/Services/PayrollService.php @@ -2,8 +2,11 @@ namespace App\Services; +use App\Models\Tenants\IncomeTaxBracket; +use App\Models\Tenants\JournalEntry; use App\Models\Tenants\Payroll; use App\Models\Tenants\PayrollSetting; +use App\Models\Tenants\TenantUserProfile; use App\Models\Tenants\Withdrawal; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; @@ -12,13 +15,12 @@ class PayrollService extends Service { + private const TAX_TABLE_YEAR = 2024; + // ========================================================================= // 급여 목록/상세 // ========================================================================= - /** - * 급여 목록 - */ public function index(array $params): LengthAwarePaginator { $tenantId = $this->tenantId(); @@ -27,34 +29,30 @@ public function index(array $params): LengthAwarePaginator ->where('tenant_id', $tenantId) ->with(['user:id,name,email', 'creator:id,name']); - // 연도 필터 if (! empty($params['year'])) { $query->where('pay_year', $params['year']); } - - // 월 필터 if (! empty($params['month'])) { $query->where('pay_month', $params['month']); } - - // 사용자 필터 if (! empty($params['user_id'])) { $query->where('user_id', $params['user_id']); } - - // 상태 필터 if (! empty($params['status'])) { $query->where('status', $params['status']); } - - // 검색 (사용자명) if (! empty($params['search'])) { $query->whereHas('user', function ($q) use ($params) { $q->where('name', 'like', "%{$params['search']}%"); }); } + if (! empty($params['department_id'])) { + $deptId = $params['department_id']; + $query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) { + $q->where('tenant_id', $tenantId)->where('department_id', $deptId); + }); + } - // 정렬 $sortBy = $params['sort_by'] ?? 'pay_year'; $sortDir = $params['sort_dir'] ?? 'desc'; @@ -64,14 +62,9 @@ public function index(array $params): LengthAwarePaginator $query->orderBy($sortBy, $sortDir); } - $perPage = $params['per_page'] ?? 20; - - return $query->paginate($perPage); + return $query->paginate($params['per_page'] ?? 20); } - /** - * 특정 연월 급여 요약 - */ public function summary(int $year, int $month): array { $tenantId = $this->tenantId(); @@ -98,27 +91,17 @@ public function summary(int $year, int $month): array 'draft_count' => (int) $stats->draft_count, 'confirmed_count' => (int) $stats->confirmed_count, 'paid_count' => (int) $stats->paid_count, - 'total_gross' => (float) $stats->total_gross, - 'total_deductions' => (float) $stats->total_deductions, - 'total_net' => (float) $stats->total_net, + 'total_gross' => (int) $stats->total_gross, + 'total_deductions' => (int) $stats->total_deductions, + 'total_net' => (int) $stats->total_net, ]; } - /** - * 급여 상세 - */ public function show(int $id): Payroll { - $tenantId = $this->tenantId(); - return Payroll::query() - ->where('tenant_id', $tenantId) - ->with([ - 'user:id,name,email', - 'confirmer:id,name', - 'withdrawal', - 'creator:id,name', - ]) + ->where('tenant_id', $this->tenantId()) + ->with(['user:id,name,email', 'confirmer:id,name', 'withdrawal', 'creator:id,name']) ->findOrFail($id); } @@ -126,59 +109,60 @@ public function show(int $id): Payroll // 급여 생성/수정/삭제 // ========================================================================= - /** - * 급여 생성 - */ public function store(array $data): Payroll { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - // 중복 확인 - $exists = Payroll::query() - ->where('tenant_id', $tenantId) - ->where('user_id', $data['user_id']) - ->where('pay_year', $data['pay_year']) - ->where('pay_month', $data['pay_month']) - ->exists(); + return DB::transaction(function () use ($data, $tenantId, $userId) { + // 중복 확인 (soft-deleted 포함) + $existing = Payroll::withTrashed() + ->where('tenant_id', $tenantId) + ->where('user_id', $data['user_id']) + ->where('pay_year', $data['pay_year']) + ->where('pay_month', $data['pay_month']) + ->first(); - if ($exists) { - throw new BadRequestHttpException(__('error.payroll.already_exists')); - } + if ($existing && ! $existing->trashed()) { + throw new BadRequestHttpException(__('error.payroll.already_exists')); + } + if ($existing && $existing->trashed()) { + $existing->forceDelete(); + } - // 금액 계산 - $grossSalary = $this->calculateGross($data); - $totalDeductions = $this->calculateDeductions($data); - $netSalary = $grossSalary - $totalDeductions; + // 자동 계산 + $settings = PayrollSetting::getOrCreate($tenantId); + $familyCount = $data['family_count'] ?? $this->resolveFamilyCount($data['user_id']); + $calculated = $this->calculateAmounts($data, $settings, $familyCount); + $this->applyDeductionOverrides($calculated, $data['deduction_overrides'] ?? null); - return Payroll::create([ - 'tenant_id' => $tenantId, - 'user_id' => $data['user_id'], - 'pay_year' => $data['pay_year'], - 'pay_month' => $data['pay_month'], - 'base_salary' => $data['base_salary'] ?? 0, - 'overtime_pay' => $data['overtime_pay'] ?? 0, - 'bonus' => $data['bonus'] ?? 0, - 'allowances' => $data['allowances'] ?? null, - 'gross_salary' => $grossSalary, - 'income_tax' => $data['income_tax'] ?? 0, - 'resident_tax' => $data['resident_tax'] ?? 0, - 'health_insurance' => $data['health_insurance'] ?? 0, - 'pension' => $data['pension'] ?? 0, - 'employment_insurance' => $data['employment_insurance'] ?? 0, - 'deductions' => $data['deductions'] ?? null, - 'total_deductions' => $totalDeductions, - 'net_salary' => $netSalary, - 'status' => Payroll::STATUS_DRAFT, - 'note' => $data['note'] ?? null, - 'created_by' => $userId, - 'updated_by' => $userId, - ]); + return Payroll::create([ + 'tenant_id' => $tenantId, + 'user_id' => $data['user_id'], + 'pay_year' => $data['pay_year'], + 'pay_month' => $data['pay_month'], + 'base_salary' => $data['base_salary'] ?? 0, + 'overtime_pay' => $data['overtime_pay'] ?? 0, + 'bonus' => $data['bonus'] ?? 0, + 'allowances' => $data['allowances'] ?? null, + 'gross_salary' => $calculated['gross_salary'], + 'income_tax' => $calculated['income_tax'], + 'resident_tax' => $calculated['resident_tax'], + 'health_insurance' => $calculated['health_insurance'], + 'long_term_care' => $calculated['long_term_care'], + 'pension' => $calculated['pension'], + 'employment_insurance' => $calculated['employment_insurance'], + 'deductions' => $data['deductions'] ?? null, + 'total_deductions' => $calculated['total_deductions'], + 'net_salary' => $calculated['net_salary'], + 'status' => Payroll::STATUS_DRAFT, + 'note' => $data['note'] ?? null, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + }); } - /** - * 급여 수정 - */ public function update(int $id, array $data): Payroll { $tenantId = $this->tenantId(); @@ -188,11 +172,12 @@ public function update(int $id, array $data): Payroll ->where('tenant_id', $tenantId) ->findOrFail($id); - if (! $payroll->isEditable()) { + $isSuperAdmin = $data['_is_super_admin'] ?? false; + if (! $payroll->isEditable($isSuperAdmin)) { throw new BadRequestHttpException(__('error.payroll.not_editable')); } - // 연월 변경 시 중복 확인 + // 연월/사원 변경 시 중복 확인 $newYear = $data['pay_year'] ?? $payroll->pay_year; $newMonth = $data['pay_month'] ?? $payroll->pay_month; $newUserId = $data['user_id'] ?? $payroll->user_id; @@ -211,41 +196,63 @@ public function update(int $id, array $data): Payroll } } - // 금액 업데이트 - $updateData = array_merge($payroll->toArray(), $data); - $grossSalary = $this->calculateGross($updateData); - $totalDeductions = $this->calculateDeductions($updateData); - $netSalary = $grossSalary - $totalDeductions; + // 지급 항목 (신규 입력값 또는 기존값) + $baseSalary = (float) ($data['base_salary'] ?? $payroll->base_salary); + $overtimePay = (float) ($data['overtime_pay'] ?? $payroll->overtime_pay); + $bonus = (float) ($data['bonus'] ?? $payroll->bonus); + $allowances = array_key_exists('allowances', $data) ? $data['allowances'] : $payroll->allowances; + $deductions = array_key_exists('deductions', $data) ? $data['deductions'] : $payroll->deductions; - $payroll->fill([ - 'user_id' => $data['user_id'] ?? $payroll->user_id, - 'pay_year' => $data['pay_year'] ?? $payroll->pay_year, - 'pay_month' => $data['pay_month'] ?? $payroll->pay_month, - 'base_salary' => $data['base_salary'] ?? $payroll->base_salary, - 'overtime_pay' => $data['overtime_pay'] ?? $payroll->overtime_pay, - 'bonus' => $data['bonus'] ?? $payroll->bonus, - 'allowances' => $data['allowances'] ?? $payroll->allowances, + $allowancesTotal = 0; + $allowancesArr = is_string($allowances) ? json_decode($allowances, true) : $allowances; + foreach ($allowancesArr ?? [] as $allowance) { + $allowancesTotal += (float) ($allowance['amount'] ?? 0); + } + $grossSalary = (int) ($baseSalary + $overtimePay + $bonus + $allowancesTotal); + + // 공제 항목 (수동 수정값 우선, 없으면 기존값 유지) + $overrides = $data['deduction_overrides'] ?? []; + $incomeTax = isset($overrides['income_tax']) ? (int) $overrides['income_tax'] : (int) $payroll->income_tax; + $residentTax = isset($overrides['resident_tax']) ? (int) $overrides['resident_tax'] : (int) $payroll->resident_tax; + $healthInsurance = isset($overrides['health_insurance']) ? (int) $overrides['health_insurance'] : (int) $payroll->health_insurance; + $longTermCare = isset($overrides['long_term_care']) ? (int) $overrides['long_term_care'] : (int) $payroll->long_term_care; + $pension = isset($overrides['pension']) ? (int) $overrides['pension'] : (int) $payroll->pension; + $employmentInsurance = isset($overrides['employment_insurance']) ? (int) $overrides['employment_insurance'] : (int) $payroll->employment_insurance; + + $extraDeductions = 0; + $deductionsArr = is_string($deductions) ? json_decode($deductions, true) : $deductions; + foreach ($deductionsArr ?? [] as $deduction) { + $extraDeductions += (float) ($deduction['amount'] ?? 0); + } + + $totalDeductions = (int) ($incomeTax + $residentTax + $healthInsurance + $longTermCare + $pension + $employmentInsurance + $extraDeductions); + $netSalary = (int) max(0, $grossSalary - $totalDeductions); + + $payroll->update([ + 'user_id' => $newUserId, + 'pay_year' => $newYear, + 'pay_month' => $newMonth, + 'base_salary' => $baseSalary, + 'overtime_pay' => $overtimePay, + 'bonus' => $bonus, + 'allowances' => $allowances, 'gross_salary' => $grossSalary, - 'income_tax' => $data['income_tax'] ?? $payroll->income_tax, - 'resident_tax' => $data['resident_tax'] ?? $payroll->resident_tax, - 'health_insurance' => $data['health_insurance'] ?? $payroll->health_insurance, - 'pension' => $data['pension'] ?? $payroll->pension, - 'employment_insurance' => $data['employment_insurance'] ?? $payroll->employment_insurance, - 'deductions' => $data['deductions'] ?? $payroll->deductions, + 'income_tax' => $incomeTax, + 'resident_tax' => $residentTax, + 'health_insurance' => $healthInsurance, + 'long_term_care' => $longTermCare, + 'pension' => $pension, + 'employment_insurance' => $employmentInsurance, + 'deductions' => $deductions, 'total_deductions' => $totalDeductions, 'net_salary' => $netSalary, 'note' => $data['note'] ?? $payroll->note, 'updated_by' => $userId, ]); - $payroll->save(); - return $payroll->fresh(['user:id,name,email', 'creator:id,name']); } - /** - * 급여 삭제 - */ public function destroy(int $id): bool { $tenantId = $this->tenantId(); @@ -267,12 +274,9 @@ public function destroy(int $id): bool } // ========================================================================= - // 급여 확정/지급 + // 상태 관리 (확정/지급/취소) // ========================================================================= - /** - * 급여 확정 - */ public function confirm(int $id): Payroll { $tenantId = $this->tenantId(); @@ -286,18 +290,39 @@ public function confirm(int $id): Payroll throw new BadRequestHttpException(__('error.payroll.not_confirmable')); } - $payroll->status = Payroll::STATUS_CONFIRMED; - $payroll->confirmed_at = now(); - $payroll->confirmed_by = $userId; - $payroll->updated_by = $userId; - $payroll->save(); + $payroll->update([ + 'status' => Payroll::STATUS_CONFIRMED, + 'confirmed_at' => now(), + 'confirmed_by' => $userId, + 'updated_by' => $userId, + ]); return $payroll->fresh(['user:id,name,email', 'confirmer:id,name']); } - /** - * 급여 지급 처리 - */ + public function unconfirm(int $id): Payroll + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $payroll = Payroll::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $payroll->isUnconfirmable()) { + throw new BadRequestHttpException(__('error.payroll.not_unconfirmable')); + } + + $payroll->update([ + 'status' => Payroll::STATUS_DRAFT, + 'confirmed_at' => null, + 'confirmed_by' => null, + 'updated_by' => $userId, + ]); + + return $payroll->fresh(['user:id,name,email']); + } + public function pay(int $id, ?int $withdrawalId = null): Payroll { $tenantId = $this->tenantId(); @@ -312,7 +337,6 @@ public function pay(int $id, ?int $withdrawalId = null): Payroll throw new BadRequestHttpException(__('error.payroll.not_payable')); } - // 출금 내역 연결 검증 if ($withdrawalId) { $withdrawal = Withdrawal::query() ->where('tenant_id', $tenantId) @@ -324,19 +348,42 @@ public function pay(int $id, ?int $withdrawalId = null): Payroll } } - $payroll->status = Payroll::STATUS_PAID; - $payroll->paid_at = now(); - $payroll->withdrawal_id = $withdrawalId; - $payroll->updated_by = $userId; - $payroll->save(); + $payroll->update([ + 'status' => Payroll::STATUS_PAID, + 'paid_at' => now(), + 'withdrawal_id' => $withdrawalId, + 'updated_by' => $userId, + ]); return $payroll->fresh(['user:id,name,email', 'withdrawal']); }); } - /** - * 일괄 확정 - */ + public function unpay(int $id): Payroll + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $payroll = Payroll::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $payroll->isUnpayable()) { + throw new BadRequestHttpException(__('error.payroll.not_unpayable')); + } + + $payroll->update([ + 'status' => Payroll::STATUS_DRAFT, + 'confirmed_at' => null, + 'confirmed_by' => null, + 'paid_at' => null, + 'withdrawal_id' => null, + 'updated_by' => $userId, + ]); + + return $payroll->fresh(['user:id,name,email']); + } + public function bulkConfirm(int $year, int $month): int { $tenantId = $this->tenantId(); @@ -356,84 +403,175 @@ public function bulkConfirm(int $year, int $month): int } // ========================================================================= - // 급여명세서 + // 일괄 처리 (생성/복사/계산) // ========================================================================= /** - * 급여명세서 데이터 + * 재직사원 일괄 생성 */ - public function payslip(int $id): array + public function bulkGenerate(int $year, int $month): array { - $payroll = $this->show($id); + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + $settings = PayrollSetting::getOrCreate($tenantId); + $created = 0; + $skipped = 0; - // 수당 목록 - $allowances = collect($payroll->allowances ?? [])->map(function ($item) { - return [ - 'name' => $item['name'] ?? '', - 'amount' => (float) ($item['amount'] ?? 0), - ]; - })->toArray(); + $employees = TenantUserProfile::query() + ->with('user:id,name') + ->where('tenant_id', $tenantId) + ->where('employee_status', 'active') + ->whereHas('user') + ->get(); - // 공제 목록 - $deductions = collect($payroll->deductions ?? [])->map(function ($item) { - return [ - 'name' => $item['name'] ?? '', - 'amount' => (float) ($item['amount'] ?? 0), - ]; - })->toArray(); + DB::transaction(function () use ($employees, $tenantId, $year, $month, $settings, $userId, &$created, &$skipped) { + foreach ($employees as $employee) { + $existing = Payroll::withTrashed() + ->where('tenant_id', $tenantId) + ->where('user_id', $employee->user_id) + ->forPeriod($year, $month) + ->first(); - return [ - 'payroll' => $payroll, - 'period' => $payroll->period_label, - 'employee' => [ - 'id' => $payroll->user->id, - 'name' => $payroll->user->name, - 'email' => $payroll->user->email, - ], - 'earnings' => [ - 'base_salary' => (float) $payroll->base_salary, - 'overtime_pay' => (float) $payroll->overtime_pay, - 'bonus' => (float) $payroll->bonus, - 'allowances' => $allowances, - 'allowances_total' => (float) $payroll->allowances_total, - 'gross_total' => (float) $payroll->gross_salary, - ], - 'deductions' => [ - 'income_tax' => (float) $payroll->income_tax, - 'resident_tax' => (float) $payroll->resident_tax, - 'health_insurance' => (float) $payroll->health_insurance, - 'pension' => (float) $payroll->pension, - 'employment_insurance' => (float) $payroll->employment_insurance, - 'other_deductions' => $deductions, - 'other_total' => (float) $payroll->deductions_total, - 'total' => (float) $payroll->total_deductions, - ], - 'net_salary' => (float) $payroll->net_salary, - 'status' => $payroll->status, - 'status_label' => $payroll->status_label, - 'paid_at' => $payroll->paid_at?->toIso8601String(), - ]; + if ($existing && ! $existing->trashed()) { + $skipped++; + + continue; + } + if ($existing && $existing->trashed()) { + $existing->forceDelete(); + } + + // 연봉에서 월급 산출 + $salaryInfo = $employee->json_extra['salary_info'] ?? $employee->json_extra ?? []; + $annualSalary = $salaryInfo['annual_salary'] ?? ($employee->json_extra['salary'] ?? 0); + $baseSalary = $annualSalary > 0 ? (int) round($annualSalary / 12) : 0; + + $data = [ + 'base_salary' => $baseSalary, + 'overtime_pay' => 0, + 'bonus' => 0, + 'allowances' => null, + 'deductions' => null, + ]; + + // 피부양자 기반 가족수 산출 + $dependents = $employee->json_extra['dependents'] ?? []; + $familyCount = 1 + collect($dependents) + ->where('is_dependent', true)->count(); + + $calculated = $this->calculateAmounts($data, $settings, $familyCount); + + Payroll::create([ + 'tenant_id' => $tenantId, + 'user_id' => $employee->user_id, + 'pay_year' => $year, + 'pay_month' => $month, + 'base_salary' => $baseSalary, + 'overtime_pay' => 0, + 'bonus' => 0, + 'allowances' => null, + 'gross_salary' => $calculated['gross_salary'], + 'income_tax' => $calculated['income_tax'], + 'resident_tax' => $calculated['resident_tax'], + 'health_insurance' => $calculated['health_insurance'], + 'long_term_care' => $calculated['long_term_care'], + 'pension' => $calculated['pension'], + 'employment_insurance' => $calculated['employment_insurance'], + 'deductions' => null, + 'total_deductions' => $calculated['total_deductions'], + 'net_salary' => $calculated['net_salary'], + 'status' => Payroll::STATUS_DRAFT, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + $created++; + } + }); + + return ['created' => $created, 'skipped' => $skipped]; } - // ========================================================================= - // 급여 일괄 계산 - // ========================================================================= + /** + * 전월 급여 복사 + */ + public function copyFromPreviousMonth(int $year, int $month): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $prevYear = $month === 1 ? $year - 1 : $year; + $prevMonth = $month === 1 ? 12 : $month - 1; + + $previousPayrolls = Payroll::query() + ->where('tenant_id', $tenantId) + ->forPeriod($prevYear, $prevMonth) + ->get(); + + if ($previousPayrolls->isEmpty()) { + throw new BadRequestHttpException(__('error.payroll.no_previous_month')); + } + + $created = 0; + $skipped = 0; + + DB::transaction(function () use ($previousPayrolls, $tenantId, $year, $month, $userId, &$created, &$skipped) { + foreach ($previousPayrolls as $prev) { + $existing = Payroll::withTrashed() + ->where('tenant_id', $tenantId) + ->where('user_id', $prev->user_id) + ->forPeriod($year, $month) + ->first(); + + if ($existing && ! $existing->trashed()) { + $skipped++; + + continue; + } + if ($existing && $existing->trashed()) { + $existing->forceDelete(); + } + + Payroll::create([ + 'tenant_id' => $tenantId, + 'user_id' => $prev->user_id, + 'pay_year' => $year, + 'pay_month' => $month, + 'base_salary' => $prev->base_salary, + 'overtime_pay' => $prev->overtime_pay, + 'bonus' => $prev->bonus, + 'allowances' => $prev->allowances, + 'gross_salary' => $prev->gross_salary, + 'income_tax' => $prev->income_tax, + 'resident_tax' => $prev->resident_tax, + 'health_insurance' => $prev->health_insurance, + 'long_term_care' => $prev->long_term_care, + 'pension' => $prev->pension, + 'employment_insurance' => $prev->employment_insurance, + 'deductions' => $prev->deductions, + 'total_deductions' => $prev->total_deductions, + 'net_salary' => $prev->net_salary, + 'status' => Payroll::STATUS_DRAFT, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + $created++; + } + }); + + return ['created' => $created, 'skipped' => $skipped]; + } /** - * 급여 일괄 계산 (생성 또는 업데이트) + * 급여 일괄 계산 (기존 draft 급여 재계산) */ public function calculate(int $year, int $month, ?array $userIds = null): Collection { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - - // 급여 설정 가져오기 $settings = PayrollSetting::getOrCreate($tenantId); - // 대상 사용자 조회 - // TODO: 실제로는 직원 목록에서 급여 대상자를 조회해야 함 - // 여기서는 기존 급여 데이터만 업데이트 - return DB::transaction(function () use ($year, $month, $userIds, $tenantId, $userId, $settings) { $query = Payroll::query() ->where('tenant_id', $tenantId) @@ -448,57 +586,118 @@ public function calculate(int $year, int $month, ?array $userIds = null): Collec $payrolls = $query->get(); foreach ($payrolls as $payroll) { - // 4대보험 재계산 - $baseSalary = (float) $payroll->base_salary; + $familyCount = $this->resolveFamilyCount($payroll->user_id); - $healthInsurance = $settings->calculateHealthInsurance($baseSalary); - $longTermCare = $settings->calculateLongTermCare($healthInsurance); - $pension = $settings->calculatePension($baseSalary); - $employmentInsurance = $settings->calculateEmploymentInsurance($baseSalary); + $data = [ + 'base_salary' => (float) $payroll->base_salary, + 'overtime_pay' => (float) $payroll->overtime_pay, + 'bonus' => (float) $payroll->bonus, + 'allowances' => $payroll->allowances, + 'deductions' => $payroll->deductions, + ]; - // 건강보험에 장기요양보험 포함 - $totalHealthInsurance = $healthInsurance + $longTermCare; + $calculated = $this->calculateAmounts($data, $settings, $familyCount); - $payroll->health_insurance = $totalHealthInsurance; - $payroll->pension = $pension; - $payroll->employment_insurance = $employmentInsurance; - - // 주민세 재계산 - $payroll->resident_tax = $settings->calculateResidentTax($payroll->income_tax); - - // 총액 재계산 - $payroll->total_deductions = $payroll->calculateTotalDeductions(); - $payroll->net_salary = $payroll->calculateNetSalary(); - $payroll->updated_by = $userId; - $payroll->save(); + $payroll->update([ + 'gross_salary' => $calculated['gross_salary'], + 'income_tax' => $calculated['income_tax'], + 'resident_tax' => $calculated['resident_tax'], + 'health_insurance' => $calculated['health_insurance'], + 'long_term_care' => $calculated['long_term_care'], + 'pension' => $calculated['pension'], + 'employment_insurance' => $calculated['employment_insurance'], + 'total_deductions' => $calculated['total_deductions'], + 'net_salary' => $calculated['net_salary'], + 'updated_by' => $userId, + ]); } return $payrolls->fresh(['user:id,name,email']); }); } + /** + * 계산 미리보기 (저장하지 않음) + */ + public function calculatePreview(array $data): array + { + $tenantId = $this->tenantId(); + $settings = PayrollSetting::getOrCreate($tenantId); + $familyCount = 1; + + if (! empty($data['user_id'])) { + $familyCount = $this->resolveFamilyCount((int) $data['user_id']); + } + + $calculated = $this->calculateAmounts($data, $settings, $familyCount); + + return array_merge($calculated, ['family_count' => $familyCount]); + } + + // ========================================================================= + // 급여명세서 + // ========================================================================= + + public function payslip(int $id): array + { + $payroll = $this->show($id); + + $allowances = collect($payroll->allowances ?? [])->map(fn ($item) => [ + 'name' => $item['name'] ?? '', + 'amount' => (int) ($item['amount'] ?? 0), + ])->toArray(); + + $deductions = collect($payroll->deductions ?? [])->map(fn ($item) => [ + 'name' => $item['name'] ?? '', + 'amount' => (int) ($item['amount'] ?? 0), + ])->toArray(); + + return [ + 'payroll' => $payroll, + 'period' => $payroll->period_label, + 'employee' => [ + 'id' => $payroll->user->id, + 'name' => $payroll->user->name, + 'email' => $payroll->user->email, + ], + 'earnings' => [ + 'base_salary' => (int) $payroll->base_salary, + 'overtime_pay' => (int) $payroll->overtime_pay, + 'bonus' => (int) $payroll->bonus, + 'allowances' => $allowances, + 'allowances_total' => (int) $payroll->allowances_total, + 'gross_total' => (int) $payroll->gross_salary, + ], + 'deductions' => [ + 'income_tax' => (int) $payroll->income_tax, + 'resident_tax' => (int) $payroll->resident_tax, + 'health_insurance' => (int) $payroll->health_insurance, + 'long_term_care' => (int) $payroll->long_term_care, + 'pension' => (int) $payroll->pension, + 'employment_insurance' => (int) $payroll->employment_insurance, + 'other_deductions' => $deductions, + 'other_total' => (int) $payroll->deductions_total, + 'total' => (int) $payroll->total_deductions, + ], + 'net_salary' => (int) $payroll->net_salary, + 'status' => $payroll->status, + 'status_label' => $payroll->status_label, + 'paid_at' => $payroll->paid_at?->toIso8601String(), + ]; + } + // ========================================================================= // 급여 설정 // ========================================================================= - /** - * 급여 설정 조회 - */ public function getSettings(): PayrollSetting { - $tenantId = $this->tenantId(); - - return PayrollSetting::getOrCreate($tenantId); + return PayrollSetting::getOrCreate($this->tenantId()); } - /** - * 급여 설정 수정 - */ public function updateSettings(array $data): PayrollSetting { - $tenantId = $this->tenantId(); - - $settings = PayrollSetting::getOrCreate($tenantId); + $settings = PayrollSetting::getOrCreate($this->tenantId()); $settings->fill([ 'income_tax_rate' => $data['income_tax_rate'] ?? $settings->income_tax_rate, @@ -521,42 +720,456 @@ public function updateSettings(array $data): PayrollSetting } // ========================================================================= - // 헬퍼 메서드 + // 계산 엔진 // ========================================================================= /** - * 총지급액 계산 + * 급여 금액 자동 계산 + * + * 식대(bonus)는 비과세 항목으로, 총 지급액에는 포함되지만 + * 4대보험 및 세금 산출 기준(과세표준)에서는 제외된다. */ - private function calculateGross(array $data): float + public function calculateAmounts(array $data, ?PayrollSetting $settings = null, int $familyCount = 1): array { + $settings = $settings ?? PayrollSetting::getOrCreate($this->tenantId()); + $baseSalary = (float) ($data['base_salary'] ?? 0); $overtimePay = (float) ($data['overtime_pay'] ?? 0); $bonus = (float) ($data['bonus'] ?? 0); $allowancesTotal = 0; if (! empty($data['allowances'])) { - $allowancesTotal = collect($data['allowances'])->sum('amount'); + $allowances = is_string($data['allowances']) ? json_decode($data['allowances'], true) : $data['allowances']; + foreach ($allowances ?? [] as $allowance) { + $allowancesTotal += (float) ($allowance['amount'] ?? 0); + } } - return $baseSalary + $overtimePay + $bonus + $allowancesTotal; + // 총 지급액 (비과세 포함) + $grossSalary = $baseSalary + $overtimePay + $bonus + $allowancesTotal; + + // 과세표준 = 총 지급액 - 식대(비과세) + $taxableBase = $grossSalary - $bonus; + + // 4대보험 (과세표준 기준) + $healthInsurance = $this->calcHealthInsurance($taxableBase, $settings); + $longTermCare = $this->calcLongTermCare($taxableBase, $settings); + $pension = $this->calcPension($taxableBase, $settings); + $employmentInsurance = $this->calcEmploymentInsurance($taxableBase, $settings); + + // 근로소득세 (간이세액표, 가족수 반영) + $incomeTax = $this->calculateIncomeTax($taxableBase, $familyCount); + // 지방소득세 (근로소득세의 10%, 10원 단위 절삭) + $residentTax = (int) (floor($incomeTax * ($settings->resident_tax_rate / 100) / 10) * 10); + + // 추가 공제 합계 + $extraDeductions = 0; + if (! empty($data['deductions'])) { + $deductions = is_string($data['deductions']) ? json_decode($data['deductions'], true) : $data['deductions']; + foreach ($deductions ?? [] as $deduction) { + $extraDeductions += (float) ($deduction['amount'] ?? 0); + } + } + + $totalDeductions = $incomeTax + $residentTax + $healthInsurance + $longTermCare + $pension + $employmentInsurance + $extraDeductions; + $netSalary = $grossSalary - $totalDeductions; + + return [ + 'gross_salary' => (int) $grossSalary, + 'taxable_base' => (int) $taxableBase, + 'income_tax' => $incomeTax, + 'resident_tax' => $residentTax, + 'health_insurance' => $healthInsurance, + 'long_term_care' => $longTermCare, + 'pension' => $pension, + 'employment_insurance' => $employmentInsurance, + 'total_deductions' => (int) $totalDeductions, + 'net_salary' => (int) max(0, $netSalary), + ]; } /** - * 총공제액 계산 + * 수동 수정된 공제 항목 반영 */ - private function calculateDeductions(array $data): float + private function applyDeductionOverrides(array &$calculated, ?array $overrides): void { - $incomeTax = (float) ($data['income_tax'] ?? 0); - $residentTax = (float) ($data['resident_tax'] ?? 0); - $healthInsurance = (float) ($data['health_insurance'] ?? 0); - $pension = (float) ($data['pension'] ?? 0); - $employmentInsurance = (float) ($data['employment_insurance'] ?? 0); - - $deductionsTotal = 0; - if (! empty($data['deductions'])) { - $deductionsTotal = collect($data['deductions'])->sum('amount'); + if (empty($overrides)) { + return; } - return $incomeTax + $residentTax + $healthInsurance + $pension + $employmentInsurance + $deductionsTotal; + $oldStatutory = $calculated['pension'] + $calculated['health_insurance'] + $calculated['long_term_care'] + + $calculated['employment_insurance'] + $calculated['income_tax'] + $calculated['resident_tax']; + $extraDeductions = max(0, $calculated['total_deductions'] - $oldStatutory); + + $fields = ['pension', 'health_insurance', 'long_term_care', 'employment_insurance', 'income_tax', 'resident_tax']; + foreach ($fields as $field) { + if (isset($overrides[$field])) { + $calculated[$field] = (int) $overrides[$field]; + } + } + + $newStatutory = $calculated['pension'] + $calculated['health_insurance'] + $calculated['long_term_care'] + + $calculated['employment_insurance'] + $calculated['income_tax'] + $calculated['resident_tax']; + $calculated['total_deductions'] = (int) ($newStatutory + $extraDeductions); + $calculated['net_salary'] = (int) max(0, $calculated['gross_salary'] - $calculated['total_deductions']); + } + + /** + * 근로소득세 계산 (2024 국세청 간이세액표 기반) + */ + public function calculateIncomeTax(float $taxableBase, int $familyCount = 1): int + { + if ($taxableBase <= 0) { + return 0; + } + + $salaryThousand = (int) floor($taxableBase / 1000); + $familyCount = max(1, min(11, $familyCount)); + + if ($salaryThousand < 770) { + return 0; + } + + if ($salaryThousand > 10000) { + return $this->calculateHighIncomeTax($salaryThousand, $familyCount); + } + + return IncomeTaxBracket::lookupTax(self::TAX_TABLE_YEAR, $salaryThousand, $familyCount); + } + + /** + * 10,000천원 초과 구간 근로소득세 공식 계산 (소득세법 시행령 별표2) + */ + private function calculateHighIncomeTax(int $salaryThousand, int $familyCount): int + { + $baseTax = IncomeTaxBracket::where('tax_year', self::TAX_TABLE_YEAR) + ->where('salary_from', 10000) + ->whereColumn('salary_from', 'salary_to') + ->where('family_count', $familyCount) + ->value('tax_amount') ?? 0; + + if ($salaryThousand <= 14000) { + $excessWon = ($salaryThousand - 10000) * 1000; + $tax = $baseTax + ($excessWon * 0.98 * 0.35) + 25000; + } elseif ($salaryThousand <= 28000) { + $excessWon = ($salaryThousand - 14000) * 1000; + $tax = $baseTax + 1397000 + ($excessWon * 0.98 * 0.38); + } elseif ($salaryThousand <= 30000) { + $excessWon = ($salaryThousand - 28000) * 1000; + $tax = $baseTax + 6610600 + ($excessWon * 0.98 * 0.40); + } elseif ($salaryThousand <= 45000) { + $excessWon = ($salaryThousand - 30000) * 1000; + $tax = $baseTax + 7394600 + ($excessWon * 0.40); + } elseif ($salaryThousand <= 87000) { + $excessWon = ($salaryThousand - 45000) * 1000; + $tax = $baseTax + 13394600 + ($excessWon * 0.42); + } else { + $excessWon = ($salaryThousand - 87000) * 1000; + $tax = $baseTax + 31034600 + ($excessWon * 0.45); + } + + return (int) (floor($tax / 10) * 10); + } + + private function calcHealthInsurance(float $taxableBase, PayrollSetting $settings): int + { + return (int) (floor($taxableBase * ($settings->health_insurance_rate / 100) / 10) * 10); + } + + private function calcLongTermCare(float $taxableBase, PayrollSetting $settings): int + { + $healthInsurance = $taxableBase * ($settings->health_insurance_rate / 100); + + return (int) (floor($healthInsurance * ($settings->long_term_care_rate / 100) / 10) * 10); + } + + private function calcPension(float $taxableBase, PayrollSetting $settings): int + { + $base = min(max($taxableBase, (float) $settings->pension_min_salary), (float) $settings->pension_max_salary); + + return (int) (floor($base * ($settings->pension_rate / 100) / 10) * 10); + } + + private function calcEmploymentInsurance(float $taxableBase, PayrollSetting $settings): int + { + return (int) (floor($taxableBase * ($settings->employment_insurance_rate / 100) / 10) * 10); + } + + /** + * user_id로 공제대상가족수 산출 (본인 1 + 피부양자) + */ + public function resolveFamilyCount(int $userId): int + { + $tenantId = $this->tenantId(); + + $profile = TenantUserProfile::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $userId) + ->first(['json_extra']); + + if (! $profile) { + return 1; + } + + $dependents = $profile->json_extra['dependents'] ?? []; + $dependentCount = collect($dependents) + ->where('is_dependent', true) + ->count(); + + return max(1, min(11, 1 + $dependentCount)); + } + + // ========================================================================= + // 엑셀 내보내기 + // ========================================================================= + + /** + * 급여 엑셀 내보내기용 데이터 + */ + public function getExportData(array $params): array + { + $tenantId = $this->tenantId(); + + $query = Payroll::query() + ->where('tenant_id', $tenantId) + ->with(['user:id,name,email', 'user.tenantProfiles' => function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId)->with('department:id,name'); + }]); + + if (! empty($params['year'])) { + $query->where('pay_year', $params['year']); + } + if (! empty($params['month'])) { + $query->where('pay_month', $params['month']); + } + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + if (! empty($params['user_id'])) { + $query->where('user_id', $params['user_id']); + } + if (! empty($params['department_id'])) { + $deptId = $params['department_id']; + $query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) { + $q->where('tenant_id', $tenantId)->where('department_id', $deptId); + }); + } + if (! empty($params['search'])) { + $query->whereHas('user', function ($q) use ($params) { + $q->where('name', 'like', "%{$params['search']}%"); + }); + } + + $sortBy = $params['sort_by'] ?? 'pay_year'; + $sortDir = $params['sort_dir'] ?? 'desc'; + + if ($sortBy === 'period') { + $query->orderBy('pay_year', $sortDir)->orderBy('pay_month', $sortDir); + } else { + $query->orderBy($sortBy, $sortDir); + } + + $payrolls = $query->get(); + + $statusLabels = [ + Payroll::STATUS_DRAFT => '작성중', + Payroll::STATUS_CONFIRMED => '확정', + Payroll::STATUS_PAID => '지급완료', + ]; + + $data = $payrolls->map(function ($payroll) use ($statusLabels) { + $profile = $payroll->user?->tenantProfiles?->first(); + $department = $profile?->department?->name ?? '-'; + + return [ + $payroll->pay_year.'년 '.$payroll->pay_month.'월', + $payroll->user?->name ?? '-', + $department, + number_format($payroll->base_salary), + number_format($payroll->overtime_pay), + number_format($payroll->bonus), + number_format($payroll->gross_salary), + number_format($payroll->income_tax), + number_format($payroll->resident_tax), + number_format($payroll->health_insurance), + number_format($payroll->long_term_care), + number_format($payroll->pension), + number_format($payroll->employment_insurance), + number_format($payroll->total_deductions), + number_format($payroll->net_salary), + $statusLabels[$payroll->status] ?? $payroll->status, + ]; + })->toArray(); + + $headings = [ + '급여월', + '직원명', + '부서', + '기본급', + '야근수당', + '상여금', + '총지급액', + '소득세', + '주민세', + '건강보험', + '장기요양', + '국민연금', + '고용보험', + '공제합계', + '실지급액', + '상태', + ]; + + return [ + 'data' => $data, + 'headings' => $headings, + ]; + } + + // ========================================================================= + // 전표 생성 + // ========================================================================= + + /** + * 급여 전표 일괄 생성 + * + * 해당 연월의 확정/지급완료 급여를 합산하여 전표를 생성한다. + * - 차변: 급여 (총지급액) + * - 대변: 각 공제항목 + 미지급금(실지급액) + */ + public function createJournalEntries(int $year, int $month, ?string $entryDate = null): JournalEntry + { + $tenantId = $this->tenantId(); + + $payrolls = Payroll::query() + ->where('tenant_id', $tenantId) + ->forPeriod($year, $month) + ->whereIn('status', [Payroll::STATUS_CONFIRMED, Payroll::STATUS_PAID]) + ->get(); + + if ($payrolls->isEmpty()) { + throw new BadRequestHttpException(__('error.payroll.no_confirmed_payrolls')); + } + + // 합산 + $totalGross = $payrolls->sum('gross_salary'); + $totalIncomeTax = $payrolls->sum('income_tax'); + $totalResidentTax = $payrolls->sum('resident_tax'); + $totalHealthInsurance = $payrolls->sum('health_insurance'); + $totalLongTermCare = $payrolls->sum('long_term_care'); + $totalPension = $payrolls->sum('pension'); + $totalEmploymentInsurance = $payrolls->sum('employment_insurance'); + $totalNet = $payrolls->sum('net_salary'); + + // 전표일자: 지정값 또는 해당월 급여지급일 + if (! $entryDate) { + $settings = PayrollSetting::getOrCreate($tenantId); + $payDay = min($settings->pay_day, 28); + $entryDate = sprintf('%04d-%02d-%02d', $year, $month, $payDay); + } + + $sourceKey = "payroll_{$year}_{$month}"; + $description = "{$year}년 {$month}월 급여"; + + // 분개 행 구성 + $rows = []; + + // 차변: 급여 (총지급액) + $rows[] = [ + 'side' => 'debit', + 'account_code' => '51100', + 'account_name' => '급여', + 'debit_amount' => (int) $totalGross, + 'credit_amount' => 0, + 'memo' => $description, + ]; + + // 대변: 소득세예수금 + if ($totalIncomeTax > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25500', + 'account_name' => '예수금-소득세', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalIncomeTax, + ]; + } + + // 대변: 주민세예수금 + if ($totalResidentTax > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25501', + 'account_name' => '예수금-주민세', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalResidentTax, + ]; + } + + // 대변: 건강보험예수금 + if ($totalHealthInsurance > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25502', + 'account_name' => '예수금-건강보험', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalHealthInsurance, + ]; + } + + // 대변: 장기요양보험예수금 + if ($totalLongTermCare > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25503', + 'account_name' => '예수금-장기요양', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalLongTermCare, + ]; + } + + // 대변: 국민연금예수금 + if ($totalPension > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25504', + 'account_name' => '예수금-국민연금', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalPension, + ]; + } + + // 대변: 고용보험예수금 + if ($totalEmploymentInsurance > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25505', + 'account_name' => '예수금-고용보험', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalEmploymentInsurance, + ]; + } + + // 대변: 미지급금 (실지급액) + if ($totalNet > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25300', + 'account_name' => '미지급금', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalNet, + 'memo' => "급여 실지급액 ({$payrolls->count()}명)", + ]; + } + + $syncService = app(JournalSyncService::class); + + return $syncService->saveForSource( + JournalEntry::SOURCE_PAYROLL, + $sourceKey, + $entryDate, + $description, + $rows + ); } } diff --git a/app/Services/QmsLotAuditService.php b/app/Services/QmsLotAuditService.php index dbd367c..c776b01 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', ]) @@ -83,13 +82,14 @@ public function show(int $id): array } /** - * 수주 루트별 8종 서류 목록 (Document[]) + * 수주 로트별 8종 서류 목록 (Document[]) */ public function routeDocuments(int $qualityDocumentOrderId): array { $docOrder = QualityDocumentOrder::with([ 'order.workOrders.process', - 'locations', + 'locations.orderItem', + 'locations.document', 'qualityDocument', ])->findOrFail($qualityDocumentOrderId); @@ -119,17 +119,15 @@ public function routeDocuments(int $qualityDocumentOrderId): array // 2. 수주서 $documents[] = $this->formatDocument('order', '수주서', collect([$order])); - // 3. 작업일지 (subType: process.process_name 기반) - $documents[] = $this->formatDocumentWithSubType('log', '작업일지', $workOrders); + // 3. 작업일지 & 4. 중간검사 성적서 (인식 가능한 공정만 — 공정별 1개씩) + $recognizedWorkOrders = $workOrders->filter(function ($wo) { + $subType = $this->mapProcessToSubType($wo->process?->process_name); - // 4. 중간검사 성적서 (PQC) - $pqcInspections = Inspection::where('inspection_type', 'PQC') - ->whereIn('work_order_id', $workOrders->pluck('id')) - ->where('status', 'completed') - ->with('workOrder.process') - ->get(); + return $subType !== null; + })->groupBy('process_id')->map(fn ($group) => $group->first()); - $documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $pqcInspections, 'workOrder'); + $documents[] = $this->formatDocumentWithSubType('log', '작업일지', $recognizedWorkOrders); + $documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $recognizedWorkOrders); // 5. 납품확인서 $shipments = $order->shipments()->get(); @@ -138,9 +136,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])); @@ -157,7 +157,7 @@ public function documentDetail(string $type, int $id): array 'import' => $this->getInspectionDetail($id, 'IQC'), 'order' => $this->getOrderDetail($id), 'log' => $this->getWorkOrderLogDetail($id), - 'report' => $this->getInspectionDetail($id, 'PQC'), + 'report' => $this->getWorkOrderLogDetail($id), 'confirmation', 'shipping' => $this->getShipmentDetail($id), 'product' => $this->getLocationDetail($id), 'quality' => $this->getQualityDocDetail($id), @@ -220,30 +220,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 @@ -252,6 +236,7 @@ private function transformRouteToFrontend(QualityDocumentOrder $docOrder, Qualit 'id' => (string) $docOrder->id, 'code' => $docOrder->order->order_no, 'date' => $docOrder->order->received_at?->toDateString(), + 'client' => $docOrder->order->client_name ?? '', 'site' => $docOrder->order->site_name ?? '', 'location_count' => $docOrder->locations->count(), 'sub_items' => $docOrder->locations->values()->map(fn ($loc, $idx) => [ @@ -313,11 +298,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; @@ -328,12 +314,18 @@ private function formatDocumentWithSubType(string $type, string $title, $collect private function formatDocumentItem(string $type, $item): array { return match ($type) { - 'import', 'report' => [ + 'import' => [ 'id' => (string) $item->id, 'title' => $item->inspection_no ?? '', 'date' => $item->inspection_date?->toDateString() ?? $item->request_date?->toDateString() ?? '', 'code' => $item->inspection_no ?? '', ], + 'report' => [ + 'id' => (string) $item->id, + 'title' => $item->process?->process_name ?? '중간검사 성적서', + 'date' => $item->created_at?->toDateString() ?? '', + 'code' => $item->work_order_no ?? '', + ], 'order' => [ 'id' => (string) $item->id, 'title' => $item->order_no, @@ -342,9 +334,9 @@ private function formatDocumentItem(string $type, $item): array ], 'log' => [ 'id' => (string) $item->id, - 'title' => $item->project_name ?? '작업일지', + 'title' => $item->process?->process_name ?? '작업일지', 'date' => $item->created_at?->toDateString() ?? '', - 'code' => $item->id, + 'code' => $item->work_order_no ?? '', ], 'confirmation', 'shipping' => [ 'id' => (string) $item->id, @@ -354,9 +346,9 @@ 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' => '', + 'code' => $item->document?->document_no ?? '', ], 'quality' => [ 'id' => (string) $item->id, @@ -479,9 +471,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 +493,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/config/services.php b/config/services.php index 6a99ace..1f29e1e 100644 --- a/config/services.php +++ b/config/services.php @@ -58,4 +58,17 @@ 'exchange_secret' => env('INTERNAL_EXCHANGE_SECRET'), ], + /* + |-------------------------------------------------------------------------- + | BaroBill (바로빌 전자세금계산서/회계 연동) + |-------------------------------------------------------------------------- + | MNG와 동일한 설정 구조를 사용한다. + */ + 'barobill' => [ + 'cert_key_test' => env('BAROBILL_CERT_KEY_TEST', ''), + 'cert_key_prod' => env('BAROBILL_CERT_KEY_PROD', ''), + 'corp_num' => env('BAROBILL_CORP_NUM', ''), + 'test_mode' => env('BAROBILL_TEST_MODE', true), + ], + ]; 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_100000_add_options_to_barobill_tables.php b/database/migrations/2026_03_11_100000_add_options_to_barobill_tables.php new file mode 100644 index 0000000..f4d49c8 --- /dev/null +++ b/database/migrations/2026_03_11_100000_add_options_to_barobill_tables.php @@ -0,0 +1,59 @@ +tables as $table) { + if (Schema::hasTable($table) && ! Schema::hasColumn($table, 'options')) { + Schema::table($table, function (Blueprint $table) { + $table->json('options')->nullable()->after('id'); + }); + } + } + } + + public function down(): void + { + foreach ($this->tables as $table) { + if (Schema::hasTable($table) && Schema::hasColumn($table, 'options')) { + Schema::table($table, function (Blueprint $table) { + $table->dropColumn('options'); + }); + } + } + } +}; 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/database/migrations/2026_03_11_213232_alter_files_table_extend_mime_type.php b/database/migrations/2026_03_11_213232_alter_files_table_extend_mime_type.php new file mode 100644 index 0000000..d6f807e --- /dev/null +++ b/database/migrations/2026_03_11_213232_alter_files_table_extend_mime_type.php @@ -0,0 +1,28 @@ +string('mime_type', 150)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('files', function (Blueprint $table) { + $table->string('mime_type', 50)->change(); + }); + } +}; diff --git a/database/migrations/2026_03_12_100000_add_options_to_equipment_tables.php b/database/migrations/2026_03_12_100000_add_options_to_equipment_tables.php new file mode 100644 index 0000000..290a0a0 --- /dev/null +++ b/database/migrations/2026_03_12_100000_add_options_to_equipment_tables.php @@ -0,0 +1,38 @@ +json('options')->nullable()->after('memo')->comment('확장 속성 JSON'); + }); + } + + if (Schema::hasTable('equipment_repairs') && ! Schema::hasColumn('equipment_repairs', 'options')) { + Schema::table('equipment_repairs', function (Blueprint $table) { + $table->json('options')->nullable()->after('memo')->comment('확장 속성 JSON'); + }); + } + } + + public function down(): void + { + if (Schema::hasTable('equipments') && Schema::hasColumn('equipments', 'options')) { + Schema::table('equipments', function (Blueprint $table) { + $table->dropColumn('options'); + }); + } + + if (Schema::hasTable('equipment_repairs') && Schema::hasColumn('equipment_repairs', 'options')) { + Schema::table('equipment_repairs', function (Blueprint $table) { + $table->dropColumn('options'); + }); + } + } +}; diff --git a/database/migrations/2026_03_12_100000_create_tenant_mail_configs_table.php b/database/migrations/2026_03_12_100000_create_tenant_mail_configs_table.php new file mode 100644 index 0000000..c1f40ce --- /dev/null +++ b/database/migrations/2026_03_12_100000_create_tenant_mail_configs_table.php @@ -0,0 +1,38 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('provider', 20)->default('platform')->comment('발송 방식: platform, smtp, ses, mailgun'); + $table->string('from_address', 255)->comment('발신 이메일'); + $table->string('from_name', 255)->comment('발신자명'); + $table->string('reply_to', 255)->nullable()->comment('회신 주소'); + $table->boolean('is_verified')->default(false)->comment('도메인 검증 여부'); + $table->unsignedInteger('daily_limit')->default(500)->comment('일일 발송 한도'); + $table->boolean('is_active')->default(true)->comment('활성 여부'); + $table->json('options')->nullable()->comment('SMTP 설정, 브랜딩, 연결 테스트 결과'); + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique('tenant_id', 'uq_tenant_mail_configs'); + $table->index(['tenant_id', 'is_active']); + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('tenant_mail_configs'); + } +}; diff --git a/database/migrations/2026_03_12_100001_create_mail_logs_table.php b/database/migrations/2026_03_12_100001_create_mail_logs_table.php new file mode 100644 index 0000000..a9a1aa7 --- /dev/null +++ b/database/migrations/2026_03_12_100001_create_mail_logs_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('mailable_type', 100)->comment('Mailable 클래스명'); + $table->string('to_address', 255)->comment('수신자'); + $table->string('from_address', 255)->comment('발신자'); + $table->string('subject', 500)->comment('제목'); + $table->string('status', 20)->default('queued')->comment('상태: queued, sent, failed, bounced'); + $table->timestamp('sent_at')->nullable()->comment('발송 시각'); + $table->json('options')->nullable()->comment('에러 메시지, 재시도 횟수, 관련 모델'); + $table->timestamps(); + + $table->index(['tenant_id', 'status'], 'idx_mail_logs_tenant_status'); + $table->index(['tenant_id', 'created_at'], 'idx_mail_logs_tenant_date'); + $table->index(['tenant_id', 'mailable_type'], 'idx_mail_logs_mailable'); + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('mail_logs'); + } +}; diff --git a/database/migrations/2026_03_12_100001_ensure_equipment_tables_exist.php b/database/migrations/2026_03_12_100001_ensure_equipment_tables_exist.php new file mode 100644 index 0000000..1bcc97e --- /dev/null +++ b/database/migrations/2026_03_12_100001_ensure_equipment_tables_exist.php @@ -0,0 +1,203 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('equipment_code', 20)->comment('설비코드 (KD-M-001 형식)'); + $table->string('name', 100)->comment('설비명'); + $table->string('equipment_type', 50)->nullable()->comment('설비유형'); + $table->string('specification', 255)->nullable()->comment('규격'); + $table->string('manufacturer', 100)->nullable()->comment('제조사'); + $table->string('model_name', 100)->nullable()->comment('모델명'); + $table->string('serial_no', 100)->nullable()->comment('제조번호'); + $table->string('location', 100)->nullable()->comment('위치'); + $table->string('production_line', 50)->nullable()->comment('생산라인'); + $table->date('purchase_date')->nullable()->comment('구입일'); + $table->date('install_date')->nullable()->comment('설치일'); + $table->decimal('purchase_price', 15, 2)->nullable()->comment('구입가격'); + $table->integer('useful_life')->nullable()->comment('내용연수'); + $table->string('status', 20)->default('active')->comment('상태: active/idle/disposed'); + $table->date('disposed_date')->nullable()->comment('폐기일'); + $table->foreignId('manager_id')->nullable()->comment('담당자 ID'); + $table->foreignId('sub_manager_id')->nullable()->comment('부 담당자 ID'); + $table->string('photo_path', 500)->nullable()->comment('설비사진 경로'); + $table->text('memo')->nullable()->comment('비고'); + $table->json('options')->nullable()->comment('확장 속성 JSON'); + $table->tinyInteger('is_active')->default(1)->comment('사용여부'); + $table->integer('sort_order')->default(0)->comment('정렬순서'); + $table->foreignId('created_by')->nullable()->comment('생성자 ID'); + $table->foreignId('updated_by')->nullable()->comment('수정자 ID'); + $table->foreignId('deleted_by')->nullable()->comment('삭제자 ID'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'equipment_code'], 'uq_equipment_code'); + $table->index(['tenant_id', 'status'], 'idx_equipment_status'); + $table->index(['tenant_id', 'production_line'], 'idx_equipment_line'); + $table->index(['tenant_id', 'equipment_type'], 'idx_equipment_type'); + }); + } + + // 2. equipment_inspection_templates (점검항목 템플릿) + if (! Schema::hasTable('equipment_inspection_templates')) { + Schema::create('equipment_inspection_templates', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('equipment_id')->comment('설비 ID'); + $table->string('inspection_cycle', 20)->default('daily') + ->comment('점검주기: daily/weekly/monthly/bimonthly/quarterly/semiannual'); + $table->integer('item_no')->comment('항목번호'); + $table->string('check_point', 50)->comment('점검개소'); + $table->string('check_item', 100)->comment('점검항목'); + $table->string('check_timing', 20)->nullable()->comment('시기: operating/stopped'); + $table->string('check_frequency', 50)->nullable()->comment('주기'); + $table->text('check_method')->nullable()->comment('점검방법 및 기준'); + $table->integer('sort_order')->default(0)->comment('정렬순서'); + $table->tinyInteger('is_active')->default(1)->comment('사용여부'); + $table->timestamps(); + + $table->unique(['equipment_id', 'inspection_cycle', 'item_no'], 'uq_equipment_cycle_item_no'); + $table->index('tenant_id', 'idx_insp_tmpl_tenant'); + $table->index('inspection_cycle', 'idx_insp_tmpl_cycle'); + + $table->foreign('equipment_id') + ->references('id') + ->on('equipments') + ->onDelete('cascade'); + }); + } + + // 3. equipment_inspections (점검 헤더) + if (! Schema::hasTable('equipment_inspections')) { + Schema::create('equipment_inspections', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('equipment_id')->comment('설비 ID'); + $table->string('inspection_cycle', 20)->default('daily') + ->comment('점검주기: daily/weekly/monthly/bimonthly/quarterly/semiannual'); + $table->string('year_month', 7)->comment('점검년월 (2026-02)'); + $table->string('overall_judgment', 10)->nullable()->comment('종합판정: OK/NG'); + $table->foreignId('inspector_id')->nullable()->comment('점검자 ID'); + $table->text('repair_note')->nullable()->comment('수리내역'); + $table->text('issue_note')->nullable()->comment('이상내용'); + $table->foreignId('created_by')->nullable()->comment('생성자 ID'); + $table->foreignId('updated_by')->nullable()->comment('수정자 ID'); + $table->timestamps(); + + $table->unique(['tenant_id', 'equipment_id', 'inspection_cycle', 'year_month'], 'uq_inspection_cycle_period'); + $table->index(['tenant_id', 'inspection_cycle', 'year_month'], 'idx_inspection_cycle_period'); + + $table->foreign('equipment_id') + ->references('id') + ->on('equipments') + ->onDelete('cascade'); + }); + } + + // 4. equipment_inspection_details (점검 상세) + if (! Schema::hasTable('equipment_inspection_details')) { + Schema::create('equipment_inspection_details', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('inspection_id')->comment('점검 헤더 ID'); + $table->unsignedBigInteger('template_item_id')->comment('점검항목 템플릿 ID'); + $table->date('check_date')->comment('점검일'); + $table->string('result', 10)->nullable()->comment('결과: good/bad/repaired'); + $table->string('note', 500)->nullable()->comment('비고'); + $table->timestamps(); + + $table->unique(['inspection_id', 'template_item_id', 'check_date'], 'uq_inspection_detail'); + + $table->foreign('inspection_id') + ->references('id') + ->on('equipment_inspections') + ->onDelete('cascade'); + + $table->foreign('template_item_id') + ->references('id') + ->on('equipment_inspection_templates') + ->onDelete('cascade'); + }); + } + + // 5. equipment_repairs (수리이력) + if (! Schema::hasTable('equipment_repairs')) { + Schema::create('equipment_repairs', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('equipment_id')->comment('설비 ID'); + $table->date('repair_date')->comment('수리일'); + $table->string('repair_type', 20)->comment('보전구분: internal/external'); + $table->decimal('repair_hours', 5, 1)->nullable()->comment('수리시간'); + $table->text('description')->nullable()->comment('수리내용'); + $table->decimal('cost', 15, 2)->nullable()->comment('수리비용'); + $table->string('vendor', 100)->nullable()->comment('외주업체'); + $table->foreignId('repaired_by')->nullable()->comment('수리자 ID'); + $table->text('memo')->nullable()->comment('비고'); + $table->json('options')->nullable()->comment('확장 속성 JSON'); + $table->foreignId('created_by')->nullable()->comment('생성자 ID'); + $table->foreignId('updated_by')->nullable()->comment('수정자 ID'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['tenant_id', 'repair_date'], 'idx_repair_date'); + $table->index(['tenant_id', 'equipment_id'], 'idx_repair_equipment'); + + $table->foreign('equipment_id') + ->references('id') + ->on('equipments') + ->onDelete('cascade'); + }); + } + + // 6. equipment_process (설비-공정 매핑) + if (! Schema::hasTable('equipment_process')) { + Schema::create('equipment_process', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('equipment_id')->comment('설비 ID'); + $table->unsignedBigInteger('process_id')->comment('공정 ID'); + $table->tinyInteger('is_primary')->default(0)->comment('주 설비 여부'); + $table->timestamps(); + + $table->unique(['equipment_id', 'process_id'], 'uq_equipment_process'); + + $table->foreign('equipment_id') + ->references('id') + ->on('equipments') + ->onDelete('cascade'); + + $table->foreign('process_id') + ->references('id') + ->on('processes') + ->onDelete('cascade'); + }); + } + } + + public function down(): void + { + // 역순으로 삭제 (FK 의존성) + Schema::dropIfExists('equipment_process'); + Schema::dropIfExists('equipment_inspection_details'); + Schema::dropIfExists('equipment_repairs'); + Schema::dropIfExists('equipment_inspections'); + Schema::dropIfExists('equipment_inspection_templates'); + Schema::dropIfExists('equipments'); + } +}; diff --git a/lang/ko/error.php b/lang/ko/error.php index bc537ea..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' => '이미 해당 수주에 출하가 존재합니다.', ], // 파일 관리 관련 @@ -282,10 +285,14 @@ 'not_editable' => '작성중 상태의 급여만 수정할 수 있습니다.', 'not_deletable' => '작성중 상태의 급여만 삭제할 수 있습니다.', 'not_confirmable' => '작성중 상태의 급여만 확정할 수 있습니다.', + 'not_unconfirmable' => '확정된 급여만 확정 취소할 수 있습니다.', 'not_payable' => '확정된 급여만 지급 처리할 수 있습니다.', + 'not_unpayable' => '지급완료된 급여만 지급 취소할 수 있습니다.', + 'no_previous_month' => '전월 급여 데이터가 없습니다.', 'invalid_withdrawal' => '유효하지 않은 출금 내역입니다.', 'user_not_found' => '직원 정보를 찾을 수 없습니다.', 'no_base_salary' => '기본급이 설정되지 않았습니다.', + 'no_confirmed_payrolls' => '해당 연월에 확정된 급여가 없습니다.', ], // 세금계산서 관련 @@ -469,6 +476,17 @@ 'invalid_status' => '유효하지 않은 입찰 상태입니다.', ], + // 설비 관리 + 'equipment' => [ + 'not_found' => '설비 정보를 찾을 수 없습니다.', + 'template_not_found' => '점검항목을 찾을 수 없습니다.', + 'inspection_not_found' => '점검 데이터를 찾을 수 없습니다.', + 'repair_not_found' => '수리이력을 찾을 수 없습니다.', + 'photo_not_found' => '사진을 찾을 수 없습니다.', + 'invalid_cycle' => '유효하지 않은 점검주기입니다.', + 'no_source_templates' => '복사할 점검항목이 없습니다.', + ], + // 전자계약 (E-Sign) 'esign' => [ 'invalid_token' => '유효하지 않은 서명 링크입니다.', diff --git a/lang/ko/message.php b/lang/ko/message.php index 60a6b53..bd1e14a 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -320,10 +320,16 @@ 'updated' => '급여가 수정되었습니다.', 'deleted' => '급여가 삭제되었습니다.', 'confirmed' => '급여가 확정되었습니다.', + 'unconfirmed' => '급여 확정이 취소되었습니다.', 'paid' => '급여가 지급 처리되었습니다.', + 'unpaid' => '급여 지급이 취소되었습니다.', 'bulk_confirmed' => '급여가 일괄 확정되었습니다.', + 'bulk_generated' => '급여가 일괄 생성되었습니다.', + 'copied' => '전월 급여가 복사되었습니다.', 'calculated' => '급여가 일괄 계산되었습니다.', 'payslip_fetched' => '급여명세서를 조회했습니다.', + 'exported' => '급여 현황이 내보내기되었습니다.', + 'journal_created' => '급여 전표가 생성되었습니다.', ], // 급여 설정 관리 @@ -571,6 +577,20 @@ 'downloaded' => '문서가 다운로드되었습니다.', ], + // 설비 관리 + 'equipment' => [ + 'created' => '설비가 등록되었습니다.', + 'updated' => '설비 정보가 수정되었습니다.', + 'deleted' => '설비가 삭제되었습니다.', + 'restored' => '설비가 복원되었습니다.', + 'inspection_saved' => '점검 정보가 저장되었습니다.', + 'inspection_reset' => '점검 데이터가 초기화되었습니다.', + 'template_created' => '점검항목이 추가되었습니다.', + 'template_copied' => '점검항목이 복사되었습니다.', + 'repair_created' => '수리이력이 등록되었습니다.', + 'photo_uploaded' => '사진이 업로드되었습니다.', + ], + // 일반전표입력 'journal_entry' => [ 'fetched' => '전표 조회 성공', diff --git a/routes/api.php b/routes/api.php index 0d329de..e82f5e5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -42,6 +42,7 @@ require __DIR__.'/api/v1/audit.php'; require __DIR__.'/api/v1/esign.php'; require __DIR__.'/api/v1/quality.php'; + require __DIR__.'/api/v1/equipment.php'; // 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖) Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download'); diff --git a/routes/api/v1/equipment.php b/routes/api/v1/equipment.php new file mode 100644 index 0000000..619f1b4 --- /dev/null +++ b/routes/api/v1/equipment.php @@ -0,0 +1,45 @@ +group(function () { + // 설비 CRUD + Route::get('', [EquipmentController::class, 'index'])->name('v1.equipment.index'); + Route::get('/options', [EquipmentController::class, 'options'])->name('v1.equipment.options'); + Route::get('/stats', [EquipmentController::class, 'stats'])->name('v1.equipment.stats'); + Route::post('', [EquipmentController::class, 'store'])->name('v1.equipment.store'); + Route::get('/{id}', [EquipmentController::class, 'show'])->whereNumber('id')->name('v1.equipment.show'); + Route::put('/{id}', [EquipmentController::class, 'update'])->whereNumber('id')->name('v1.equipment.update'); + Route::delete('/{id}', [EquipmentController::class, 'destroy'])->whereNumber('id')->name('v1.equipment.destroy'); + Route::post('/{id}/restore', [EquipmentController::class, 'restore'])->whereNumber('id')->name('v1.equipment.restore'); + Route::patch('/{id}/toggle', [EquipmentController::class, 'toggleActive'])->whereNumber('id')->name('v1.equipment.toggle'); + + // 점검 템플릿 + Route::get('/{id}/templates', [EquipmentInspectionController::class, 'templates'])->whereNumber('id')->name('v1.equipment.templates'); + Route::post('/{id}/templates', [EquipmentInspectionController::class, 'storeTemplate'])->whereNumber('id')->name('v1.equipment.templates.store'); + Route::put('/templates/{templateId}', [EquipmentInspectionController::class, 'updateTemplate'])->whereNumber('templateId')->name('v1.equipment.templates.update'); + Route::delete('/templates/{templateId}', [EquipmentInspectionController::class, 'deleteTemplate'])->whereNumber('templateId')->name('v1.equipment.templates.destroy'); + Route::post('/{id}/templates/copy', [EquipmentInspectionController::class, 'copyTemplates'])->whereNumber('id')->name('v1.equipment.templates.copy'); + + // 점검 + Route::get('/inspections', [EquipmentInspectionController::class, 'index'])->name('v1.equipment.inspections.index'); + Route::patch('/inspections/toggle', [EquipmentInspectionController::class, 'toggleDetail'])->name('v1.equipment.inspections.toggle'); + Route::patch('/inspections/set-result', [EquipmentInspectionController::class, 'setResult'])->name('v1.equipment.inspections.set-result'); + Route::patch('/inspections/notes', [EquipmentInspectionController::class, 'updateNotes'])->name('v1.equipment.inspections.notes'); + Route::delete('/inspections/reset', [EquipmentInspectionController::class, 'resetInspection'])->name('v1.equipment.inspections.reset'); + + // 수리이력 + Route::get('/repairs', [EquipmentRepairController::class, 'index'])->name('v1.equipment.repairs.index'); + Route::post('/repairs', [EquipmentRepairController::class, 'store'])->name('v1.equipment.repairs.store'); + Route::put('/repairs/{id}', [EquipmentRepairController::class, 'update'])->whereNumber('id')->name('v1.equipment.repairs.update'); + Route::delete('/repairs/{id}', [EquipmentRepairController::class, 'destroy'])->whereNumber('id')->name('v1.equipment.repairs.destroy'); + + // 사진 + Route::get('/{id}/photos', [EquipmentPhotoController::class, 'index'])->whereNumber('id')->name('v1.equipment.photos.index'); + Route::post('/{id}/photos', [EquipmentPhotoController::class, 'store'])->whereNumber('id')->name('v1.equipment.photos.store'); + Route::delete('/{id}/photos/{fileId}', [EquipmentPhotoController::class, 'destroy'])->whereNumber('id')->name('v1.equipment.photos.destroy'); +}); 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/finance.php b/routes/api/v1/finance.php index 2b996bd..0a18dc4 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -16,6 +16,8 @@ use App\Http\Controllers\Api\V1\BadDebtController; use App\Http\Controllers\Api\V1\BankAccountController; use App\Http\Controllers\Api\V1\BankTransactionController; +use App\Http\Controllers\Api\V1\BarobillBankTransactionController; +use App\Http\Controllers\Api\V1\BarobillCardTransactionController; use App\Http\Controllers\Api\V1\BarobillController; use App\Http\Controllers\Api\V1\BarobillSettingController; use App\Http\Controllers\Api\V1\BillController; @@ -28,6 +30,7 @@ use App\Http\Controllers\Api\V1\EntertainmentController; use App\Http\Controllers\Api\V1\ExpectedExpenseController; use App\Http\Controllers\Api\V1\GeneralJournalEntryController; +use App\Http\Controllers\Api\V1\HometaxInvoiceController; use App\Http\Controllers\Api\V1\LoanController; use App\Http\Controllers\Api\V1\PaymentController; use App\Http\Controllers\Api\V1\PayrollController; @@ -96,12 +99,19 @@ Route::post('', [PayrollController::class, 'store'])->name('v1.payrolls.store'); Route::get('/summary', [PayrollController::class, 'summary'])->name('v1.payrolls.summary'); Route::post('/calculate', [PayrollController::class, 'calculate'])->name('v1.payrolls.calculate'); + Route::post('/calculate-preview', [PayrollController::class, 'calculatePreview'])->name('v1.payrolls.calculate-preview'); Route::post('/bulk-confirm', [PayrollController::class, 'bulkConfirm'])->name('v1.payrolls.bulk-confirm'); + Route::post('/bulk-generate', [PayrollController::class, 'bulkGenerate'])->name('v1.payrolls.bulk-generate'); + Route::post('/copy-from-previous', [PayrollController::class, 'copyFromPrevious'])->name('v1.payrolls.copy-from-previous'); + Route::get('/export', [PayrollController::class, 'export'])->name('v1.payrolls.export'); + Route::post('/journal-entries', [PayrollController::class, 'journalEntries'])->name('v1.payrolls.journal-entries'); Route::get('/{id}', [PayrollController::class, 'show'])->whereNumber('id')->name('v1.payrolls.show'); Route::put('/{id}', [PayrollController::class, 'update'])->whereNumber('id')->name('v1.payrolls.update'); Route::delete('/{id}', [PayrollController::class, 'destroy'])->whereNumber('id')->name('v1.payrolls.destroy'); Route::post('/{id}/confirm', [PayrollController::class, 'confirm'])->whereNumber('id')->name('v1.payrolls.confirm'); + Route::post('/{id}/unconfirm', [PayrollController::class, 'unconfirm'])->whereNumber('id')->name('v1.payrolls.unconfirm'); Route::post('/{id}/pay', [PayrollController::class, 'pay'])->whereNumber('id')->name('v1.payrolls.pay'); + Route::post('/{id}/unpay', [PayrollController::class, 'unpay'])->whereNumber('id')->name('v1.payrolls.unpay'); Route::get('/{id}/payslip', [PayrollController::class, 'payslip'])->whereNumber('id')->name('v1.payrolls.payslip'); }); @@ -277,6 +287,60 @@ Route::get('/certificate-url', [BarobillController::class, 'certificateUrl'])->name('v1.barobill.certificate-url'); }); +// Barobill Card Transaction API (바로빌 카드 거래 - React 연동) +Route::prefix('barobill-card-transactions')->group(function () { + Route::get('', [BarobillCardTransactionController::class, 'index'])->name('v1.barobill-card-transactions.index'); + Route::get('/card-numbers', [BarobillCardTransactionController::class, 'cardNumbers'])->name('v1.barobill-card-transactions.card-numbers'); + Route::get('/hidden', [BarobillCardTransactionController::class, 'hiddenList'])->name('v1.barobill-card-transactions.hidden'); + Route::get('/splits', [BarobillCardTransactionController::class, 'getSplits'])->name('v1.barobill-card-transactions.splits.show'); + Route::post('/splits', [BarobillCardTransactionController::class, 'saveSplits'])->name('v1.barobill-card-transactions.splits.store'); + Route::delete('/splits', [BarobillCardTransactionController::class, 'deleteSplits'])->name('v1.barobill-card-transactions.splits.destroy'); + Route::post('/manual', [BarobillCardTransactionController::class, 'storeManual'])->name('v1.barobill-card-transactions.manual.store'); + Route::put('/manual/{id}', [BarobillCardTransactionController::class, 'updateManual'])->whereNumber('id')->name('v1.barobill-card-transactions.manual.update'); + Route::delete('/manual/{id}', [BarobillCardTransactionController::class, 'destroyManual'])->whereNumber('id')->name('v1.barobill-card-transactions.manual.destroy'); + Route::get('/{id}', [BarobillCardTransactionController::class, 'show'])->whereNumber('id')->name('v1.barobill-card-transactions.show'); + Route::post('/{id}/hide', [BarobillCardTransactionController::class, 'hide'])->whereNumber('id')->name('v1.barobill-card-transactions.hide'); + Route::post('/{id}/restore', [BarobillCardTransactionController::class, 'restore'])->whereNumber('id')->name('v1.barobill-card-transactions.restore'); + Route::put('/{id}/amount', [BarobillCardTransactionController::class, 'updateAmount'])->whereNumber('id')->name('v1.barobill-card-transactions.update-amount'); + Route::get('/{id}/journal-entries', [BarobillCardTransactionController::class, 'getJournalEntries'])->whereNumber('id')->name('v1.barobill-card-transactions.journal-entries.show'); + Route::post('/{id}/journal-entries', [BarobillCardTransactionController::class, 'storeJournalEntries'])->whereNumber('id')->name('v1.barobill-card-transactions.journal-entries.store'); + Route::delete('/{id}/journal-entries', [BarobillCardTransactionController::class, 'deleteJournalEntries'])->whereNumber('id')->name('v1.barobill-card-transactions.journal-entries.destroy'); +}); + +// Barobill Bank Transaction API (바로빌 은행 거래 - React 연동) +Route::prefix('barobill-bank-transactions')->group(function () { + Route::get('', [BarobillBankTransactionController::class, 'index'])->name('v1.barobill-bank-transactions.index'); + Route::get('/accounts', [BarobillBankTransactionController::class, 'accounts'])->name('v1.barobill-bank-transactions.accounts'); + Route::get('/balance-summary', [BarobillBankTransactionController::class, 'balanceSummary'])->name('v1.barobill-bank-transactions.balance-summary'); + Route::get('/splits', [BarobillBankTransactionController::class, 'getSplits'])->name('v1.barobill-bank-transactions.splits.show'); + Route::post('/splits', [BarobillBankTransactionController::class, 'saveSplits'])->name('v1.barobill-bank-transactions.splits.store'); + Route::delete('/splits', [BarobillBankTransactionController::class, 'deleteSplits'])->name('v1.barobill-bank-transactions.splits.destroy'); + Route::post('/override', [BarobillBankTransactionController::class, 'saveOverride'])->name('v1.barobill-bank-transactions.override'); + Route::post('/manual', [BarobillBankTransactionController::class, 'storeManual'])->name('v1.barobill-bank-transactions.manual.store'); + Route::put('/manual/{id}', [BarobillBankTransactionController::class, 'updateManual'])->whereNumber('id')->name('v1.barobill-bank-transactions.manual.update'); + Route::delete('/manual/{id}', [BarobillBankTransactionController::class, 'destroyManual'])->whereNumber('id')->name('v1.barobill-bank-transactions.manual.destroy'); + Route::get('/{id}/journal-entries', [BarobillBankTransactionController::class, 'getJournalEntries'])->whereNumber('id')->name('v1.barobill-bank-transactions.journal-entries.show'); + Route::post('/{id}/journal-entries', [BarobillBankTransactionController::class, 'storeJournalEntries'])->whereNumber('id')->name('v1.barobill-bank-transactions.journal-entries.store'); + Route::delete('/{id}/journal-entries', [BarobillBankTransactionController::class, 'deleteJournalEntries'])->whereNumber('id')->name('v1.barobill-bank-transactions.journal-entries.destroy'); +}); + +// Hometax Invoice API (홈택스 세금계산서 - React 연동) +Route::prefix('hometax-invoices')->group(function () { + Route::get('/sales', [HometaxInvoiceController::class, 'sales'])->name('v1.hometax-invoices.sales'); + Route::get('/purchases', [HometaxInvoiceController::class, 'purchases'])->name('v1.hometax-invoices.purchases'); + Route::get('/summary', [HometaxInvoiceController::class, 'summary'])->name('v1.hometax-invoices.summary'); + Route::post('', [HometaxInvoiceController::class, 'store'])->name('v1.hometax-invoices.store'); + Route::get('/{id}', [HometaxInvoiceController::class, 'show'])->whereNumber('id')->name('v1.hometax-invoices.show'); + Route::put('/{id}', [HometaxInvoiceController::class, 'update'])->whereNumber('id')->name('v1.hometax-invoices.update'); + Route::delete('/{id}', [HometaxInvoiceController::class, 'destroy'])->whereNumber('id')->name('v1.hometax-invoices.destroy'); + Route::get('/{id}/journals', [HometaxInvoiceController::class, 'getJournals'])->whereNumber('id')->name('v1.hometax-invoices.journals.show'); + Route::post('/{id}/journals', [HometaxInvoiceController::class, 'saveJournals'])->whereNumber('id')->name('v1.hometax-invoices.journals.store'); + Route::delete('/{id}/journals', [HometaxInvoiceController::class, 'deleteJournals'])->whereNumber('id')->name('v1.hometax-invoices.journals.destroy'); + Route::get('/{id}/journal-entries', [HometaxInvoiceController::class, 'getJournalEntries'])->whereNumber('id')->name('v1.hometax-invoices.journal-entries.show'); + Route::post('/{id}/journal-entries', [HometaxInvoiceController::class, 'storeJournalEntries'])->whereNumber('id')->name('v1.hometax-invoices.journal-entries.store'); + Route::delete('/{id}/journal-entries', [HometaxInvoiceController::class, 'deleteJournalEntries'])->whereNumber('id')->name('v1.hometax-invoices.journal-entries.destroy'); +}); + // Tax Invoice API (세금계산서) Route::prefix('tax-invoices')->group(function () { Route::get('', [TaxInvoiceController::class, 'index'])->name('v1.tax-invoices.index'); 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..c09c5f1 100644 --- a/routes/api/v1/quality.php +++ b/routes/api/v1/quality.php @@ -7,7 +7,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,15 +50,18 @@ Route::patch('/units/{id}/confirm', [QmsLotAuditController::class, 'confirm'])->whereNumber('id')->name('v1.qms.lot-audit.units.confirm'); }); -// QMS 기준/매뉴얼 심사 (1일차) -Route::prefix('qms')->group(function () { - Route::get('/checklists', [AuditChecklistController::class, 'index'])->name('v1.qms.checklists.index'); - Route::post('/checklists', [AuditChecklistController::class, 'store'])->name('v1.qms.checklists.store'); - Route::get('/checklists/{id}', [AuditChecklistController::class, 'show'])->whereNumber('id')->name('v1.qms.checklists.show'); - Route::put('/checklists/{id}', [AuditChecklistController::class, 'update'])->whereNumber('id')->name('v1.qms.checklists.update'); - Route::patch('/checklists/{id}/complete', [AuditChecklistController::class, 'complete'])->whereNumber('id')->name('v1.qms.checklists.complete'); - Route::patch('/checklist-items/{id}/toggle', [AuditChecklistController::class, 'toggleItem'])->whereNumber('id')->name('v1.qms.checklist-items.toggle'); - Route::get('/checklist-items/{id}/documents', [AuditChecklistController::class, 'itemDocuments'])->whereNumber('id')->name('v1.qms.checklist-items.documents'); - Route::post('/checklist-items/{id}/documents', [AuditChecklistController::class, 'attachDocument'])->whereNumber('id')->name('v1.qms.checklist-items.documents.attach'); - Route::delete('/checklist-items/{id}/documents/{docId}', [AuditChecklistController::class, 'detachDocument'])->whereNumber('id')->whereNumber('docId')->name('v1.qms.checklist-items.documents.detach'); +// 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'); + Route::patch('/{id}/items/{subItemId}/toggle', [ChecklistTemplateController::class, 'toggleItem'])->whereNumber('id')->name('v1.quality.checklist-templates.toggle-item'); }); + +// 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일차) — checklist_templates로 통합됨, AuditChecklistController 제거 diff --git a/storage/api-docs/api-docs-v1.json b/storage/api-docs/api-docs-v1.json index a12284d..cb709d1 100755 --- a/storage/api-docs/api-docs-v1.json +++ b/storage/api-docs/api-docs-v1.json @@ -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": "매입 관리"