Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop

This commit is contained in:
김보곤
2026-01-31 19:35:27 +09:00
35 changed files with 2079 additions and 124 deletions

View File

@@ -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 완료
### 작업 목표

View 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]);
}
}

View 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&current_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);
}
}

View File

@@ -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();

View File

@@ -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'));
}
}

View File

@@ -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'));
}
}

View File

@@ -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만 필요)
];
// 현재 라우트 확인 (경로 또는 이름)

View 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',
];
}
}

View 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',
];
}
}

View 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
View 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',
];
}

View File

@@ -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');
}
/**

View 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);
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -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' => '㎡',
],

View File

@@ -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); // mmm 변환 후 올림
$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);

View 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(),
]);
}
}
}

View File

@@ -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(),
]);
}
}
/**
* 알림 해결 처리
*/

View 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() {}
}

View File

@@ -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() {}
}

View 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() {}
}

View File

@@ -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'),

View File

@@ -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');
}
};

View File

@@ -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
View 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']);
});

View File

@@ -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');
});

View File

@@ -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')

View 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
View 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 "$@"