Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop
This commit is contained in:
@@ -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 완료
|
||||
|
||||
### 작업 목표
|
||||
|
||||
115
app/Console/Commands/BackupCheckCommand.php
Normal file
115
app/Console/Commands/BackupCheckCommand.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\SlackNotificationService;
|
||||
use App\Services\Stats\StatMonitorService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class BackupCheckCommand extends Command
|
||||
{
|
||||
protected $signature = 'db:backup-check
|
||||
{--path= : 백업 경로 오버라이드}';
|
||||
|
||||
protected $description = 'DB 백업 상태를 확인하고 이상 시 알림을 생성합니다';
|
||||
|
||||
public function handle(StatMonitorService $monitorService): int
|
||||
{
|
||||
$this->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]);
|
||||
}
|
||||
}
|
||||
38
app/Http/Controllers/Api/V1/AppVersionController.php
Normal file
38
app/Http/Controllers/Api/V1/AppVersionController.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AppVersionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AppVersionController extends Controller
|
||||
{
|
||||
/**
|
||||
* 최신 버전 확인
|
||||
* GET /api/v1/app/version?platform=android¤t_version_code=1
|
||||
*/
|
||||
public function latestVersion(Request $request): JsonResponse
|
||||
{
|
||||
$platform = $request->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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Documents;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\DocumentTemplate\IndexRequest;
|
||||
use App\Services\DocumentTemplateService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class DocumentTemplateController extends Controller
|
||||
{
|
||||
public function __construct(private DocumentTemplateService $service) {}
|
||||
|
||||
/**
|
||||
* 양식 목록 조회
|
||||
* GET /v1/document-templates
|
||||
*/
|
||||
public function index(IndexRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->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'));
|
||||
}
|
||||
}
|
||||
@@ -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만 필요)
|
||||
];
|
||||
|
||||
// 현재 라우트 확인 (경로 또는 이름)
|
||||
|
||||
20
app/Http/Requests/Document/ApproveRequest.php
Normal file
20
app/Http/Requests/Document/ApproveRequest.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Document;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ApproveRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'comment' => 'nullable|string|max:500',
|
||||
];
|
||||
}
|
||||
}
|
||||
20
app/Http/Requests/Document/RejectRequest.php
Normal file
20
app/Http/Requests/Document/RejectRequest.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Document;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class RejectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'comment' => 'required|string|max:500',
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Http/Requests/DocumentTemplate/IndexRequest.php
Normal file
26
app/Http/Requests/DocumentTemplate/IndexRequest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\DocumentTemplate;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class IndexRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => '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',
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Models/AppVersion.php
Normal file
36
app/Models/AppVersion.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class AppVersion extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'version_code',
|
||||
'version_name',
|
||||
'platform',
|
||||
'release_notes',
|
||||
'apk_path',
|
||||
'apk_size',
|
||||
'apk_original_name',
|
||||
'force_update',
|
||||
'is_active',
|
||||
'download_count',
|
||||
'published_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'version_code' => 'integer',
|
||||
'apk_size' => 'integer',
|
||||
'force_update' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'download_count' => 'integer',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
113
app/Models/Documents/DocumentTemplate.php
Normal file
113
app/Models/Documents/DocumentTemplate.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Documents;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 문서 양식(템플릿) 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property string $name 양식명
|
||||
* @property string $category 분류 (품질, 생산 등)
|
||||
* @property string|null $title 문서 제목
|
||||
* @property string|null $company_name 회사명
|
||||
* @property string|null $company_address 회사 주소
|
||||
* @property string|null $company_contact 연락처
|
||||
* @property string $footer_remark_label 하단 비고 라벨
|
||||
* @property string $footer_judgement_label 하단 판정 라벨
|
||||
* @property array|null $footer_judgement_options 판정 옵션 (JSON)
|
||||
* @property bool $is_active 활성 여부
|
||||
* @property \Carbon\Carbon|null $created_at
|
||||
* @property \Carbon\Carbon|null $updated_at
|
||||
* @property \Carbon\Carbon|null $deleted_at
|
||||
*/
|
||||
class DocumentTemplate extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'document_templates';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'name',
|
||||
'category',
|
||||
'title',
|
||||
'company_name',
|
||||
'company_address',
|
||||
'company_contact',
|
||||
'footer_remark_label',
|
||||
'footer_judgement_label',
|
||||
'footer_judgement_options',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'footer_judgement_options' => '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);
|
||||
}
|
||||
}
|
||||
38
app/Models/Documents/DocumentTemplateApprovalLine.php
Normal file
38
app/Models/Documents/DocumentTemplateApprovalLine.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Documents;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 문서 양식 결재라인 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $template_id
|
||||
* @property string $name 결재자 이름/직책
|
||||
* @property string|null $dept 부서
|
||||
* @property string $role 역할 (작성/검토/승인)
|
||||
* @property int $sort_order 정렬 순서
|
||||
*/
|
||||
class DocumentTemplateApprovalLine extends Model
|
||||
{
|
||||
protected $table = 'document_template_approval_lines';
|
||||
|
||||
protected $fillable = [
|
||||
'template_id',
|
||||
'name',
|
||||
'dept',
|
||||
'role',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DocumentTemplate::class, 'template_id');
|
||||
}
|
||||
}
|
||||
38
app/Models/Documents/DocumentTemplateBasicField.php
Normal file
38
app/Models/Documents/DocumentTemplateBasicField.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Documents;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 문서 양식 기본필드 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $template_id
|
||||
* @property string $label 필드 라벨
|
||||
* @property string $field_type 필드 타입 (text/date/select 등)
|
||||
* @property string|null $default_value 기본값
|
||||
* @property int $sort_order 정렬 순서
|
||||
*/
|
||||
class DocumentTemplateBasicField extends Model
|
||||
{
|
||||
protected $table = 'document_template_basic_fields';
|
||||
|
||||
protected $fillable = [
|
||||
'template_id',
|
||||
'label',
|
||||
'field_type',
|
||||
'default_value',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DocumentTemplate::class, 'template_id');
|
||||
}
|
||||
}
|
||||
43
app/Models/Documents/DocumentTemplateColumn.php
Normal file
43
app/Models/Documents/DocumentTemplateColumn.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Documents;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 문서 양식 테이블 컬럼 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $template_id
|
||||
* @property string $label 컬럼 라벨
|
||||
* @property string|null $width 컬럼 너비
|
||||
* @property string $column_type 컬럼 타입 (text/check/complex/select/measurement)
|
||||
* @property string|null $group_name 그룹명
|
||||
* @property array|null $sub_labels 하위 라벨 (complex 타입)
|
||||
* @property int $sort_order 정렬 순서
|
||||
*/
|
||||
class DocumentTemplateColumn extends Model
|
||||
{
|
||||
protected $table = 'document_template_columns';
|
||||
|
||||
protected $fillable = [
|
||||
'template_id',
|
||||
'label',
|
||||
'width',
|
||||
'column_type',
|
||||
'group_name',
|
||||
'sub_labels',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sub_labels' => 'array',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DocumentTemplate::class, 'template_id');
|
||||
}
|
||||
}
|
||||
43
app/Models/Documents/DocumentTemplateSection.php
Normal file
43
app/Models/Documents/DocumentTemplateSection.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Documents;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* 문서 양식 섹션 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $template_id
|
||||
* @property string $title 섹션 제목
|
||||
* @property string|null $image_path 검사 기준 이미지 경로
|
||||
* @property int $sort_order 정렬 순서
|
||||
*/
|
||||
class DocumentTemplateSection extends Model
|
||||
{
|
||||
protected $table = 'document_template_sections';
|
||||
|
||||
protected $fillable = [
|
||||
'template_id',
|
||||
'title',
|
||||
'image_path',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => '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');
|
||||
}
|
||||
}
|
||||
44
app/Models/Documents/DocumentTemplateSectionItem.php
Normal file
44
app/Models/Documents/DocumentTemplateSectionItem.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Documents;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 문서 양식 섹션 검사항목 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $section_id
|
||||
* @property string|null $category 항목 분류
|
||||
* @property string $item 검사항목
|
||||
* @property string|null $standard 검사기준
|
||||
* @property string|null $method 검사방식
|
||||
* @property string|null $frequency 검사주기
|
||||
* @property string|null $regulation 관련 규격
|
||||
* @property int $sort_order 정렬 순서
|
||||
*/
|
||||
class DocumentTemplateSectionItem extends Model
|
||||
{
|
||||
protected $table = 'document_template_section_items';
|
||||
|
||||
protected $fillable = [
|
||||
'section_id',
|
||||
'category',
|
||||
'item',
|
||||
'standard',
|
||||
'method',
|
||||
'frequency',
|
||||
'regulation',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function section(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DocumentTemplateSection::class, 'section_id');
|
||||
}
|
||||
}
|
||||
62
app/Services/AppVersionService.php
Normal file
62
app/Services/AppVersionService.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AppVersion;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AppVersionService
|
||||
{
|
||||
/**
|
||||
* 최신 버전 확인
|
||||
*/
|
||||
public static function getLatestVersion(string $platform, int $currentVersionCode): array
|
||||
{
|
||||
$latest = AppVersion::where('platform', $platform)
|
||||
->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);
|
||||
}
|
||||
}
|
||||
67
app/Services/DocumentTemplateService.php
Normal file
67
app/Services/DocumentTemplateService.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Documents\DocumentTemplate;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
class DocumentTemplateService extends Service
|
||||
{
|
||||
/**
|
||||
* 양식 목록 조회
|
||||
*/
|
||||
public function list(array $params): LengthAwarePaginator
|
||||
{
|
||||
$tenantId = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -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' => '㎡',
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
136
app/Services/SlackNotificationService.php
Normal file
136
app/Services/SlackNotificationService.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SlackNotificationService
|
||||
{
|
||||
private ?string $webhookUrl;
|
||||
|
||||
private string $serverName;
|
||||
|
||||
private bool $enabled;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 해결 처리
|
||||
*/
|
||||
|
||||
126
app/Swagger/v1/AppVersionApi.php
Normal file
126
app/Swagger/v1/AppVersionApi.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Swagger\v1;
|
||||
|
||||
/**
|
||||
* @OA\Tag(name="App Version", description="앱 버전 관리 (인앱 업데이트)")
|
||||
*/
|
||||
|
||||
/**
|
||||
* App Version 관련 스키마 정의
|
||||
* -----------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* @OA\Schema(
|
||||
* schema="AppVersionLatest",
|
||||
* type="object",
|
||||
* description="최신 앱 버전 정보",
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=3),
|
||||
* @OA\Property(property="version_code", type="integer", example=2, description="정수 비교용 버전 코드"),
|
||||
* @OA\Property(property="version_name", type="string", example="0.2", description="표시용 버전명"),
|
||||
* @OA\Property(property="release_notes", type="string", nullable=true, example="- 알림음 채널 정리\n- 인앱 업데이트 추가", description="변경사항"),
|
||||
* @OA\Property(property="force_update", type="boolean", example=false, description="강제 업데이트 여부"),
|
||||
* @OA\Property(property="apk_size", type="integer", nullable=true, example=15728640, description="APK 파일 크기(bytes)"),
|
||||
* @OA\Property(property="download_url", type="string", example="https://api.codebridge-x.com/api/v1/app/download/3", description="APK 다운로드 URL"),
|
||||
* @OA\Property(property="published_at", type="string", format="date", nullable=true, example="2026-01-30", description="배포일")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="AppVersionCheckResponse",
|
||||
* type="object",
|
||||
* description="버전 확인 응답",
|
||||
*
|
||||
* @OA\Property(property="has_update", type="boolean", example=true, description="업데이트 존재 여부"),
|
||||
* @OA\Property(
|
||||
* property="latest_version",
|
||||
* nullable=true,
|
||||
* ref="#/components/schemas/AppVersionLatest",
|
||||
* description="최신 버전 정보 (has_update=false면 null)"
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
class AppVersionApi
|
||||
{
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/app/version",
|
||||
* tags={"App Version"},
|
||||
* summary="최신 버전 확인",
|
||||
* description="현재 앱 버전과 서버의 최신 버전을 비교하여 업데이트 필요 여부를 반환합니다. Bearer 토큰 불필요, API Key만 필요합니다.",
|
||||
* security={{"ApiKeyAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="platform",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="플랫폼 (기본값: android)",
|
||||
*
|
||||
* @OA\Schema(type="string", enum={"android", "ios"}, example="android")
|
||||
* ),
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="current_version_code",
|
||||
* in="query",
|
||||
* required=true,
|
||||
* description="현재 앱의 버전 코드 (정수)",
|
||||
*
|
||||
* @OA\Schema(type="integer", example=1)
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="버전 확인 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(
|
||||
*
|
||||
* @OA\Property(property="data", ref="#/components/schemas/AppVersionCheckResponse")
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="API Key 인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function latestVersion() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/app/download/{id}",
|
||||
* tags={"App Version"},
|
||||
* summary="APK 다운로드",
|
||||
* description="지정된 버전의 APK 파일을 다운로드합니다. 다운로드 카운트가 자동으로 증가합니다. Bearer 토큰 불필요, API Key만 필요합니다.",
|
||||
* security={{"ApiKeyAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* required=true,
|
||||
* description="앱 버전 ID",
|
||||
*
|
||||
* @OA\Schema(type="integer", example=3)
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="APK 파일 다운로드",
|
||||
*
|
||||
* @OA\MediaType(
|
||||
* mediaType="application/vnd.android.package-archive",
|
||||
*
|
||||
* @OA\Schema(type="string", format="binary")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="APK 파일을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=401, description="API Key 인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function download() {}
|
||||
}
|
||||
@@ -334,4 +334,151 @@ public function update() {}
|
||||
* )
|
||||
*/
|
||||
public function destroy() {}
|
||||
|
||||
// =========================================================================
|
||||
// 결재 워크플로우
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/documents/{id}/submit",
|
||||
* tags={"Documents"},
|
||||
* summary="결재 제출",
|
||||
* description="DRAFT 또는 REJECTED 상태의 문서를 결재 요청합니다 (PENDING 상태로 변경).",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="문서 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="결재 제출 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Document"))
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=400, description="잘못된 요청 (제출 불가 상태 또는 결재선 미설정)", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=404, description="문서를 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function submit() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/documents/{id}/approve",
|
||||
* tags={"Documents"},
|
||||
* summary="결재 승인",
|
||||
* description="현재 사용자의 결재 단계를 승인합니다. 모든 단계 완료 시 문서가 APPROVED 상태로 변경됩니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="문서 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=false,
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="comment", type="string", example="승인합니다.", nullable=true, description="결재 의견")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="승인 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Document"))
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=400, description="잘못된 요청 (승인 불가 상태 또는 차례 아님)", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=404, description="문서를 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function approve() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/documents/{id}/reject",
|
||||
* tags={"Documents"},
|
||||
* summary="결재 반려",
|
||||
* description="현재 사용자의 결재 단계를 반려합니다. 문서가 REJECTED 상태로 변경됩니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="문서 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* required={"comment"},
|
||||
*
|
||||
* @OA\Property(property="comment", type="string", example="검사 기준 미달로 반려합니다.", description="반려 사유 (필수)")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="반려 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Document"))
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=400, description="잘못된 요청 (반려 불가 상태 또는 차례 아님)", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=404, description="문서를 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function reject() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/documents/{id}/cancel",
|
||||
* tags={"Documents"},
|
||||
* summary="결재 취소/회수",
|
||||
* description="작성자만 DRAFT 또는 PENDING 상태의 문서를 취소할 수 있습니다. CANCELLED 상태로 변경됩니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="문서 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="취소 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Document"))
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=400, description="잘못된 요청 (취소 불가 상태 또는 작성자 아님)", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=404, description="문서를 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function cancel() {}
|
||||
}
|
||||
|
||||
194
app/Swagger/v1/DocumentTemplateApi.php
Normal file
194
app/Swagger/v1/DocumentTemplateApi.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace App\Swagger\v1;
|
||||
|
||||
/**
|
||||
* @OA\Tag(name="DocumentTemplates", description="문서 양식(템플릿) 관리")
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="DocumentTemplate",
|
||||
* type="object",
|
||||
* description="문서 양식 정보",
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1, description="양식 ID"),
|
||||
* @OA\Property(property="tenant_id", type="integer", example=1, description="테넌트 ID"),
|
||||
* @OA\Property(property="name", type="string", example="수입검사 성적서 (EGI)", description="양식명"),
|
||||
* @OA\Property(property="category", type="string", example="품질", description="분류"),
|
||||
* @OA\Property(property="title", type="string", example="수입검사 성적서", nullable=true, description="문서 제목"),
|
||||
* @OA\Property(property="company_name", type="string", example="(주)SAM", nullable=true, description="회사명"),
|
||||
* @OA\Property(property="company_address", type="string", nullable=true, description="회사 주소"),
|
||||
* @OA\Property(property="company_contact", type="string", nullable=true, description="연락처"),
|
||||
* @OA\Property(property="footer_remark_label", type="string", example="부적합 내용", description="하단 비고 라벨"),
|
||||
* @OA\Property(property="footer_judgement_label", type="string", example="종합판정", description="하단 판정 라벨"),
|
||||
* @OA\Property(property="footer_judgement_options", type="array", nullable=true, description="판정 옵션",
|
||||
* @OA\Items(type="string", example="적합")
|
||||
* ),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true, description="활성 여부"),
|
||||
* @OA\Property(property="approval_lines", type="array", description="결재라인",
|
||||
* @OA\Items(ref="#/components/schemas/DocumentTemplateApprovalLine")
|
||||
* ),
|
||||
* @OA\Property(property="basic_fields", type="array", description="기본 필드",
|
||||
* @OA\Items(ref="#/components/schemas/DocumentTemplateBasicField")
|
||||
* ),
|
||||
* @OA\Property(property="sections", type="array", description="검사 기준서 섹션",
|
||||
* @OA\Items(ref="#/components/schemas/DocumentTemplateSection")
|
||||
* ),
|
||||
* @OA\Property(property="columns", type="array", description="테이블 컬럼",
|
||||
* @OA\Items(ref="#/components/schemas/DocumentTemplateColumn")
|
||||
* ),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2026-01-28T09:00:00Z"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2026-01-28T09:00:00Z")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="DocumentTemplateApprovalLine",
|
||||
* type="object",
|
||||
* description="양식 결재라인",
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="template_id", type="integer", example=1),
|
||||
* @OA\Property(property="name", type="string", example="작성", description="결재자 이름/직책"),
|
||||
* @OA\Property(property="dept", type="string", example="품질", nullable=true, description="부서"),
|
||||
* @OA\Property(property="role", type="string", example="작성", description="역할"),
|
||||
* @OA\Property(property="sort_order", type="integer", example=1, description="정렬 순서")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="DocumentTemplateBasicField",
|
||||
* type="object",
|
||||
* description="양식 기본 필드",
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="template_id", type="integer", example=1),
|
||||
* @OA\Property(property="label", type="string", example="품명", description="필드 라벨"),
|
||||
* @OA\Property(property="field_type", type="string", example="text", description="필드 타입"),
|
||||
* @OA\Property(property="default_value", type="string", example="", nullable=true, description="기본값"),
|
||||
* @OA\Property(property="sort_order", type="integer", example=1, description="정렬 순서")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="DocumentTemplateSection",
|
||||
* type="object",
|
||||
* description="양식 검사 기준서 섹션",
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="template_id", type="integer", example=1),
|
||||
* @OA\Property(property="title", type="string", example="검사항목", description="섹션 제목"),
|
||||
* @OA\Property(property="image_path", type="string", example="/img/inspection/screen_inspection.jpg", nullable=true, description="검사 기준 이미지"),
|
||||
* @OA\Property(property="sort_order", type="integer", example=1, description="정렬 순서"),
|
||||
* @OA\Property(property="items", type="array", description="검사항목",
|
||||
* @OA\Items(ref="#/components/schemas/DocumentTemplateSectionItem")
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="DocumentTemplateSectionItem",
|
||||
* type="object",
|
||||
* description="양식 검사항목",
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="section_id", type="integer", example=1),
|
||||
* @OA\Property(property="category", type="string", example="외관", nullable=true, description="항목 분류"),
|
||||
* @OA\Property(property="item", type="string", example="표면 스크래치", description="검사항목"),
|
||||
* @OA\Property(property="standard", type="string", example="스크래치 없을 것", nullable=true, description="검사기준"),
|
||||
* @OA\Property(property="method", type="string", example="육안검사", nullable=true, description="검사방식"),
|
||||
* @OA\Property(property="frequency", type="string", example="전수", nullable=true, description="검사주기"),
|
||||
* @OA\Property(property="regulation", type="string", nullable=true, description="관련 규격"),
|
||||
* @OA\Property(property="sort_order", type="integer", example=1, description="정렬 순서")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="DocumentTemplateColumn",
|
||||
* type="object",
|
||||
* description="양식 테이블 컬럼",
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="template_id", type="integer", example=1),
|
||||
* @OA\Property(property="label", type="string", example="1차", description="컬럼 라벨"),
|
||||
* @OA\Property(property="width", type="string", example="80px", nullable=true, description="컬럼 너비"),
|
||||
* @OA\Property(property="column_type", type="string", example="check", enum={"text","check","complex","select","measurement"}, description="컬럼 타입"),
|
||||
* @OA\Property(property="group_name", type="string", example="측정치", nullable=true, description="그룹명"),
|
||||
* @OA\Property(property="sub_labels", type="array", nullable=true, description="하위 라벨 (complex 타입)",
|
||||
* @OA\Items(type="string", example="n1")
|
||||
* ),
|
||||
* @OA\Property(property="sort_order", type="integer", example=1, description="정렬 순서")
|
||||
* )
|
||||
*/
|
||||
class DocumentTemplateApi
|
||||
{
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/document-templates",
|
||||
* tags={"DocumentTemplates"},
|
||||
* summary="양식 목록 조회",
|
||||
* description="문서 양식(템플릿) 목록을 조회합니다. 결재라인과 기본필드를 포함합니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="is_active", in="query", description="활성 상태 필터", @OA\Schema(type="boolean")),
|
||||
* @OA\Parameter(name="category", in="query", description="카테고리 필터", @OA\Schema(type="string", example="품질")),
|
||||
* @OA\Parameter(name="search", in="query", description="검색어 (양식명, 제목)", @OA\Schema(type="string")),
|
||||
* @OA\Parameter(name="sort_by", in="query", description="정렬 기준", @OA\Schema(type="string", enum={"created_at","name","category"}, default="created_at")),
|
||||
* @OA\Parameter(name="sort_dir", in="query", description="정렬 방향", @OA\Schema(type="string", enum={"asc","desc"}, default="desc")),
|
||||
* @OA\Parameter(ref="#/components/parameters/Page"),
|
||||
* @OA\Parameter(ref="#/components/parameters/Size"),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="조회 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(
|
||||
*
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="object",
|
||||
* @OA\Property(property="current_page", type="integer", example=1),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DocumentTemplate")),
|
||||
* @OA\Property(property="per_page", type="integer", example=20),
|
||||
* @OA\Property(property="total", type="integer", example=10)
|
||||
* )
|
||||
* )
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function index() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/document-templates/{id}",
|
||||
* tags={"DocumentTemplates"},
|
||||
* summary="양식 상세 조회",
|
||||
* description="ID 기준 양식 상세 정보를 조회합니다. 결재라인, 기본필드, 섹션(검사항목 포함), 컬럼 전체를 반환합니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="양식 ID", @OA\Schema(type="integer", example=7)),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="조회 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/DocumentTemplate"))
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=404, description="양식을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function show() {}
|
||||
}
|
||||
@@ -55,6 +55,14 @@
|
||||
'report' => 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'),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('app_versions', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
16
routes/api/v1/app.php
Normal file
16
routes/api/v1/app.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\V1\AppVersionController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| App Version Routes (앱 버전 관리)
|
||||
|--------------------------------------------------------------------------
|
||||
| 인앱 업데이트용 API (Bearer 토큰 불필요, API Key만 필요)
|
||||
*/
|
||||
|
||||
Route::prefix('app')->group(function () {
|
||||
Route::get('/version', [AppVersionController::class, 'latestVersion']);
|
||||
Route::get('/download/{id}', [AppVersionController::class, 'download']);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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')
|
||||
|
||||
34
scripts/backup/backup.conf.example
Normal file
34
scripts/backup/backup.conf.example
Normal file
@@ -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
|
||||
190
scripts/backup/sam-db-backup.sh
Executable file
190
scripts/backup/sam-db-backup.sh
Executable file
@@ -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" <<EOF
|
||||
{
|
||||
"last_run": "$(date '+%Y-%m-%dT%H:%M:%S%z' | sed 's/\(..\)$/:\1/')",
|
||||
"status": "${status}",
|
||||
"databases": ${databases_json},
|
||||
"errors": ${errors_json}
|
||||
}
|
||||
EOF
|
||||
|
||||
log "INFO" "상태 파일 기록: ${status}"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 메인 실행
|
||||
# =============================================================================
|
||||
|
||||
main() {
|
||||
log "INFO" "========== SAM DB 백업 시작 =========="
|
||||
|
||||
ensure_dirs
|
||||
|
||||
local has_error=0
|
||||
|
||||
for db in $DATABASES; do
|
||||
if ! backup_database "$db"; then
|
||||
has_error=1
|
||||
fi
|
||||
done
|
||||
|
||||
cleanup_old_backups
|
||||
write_status_file
|
||||
|
||||
if [[ $has_error -eq 1 ]]; then
|
||||
log "ERROR" "========== SAM DB 백업 완료 (일부 실패) =========="
|
||||
exit 1
|
||||
else
|
||||
log "INFO" "========== SAM DB 백업 완료 (성공) =========="
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user