diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index d5bc318..d35f84d 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -1,3 +1,63 @@ +## 2026-01-30 (목) - 5130↔SAM 견적 교차 검증 완료 + 마이그레이션 검증 + +### 작업 목표 +- SAM 견적 계산이 5130 레거시 시스템과 100% 일치하는지 교차 검증 +- FormulaEvaluatorService 슬랫/스틸 지원 완성 +- MigrateBDModelsPrices 커맨드 동작 검증 + +### 수정된 파일 +| 파일명 | 설명 | +|--------|------| +| `app/Services/Quote/FormulaEvaluatorService.php` | 제품타입별 면적/중량 공식 분기, 모터/브라켓 입력값 오버라이드, 디버그 포뮬러 동적 표시 | +| `app/Services/Quote/Handlers/KyungdongFormulaHandler.php` | 제품타입별 면적/중량 공식, normalizeGuideType() 추가, guide_rail_spec 파라미터 별칭 | + +### 핵심 수정 내용 + +#### 1. 제품타입별 면적/중량 공식 (FormulaEvaluatorService + Handler) +- **Screen**: area = (W1 × (H1+550)) / 1M, weight = area×2 + (W0/1000)×14.17 +- **Slat**: area = (W0 × (H0+50)) / 1M, weight = area×25 +- **Steel**: area = (W1 × (H1+550)) / 1M, weight = area×25 + +#### 2. 모터/브라켓 입력값 오버라이드 +- 기존: 항상 자동 계산 +- 수정: `MOTOR_CAPACITY`, `BRACKET_SIZE` 입력값이 있으면 우선 사용 + +#### 3. 가이드타입 정규화 +- `normalizeGuideType()` 메서드 추가 (벽면↔벽면형, 측면↔측면형, 혼합↔혼합형) +- `guide_rail_spec` 파라미터 별칭 지원 + +### 검증 결과 + +#### 전 모델 교차 검증 (Task #6) ✅ +``` +16/16 ALL PASS +- 10개 스크린 조합 (KSS01, KSS02, KSE01, KWE01, KTE01, KQTS01, KDSS01 × SUS/EGI) +- 6개 슬랫 조합 (KSS02, KSE01, KTE01 × SUS × 2사이즈) +- 조건: 6800×2700, QTY=1, 300K 모터, 5인치 브라켓 +``` + +#### 가이드타입 교차 검증 (Task #7) ✅ +``` +21/21 ALL PASS +- 벽면/측면/혼합 × 4모델(KSS02, KSE01, KTE01, KDSS01) × screen +- 벽면/측면/혼합 × 3모델(KSS02, KSE01, KTE01) × slat +- 혼합형: 5130은 col6에 "혼합 120*70/120*120" 두 규격 필요 +``` + +#### MigrateBDModelsPrices 커맨드 검증 (Task #4, #5) ✅ +``` +커맨드 정상 동작 확인 +- BD-* (절곡품): 58건 마이그레이션 완료 +- EST-* (모터/제어기/원자재 등): 71건 마이그레이션 완료 +- chandj 원본 가격 일치: 7/7 검증 통과 +- --dry-run, --fresh 옵션 정상 동작 +``` + +### Git 커밋 +- `f4a902f` - fix: FormulaEvaluatorService 슬랫/스틸 제품타입별 면적/중량/모터/가이드 수정 + +--- + ## 2026-01-29 (수) - 경동기업 견적 로직 Phase 4 완료 ### 작업 목표 diff --git a/app/Console/Commands/BackupCheckCommand.php b/app/Console/Commands/BackupCheckCommand.php new file mode 100644 index 0000000..221928b --- /dev/null +++ b/app/Console/Commands/BackupCheckCommand.php @@ -0,0 +1,115 @@ +info('DB 백업 상태 확인 시작...'); + + $statusFile = $this->option('path') + ? rtrim($this->option('path'), '/') . '/.backup_status' + : env('BACKUP_STATUS_FILE', '/data/backup/mysql/.backup_status'); + + $errors = []; + + // 1. 상태 파일 존재 여부 + if (! file_exists($statusFile)) { + $errors[] = '백업 상태 파일 없음: ' . $statusFile; + $this->reportErrors($monitorService, $errors); + + return self::FAILURE; + } + + $status = json_decode(file_get_contents($statusFile), true); + + if (json_last_error() !== JSON_ERROR_NONE) { + $errors[] = '상태 파일 JSON 파싱 실패: ' . json_last_error_msg(); + $this->reportErrors($monitorService, $errors); + + return self::FAILURE; + } + + // 2. last_run이 25시간 이내인지 + $lastRun = strtotime($status['last_run'] ?? ''); + if (! $lastRun || (time() - $lastRun) > 25 * 3600) { + $lastRunStr = $status['last_run'] ?? 'unknown'; + $errors[] = "마지막 백업이 25시간 초과: {$lastRunStr}"; + } + + // 3. status가 success인지 + if (($status['status'] ?? '') !== 'success') { + $errors[] = '백업 상태 실패: ' . ($status['status'] ?? 'unknown'); + } + + // 4. 각 DB 백업 파일 크기 검증 + $minSizes = [ + 'sam' => (int) env('BACKUP_MIN_SIZE_SAM', 1048576), + 'sam_stat' => (int) env('BACKUP_MIN_SIZE_STAT', 102400), + ]; + + $databases = $status['databases'] ?? []; + foreach ($minSizes as $dbName => $minSize) { + if (! isset($databases[$dbName])) { + $errors[] = "{$dbName} DB 백업 정보 없음"; + + continue; + } + + $sizeBytes = $databases[$dbName]['size_bytes'] ?? 0; + if ($sizeBytes < $minSize) { + $errors[] = "{$dbName} 백업 파일 크기 부족: {$sizeBytes} bytes (최소 {$minSize})"; + } + } + + // 결과 처리 + if (! empty($errors)) { + $this->reportErrors($monitorService, $errors); + + return self::FAILURE; + } + + $this->info('✅ DB 백업 상태 정상'); + Log::info('db:backup-check 정상', [ + 'last_run' => $status['last_run'], + 'databases' => array_keys($databases), + ]); + + return self::SUCCESS; + } + + private function reportErrors(StatMonitorService $monitorService, array $errors): void + { + $errorMessage = implode("\n", $errors); + + $this->error('❌ DB 백업 이상 감지:'); + foreach ($errors as $error) { + $this->error(" - {$error}"); + } + + // stat_alerts에 기록 + $monitorService->recordBackupFailure( + '[backup] DB 백업 이상 감지', + $errorMessage + ); + + // Slack 알림 전송 + app(SlackNotificationService::class)->sendBackupAlert( + 'DB 백업 이상 감지', + $errorMessage + ); + + Log::error('db:backup-check 실패', ['errors' => $errors]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/AppVersionController.php b/app/Http/Controllers/Api/V1/AppVersionController.php new file mode 100644 index 0000000..1fe030f --- /dev/null +++ b/app/Http/Controllers/Api/V1/AppVersionController.php @@ -0,0 +1,38 @@ +input('platform', 'android'); + $currentVersionCode = (int) $request->input('current_version_code', 0); + + $result = AppVersionService::getLatestVersion($platform, $currentVersionCode); + + return response()->json([ + 'success' => true, + 'data' => $result, + ]); + } + + /** + * APK 다운로드 + * GET /api/v1/app/download/{id} + */ + public function download(int $id): StreamedResponse + { + return AppVersionService::downloadApk($id); + } +} diff --git a/app/Http/Controllers/Api/V1/CommonController.php b/app/Http/Controllers/Api/V1/CommonController.php index 84ec27a..93fc6a4 100644 --- a/app/Http/Controllers/Api/V1/CommonController.php +++ b/app/Http/Controllers/Api/V1/CommonController.php @@ -3,17 +3,17 @@ namespace App\Http\Controllers\Api\V1; use App\Helpers\ApiResponse; +use App\Models\Products\CommonCode; +use App\Models\Scopes\TenantScope; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; class CommonController { public static function getComeCode() { return ApiResponse::handle(function () { - return DB::table('common_codes') + return CommonCode::query() ->select(['code_group', 'code', 'name', 'description', 'is_active']) - ->where('tenant_id', app('tenant_id')) ->get(); }, '공통코드'); } @@ -36,13 +36,22 @@ public function index(Request $request, string $group) return ApiResponse::handle(function () use ($group) { $tenantId = app('tenant_id'); - return DB::table('common_codes') - ->select(['id', 'code', 'name', 'description', 'sort_order', 'attributes']) + // BelongsToTenant 스코프 해제 (글로벌 폴백 로직 직접 처리) + $base = CommonCode::withoutGlobalScope(TenantScope::class) ->where('code_group', $group) - ->where('is_active', true) - ->where(function ($query) use ($tenantId) { - $query->where('tenant_id', $tenantId) - ->orWhereNull('tenant_id'); + ->where('is_active', true); + + // 테넌트 전용 데이터가 있으면 테넌트만, 없으면 글로벌 폴백 + $hasTenantData = (clone $base)->where('tenant_id', $tenantId)->exists(); + + return (clone $base) + ->select(['id', 'code', 'name', 'description', 'sort_order', 'attributes']) + ->where(function ($query) use ($tenantId, $hasTenantData) { + if ($hasTenantData) { + $query->where('tenant_id', $tenantId); + } else { + $query->whereNull('tenant_id'); + } }) ->orderBy('sort_order') ->get(); diff --git a/app/Http/Controllers/Api/V1/Documents/DocumentController.php b/app/Http/Controllers/Api/V1/Documents/DocumentController.php index 639031f..9cbc832 100644 --- a/app/Http/Controllers/Api/V1/Documents/DocumentController.php +++ b/app/Http/Controllers/Api/V1/Documents/DocumentController.php @@ -4,7 +4,9 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\Document\ApproveRequest; use App\Http\Requests\Document\IndexRequest; +use App\Http\Requests\Document\RejectRequest; use App\Http\Requests\Document\StoreRequest; use App\Http\Requests\Document\UpdateRequest; use App\Services\DocumentService; @@ -70,10 +72,50 @@ public function destroy(int $id): JsonResponse } // ========================================================================= - // 결재 관련 메서드 (보류 - 기존 시스템 연동 필요) + // 결재 워크플로우 // ========================================================================= - // public function submit(int $id): JsonResponse - // public function approve(int $id, ApproveRequest $request): JsonResponse - // public function reject(int $id, RejectRequest $request): JsonResponse - // public function cancel(int $id): JsonResponse + + /** + * 결재 제출 (DRAFT → PENDING) + * POST /v1/documents/{id}/submit + */ + public function submit(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->submit($id); + }, __('message.updated')); + } + + /** + * 결재 승인 + * POST /v1/documents/{id}/approve + */ + public function approve(int $id, ApproveRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->approve($id, $request->validated()['comment'] ?? null); + }, __('message.updated')); + } + + /** + * 결재 반려 + * POST /v1/documents/{id}/reject + */ + public function reject(int $id, RejectRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->reject($id, $request->validated()['comment']); + }, __('message.updated')); + } + + /** + * 결재 취소/회수 + * POST /v1/documents/{id}/cancel + */ + public function cancel(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->cancel($id); + }, __('message.updated')); + } } diff --git a/app/Http/Controllers/Api/V1/Documents/DocumentTemplateController.php b/app/Http/Controllers/Api/V1/Documents/DocumentTemplateController.php new file mode 100644 index 0000000..3173881 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Documents/DocumentTemplateController.php @@ -0,0 +1,36 @@ +service->list($request->validated()); + }, __('message.fetched')); + } + + /** + * 양식 상세 조회 + * GET /v1/document-templates/{id} + */ + public function show(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.fetched')); + } +} diff --git a/app/Http/Middleware/ApiKeyMiddleware.php b/app/Http/Middleware/ApiKeyMiddleware.php index 3b4f110..5a6d769 100644 --- a/app/Http/Middleware/ApiKeyMiddleware.php +++ b/app/Http/Middleware/ApiKeyMiddleware.php @@ -123,6 +123,7 @@ public function handle(Request $request, Closure $next) 'api/v1/debug-apikey', 'api/v1/internal/exchange-token', // 내부 서버간 토큰 교환 (HMAC 인증 사용) 'api/v1/admin/fcm/*', // Admin FCM API (MNG에서 API Key만으로 접근) + 'api/v1/app/*', // 앱 버전 확인/다운로드 (API Key만 필요) ]; // 현재 라우트 확인 (경로 또는 이름) diff --git a/app/Http/Requests/Document/ApproveRequest.php b/app/Http/Requests/Document/ApproveRequest.php new file mode 100644 index 0000000..83da347 --- /dev/null +++ b/app/Http/Requests/Document/ApproveRequest.php @@ -0,0 +1,20 @@ + 'nullable|string|max:500', + ]; + } +} diff --git a/app/Http/Requests/Document/RejectRequest.php b/app/Http/Requests/Document/RejectRequest.php new file mode 100644 index 0000000..e2946ff --- /dev/null +++ b/app/Http/Requests/Document/RejectRequest.php @@ -0,0 +1,20 @@ + 'required|string|max:500', + ]; + } +} diff --git a/app/Http/Requests/DocumentTemplate/IndexRequest.php b/app/Http/Requests/DocumentTemplate/IndexRequest.php new file mode 100644 index 0000000..260c6cd --- /dev/null +++ b/app/Http/Requests/DocumentTemplate/IndexRequest.php @@ -0,0 +1,26 @@ + 'nullable|boolean', + 'category' => 'nullable|string|max:50', + 'search' => 'nullable|string|max:100', + 'sort_by' => 'nullable|string|in:created_at,name,category', + 'sort_dir' => 'nullable|string|in:asc,desc', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]; + } +} diff --git a/app/Models/AppVersion.php b/app/Models/AppVersion.php new file mode 100644 index 0000000..4129a64 --- /dev/null +++ b/app/Models/AppVersion.php @@ -0,0 +1,36 @@ + 'integer', + 'apk_size' => 'integer', + 'force_update' => 'boolean', + 'is_active' => 'boolean', + 'download_count' => 'integer', + 'published_at' => 'datetime', + ]; +} diff --git a/app/Models/Documents/Document.php b/app/Models/Documents/Document.php index 6f3b2c2..1f5b4ed 100644 --- a/app/Models/Documents/Document.php +++ b/app/Models/Documents/Document.php @@ -96,7 +96,7 @@ class Document extends Model */ public function template(): BelongsTo { - return $this->belongsTo(\App\Models\DocumentTemplate::class, 'template_id'); + return $this->belongsTo(DocumentTemplate::class, 'template_id'); } /** diff --git a/app/Models/Documents/DocumentTemplate.php b/app/Models/Documents/DocumentTemplate.php new file mode 100644 index 0000000..a1dd054 --- /dev/null +++ b/app/Models/Documents/DocumentTemplate.php @@ -0,0 +1,113 @@ + 'array', + 'is_active' => 'boolean', + ]; + + // ========================================================================= + // Relationships + // ========================================================================= + + /** + * 결재라인 + */ + public function approvalLines(): HasMany + { + return $this->hasMany(DocumentTemplateApprovalLine::class, 'template_id') + ->orderBy('sort_order'); + } + + /** + * 기본 필드 + */ + public function basicFields(): HasMany + { + return $this->hasMany(DocumentTemplateBasicField::class, 'template_id') + ->orderBy('sort_order'); + } + + /** + * 검사 기준서 섹션 + */ + public function sections(): HasMany + { + return $this->hasMany(DocumentTemplateSection::class, 'template_id') + ->orderBy('sort_order'); + } + + /** + * 테이블 컬럼 + */ + public function columns(): HasMany + { + return $this->hasMany(DocumentTemplateColumn::class, 'template_id') + ->orderBy('sort_order'); + } + + // ========================================================================= + // Scopes + // ========================================================================= + + /** + * 활성 양식만 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * 카테고리 필터 + */ + public function scopeCategory($query, string $category) + { + return $query->where('category', $category); + } +} diff --git a/app/Models/Documents/DocumentTemplateApprovalLine.php b/app/Models/Documents/DocumentTemplateApprovalLine.php new file mode 100644 index 0000000..b6b274b --- /dev/null +++ b/app/Models/Documents/DocumentTemplateApprovalLine.php @@ -0,0 +1,38 @@ + 'integer', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(DocumentTemplate::class, 'template_id'); + } +} diff --git a/app/Models/Documents/DocumentTemplateBasicField.php b/app/Models/Documents/DocumentTemplateBasicField.php new file mode 100644 index 0000000..c2e22c6 --- /dev/null +++ b/app/Models/Documents/DocumentTemplateBasicField.php @@ -0,0 +1,38 @@ + 'integer', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(DocumentTemplate::class, 'template_id'); + } +} diff --git a/app/Models/Documents/DocumentTemplateColumn.php b/app/Models/Documents/DocumentTemplateColumn.php new file mode 100644 index 0000000..af39ef2 --- /dev/null +++ b/app/Models/Documents/DocumentTemplateColumn.php @@ -0,0 +1,43 @@ + 'array', + 'sort_order' => 'integer', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(DocumentTemplate::class, 'template_id'); + } +} diff --git a/app/Models/Documents/DocumentTemplateSection.php b/app/Models/Documents/DocumentTemplateSection.php new file mode 100644 index 0000000..1346f35 --- /dev/null +++ b/app/Models/Documents/DocumentTemplateSection.php @@ -0,0 +1,43 @@ + 'integer', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(DocumentTemplate::class, 'template_id'); + } + + public function items(): HasMany + { + return $this->hasMany(DocumentTemplateSectionItem::class, 'section_id') + ->orderBy('sort_order'); + } +} diff --git a/app/Models/Documents/DocumentTemplateSectionItem.php b/app/Models/Documents/DocumentTemplateSectionItem.php new file mode 100644 index 0000000..b61fd5e --- /dev/null +++ b/app/Models/Documents/DocumentTemplateSectionItem.php @@ -0,0 +1,44 @@ + 'integer', + ]; + + public function section(): BelongsTo + { + return $this->belongsTo(DocumentTemplateSection::class, 'section_id'); + } +} diff --git a/app/Services/AppVersionService.php b/app/Services/AppVersionService.php new file mode 100644 index 0000000..465a4b5 --- /dev/null +++ b/app/Services/AppVersionService.php @@ -0,0 +1,62 @@ +where('is_active', true) + ->whereNotNull('published_at') + ->orderByDesc('version_code') + ->first(); + + if (! $latest || $latest->version_code <= $currentVersionCode) { + return [ + 'has_update' => false, + 'latest_version' => null, + ]; + } + + return [ + 'has_update' => true, + 'latest_version' => [ + 'id' => $latest->id, + 'version_code' => $latest->version_code, + 'version_name' => $latest->version_name, + 'release_notes' => $latest->release_notes, + 'force_update' => $latest->force_update, + 'apk_size' => $latest->apk_size, + 'download_url' => url("/api/v1/app/download/{$latest->id}"), + 'published_at' => $latest->published_at?->format('Y-m-d'), + ], + ]; + } + + /** + * APK 다운로드 + */ + public static function downloadApk(int $id): StreamedResponse + { + $version = AppVersion::where('is_active', true)->findOrFail($id); + + if (! $version->apk_path || ! Storage::disk('app_releases')->exists($version->apk_path)) { + abort(404, 'APK 파일을 찾을 수 없습니다.'); + } + + // 다운로드 수 증가 + $version->increment('download_count'); + + $fileName = $version->apk_original_name ?: "app-v{$version->version_name}.apk"; + + return Storage::disk('app_releases')->download($version->apk_path, $fileName); + } +} diff --git a/app/Services/DocumentTemplateService.php b/app/Services/DocumentTemplateService.php new file mode 100644 index 0000000..bbce2c5 --- /dev/null +++ b/app/Services/DocumentTemplateService.php @@ -0,0 +1,67 @@ +tenantId(); + + $query = DocumentTemplate::query() + ->where('tenant_id', $tenantId) + ->with(['approvalLines', 'basicFields']); + + // 활성 상태 필터 + if (isset($params['is_active'])) { + $query->where('is_active', filter_var($params['is_active'], FILTER_VALIDATE_BOOLEAN)); + } + + // 카테고리 필터 + if (! empty($params['category'])) { + $query->where('category', $params['category']); + } + + // 검색 (양식명) + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('title', 'like', "%{$search}%"); + }); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'created_at'; + $sortDir = $params['sort_dir'] ?? 'desc'; + $query->orderBy($sortBy, $sortDir); + + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 양식 상세 조회 (전체 관계 포함) + */ + public function show(int $id): DocumentTemplate + { + $tenantId = $this->tenantId(); + + return DocumentTemplate::query() + ->where('tenant_id', $tenantId) + ->with([ + 'approvalLines', + 'basicFields', + 'sections.items', + 'columns', + ]) + ->findOrFail($id); + } +} diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php index e679490..0e3098f 100644 --- a/app/Services/Quote/FormulaEvaluatorService.php +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -1627,27 +1627,45 @@ private function calculateKyungdongBom( // KyungdongFormulaHandler 인스턴스 생성 $handler = new KyungdongFormulaHandler; - // Step 3: 경동 전용 변수 계산 + // Step 3: 경동 전용 변수 계산 (제품타입별 면적/중량 공식) $W1 = $W0 + 160; $H1 = $H0 + 350; - $area = ($W1 * ($H1 + 550)) / 1000000; - // 중량 계산 (제품타입별) - if ($productType === 'steel') { + if ($productType === 'slat') { + // 슬랫: W0 × (H0 + 50) / 1M + $area = ($W0 * ($H0 + 50)) / 1000000; $weight = $area * 25; - $weightFormula = "AREA × 25"; + $areaFormula = '(W0 × (H0 + 50)) / 1,000,000'; + $areaCalc = "({$W0} × ({$H0} + 50)) / 1,000,000"; + $weightFormula = 'AREA × 25'; + $weightCalc = "{$area} × 25"; + } elseif ($productType === 'steel') { + // 철재: W1 × (H1 + 550) / 1M, 중량 = 면적 × 25 + $area = ($W1 * ($H1 + 550)) / 1000000; + $weight = $area * 25; + $areaFormula = '(W1 × (H1 + 550)) / 1,000,000'; + $areaCalc = "({$W1} × ({$H1} + 550)) / 1,000,000"; + $weightFormula = 'AREA × 25'; $weightCalc = "{$area} × 25"; } else { + // 스크린: W1 × (H1 + 550) / 1M + $area = ($W1 * ($H1 + 550)) / 1000000; $weight = $area * 2 + ($W0 / 1000) * 14.17; - $weightFormula = "AREA × 2 + (W0 / 1000) × 14.17"; + $areaFormula = '(W1 × (H1 + 550)) / 1,000,000'; + $areaCalc = "({$W1} × ({$H1} + 550)) / 1,000,000"; + $weightFormula = 'AREA × 2 + (W0 / 1000) × 14.17'; $weightCalc = "{$area} × 2 + ({$W0} / 1000) × 14.17"; } - // 모터 용량 결정 - $motorCapacity = $handler->calculateMotorCapacity($productType, $weight, $bracketInch); + // 모터 용량 결정 (입력값 우선, 없으면 자동계산) + $motorCapacity = $inputVariables['MOTOR_CAPACITY'] + ?? $inputVariables['motor_capacity'] + ?? $handler->calculateMotorCapacity($productType, $weight, $bracketInch); - // 브라켓 크기 결정 - $bracketSize = $handler->calculateBracketSize($weight, $bracketInch); + // 브라켓 크기 결정 (입력값 우선, 없으면 자동계산) + $bracketSize = $inputVariables['BRACKET_SIZE'] + ?? $inputVariables['bracket_size'] + ?? $handler->calculateBracketSize($weight, $bracketInch); // 핸들러가 필요한 키 보장 (inputVariables에서 전달되지 않으면 기본값) $productModel = $inputVariables['product_model'] ?? 'KSS01'; @@ -1692,8 +1710,8 @@ private function calculateKyungdongBom( [ 'var' => 'AREA', 'desc' => '면적', - 'formula' => '(W1 × (H1 + 550)) / 1,000,000', - 'calculation' => "({$W1} × ({$H1} + 550)) / 1,000,000", + 'formula' => $areaFormula, + 'calculation' => $areaCalc, 'result' => round($area, 4), 'unit' => '㎡', ], diff --git a/app/Services/Quote/Handlers/KyungdongFormulaHandler.php b/app/Services/Quote/Handlers/KyungdongFormulaHandler.php index 1d6fe4d..2239b6d 100644 --- a/app/Services/Quote/Handlers/KyungdongFormulaHandler.php +++ b/app/Services/Quote/Handlers/KyungdongFormulaHandler.php @@ -229,12 +229,10 @@ private function getMotorCapacityByWeight(float $weight, ?string $bracketInch = */ public function calculateScreenPrice(float $width, float $height): array { - // 면적 계산: W1 × (H1 + 550) / 1,000,000 - // W1 = W0 + 160, H1 = H0 + 350 (레거시 5130 공식) - $W1 = $width + 160; - $H1 = $height + 350; - $calculateHeight = $H1 + 550; - $area = ($W1 * $calculateHeight) / 1000000; + // 면적 계산: W0 × (H0 + 550) / 1,000,000 + // 5130 공식: col10 × (col11 + 550) / 1,000,000 + $calculateHeight = $height + 550; + $area = ($width * $calculateHeight) / 1000000; // 원자재 단가 조회 (실리카/스크린) $unitPrice = $this->getRawMaterialPrice('실리카'); @@ -249,6 +247,29 @@ public function calculateScreenPrice(float $width, float $height): array ]; } + /** + * 슬랫(철재) 주자재 가격 계산 + * 5130 공식: W0 × (H0 + 50) / 1,000,000 × 단가 + * + * @return array [unit_price, area, total_price] + */ + public function calculateSlatPrice(float $width, float $height): array + { + $calculateHeight = $height + 50; + $area = ($width * $calculateHeight) / 1000000; + + // 원자재 단가 조회 (방화/슬랫) + $unitPrice = $this->getRawMaterialPrice('방화'); + + $roundedArea = round($area, 2); + + return [ + 'unit_price' => $unitPrice, + 'area' => $roundedArea, + 'total_price' => round($unitPrice * $roundedArea), + ]; + } + // ========================================================================= // 단가 조회 메서드 (EstimatePriceService 사용) // ========================================================================= @@ -285,6 +306,33 @@ public function getShaftPrice(string $size, float $length): float return $this->priceService->getShaftPrice($size, $length); } + /** + * 5130 고정 샤프트 제품 규격 매핑 + * col59~65: 3인치 300, 4인치 3000/4500/6000, 5인치 6000/7000/8200 + * + * @param string $size 인치 (3, 4, 5) + * @param float $lengthMm W0 올림값 (mm) + * @return float 매핑된 길이 (m 단위), 0이면 매핑 불가 + */ + private function mapShaftToFixedProduct(string $size, float $lengthMm): float + { + $products = match ($size) { + '3' => [300], + '4' => [3000, 4500, 6000], + '5' => [6000, 7000, 8200], + default => [6000, 7000, 8200], // 기본 5인치 + }; + + // 올림값 이상인 제품 중 가장 작은 것 선택 + foreach ($products as $productMm) { + if ($lengthMm <= $productMm) { + return $productMm / 1000; // mm → m + } + } + + return 0; // 매핑 불가 (초과) + } + /** * 파이프 단가 조회 */ @@ -335,14 +383,17 @@ public function calculateSteelItems(array $params): array $width = (float) ($params['W0'] ?? 0); $height = (float) ($params['H0'] ?? 0); $quantity = (int) ($params['QTY'] ?? 1); + $productType = $params['product_type'] ?? 'screen'; $modelName = $params['model_name'] ?? $params['product_model'] ?? 'KSS01'; - $finishingType = $params['finishing_type'] ?? 'SUS'; + $rawFinish = $params['finishing_type'] ?? 'SUS'; + // DB에는 'SUS', 'EGI'로 저장 → 'SUS마감' → 'SUS' 변환 + $finishingType = str_replace('마감', '', $rawFinish); // 절곡품 관련 파라미터 $caseSpec = $params['case_spec'] ?? '500*380'; $caseLength = (float) ($params['case_length'] ?? ($width + 220)); // mm 단위 (레거시: W0+220) - $guideType = $params['guide_type'] ?? '벽면형'; // 벽면형, 측면형, 혼합형 - $guideSpec = $params['guide_spec'] ?? '120*70'; // 120*70, 120*100 + $guideType = $this->normalizeGuideType($params['guide_type'] ?? '벽면형'); + $guideSpec = $params['guide_spec'] ?? $params['guide_rail_spec'] ?? '120*70'; $guideLength = (float) ($params['guide_length'] ?? ($height + 250)) / 1000; // m 단위 (레거시: H0+250) $bottomBarLength = (float) ($params['bottombar_length'] ?? $width) / 1000; // m 단위 (레거시: W0) $lbarLength = (float) ($params['lbar_length'] ?? ($width + 220)) / 1000; // m 단위 (레거시: W0+220) @@ -368,7 +419,8 @@ public function calculateSteelItems(array $params): array // 1. 케이스 (단가/1000 × 길이mm × 수량) $casePrice = $this->priceService->getCasePrice($caseSpec); if ($casePrice > 0 && $caseLength > 0) { - $totalPrice = ($casePrice / 1000) * $caseLength * $quantity; + // 5130: round($shutter_price * $total_length * 1000) * $su → 단건 반올림 후 × QTY + $perUnitPrice = round(($casePrice / 1000) * $caseLength); $items[] = [ 'category' => 'steel', 'item_name' => '케이스', @@ -376,14 +428,15 @@ public function calculateSteelItems(array $params): array 'unit' => 'm', 'quantity' => $caseLength / 1000 * $quantity, 'unit_price' => $casePrice, - 'total_price' => round($totalPrice), + 'total_price' => $perUnitPrice * $quantity, ]; } - // 2. 케이스용 연기차단재 (단가 × 길이m × 수량) + // 2. 케이스용 연기차단재 - 5130: round(단가 × 길이m) × QTY $caseSmokePrice = $this->priceService->getCaseSmokeBlockPrice(); if ($caseSmokePrice > 0 && $caseLength > 0) { $lengthM = $caseLength / 1000; + $perUnitSmoke = round($caseSmokePrice * $lengthM); $items[] = [ 'category' => 'steel', 'item_name' => '케이스용 연기차단재', @@ -391,16 +444,15 @@ public function calculateSteelItems(array $params): array 'unit' => 'm', 'quantity' => $lengthM * $quantity, 'unit_price' => $caseSmokePrice, - 'total_price' => round($caseSmokePrice * $lengthM * $quantity), + 'total_price' => $perUnitSmoke * $quantity, ]; } - // 3. 케이스 마구리 (단가 × 수량) - // 마구리 규격 = 케이스 규격 각 치수 + 5mm (레거시 updateCol45 공식) + // 3. 케이스 마구리 - 5130: round(단가 × QTY) $caseCapSpec = $this->convertToCaseCapSpec($caseSpec); $caseCapPrice = $this->priceService->getCaseCapPrice($caseCapSpec); if ($caseCapPrice > 0) { - $capQty = $quantity; // 5130: maguriPrices × $su (수량) + $capQty = $quantity; $items[] = [ 'category' => 'steel', 'item_name' => '케이스 마구리', @@ -416,18 +468,20 @@ public function calculateSteelItems(array $params): array $guideItems = $this->calculateGuideRails($modelName, $finishingType, $guideType, $guideSpec, $guideLength, $quantity); $items = array_merge($items, $guideItems); - // 5. 레일용 연기차단재 (단가 × 길이m × 2 × 수량) + // 5. 레일용 연기차단재 - 5130: round(단가 × 길이m) × multiplier × QTY $railSmokePrice = $this->priceService->getRailSmokeBlockPrice(); if ($railSmokePrice > 0 && $guideLength > 0) { - $railSmokeQty = 2 * $quantity; // 좌우 2개 + $railSmokeMultiplier = ($productType === 'slat') ? 1 : 2; + $railSmokeQty = $railSmokeMultiplier * $quantity; + $perUnitRailSmoke = round($railSmokePrice * $guideLength); $items[] = [ 'category' => 'steel', 'item_name' => '레일용 연기차단재', - 'specification' => "{$guideLength}m × 2", + 'specification' => ($railSmokeMultiplier > 1) ? "{$guideLength}m × 2" : "{$guideLength}m", 'unit' => 'm', 'quantity' => $guideLength * $railSmokeQty, 'unit_price' => $railSmokePrice, - 'total_price' => round($railSmokePrice * $guideLength * $railSmokeQty), + 'total_price' => $perUnitRailSmoke * $railSmokeQty, ]; } @@ -445,8 +499,8 @@ public function calculateSteelItems(array $params): array ]; } - // 7. L바 (단가 × 길이m × 수량) - $lbarPrice = $this->priceService->getLBarPrice($modelName); + // 7. L바 (단가 × 길이m × 수량) - 스크린 전용, 슬랫 미사용 + $lbarPrice = ($productType !== 'slat') ? $this->priceService->getLBarPrice($modelName) : 0; if ($lbarPrice > 0 && $lbarLength > 0) { $items[] = [ 'category' => 'steel', @@ -459,8 +513,8 @@ public function calculateSteelItems(array $params): array ]; } - // 8. 보강평철 (단가 × 길이m × 수량) - $flatBarPrice = $this->priceService->getFlatBarPrice(); + // 8. 보강평철 (단가 × 길이m × 수량) - 스크린 전용, 슬랫 미사용 + $flatBarPrice = ($productType !== 'slat') ? $this->priceService->getFlatBarPrice() : 0; if ($flatBarPrice > 0 && $flatBarLength > 0) { $items[] = [ 'category' => 'steel', @@ -487,17 +541,17 @@ public function calculateSteelItems(array $params): array ]; } - // 10. 환봉 (고정 2,000원 × 수량) - if ($roundBarQty > 0) { + // 10. 환봉 (고정 2,000원 × 수량) - 스크린 전용, 슬랫 미사용 + if ($roundBarQty > 0 && $productType !== 'slat') { $roundBarPrice = 2000; $items[] = [ 'category' => 'steel', 'item_name' => '환봉', 'specification' => '', 'unit' => 'EA', - 'quantity' => $roundBarQty * $quantity, + 'quantity' => $roundBarQty, 'unit_price' => $roundBarPrice, - 'total_price' => $roundBarPrice * $roundBarQty * $quantity, + 'total_price' => $roundBarPrice * $roundBarQty, ]; } @@ -515,6 +569,23 @@ public function calculateSteelItems(array $params): array * @param int $quantity 수량 * @return array 가이드레일 항목 배열 */ + /** + * 모델별 가이드레일 규격 매핑 + * + * BDmodels 테이블 기준: + * KSS01/02, KSE01, KWE01 → 120*70 / 120*120 + * KTE01, KQTS01 → 130*75 / 130*125 + * KDSS01 → 150*150 / 150*212 + */ + private function getGuideRailSpecs(string $modelName): array + { + return match ($modelName) { + 'KTE01', 'KQTS01' => ['wall' => '130*75', 'side' => '130*125'], + 'KDSS01' => ['wall' => '150*150', 'side' => '150*212'], + default => ['wall' => '120*70', 'side' => '120*120'], + }; + } + private function calculateGuideRails( string $modelName, string $finishingType, @@ -529,66 +600,63 @@ private function calculateGuideRails( return $items; } + $specs = $this->getGuideRailSpecs($modelName); + $wallSpec = $specs['wall']; + $sideSpec = $specs['side']; + + // 5130: 세트가격(단가×2 또는 wall+side) → round(세트가격 × 길이m) × QTY switch ($guideType) { case '벽면형': - // 120*70 × 2개 - $price = $this->priceService->getGuideRailPrice($modelName, $finishingType, '120*70'); + $price = $this->priceService->getGuideRailPrice($modelName, $finishingType, $wallSpec); if ($price > 0) { - $guideQty = 2 * $quantity; + $setPrice = $price * 2; // 5130: 2개 세트 가격 + $perSetTotal = round($setPrice * $guideLength); $items[] = [ 'category' => 'steel', 'item_name' => '가이드레일', - 'specification' => "{$modelName} {$finishingType} 120*70 {$guideLength}m × 2", + 'specification' => "{$modelName} {$finishingType} {$wallSpec} {$guideLength}m × 2", 'unit' => 'm', - 'quantity' => $guideLength * $guideQty, + 'quantity' => $guideLength * 2 * $quantity, 'unit_price' => $price, - 'total_price' => round($price * $guideLength * $guideQty), + 'total_price' => $perSetTotal * $quantity, ]; } break; case '측면형': - // 120*100 × 2개 - $price = $this->priceService->getGuideRailPrice($modelName, $finishingType, '120*100'); + $price = $this->priceService->getGuideRailPrice($modelName, $finishingType, $sideSpec); if ($price > 0) { - $guideQty = 2 * $quantity; + $setPrice = $price * 2; // 5130: 2개 세트 가격 + $perSetTotal = round($setPrice * $guideLength); $items[] = [ 'category' => 'steel', 'item_name' => '가이드레일', - 'specification' => "{$modelName} {$finishingType} 120*100 {$guideLength}m × 2", + 'specification' => "{$modelName} {$finishingType} {$sideSpec} {$guideLength}m × 2", 'unit' => 'm', - 'quantity' => $guideLength * $guideQty, + 'quantity' => $guideLength * 2 * $quantity, 'unit_price' => $price, - 'total_price' => round($price * $guideLength * $guideQty), + 'total_price' => $perSetTotal * $quantity, ]; } break; case '혼합형': - // 120*70 × 1개 + 120*100 × 1개 - $price70 = $this->priceService->getGuideRailPrice($modelName, $finishingType, '120*70'); - $price100 = $this->priceService->getGuideRailPrice($modelName, $finishingType, '120*100'); + $priceWall = $this->priceService->getGuideRailPrice($modelName, $finishingType, $wallSpec); + $priceSide = $this->priceService->getGuideRailPrice($modelName, $finishingType, $sideSpec); - if ($price70 > 0) { + // 5130: (wallPrice + sidePrice) → round(합산가격 × 길이m) × QTY (단일 항목) + $setPrice = ($priceWall ?: 0) + ($priceSide ?: 0); + if ($setPrice > 0) { + $perSetTotal = round($setPrice * $guideLength); + $spec = "{$modelName} {$finishingType} {$wallSpec}/{$sideSpec} {$guideLength}m"; $items[] = [ 'category' => 'steel', 'item_name' => '가이드레일', - 'specification' => "{$modelName} {$finishingType} 120*70 {$guideLength}m", + 'specification' => $spec, 'unit' => 'm', - 'quantity' => $guideLength * $quantity, - 'unit_price' => $price70, - 'total_price' => round($price70 * $guideLength * $quantity), - ]; - } - if ($price100 > 0) { - $items[] = [ - 'category' => 'steel', - 'item_name' => '가이드레일', - 'specification' => "{$modelName} {$finishingType} 120*100 {$guideLength}m", - 'unit' => 'm', - 'quantity' => $guideLength * $quantity, - 'unit_price' => $price100, - 'total_price' => round($price100 * $guideLength * $quantity), + 'quantity' => $guideLength * 2 * $quantity, + 'unit_price' => $setPrice, + 'total_price' => $perSetTotal * $quantity, ]; } break; @@ -597,6 +665,22 @@ private function calculateGuideRails( return $items; } + /** + * 가이드타입 정규화 (5130 ↔ SAM 호환) + * + * 5130: '벽면', '측면', '혼합' (col6 필드) + * SAM: '벽면형', '측면형', '혼합형' (switch case) + */ + private function normalizeGuideType(string $type): string + { + return match ($type) { + '벽면', '벽면형' => '벽면형', + '측면', '측면형' => '측면형', + '혼합', '혼합형' => '혼합형', + default => $type, + }; + } + // ========================================================================= // 부자재 계산 (3종) // ========================================================================= @@ -617,10 +701,12 @@ public function calculatePartItems(array $params): array $productType = $params['product_type'] ?? 'screen'; $quantity = (int) ($params['QTY'] ?? 1); - // 1. 감기샤프트 + // 1. 감기샤프트 (5130: col59~65 고정 제품) + // 5130 고정 규격: 3인치→0.3m, 4인치→3/4.5/6m, 5인치→6/7/8.2m $shaftSize = $bracketInch; - $shaftLength = ceil($width / 1000); // mm → m 변환 후 올림 - $shaftPrice = $this->getShaftPrice($shaftSize, $shaftLength); + $shaftLengthMm = ceil($width / 1000) * 1000; // W0 → 올림 (mm) + $shaftLength = $this->mapShaftToFixedProduct($shaftSize, $shaftLengthMm); + $shaftPrice = $shaftLength > 0 ? $this->getShaftPrice($shaftSize, $shaftLength) : 0; if ($shaftPrice > 0) { $items[] = [ 'category' => 'parts', @@ -700,11 +786,13 @@ public function calculatePartItems(array $params): array // 3. 모터 받침용 앵글 (bracket angle) // 5130: calculateAngle(qty, itemList, '스크린용') → col2 검색, qty × $su × 4 + // 5130 슬랫: col23(앵글사이즈) 비어있으면 생략 $motorCapacity = $params['MOTOR_CAPACITY'] ?? '300K'; + $bracketAngleEnabled = (bool) ($params['bracket_angle_enabled'] ?? ($productType !== 'slat')); if ($productType === 'screen') { $angleSearchOption = '스크린용'; } else { - // 철재: bracketSize로 매핑 (530*320→철제300K, 600*350→철제400K, 690*390→철제800K) + // 철재/슬랫: bracketSize로 매핑 $angleSearchOption = match ($bracketSize) { '530*320' => '철제300K', '600*350' => '철제400K', @@ -712,7 +800,7 @@ public function calculatePartItems(array $params): array default => '철제300K', }; } - $anglePrice = $this->getAnglePrice($angleSearchOption); + $anglePrice = $bracketAngleEnabled ? $this->getAnglePrice($angleSearchOption) : 0; if ($anglePrice > 0) { $angleQty = 4 * $quantity; // 5130: $su * 4 $items[] = [ @@ -727,10 +815,11 @@ public function calculatePartItems(array $params): array } // 4. 부자재 앵글 (main angle) - // 5130: calculateMainAngle(1, $itemList, '앵글3T', '2.5') × col71 - $mainAngleType = $bracketSize === '690*390' ? '앵글4T' : '앵글3T'; + // 스크린 5130: calculateMainAngle(1, $itemList, '앵글3T', '2.5') × col71 + // 슬랫 5130: calculateMainAngle(1, $itemList, '앵글4T', '2.5') × col77 + $mainAngleType = ($productType === 'slat') ? '앵글4T' : ($bracketSize === '690*390' ? '앵글4T' : '앵글3T'); $mainAngleSize = '2.5'; - $mainAngleQty = (int) ($params['main_angle_qty'] ?? 2); // col71, default 2 (좌우) + $mainAngleQty = (int) ($params['main_angle_qty'] ?? 2); // col71/col77, default 2 (좌우) $mainAnglePrice = $this->getMainAnglePrice($mainAngleType, $mainAngleSize); if ($mainAnglePrice > 0 && $mainAngleQty > 0) { $items[] = [ @@ -738,12 +827,31 @@ public function calculatePartItems(array $params): array 'item_name' => "앵글 {$mainAngleType}", 'specification' => "{$mainAngleSize}m", 'unit' => 'EA', - 'quantity' => $mainAngleQty * $quantity, + 'quantity' => $mainAngleQty, 'unit_price' => $mainAnglePrice, - 'total_price' => $mainAnglePrice * $mainAngleQty * $quantity, + 'total_price' => $mainAnglePrice * $mainAngleQty, ]; } + // 5. 조인트바 (슬랫 전용, 5130: price × col76, QTY 미적용) + if ($productType === 'slat') { + $jointBarQty = (int) ($params['joint_bar_qty'] ?? 0); + if ($jointBarQty > 0) { + $jointBarPrice = $this->getRawMaterialPrice('조인트바'); + if ($jointBarPrice > 0) { + $items[] = [ + 'category' => 'parts', + 'item_name' => '조인트바', + 'specification' => '', + 'unit' => 'EA', + 'quantity' => $jointBarQty, + 'unit_price' => $jointBarPrice, + 'total_price' => round($jointBarPrice * $jointBarQty), + ]; + } + } + } + return $items; } @@ -767,15 +875,26 @@ public function calculateDynamicItems(array $inputs): array $bracketInch = $inputs['bracket_inch'] ?? '5'; $productType = $inputs['product_type'] ?? 'screen'; - // 중량 계산 (5130 로직) - W1, H1 기반 - $W1 = $width + 160; - $H1 = $height + 350; - $area = ($W1 * ($H1 + 550)) / 1000000; - $weight = $area * ($productType === 'steel' ? 25 : 2) + ($width / 1000) * 14.17; + // 중량 계산 (5130 로직) - 제품타입별 면적/중량 공식 + if ($productType === 'slat') { + // 슬랫: W0 × (H0 + 50) / 1M, 중량 = 면적 × 25 + $area = ($width * ($height + 50)) / 1000000; + $weight = $area * 25; + } else { + // 스크린/철재: W1 × (H1 + 550) / 1M + $W1 = $width + 160; + $H1 = $height + 350; + $area = ($W1 * ($H1 + 550)) / 1000000; + if ($productType === 'steel') { + $weight = $area * 25; + } else { + $weight = $area * 2 + ($width / 1000) * 14.17; + } + } - // 모터 용량/브라켓 크기 계산 - $motorCapacity = $this->calculateMotorCapacity($productType, $weight, $bracketInch); - $bracketSize = $this->calculateBracketSize($weight, $bracketInch); + // 모터 용량/브라켓 크기 계산 (입력값 우선, 없으면 자동계산) + $motorCapacity = $inputs['MOTOR_CAPACITY'] ?? $this->calculateMotorCapacity($productType, $weight, $bracketInch); + $bracketSize = $inputs['BRACKET_SIZE'] ?? $this->calculateBracketSize($weight, $bracketInch); // 입력값에 계산된 값 추가 (부자재 계산용) $inputs['WEIGHT'] = $weight; @@ -797,17 +916,25 @@ public function calculateDynamicItems(array $inputs): array ]; } - // 1. 주자재 (스크린) - $screenResult = $this->calculateScreenPrice($width, $height); + // 1. 주자재 (스크린 또는 슬랫) + if ($productType === 'slat') { + $materialResult = $this->calculateSlatPrice($width, $height); + $materialName = '주자재(슬랫)'; + $materialCode = 'KD-SLAT'; + } else { + $materialResult = $this->calculateScreenPrice($width, $height); + $materialName = '주자재(스크린)'; + $materialCode = 'KD-SCREEN'; + } $items[] = [ 'category' => 'material', - 'item_code' => 'KD-SCREEN', - 'item_name' => '주자재(스크린)', - 'specification' => "면적 {$screenResult['area']}㎡", + 'item_code' => $materialCode, + 'item_name' => $materialName, + 'specification' => "면적 {$materialResult['area']}㎡", 'unit' => '㎡', - 'quantity' => $screenResult['area'] * $quantity, - 'unit_price' => $screenResult['unit_price'], - 'total_price' => $screenResult['total_price'] * $quantity, + 'quantity' => $materialResult['area'] * $quantity, + 'unit_price' => $materialResult['unit_price'], + 'total_price' => $materialResult['total_price'] * $quantity, ]; // 2. 모터 @@ -824,6 +951,8 @@ public function calculateDynamicItems(array $inputs): array ]; // 3. 제어기 (5130: 매립형×col15 + 노출형×col16 + 뒷박스×col17) + // 5130: 제어기 = price_매립 × col15 + price_노출 × col16 + price_뒷박스 × col17 + // col15/col16/col17은 고정 수량 (QTY와 무관, $su를 곱하지 않음) $controllerType = $inputs['controller_type'] ?? '매립형'; $controllerQty = (int) ($inputs['controller_qty'] ?? 1); $controllerPrice = $this->getControllerPrice($controllerType); @@ -834,13 +963,13 @@ public function calculateDynamicItems(array $inputs): array 'item_name' => "제어기 {$controllerType}", 'specification' => $controllerType, 'unit' => 'EA', - 'quantity' => $controllerQty * $quantity, + 'quantity' => $controllerQty, 'unit_price' => $controllerPrice, - 'total_price' => $controllerPrice * $controllerQty * $quantity, + 'total_price' => $controllerPrice * $controllerQty, ]; } - // 뒷박스 (5130: col17 수량) + // 뒷박스 (5130: col17 수량, QTY와 무관) $backboxQty = (int) ($inputs['backbox_qty'] ?? 1); if ($backboxQty > 0) { $backboxPrice = $this->getControllerPrice('뒷박스'); @@ -851,14 +980,18 @@ public function calculateDynamicItems(array $inputs): array 'item_name' => '뒷박스', 'specification' => '', 'unit' => 'EA', - 'quantity' => $backboxQty * $quantity, + 'quantity' => $backboxQty, 'unit_price' => $backboxPrice, - 'total_price' => $backboxPrice * $backboxQty * $quantity, + 'total_price' => $backboxPrice * $backboxQty, ]; } } // 4. 절곡품 + // installation_type → guide_type 매핑 (calculateSteelItems는 guide_type 사용) + if (isset($inputs['installation_type']) && ! isset($inputs['guide_type'])) { + $inputs['guide_type'] = $this->normalizeGuideType($inputs['installation_type']); + } $steelItems = $this->calculateSteelItems($inputs); $items = array_merge($items, $steelItems); diff --git a/app/Services/SlackNotificationService.php b/app/Services/SlackNotificationService.php new file mode 100644 index 0000000..9faaa76 --- /dev/null +++ b/app/Services/SlackNotificationService.php @@ -0,0 +1,136 @@ +webhookUrl = env('SLACK_ALERT_WEBHOOK_URL') ?: env('LOG_SLACK_WEBHOOK_URL'); + $this->serverName = env('SLACK_ALERT_SERVER_NAME', config('app.env', 'unknown')); + $this->enabled = (bool) env('SLACK_ALERT_ENABLED', false); + } + + /** + * 일반 알림 전송 + */ + public function sendAlert(string $title, string $message, string $severity = 'critical'): void + { + $color = match ($severity) { + 'critical' => '#FF0000', + 'warning' => '#FFA500', + default => '#3498DB', + }; + + $emoji = match ($severity) { + 'critical' => ':rotating_light:', + 'warning' => ':warning:', + default => ':information_source:', + }; + + $this->send([ + 'attachments' => [ + [ + 'color' => $color, + 'blocks' => [ + [ + 'type' => 'header', + 'text' => [ + 'type' => 'plain_text', + 'text' => "{$emoji} {$title}", + 'emoji' => true, + ], + ], + [ + 'type' => 'section', + 'fields' => [ + [ + 'type' => 'mrkdwn', + 'text' => "*서버:*\n{$this->serverName}", + ], + [ + 'type' => 'mrkdwn', + 'text' => '*시간:*\n' . now()->format('Y-m-d H:i:s'), + ], + ], + ], + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => "*상세:*\n{$message}", + ], + ], + [ + 'type' => 'context', + 'elements' => [ + [ + 'type' => 'mrkdwn', + 'text' => "환경: `" . config('app.env') . "` | 심각도: `{$severity}`", + ], + ], + ], + ], + ], + ], + ]); + } + + /** + * 백업 실패 알림 + */ + public function sendBackupAlert(string $title, string $message): void + { + $this->sendAlert("[SAM 백업 실패] {$title}", $message, 'critical'); + } + + /** + * 통계/모니터링 알림 + */ + public function sendStatAlert(string $title, string $message, string $domain): void + { + $this->sendAlert("[SAM {$domain}] {$title}", $message, 'critical'); + } + + /** + * Slack 웹훅으로 메시지 전송 + */ + private function send(array $payload): void + { + if (! $this->enabled) { + Log::debug('Slack 알림 비활성화 상태 (SLACK_ALERT_ENABLED=false)'); + + return; + } + + if (empty($this->webhookUrl)) { + Log::warning('Slack 웹훅 URL 미설정'); + + return; + } + + try { + $response = Http::timeout(10)->post($this->webhookUrl, $payload); + + if (! $response->successful()) { + Log::error('Slack 알림 전송 실패', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + } + } catch (\Throwable $e) { + Log::error('Slack 알림 전송 예외', [ + 'error' => $e->getMessage(), + ]); + } + } +} \ No newline at end of file diff --git a/app/Services/Stats/StatMonitorService.php b/app/Services/Stats/StatMonitorService.php index 292df47..044532f 100644 --- a/app/Services/Stats/StatMonitorService.php +++ b/app/Services/Stats/StatMonitorService.php @@ -3,6 +3,7 @@ namespace App\Services\Stats; use App\Models\Stats\StatAlert; +use App\Services\SlackNotificationService; use Illuminate\Support\Facades\Log; class StatMonitorService @@ -26,6 +27,13 @@ public function recordAggregationFailure(int $tenantId, string $domain, string $ 'is_resolved' => false, 'created_at' => now(), ]); + + // critical 알림 Slack 전송 + app(SlackNotificationService::class)->sendStatAlert( + "[{$jobType}] 집계 실패", + mb_substr($errorMessage, 0, 300), + $domain + ); } catch (\Throwable $e) { Log::error('stat_alert 기록 실패', [ 'tenant_id' => $tenantId, @@ -94,6 +102,13 @@ public function recordMismatch(int $tenantId, string $domain, string $label, flo 'is_resolved' => false, 'created_at' => now(), ]); + + // critical 알림 Slack 전송 + app(SlackNotificationService::class)->sendStatAlert( + "[{$label}] 정합성 불일치", + "원본={$expected}, 통계={$actual}, 차이=" . ($actual - $expected), + $domain + ); } catch (\Throwable $e) { Log::error('stat_alert 기록 실패 (mismatch)', [ 'tenant_id' => $tenantId, @@ -103,6 +118,33 @@ public function recordMismatch(int $tenantId, string $domain, string $label, flo } } + /** + * 백업 실패 알림 기록 (시스템 레벨, tenantId=0) + */ + public function recordBackupFailure(string $title, string $message): void + { + try { + StatAlert::create([ + 'tenant_id' => 0, + 'alert_type' => 'backup_failure', + 'domain' => 'backup', + 'severity' => 'critical', + 'title' => $title, + 'message' => mb_substr($message, 0, 500), + 'current_value' => 0, + 'threshold_value' => 0, + 'is_read' => false, + 'is_resolved' => false, + 'created_at' => now(), + ]); + } catch (\Throwable $e) { + Log::error('stat_alert 기록 실패 (backup_failure)', [ + 'title' => $title, + 'error' => $e->getMessage(), + ]); + } + } + /** * 알림 해결 처리 */ diff --git a/app/Swagger/v1/AppVersionApi.php b/app/Swagger/v1/AppVersionApi.php new file mode 100644 index 0000000..8b5f797 --- /dev/null +++ b/app/Swagger/v1/AppVersionApi.php @@ -0,0 +1,126 @@ + false, ], + 'app_releases' => [ + 'driver' => 'local', + 'root' => storage_path('app/releases'), + 'visibility' => 'private', + 'throw' => false, + 'report' => false, + ], + 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), diff --git a/database/migrations/2026_01_30_200000_create_app_versions_table.php b/database/migrations/2026_01_30_200000_create_app_versions_table.php new file mode 100644 index 0000000..7c718cb --- /dev/null +++ b/database/migrations/2026_01_30_200000_create_app_versions_table.php @@ -0,0 +1,37 @@ +id(); + $table->unsignedInteger('version_code')->unique()->comment('정수 비교용 버전 코드'); + $table->string('version_name', 20)->comment('표시용 버전명 (예: 0.2)'); + $table->string('platform', 10)->default('android')->comment('android/ios'); + $table->text('release_notes')->nullable()->comment('변경사항'); + $table->string('apk_path', 500)->nullable()->comment('스토리지 경로'); + $table->unsignedBigInteger('apk_size')->nullable()->comment('파일 크기(bytes)'); + $table->string('apk_original_name', 255)->nullable()->comment('원본 파일명'); + $table->boolean('force_update')->default(false)->comment('강제 업데이트 여부'); + $table->boolean('is_active')->default(true)->comment('활성 여부'); + $table->unsignedInteger('download_count')->default(0)->comment('다운로드 수'); + $table->timestamp('published_at')->nullable()->comment('배포일'); + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['platform', 'is_active']); + }); + } + + public function down(): void + { + Schema::dropIfExists('app_versions'); + } +}; diff --git a/routes/api.php b/routes/api.php index daf53ea..c7a3f3e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -38,6 +38,7 @@ require __DIR__.'/api/v1/documents.php'; require __DIR__.'/api/v1/common.php'; require __DIR__.'/api/v1/stats.php'; + require __DIR__.'/api/v1/app.php'; // 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖) Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download'); diff --git a/routes/api/v1/app.php b/routes/api/v1/app.php new file mode 100644 index 0000000..414cb8b --- /dev/null +++ b/routes/api/v1/app.php @@ -0,0 +1,16 @@ +group(function () { + Route::get('/version', [AppVersionController::class, 'latestVersion']); + Route::get('/download/{id}', [AppVersionController::class, 'download']); +}); diff --git a/routes/api/v1/documents.php b/routes/api/v1/documents.php index ff1de56..f28f438 100644 --- a/routes/api/v1/documents.php +++ b/routes/api/v1/documents.php @@ -3,13 +3,22 @@ /** * 문서 관리 API 라우트 (v1) * + * - 문서 양식(템플릿) 조회 * - 문서 CRUD - * - 결재 워크플로우 (보류 - 기존 시스템 연동 필요) + * - 결재 워크플로우 */ use App\Http\Controllers\Api\V1\Documents\DocumentController; +use App\Http\Controllers\Api\V1\Documents\DocumentTemplateController; use Illuminate\Support\Facades\Route; +// 문서 양식(템플릿) - 읽기 전용 +Route::prefix('document-templates')->group(function () { + Route::get('/', [DocumentTemplateController::class, 'index'])->name('v1.document-templates.index'); + Route::get('/{id}', [DocumentTemplateController::class, 'show'])->whereNumber('id')->name('v1.document-templates.show'); +}); + +// 문서 CRUD + 결재 Route::prefix('documents')->group(function () { // 문서 CRUD Route::get('/', [DocumentController::class, 'index'])->name('v1.documents.index'); @@ -18,9 +27,9 @@ Route::patch('/{id}', [DocumentController::class, 'update'])->whereNumber('id')->name('v1.documents.update'); Route::delete('/{id}', [DocumentController::class, 'destroy'])->whereNumber('id')->name('v1.documents.destroy'); - // 결재 워크플로우 (보류 - 기존 시스템 연동 필요) - // Route::post('/{id}/submit', [DocumentController::class, 'submit'])->name('v1.documents.submit'); - // Route::post('/{id}/approve', [DocumentController::class, 'approve'])->name('v1.documents.approve'); - // Route::post('/{id}/reject', [DocumentController::class, 'reject'])->name('v1.documents.reject'); - // Route::post('/{id}/cancel', [DocumentController::class, 'cancel'])->name('v1.documents.cancel'); -}); + // 결재 워크플로우 + Route::post('/{id}/submit', [DocumentController::class, 'submit'])->whereNumber('id')->name('v1.documents.submit'); + Route::post('/{id}/approve', [DocumentController::class, 'approve'])->whereNumber('id')->name('v1.documents.approve'); + Route::post('/{id}/reject', [DocumentController::class, 'reject'])->whereNumber('id')->name('v1.documents.reject'); + Route::post('/{id}/cancel', [DocumentController::class, 'cancel'])->whereNumber('id')->name('v1.documents.cancel'); +}); \ No newline at end of file diff --git a/routes/console.php b/routes/console.php index 190c612..e749df0 100644 --- a/routes/console.php +++ b/routes/console.php @@ -120,6 +120,19 @@ \Illuminate\Support\Facades\Log::error('❌ stat:aggregate-monthly 스케줄러 실행 실패', ['time' => now()]); }); +// ─── DB 백업 모니터링 ─── + +// 매일 새벽 05:00에 DB 백업 상태 확인 (04:30 백업 완료 후 점검) +Schedule::command('db:backup-check') + ->dailyAt('05:00') + ->appendOutputTo(storage_path('logs/scheduler.log')) + ->onSuccess(function () { + \Illuminate\Support\Facades\Log::info('✅ db:backup-check 스케줄러 실행 성공', ['time' => now()]); + }) + ->onFailure(function () { + \Illuminate\Support\Facades\Log::error('❌ db:backup-check 스케줄러 실행 실패', ['time' => now()]); + }); + // 매일 오전 09:00에 KPI 목표 대비 알림 체크 Schedule::command('stat:check-kpi-alerts') ->dailyAt('09:00') diff --git a/scripts/backup/backup.conf.example b/scripts/backup/backup.conf.example new file mode 100644 index 0000000..d665c10 --- /dev/null +++ b/scripts/backup/backup.conf.example @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# ============================================================================= +# SAM DB Backup Configuration +# ============================================================================= +# 사용법: 이 파일을 backup.conf로 복사 후 환경에 맞게 수정 +# cp backup.conf.example backup.conf +# chmod 600 backup.conf +# ============================================================================= + +# DB 접속 정보 +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_USER=codebridge +DB_PASS="code**bridge" + +# 백업 대상 DB (공백 구분) +DATABASES="sam sam_stat" + +# 백업 저장 경로 +BACKUP_BASE_DIR=/data/backup/mysql + +# 보관 정책 +DAILY_RETENTION_DAYS=7 +WEEKLY_RETENTION_DAYS=28 + +# 로그 +LOG_FILE=/data/backup/mysql/logs/backup.log + +# 상태 파일 (Laravel 모니터링용) +STATUS_FILE=/data/backup/mysql/.backup_status + +# 최소 백업 파일 크기 (bytes) — 이보다 작으면 실패로 간주 +MIN_SIZE_SAM=1048576 # 1MB +MIN_SIZE_SAM_STAT=102400 # 100KB \ No newline at end of file diff --git a/scripts/backup/sam-db-backup.sh b/scripts/backup/sam-db-backup.sh new file mode 100755 index 0000000..893e2de --- /dev/null +++ b/scripts/backup/sam-db-backup.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# ============================================================================= +# SAM DB Backup Script +# ============================================================================= +# 용도: MySQL 데이터베이스 백업 (mysqldump + gzip) +# 실행: crontab에서 매일 04:30 실행 +# 30 4 * * * /home/webservice/api/scripts/backup/sam-db-backup.sh +# ============================================================================= + +set -euo pipefail + +# 스크립트 경로 기준으로 설정 파일 로드 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONF_FILE="${SCRIPT_DIR}/backup.conf" + +if [[ ! -f "$CONF_FILE" ]]; then + echo "[FATAL] 설정 파일 없음: $CONF_FILE" >&2 + exit 1 +fi + +# shellcheck source=backup.conf.example +source "$CONF_FILE" + +# ============================================================================= +# 변수 초기화 +# ============================================================================= +TODAY=$(date +%Y-%m-%d) +TIMESTAMP=$(date +%Y%m%d_%H%M) +DAY_OF_WEEK=$(date +%u) # 1=월 ~ 7=일 +DAILY_DIR="${BACKUP_BASE_DIR}/daily/${TODAY}" +WEEKLY_DIR="${BACKUP_BASE_DIR}/weekly" +LOG_DIR=$(dirname "$LOG_FILE") +ERRORS=() +DB_RESULTS=() + +# ============================================================================= +# 함수 정의 +# ============================================================================= + +log() { + local level="$1" + shift + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" | tee -a "$LOG_FILE" +} + +ensure_dirs() { + mkdir -p "$DAILY_DIR" "$WEEKLY_DIR" "$LOG_DIR" +} + +backup_database() { + local db_name="$1" + local output_file="${DAILY_DIR}/${db_name}_${TIMESTAMP}.sql.gz" + + log "INFO" "백업 시작: ${db_name}" + + if mysqldump \ + --host="$DB_HOST" \ + --port="$DB_PORT" \ + --user="$DB_USER" \ + --password="$DB_PASS" \ + --single-transaction \ + --routines \ + --triggers \ + --quick \ + --lock-tables=false \ + "$db_name" 2>>"$LOG_FILE" | gzip > "$output_file"; then + + local file_size + file_size=$(stat -f%z "$output_file" 2>/dev/null || stat -c%s "$output_file" 2>/dev/null || echo 0) + + # 최소 크기 검증 + local min_size_var="MIN_SIZE_$(echo "$db_name" | tr '[:lower:]' '[:upper:]')" + local min_size="${!min_size_var:-0}" + + if [[ "$file_size" -lt "$min_size" ]]; then + log "ERROR" "백업 파일 크기 부족: ${db_name} (${file_size} bytes < ${min_size} bytes)" + ERRORS+=("${db_name}: 파일 크기 부족 (${file_size} < ${min_size})") + DB_RESULTS+=("{\"db\":\"${db_name}\",\"file\":\"$(basename "$output_file")\",\"size_bytes\":${file_size},\"status\":\"size_error\"}") + return 1 + fi + + log "INFO" "백업 완료: ${db_name} (${file_size} bytes)" + DB_RESULTS+=("{\"db\":\"${db_name}\",\"file\":\"$(basename "$output_file")\",\"size_bytes\":${file_size},\"status\":\"success\"}") + + # 일요일이면 weekly 복사 + if [[ "$DAY_OF_WEEK" -eq 7 ]]; then + local weekly_file="${WEEKLY_DIR}/${db_name}_${TIMESTAMP}_week.sql.gz" + cp "$output_file" "$weekly_file" + log "INFO" "주간 백업 복사: ${db_name}" + fi + + return 0 + else + log "ERROR" "mysqldump 실패: ${db_name}" + ERRORS+=("${db_name}: mysqldump 실패") + DB_RESULTS+=("{\"db\":\"${db_name}\",\"file\":\"\",\"size_bytes\":0,\"status\":\"dump_error\"}") + rm -f "$output_file" + return 1 + fi +} + +cleanup_old_backups() { + log "INFO" "오래된 백업 정리 시작" + + # daily: DAILY_RETENTION_DAYS일 초과 디렉토리 삭제 + if [[ -d "${BACKUP_BASE_DIR}/daily" ]]; then + find "${BACKUP_BASE_DIR}/daily" -mindepth 1 -maxdepth 1 -type d -mtime +"$DAILY_RETENTION_DAYS" -exec rm -rf {} \; 2>>"$LOG_FILE" + local daily_deleted=$? + log "INFO" "일간 백업 정리 완료 (${DAILY_RETENTION_DAYS}일 초과 삭제)" + fi + + # weekly: WEEKLY_RETENTION_DAYS일 초과 파일 삭제 + if [[ -d "$WEEKLY_DIR" ]]; then + find "$WEEKLY_DIR" -type f -name "*.sql.gz" -mtime +"$WEEKLY_RETENTION_DAYS" -delete 2>>"$LOG_FILE" + log "INFO" "주간 백업 정리 완료 (${WEEKLY_RETENTION_DAYS}일 초과 삭제)" + fi +} + +write_status_file() { + local status="success" + local errors_json="[]" + + if [[ ${#ERRORS[@]} -gt 0 ]]; then + status="failure" + # 에러 배열을 JSON 배열로 변환 + errors_json="[" + for i in "${!ERRORS[@]}"; do + [[ $i -gt 0 ]] && errors_json+="," + errors_json+="\"${ERRORS[$i]}\"" + done + errors_json+="]" + fi + + # databases 객체 구성 + local databases_json="{" + for i in "${!DB_RESULTS[@]}"; do + [[ $i -gt 0 ]] && databases_json+="," + local result="${DB_RESULTS[$i]}" + local db_name + db_name=$(echo "$result" | sed 's/.*"db":"\([^"]*\)".*/\1/') + local file_name + file_name=$(echo "$result" | sed 's/.*"file":"\([^"]*\)".*/\1/') + local size + size=$(echo "$result" | sed 's/.*"size_bytes":\([0-9]*\).*/\1/') + databases_json+="\"${db_name}\":{\"file\":\"${file_name}\",\"size_bytes\":${size}}" + done + databases_json+="}" + + cat > "$STATUS_FILE" <