feat: [quote] 견적 API Phase 2-3 완료 (Service + Controller Layer)

Phase 2 - Service Layer:
- QuoteService: 견적 CRUD + 상태관리 (확정/전환)
- QuoteNumberService: 견적번호 채번 (KD-{PREFIX}-YYMMDD-SEQ)
- FormulaEvaluatorService: 수식 평가 엔진 (SUM, IF, ROUND 등)
- QuoteCalculationService: 자동산출 (스크린/철재 제품)
- QuoteDocumentService: PDF 생성 및 이메일/카카오 발송

Phase 3 - Controller Layer:
- QuoteController: 16개 엔드포인트
- FormRequest 7개: Index, Store, Update, BulkDelete, Calculate, SendEmail, SendKakao
- QuoteApi.php: Swagger 문서 (12개 스키마, 16개 엔드포인트)
- routes/api.php: 16개 라우트 등록

i18n 키 추가:
- error.php: quote_not_found, formula_* 등
- message.php: quote.* 성공 메시지
This commit is contained in:
2025-12-04 22:03:40 +09:00
parent d164bb4c4a
commit 40ca8b8697
18 changed files with 3264 additions and 3 deletions

View File

@@ -1,5 +1,250 @@
# SAM API 작업 현황
## 2025-12-04 (수) - 견적 API Phase 3: Controller + FormRequest + Routes + Swagger 완료
### 작업 목표
- 견적 API Phase 3 Controller Layer 구현
- 16개 API 엔드포인트 구현 완료
### 생성된 파일
**Controller (1개):**
- `app/Http/Controllers/Api/V1/QuoteController.php`
- 16개 메서드: index, show, store, update, destroy, bulkDestroy, finalize, cancelFinalize, convertToOrder, previewNumber, calculate, calculationSchema, generatePdf, sendEmail, sendKakao, sendHistory
- 4개 Service DI: QuoteService, QuoteNumberService, QuoteCalculationService, QuoteDocumentService
- ApiResponse::handle() 패턴 적용
**FormRequest (7개):**
| 파일 | 설명 |
|------|------|
| `QuoteIndexRequest.php` | 목록 조회 파라미터 검증 |
| `QuoteStoreRequest.php` | 견적 생성 검증 (items 배열 포함) |
| `QuoteUpdateRequest.php` | 견적 수정 검증 |
| `QuoteBulkDeleteRequest.php` | 일괄 삭제 IDs 검증 |
| `QuoteCalculateRequest.php` | 자동산출 입력값 검증 |
| `QuoteSendEmailRequest.php` | 이메일 발송 검증 |
| `QuoteSendKakaoRequest.php` | 카카오 발송 검증 |
**Swagger (1개):**
- `app/Swagger/v1/QuoteApi.php`
- 12개 스키마: Quote, QuoteItem, QuotePagination, QuoteCreateRequest, QuoteUpdateRequest 등
- 16개 엔드포인트 문서화
### API 엔드포인트 (16개)
| Method | Endpoint | 설명 |
|--------|----------|------|
| GET | `/api/v1/quotes` | 견적 목록 (페이지네이션) |
| POST | `/api/v1/quotes` | 견적 생성 |
| GET | `/api/v1/quotes/number/preview` | 견적번호 미리보기 |
| POST | `/api/v1/quotes/calculate` | 자동산출 |
| GET | `/api/v1/quotes/calculate/schema` | 산출 스키마 조회 |
| DELETE | `/api/v1/quotes/bulk` | 일괄 삭제 |
| GET | `/api/v1/quotes/{id}` | 견적 상세 |
| PUT | `/api/v1/quotes/{id}` | 견적 수정 |
| DELETE | `/api/v1/quotes/{id}` | 견적 삭제 |
| POST | `/api/v1/quotes/{id}/finalize` | 확정 |
| POST | `/api/v1/quotes/{id}/cancel-finalize` | 확정 취소 |
| POST | `/api/v1/quotes/{id}/convert` | 주문 전환 |
| GET | `/api/v1/quotes/{id}/pdf` | PDF 생성 |
| POST | `/api/v1/quotes/{id}/send/email` | 이메일 발송 |
| POST | `/api/v1/quotes/{id}/send/kakao` | 카카오 발송 |
| GET | `/api/v1/quotes/{id}/send/history` | 발송 이력 |
### 검증 결과
- PHP 문법 검사: ✅ 9개 파일 통과
- Pint 코드 포맷팅: ✅ 완료
- Swagger 문서 생성: ✅ 완료
- 라우트 등록: ✅ 16개 라우트 확인
### 다음 작업 (Phase 4)
- [ ] 단위 테스트 작성
- [ ] 통합 테스트 작성
- [ ] 마이그레이션 실행 및 실제 데이터 검증
---
## 2025-12-04 (수) - 견적 API Phase 2: Service Layer 구현 완료
### 작업 목표
- 견적 API Phase 2 Service Layer 구현
- 5개 Service 파일 생성 완료
### 생성된 파일
**Service Layer (5개):**
| 파일 | 설명 | 주요 기능 |
|------|------|----------|
| `QuoteService.php` | 견적 CRUD + 상태관리 | index, show, store, update, destroy, bulkDestroy, finalize, cancelFinalize, convertToOrder |
| `QuoteNumberService.php` | 견적번호 채번 | generate, preview, validate, parse, isUnique |
| `FormulaEvaluatorService.php` | 수식 평가 엔진 | validateFormula, evaluate, evaluateMultiple, evaluateRange, evaluateMapping |
| `QuoteCalculationService.php` | 견적 자동산출 | calculate, preview, recalculate, getInputSchema |
| `QuoteDocumentService.php` | 문서 생성/발송 | generatePdf, sendEmail, sendKakao, getSendHistory |
### 견적번호 형식
```
KD-{PREFIX}-{YYMMDD}-{SEQ}
예: KD-SC-251204-01 (스크린), KD-ST-251204-01 (철재)
```
### FormulaEvaluatorService 지원 함수
- 수학: `SUM`, `ROUND`, `CEIL`, `FLOOR`, `ABS`, `MIN`, `MAX`
- 논리: `IF`, `AND`, `OR`, `NOT`
### QuoteCalculationService 입력 스키마
**공통 입력:**
- `W0`: 개구부 폭 (mm)
- `H0`: 개구부 높이 (mm)
- `QTY`: 수량
**스크린 제품 추가:**
- `INSTALL_TYPE`: 설치 유형 (wall/ceiling/floor)
- `MOTOR_TYPE`: 모터 유형 (standard/heavy)
- `CONTROL_TYPE`: 제어 방식 (switch/remote/smart)
- `CHAIN_SIDE`: 체인 위치 (left/right)
**철재 제품 추가:**
- `MATERIAL`: 재질 (ss304/ss316/galvanized)
- `THICKNESS`: 두께 (mm)
- `FINISH`: 표면처리 (hairline/mirror/matte)
- `WELDING`: 용접 방식 (tig/mig/spot)
### i18n 키 추가
**에러 메시지 (error.php):**
- `quote_not_found`, `quote_not_editable`, `quote_not_deletable`
- `quote_not_finalizable`, `quote_not_finalized`, `quote_already_converted`
- `quote_not_convertible`, `quote_email_not_found`, `quote_phone_not_found`
- `formula_empty`, `formula_parentheses_mismatch`, `formula_unsupported_function`, `formula_calculation_error`
**성공 메시지 (message.php):**
- `quote.fetched`, `quote.created`, `quote.updated`, `quote.deleted`
- `quote.bulk_deleted`, `quote.finalized`, `quote.finalize_cancelled`
- `quote.converted`, `quote.calculated`, `quote.pdf_generated`
- `quote_email_sent`, `quote_kakao_sent`
### 검증 결과
- PHP 문법 검사: ✅ 5개 파일 통과
- Pint 코드 포맷팅: ✅ 완료
### 다음 작업 (Phase 3)
- [ ] QuoteController.php 생성
- [ ] FormRequest 생성 (QuoteStoreRequest, QuoteUpdateRequest 등)
- [ ] Swagger 문서 작성 (QuoteApi.php)
- [ ] 라우트 등록
---
## 2025-12-04 (수) - 거래처 API 2차 필드 추가 및 견적 API 계획 업데이트
### 작업 목표
- 거래처 API에 2차 필드 추가 (17개 신규 필드)
- 견적 API 변경사항 분석 및 계획 문서 업데이트
### 거래처 API 2차 필드 추가
**추가된 필드 (7개 섹션, 20개 필드):**
| 섹션 | 필드 | 설명 |
|------|------|------|
| 거래처 유형 | `client_type` | 매입/매출/매입매출 |
| 연락처 | `mobile`, `fax` | 모바일, 팩스 |
| 담당자 | `manager_name`, `manager_tel`, `system_manager` | 담당자 정보 |
| 발주처 설정 | `account_id`, `account_password`, `purchase_payment_day`, `sales_payment_day` | 계정 및 결제일 |
| 약정 세금 | `tax_agreement`, `tax_amount`, `tax_start_date`, `tax_end_date` | 세금 약정 정보 |
| 악성채권 | `bad_debt`, `bad_debt_amount`, `bad_debt_receive_date`, `bad_debt_end_date`, `bad_debt_progress` | 채권 정보 |
| 기타 | `memo` | 메모 |
**수정된 파일:**
- `database/migrations/2025_12_04_205603_add_extended_fields_to_clients_table.php` (NEW)
- `app/Models/Orders/Client.php` - fillable, casts, hidden 업데이트
- `app/Http/Requests/Client/ClientStoreRequest.php` - 검증 규칙 추가
- `app/Http/Requests/Client/ClientUpdateRequest.php` - 검증 규칙 추가
- `app/Services/ClientService.php` - store/update 검증 추가
- `app/Swagger/v1/ClientApi.php` - 3개 스키마 업데이트
### 견적 API 계획 업데이트
**신규 요청 - 문서 발송 API (Section 3.5):**
| Method | Endpoint | 설명 |
|--------|----------|------|
| POST | `/api/v1/quotes/{id}/send/email` | 이메일 발송 |
| POST | `/api/v1/quotes/{id}/send/fax` | 팩스 발송 |
| POST | `/api/v1/quotes/{id}/send/kakao` | 카카오톡 발송 |
**계획 문서 업데이트 내용:**
- Phase 2: `QuoteDocumentService` 추가
- Phase 3: `QuoteSendEmailRequest`, `QuoteSendFaxRequest`, `QuoteSendKakaoRequest` 추가
- Service 5개, FormRequest 8개로 조정
### Git 커밋
```
commit d164bb4
feat: [client] 거래처 API 2차 필드 추가 및 견적 계획 업데이트
```
### 다음 작업
- 견적 API Phase 2: Service Layer 구현
---
## 2025-12-04 (수) - 견적수식 시드 데이터 구현
### 작업 목표
- design/src/components/utils/formulaSampleData.ts의 데이터를 MNG에서 관리할 수 있도록 시드 데이터 구현
- 26개 수식 규칙, 11개 카테고리를 DB에 입력
### 추가된 파일
**Seeder (2개):**
- `database/seeders/QuoteFormulaCategorySeeder.php`
- 11개 카테고리 시드 (OPEN_SIZE, MAKE_SIZE, AREA, WEIGHT, GUIDE_RAIL, CASE, MOTOR, CONTROLLER, EDGE_WING, INSPECTION, PRICE_FORMULA)
- updateOrInsert 패턴으로 멱등성 보장
- `database/seeders/QuoteFormulaSeeder.php`
- 29개 수식 시드 (input 2개, calculation 18개, range 3개, mapping 1개, 단가수식 8개)
- 8개 범위 데이터 (quote_formula_ranges)
- 카테고리 코드 → ID 매핑으로 FK 참조
### 시드 데이터 상세
| 카테고리 | 코드 | 수식 수 | 설명 |
|----------|------|---------|------|
| 오픈사이즈 | OPEN_SIZE | 2 | W0, H0 입력 |
| 제작사이즈 | MAKE_SIZE | 4 | W1/H1 (스크린/철재) |
| 면적 | AREA | 1 | W1 × H1 / 1000000 |
| 중량 | WEIGHT | 2 | 스크린/철재 중량 계산 |
| 가이드레일 | GUIDE_RAIL | 5 | 길이, 자동선택, 설치유형별 수량 |
| 케이스 | CASE | 3 | 사이즈, 자재 자동선택 |
| 모터 | MOTOR | 1 | 중량 기반 자동선택 |
| 제어기 | CONTROLLER | 1 | 유형별 자동선택 |
| 마구리 | EDGE_WING | 1 | 날개 수량 계산 |
| 검사 | INSPECTION | 1 | 검사비 고정 |
| 단가수식 | PRICE_FORMULA | 8 | 품목별 단가 계산 |
### 실행 명령어
```bash
# 순서대로 실행
php artisan db:seed --class=QuoteFormulaCategorySeeder
php artisan db:seed --class=QuoteFormulaSeeder
```
### 검증 결과
- 카테고리: 11개 생성 완료 ✅
- 수식: 29개 생성 완료 ✅
- 범위 데이터: 8개 생성 완료 ✅
### 참조 문서
- `mng/docs/QUOTE_FORMULA_SEED_PLAN.md` - 구현 계획서
- `design/src/components/utils/formulaSampleData.ts` - 소스 데이터
---
## 2025-12-02 (월) - 메뉴 통합관리 시스템 구현 (Phase 1-2)
### 작업 목표

View File

@@ -0,0 +1,196 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Quote\QuoteBulkDeleteRequest;
use App\Http\Requests\Quote\QuoteCalculateRequest;
use App\Http\Requests\Quote\QuoteIndexRequest;
use App\Http\Requests\Quote\QuoteSendEmailRequest;
use App\Http\Requests\Quote\QuoteSendKakaoRequest;
use App\Http\Requests\Quote\QuoteStoreRequest;
use App\Http\Requests\Quote\QuoteUpdateRequest;
use App\Services\Quote\QuoteCalculationService;
use App\Services\Quote\QuoteDocumentService;
use App\Services\Quote\QuoteNumberService;
use App\Services\Quote\QuoteService;
class QuoteController extends Controller
{
public function __construct(
private QuoteService $quoteService,
private QuoteNumberService $numberService,
private QuoteCalculationService $calculationService,
private QuoteDocumentService $documentService
) {}
/**
* 견적 목록 조회
*/
public function index(QuoteIndexRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->quoteService->index($request->validated());
}, __('message.quote.fetched'));
}
/**
* 견적 단건 조회
*/
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->quoteService->show($id);
}, __('message.quote.fetched'));
}
/**
* 견적 생성
*/
public function store(QuoteStoreRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->quoteService->store($request->validated());
}, __('message.quote.created'));
}
/**
* 견적 수정
*/
public function update(QuoteUpdateRequest $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->quoteService->update($id, $request->validated());
}, __('message.quote.updated'));
}
/**
* 견적 삭제
*/
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
$this->quoteService->destroy($id);
return null;
}, __('message.quote.deleted'));
}
/**
* 견적 일괄 삭제
*/
public function bulkDestroy(QuoteBulkDeleteRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$count = $this->quoteService->bulkDestroy($request->validated()['ids']);
return ['deleted_count' => $count];
}, __('message.quote.bulk_deleted'));
}
/**
* 견적 확정
*/
public function finalize(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->quoteService->finalize($id);
}, __('message.quote.finalized'));
}
/**
* 견적 확정 취소
*/
public function cancelFinalize(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->quoteService->cancelFinalize($id);
}, __('message.quote.finalize_cancelled'));
}
/**
* 수주 전환
*/
public function convertToOrder(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->quoteService->convertToOrder($id);
}, __('message.quote.converted'));
}
/**
* 견적번호 미리보기
*/
public function previewNumber(?string $productCategory = null)
{
return ApiResponse::handle(function () use ($productCategory) {
return $this->numberService->preview($productCategory);
}, __('message.fetched'));
}
/**
* 자동산출 미리보기
*/
public function calculate(QuoteCalculateRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validated();
return $this->calculationService->calculate(
$validated['inputs'] ?? $validated,
$validated['product_category'] ?? null
);
}, __('message.quote.calculated'));
}
/**
* 자동산출 입력 스키마 조회
*/
public function calculationSchema(?string $productCategory = null)
{
return ApiResponse::handle(function () use ($productCategory) {
return $this->calculationService->getInputSchema($productCategory);
}, __('message.fetched'));
}
/**
* 견적서 PDF 생성
*/
public function generatePdf(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->documentService->generatePdf($id);
}, __('message.quote.pdf_generated'));
}
/**
* 견적서 이메일 발송
*/
public function sendEmail(QuoteSendEmailRequest $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->documentService->sendEmail($id, $request->validated());
}, __('message.quote_email_sent'));
}
/**
* 견적서 카카오톡 발송
*/
public function sendKakao(QuoteSendKakaoRequest $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->documentService->sendKakao($id, $request->validated());
}, __('message.quote_kakao_sent'));
}
/**
* 발송 이력 조회
*/
public function sendHistory(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->documentService->getSendHistory($id);
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Quote;
use Illuminate\Foundation\Http\FormRequest;
class QuoteBulkDeleteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'ids' => 'required|array|min:1',
'ids.*' => 'required|integer',
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Requests\Quote;
use App\Models\Quote\Quote;
use Illuminate\Foundation\Http\FormRequest;
class QuoteCalculateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'product_category' => 'nullable|in:'.Quote::CATEGORY_SCREEN.','.Quote::CATEGORY_STEEL,
// 입력값 (직접 또는 inputs 객체로)
'inputs' => 'nullable|array',
// 공통 입력
'W0' => 'nullable|numeric|min:0',
'H0' => 'nullable|numeric|min:0',
'QTY' => 'nullable|integer|min:1',
'inputs.W0' => 'nullable|numeric|min:0',
'inputs.H0' => 'nullable|numeric|min:0',
'inputs.QTY' => 'nullable|integer|min:1',
// 스크린 제품 입력
'INSTALL_TYPE' => 'nullable|in:wall,ceiling,floor',
'MOTOR_TYPE' => 'nullable|in:standard,heavy',
'CONTROL_TYPE' => 'nullable|in:switch,remote,smart',
'CHAIN_SIDE' => 'nullable|in:left,right',
'inputs.INSTALL_TYPE' => 'nullable|in:wall,ceiling,floor',
'inputs.MOTOR_TYPE' => 'nullable|in:standard,heavy',
'inputs.CONTROL_TYPE' => 'nullable|in:switch,remote,smart',
'inputs.CHAIN_SIDE' => 'nullable|in:left,right',
// 철재 제품 입력
'MATERIAL' => 'nullable|in:ss304,ss316,galvanized',
'THICKNESS' => 'nullable|numeric|min:0.1|max:50',
'FINISH' => 'nullable|in:hairline,mirror,matte',
'WELDING' => 'nullable|in:tig,mig,spot',
'inputs.MATERIAL' => 'nullable|in:ss304,ss316,galvanized',
'inputs.THICKNESS' => 'nullable|numeric|min:0.1|max:50',
'inputs.FINISH' => 'nullable|in:hairline,mirror,matte',
'inputs.WELDING' => 'nullable|in:tig,mig,spot',
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests\Quote;
use App\Models\Quote\Quote;
use Illuminate\Foundation\Http\FormRequest;
class QuoteIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'page' => 'nullable|integer|min:1',
'size' => 'nullable|integer|min:1|max:100',
'q' => 'nullable|string|max:100',
'status' => 'nullable|in:'.implode(',', [
Quote::STATUS_DRAFT,
Quote::STATUS_SENT,
Quote::STATUS_APPROVED,
Quote::STATUS_REJECTED,
Quote::STATUS_FINALIZED,
Quote::STATUS_CONVERTED,
]),
'product_category' => 'nullable|in:'.Quote::CATEGORY_SCREEN.','.Quote::CATEGORY_STEEL,
'client_id' => 'nullable|integer',
'date_from' => 'nullable|date',
'date_to' => 'nullable|date|after_or_equal:date_from',
'sort_by' => 'nullable|in:registration_date,quote_number,client_name,total_amount,status,created_at',
'sort_order' => 'nullable|in:asc,desc',
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Quote;
use Illuminate\Foundation\Http\FormRequest;
class QuoteSendEmailRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'email' => 'nullable|email|max:100',
'name' => 'nullable|string|max:100',
'subject' => 'nullable|string|max:200',
'message' => 'nullable|string|max:2000',
'cc' => 'nullable|array',
'cc.*' => 'nullable|email|max:100',
'attach_pdf' => 'nullable|boolean',
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Quote;
use Illuminate\Foundation\Http\FormRequest;
class QuoteSendKakaoRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'phone' => 'nullable|string|max:20',
'name' => 'nullable|string|max:100',
'template_code' => 'nullable|string|max:50',
'view_url' => 'nullable|url|max:500',
];
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Http\Requests\Quote;
use App\Models\Quote\Quote;
use Illuminate\Foundation\Http\FormRequest;
class QuoteStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
// 기본 정보
'quote_number' => 'nullable|string|max:50',
'registration_date' => 'nullable|date',
'receipt_date' => 'nullable|date',
'author' => 'nullable|string|max:50',
// 발주처 정보
'client_id' => 'nullable|integer',
'client_name' => 'nullable|string|max:100',
'manager' => 'nullable|string|max:50',
'contact' => 'nullable|string|max:50',
// 현장 정보
'site_id' => 'nullable|integer',
'site_name' => 'nullable|string|max:100',
'site_code' => 'nullable|string|max:50',
// 제품 정보
'product_category' => 'nullable|in:'.Quote::CATEGORY_SCREEN.','.Quote::CATEGORY_STEEL,
'product_id' => 'nullable|integer',
'product_code' => 'nullable|string|max:50',
'product_name' => 'nullable|string|max:100',
// 규격 정보
'open_size_width' => 'nullable|numeric|min:0',
'open_size_height' => 'nullable|numeric|min:0',
'quantity' => 'nullable|integer|min:1',
'unit_symbol' => 'nullable|string|max:10',
'floors' => 'nullable|string|max:50',
// 금액 정보
'material_cost' => 'nullable|numeric|min:0',
'labor_cost' => 'nullable|numeric|min:0',
'install_cost' => 'nullable|numeric|min:0',
'discount_rate' => 'nullable|numeric|min:0|max:100',
'total_amount' => 'nullable|numeric|min:0',
// 기타 정보
'completion_date' => 'nullable|date',
'remarks' => 'nullable|string|max:500',
'memo' => 'nullable|string',
'notes' => 'nullable|string',
// 자동산출 입력값
'calculation_inputs' => 'nullable|array',
'calculation_inputs.*' => 'nullable',
// 품목 배열
'items' => 'nullable|array',
'items.*.item_id' => 'nullable|integer',
'items.*.item_code' => 'nullable|string|max:50',
'items.*.item_name' => 'nullable|string|max:100',
'items.*.specification' => 'nullable|string|max:200',
'items.*.unit' => 'nullable|string|max:20',
'items.*.base_quantity' => 'nullable|numeric|min:0',
'items.*.calculated_quantity' => 'nullable|numeric|min:0',
'items.*.unit_price' => 'nullable|numeric|min:0',
'items.*.total_price' => 'nullable|numeric|min:0',
'items.*.formula' => 'nullable|string|max:500',
'items.*.formula_result' => 'nullable|string|max:200',
'items.*.formula_source' => 'nullable|string|max:100',
'items.*.formula_category' => 'nullable|string|max:50',
'items.*.data_source' => 'nullable|string|max:100',
'items.*.delivery_date' => 'nullable|date',
'items.*.note' => 'nullable|string|max:500',
'items.*.sort_order' => 'nullable|integer|min:0',
];
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Http\Requests\Quote;
use App\Models\Quote\Quote;
use Illuminate\Foundation\Http\FormRequest;
class QuoteUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
// 기본 정보
'receipt_date' => 'nullable|date',
'author' => 'nullable|string|max:50',
// 발주처 정보
'client_id' => 'nullable|integer',
'client_name' => 'nullable|string|max:100',
'manager' => 'nullable|string|max:50',
'contact' => 'nullable|string|max:50',
// 현장 정보
'site_id' => 'nullable|integer',
'site_name' => 'nullable|string|max:100',
'site_code' => 'nullable|string|max:50',
// 제품 정보
'product_category' => 'nullable|in:'.Quote::CATEGORY_SCREEN.','.Quote::CATEGORY_STEEL,
'product_id' => 'nullable|integer',
'product_code' => 'nullable|string|max:50',
'product_name' => 'nullable|string|max:100',
// 규격 정보
'open_size_width' => 'nullable|numeric|min:0',
'open_size_height' => 'nullable|numeric|min:0',
'quantity' => 'nullable|integer|min:1',
'unit_symbol' => 'nullable|string|max:10',
'floors' => 'nullable|string|max:50',
// 금액 정보
'material_cost' => 'nullable|numeric|min:0',
'labor_cost' => 'nullable|numeric|min:0',
'install_cost' => 'nullable|numeric|min:0',
'discount_rate' => 'nullable|numeric|min:0|max:100',
'total_amount' => 'nullable|numeric|min:0',
// 기타 정보
'completion_date' => 'nullable|date',
'remarks' => 'nullable|string|max:500',
'memo' => 'nullable|string',
'notes' => 'nullable|string',
// 자동산출 입력값
'calculation_inputs' => 'nullable|array',
'calculation_inputs.*' => 'nullable',
// 품목 배열 (전체 교체)
'items' => 'nullable|array',
'items.*.item_id' => 'nullable|integer',
'items.*.item_code' => 'nullable|string|max:50',
'items.*.item_name' => 'nullable|string|max:100',
'items.*.specification' => 'nullable|string|max:200',
'items.*.unit' => 'nullable|string|max:20',
'items.*.base_quantity' => 'nullable|numeric|min:0',
'items.*.calculated_quantity' => 'nullable|numeric|min:0',
'items.*.unit_price' => 'nullable|numeric|min:0',
'items.*.total_price' => 'nullable|numeric|min:0',
'items.*.formula' => 'nullable|string|max:500',
'items.*.formula_result' => 'nullable|string|max:200',
'items.*.formula_source' => 'nullable|string|max:100',
'items.*.formula_category' => 'nullable|string|max:50',
'items.*.data_source' => 'nullable|string|max:100',
'items.*.delivery_date' => 'nullable|date',
'items.*.note' => 'nullable|string|max:500',
'items.*.sort_order' => 'nullable|integer|min:0',
];
}
}

View File

@@ -0,0 +1,398 @@
<?php
namespace App\Services\Quote;
use App\Services\Service;
/**
* 수식 평가 서비스
*
* 견적 자동산출을 위한 수식 검증 및 평가 엔진
* 지원 함수: SUM, ROUND, CEIL, FLOOR, ABS, MIN, MAX, IF, AND, OR, NOT
*/
class FormulaEvaluatorService extends Service
{
/**
* 지원 함수 목록
*/
public const SUPPORTED_FUNCTIONS = [
'SUM', 'ROUND', 'CEIL', 'FLOOR', 'ABS', 'MIN', 'MAX', 'IF', 'AND', 'OR', 'NOT',
];
private array $variables = [];
private array $errors = [];
/**
* 수식 검증
*/
public function validateFormula(string $formula): array
{
$errors = [];
// 기본 문법 검증
if (empty(trim($formula))) {
return [
'success' => false,
'errors' => [__('error.formula_empty')],
];
}
// 괄호 매칭 검증
if (! $this->validateParentheses($formula)) {
$errors[] = __('error.formula_parentheses_mismatch');
}
// 변수 추출
$variables = $this->extractVariables($formula);
// 지원 함수 검증
$functions = $this->extractFunctions($formula);
foreach ($functions as $func) {
if (! in_array(strtoupper($func), self::SUPPORTED_FUNCTIONS)) {
$errors[] = __('error.formula_unsupported_function', ['function' => $func]);
}
}
return [
'success' => empty($errors),
'errors' => $errors,
'variables' => $variables,
'functions' => $functions,
];
}
/**
* 수식 평가
*
* @param string $formula 수식 문자열
* @param array $variables 변수 배열 [변수명 => 값]
*/
public function evaluate(string $formula, array $variables = []): mixed
{
$this->variables = array_merge($this->variables, $variables);
$this->errors = [];
try {
// 변수 치환
$expression = $this->substituteVariables($formula);
// 함수 처리
$expression = $this->processFunctions($expression);
// 최종 계산
return $this->calculateExpression($expression);
} catch (\Exception $e) {
$this->errors[] = $e->getMessage();
return null;
}
}
/**
* 다중 수식 일괄 평가
*
* @param array $formulas [변수명 => 수식] 배열
* @param array $inputVariables 입력 변수
* @return array [변수명 => 결과값]
*/
public function evaluateMultiple(array $formulas, array $inputVariables = []): array
{
$this->variables = $inputVariables;
$results = [];
foreach ($formulas as $variable => $formula) {
$result = $this->evaluate($formula);
$this->variables[$variable] = $result;
$results[$variable] = $result;
}
return [
'results' => $results,
'errors' => $this->errors,
];
}
/**
* 범위 기반 값 결정
*
* @param float $value 검사할 값
* @param array $ranges 범위 배열 [['min' => 0, 'max' => 100, 'result' => 값], ...]
* @param mixed $default 기본값
*/
public function evaluateRange(float $value, array $ranges, mixed $default = null): mixed
{
foreach ($ranges as $range) {
$min = $range['min'] ?? null;
$max = $range['max'] ?? null;
$inRange = true;
if ($min !== null && $value < $min) {
$inRange = false;
}
if ($max !== null && $value > $max) {
$inRange = false;
}
if ($inRange) {
$result = $range['result'] ?? $default;
// result가 수식인 경우 평가
if (is_string($result) && $this->isFormula($result)) {
return $this->evaluate($result);
}
return $result;
}
}
return $default;
}
/**
* 매핑 기반 값 결정
*
* @param mixed $sourceValue 소스 값
* @param array $mappings 매핑 배열 [['source' => 값, 'result' => 결과], ...]
* @param mixed $default 기본값
*/
public function evaluateMapping(mixed $sourceValue, array $mappings, mixed $default = null): mixed
{
foreach ($mappings as $mapping) {
$source = $mapping['source'] ?? null;
$result = $mapping['result'] ?? $default;
if ($sourceValue == $source) {
// result가 수식인 경우 평가
if (is_string($result) && $this->isFormula($result)) {
return $this->evaluate($result);
}
return $result;
}
}
return $default;
}
/**
* 괄호 매칭 검증
*/
private function validateParentheses(string $formula): bool
{
$count = 0;
foreach (str_split($formula) as $char) {
if ($char === '(') {
$count++;
}
if ($char === ')') {
$count--;
}
if ($count < 0) {
return false;
}
}
return $count === 0;
}
/**
* 수식에서 변수 추출
*/
private function extractVariables(string $formula): array
{
preg_match_all('/\b([A-Z][A-Z0-9_]*)\b/', $formula, $matches);
$variables = array_unique($matches[1] ?? []);
// 함수명 제외
return array_values(array_diff($variables, self::SUPPORTED_FUNCTIONS));
}
/**
* 수식에서 함수 추출
*/
private function extractFunctions(string $formula): array
{
preg_match_all('/\b([A-Za-z_]+)\s*\(/', $formula, $matches);
return array_unique($matches[1] ?? []);
}
/**
* 변수 치환
*/
private function substituteVariables(string $formula): string
{
foreach ($this->variables as $var => $value) {
$formula = preg_replace('/\b'.preg_quote($var, '/').'\b/', (string) $value, $formula);
}
return $formula;
}
/**
* 함수 처리
*/
private function processFunctions(string $expression): string
{
// ROUND(value, decimals)
$expression = preg_replace_callback(
'/ROUND\s*\(\s*([^,]+)\s*,\s*(\d+)\s*\)/i',
fn ($m) => round((float) $this->calculateExpression($m[1]), (int) $m[2]),
$expression
);
// SUM(a, b, c, ...)
$expression = preg_replace_callback(
'/SUM\s*\(([^)]+)\)/i',
fn ($m) => array_sum(array_map('floatval', explode(',', $m[1]))),
$expression
);
// MIN, MAX
$expression = preg_replace_callback(
'/MIN\s*\(([^)]+)\)/i',
fn ($m) => min(array_map('floatval', explode(',', $m[1]))),
$expression
);
$expression = preg_replace_callback(
'/MAX\s*\(([^)]+)\)/i',
fn ($m) => max(array_map('floatval', explode(',', $m[1]))),
$expression
);
// IF(condition, true_val, false_val)
$expression = preg_replace_callback(
'/IF\s*\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^)]+)\s*\)/i',
function ($m) {
$condition = $this->evaluateCondition($m[1]);
return $condition ? $this->calculateExpression($m[2]) : $this->calculateExpression($m[3]);
},
$expression
);
// ABS, CEIL, FLOOR
$expression = preg_replace_callback(
'/ABS\s*\(([^)]+)\)/i',
fn ($m) => abs((float) $this->calculateExpression($m[1])),
$expression
);
$expression = preg_replace_callback(
'/CEIL\s*\(([^)]+)\)/i',
fn ($m) => ceil((float) $this->calculateExpression($m[1])),
$expression
);
$expression = preg_replace_callback(
'/FLOOR\s*\(([^)]+)\)/i',
fn ($m) => floor((float) $this->calculateExpression($m[1])),
$expression
);
return $expression;
}
/**
* 수식 계산 (안전한 평가)
*/
private function calculateExpression(string $expression): float
{
// 안전한 수식 평가 (숫자, 연산자, 괄호만 허용)
$expression = preg_replace('/[^0-9+\-*\/().%\s]/', '', $expression);
if (empty(trim($expression))) {
return 0;
}
try {
// TODO: 프로덕션에서는 symfony/expression-language 등 안전한 라이브러리 사용 권장
return (float) eval("return {$expression};");
} catch (\Throwable $e) {
$this->errors[] = __('error.formula_calculation_error', ['expression' => $expression]);
return 0;
}
}
/**
* 조건식 평가
*/
private function evaluateCondition(string $condition): bool
{
// 비교 연산자 처리
if (preg_match('/(.+)(>=|<=|>|<|==|!=)(.+)/', $condition, $m)) {
$left = (float) $this->calculateExpression(trim($m[1]));
$right = (float) $this->calculateExpression(trim($m[3]));
$op = $m[2];
return match ($op) {
'>=' => $left >= $right,
'<=' => $left <= $right,
'>' => $left > $right,
'<' => $left < $right,
'==' => $left == $right,
'!=' => $left != $right,
default => false,
};
}
return (bool) $this->calculateExpression($condition);
}
/**
* 문자열이 수식인지 확인
*/
private function isFormula(string $value): bool
{
// 연산자나 함수가 포함되어 있으면 수식으로 판단
return preg_match('/[+\-*\/()]|[A-Z]+\s*\(/', $value) === 1;
}
/**
* 에러 목록 반환
*/
public function getErrors(): array
{
return $this->errors;
}
/**
* 현재 변수 상태 반환
*/
public function getVariables(): array
{
return $this->variables;
}
/**
* 변수 설정
*/
public function setVariables(array $variables): self
{
$this->variables = $variables;
return $this;
}
/**
* 변수 추가
*/
public function addVariable(string $name, mixed $value): self
{
$this->variables[$name] = $value;
return $this;
}
/**
* 변수 및 에러 초기화
*/
public function reset(): void
{
$this->variables = [];
$this->errors = [];
}
}

View File

@@ -0,0 +1,538 @@
<?php
namespace App\Services\Quote;
use App\Models\Quote\Quote;
use App\Services\Service;
/**
* 견적 자동산출 서비스
*
* 입력 파라미터(W0, H0 등)를 기반으로 견적 품목과 금액을 자동 계산합니다.
* 제품 카테고리(스크린/철재)별 계산 로직을 지원합니다.
*/
class QuoteCalculationService extends Service
{
public function __construct(
private FormulaEvaluatorService $formulaEvaluator
) {}
/**
* 견적 자동산출 실행
*
* @param array $inputs 입력 파라미터
* @param string|null $productCategory 제품 카테고리
* @return array 산출 결과
*/
public function calculate(array $inputs, ?string $productCategory = null): array
{
$category = $productCategory ?? Quote::CATEGORY_SCREEN;
// 기본 변수 초기화
$this->formulaEvaluator->reset();
// 입력값 검증 및 변수 설정
$validatedInputs = $this->validateInputs($inputs, $category);
$this->formulaEvaluator->setVariables($validatedInputs);
// 카테고리별 산출 로직 실행
$result = match ($category) {
Quote::CATEGORY_SCREEN => $this->calculateScreen($validatedInputs),
Quote::CATEGORY_STEEL => $this->calculateSteel($validatedInputs),
default => $this->calculateScreen($validatedInputs),
};
return [
'inputs' => $validatedInputs,
'outputs' => $result['outputs'],
'items' => $result['items'],
'costs' => $result['costs'],
'errors' => $this->formulaEvaluator->getErrors(),
];
}
/**
* 견적 미리보기 (저장 없이 계산만)
*/
public function preview(array $inputs, ?string $productCategory = null): array
{
return $this->calculate($inputs, $productCategory);
}
/**
* 견적 품목 재계산 (기존 견적 기준)
*/
public function recalculate(Quote $quote): array
{
$inputs = $quote->calculation_inputs ?? [];
$category = $quote->product_category;
return $this->calculate($inputs, $category);
}
/**
* 입력값 검증 및 기본값 설정
*/
private function validateInputs(array $inputs, string $category): array
{
// 공통 입력값
$validated = [
'W0' => (float) ($inputs['W0'] ?? $inputs['open_size_width'] ?? 0),
'H0' => (float) ($inputs['H0'] ?? $inputs['open_size_height'] ?? 0),
'QTY' => (int) ($inputs['QTY'] ?? $inputs['quantity'] ?? 1),
];
// 카테고리별 추가 입력값
if ($category === Quote::CATEGORY_SCREEN) {
$validated = array_merge($validated, [
'INSTALL_TYPE' => $inputs['INSTALL_TYPE'] ?? 'wall', // wall, ceiling, floor
'MOTOR_TYPE' => $inputs['MOTOR_TYPE'] ?? 'standard', // standard, heavy
'CONTROL_TYPE' => $inputs['CONTROL_TYPE'] ?? 'switch', // switch, remote, smart
'CHAIN_SIDE' => $inputs['CHAIN_SIDE'] ?? 'left', // left, right
]);
} elseif ($category === Quote::CATEGORY_STEEL) {
$validated = array_merge($validated, [
'MATERIAL' => $inputs['MATERIAL'] ?? 'ss304', // ss304, ss316, galvanized
'THICKNESS' => (float) ($inputs['THICKNESS'] ?? 1.5),
'FINISH' => $inputs['FINISH'] ?? 'hairline', // hairline, mirror, matte
'WELDING' => $inputs['WELDING'] ?? 'tig', // tig, mig, spot
]);
}
return $validated;
}
/**
* 스크린 제품 산출
*/
private function calculateScreen(array $inputs): array
{
$w = $inputs['W0'];
$h = $inputs['H0'];
$qty = $inputs['QTY'];
// 파생 계산값
$outputs = [];
// W1: 실제 폭 (케이스 마진 포함)
$outputs['W1'] = $this->formulaEvaluator->evaluate('W0 + 100', ['W0' => $w]);
// H1: 실제 높이 (브라켓 마진 포함)
$outputs['H1'] = $this->formulaEvaluator->evaluate('H0 + 150', ['H0' => $h]);
// 면적 (m²)
$outputs['AREA'] = $this->formulaEvaluator->evaluate('(W1 * H1) / 1000000', [
'W1' => $outputs['W1'],
'H1' => $outputs['H1'],
]);
// 무게 (kg) - 대략 계산
$outputs['WEIGHT'] = $this->formulaEvaluator->evaluate('AREA * 5', [
'AREA' => $outputs['AREA'],
]);
// 모터 용량 결정 (면적 기준)
$outputs['MOTOR_CAPACITY'] = $this->formulaEvaluator->evaluateRange($outputs['AREA'], [
['min' => 0, 'max' => 5, 'result' => '50W'],
['min' => 5, 'max' => 10, 'result' => '100W'],
['min' => 10, 'max' => 20, 'result' => '200W'],
['min' => 20, 'max' => null, 'result' => '300W'],
], '100W');
// 품목 생성
$items = $this->generateScreenItems($inputs, $outputs, $qty);
// 비용 계산
$costs = $this->calculateCosts($items);
return [
'outputs' => $outputs,
'items' => $items,
'costs' => $costs,
];
}
/**
* 철재 제품 산출
*/
private function calculateSteel(array $inputs): array
{
$w = $inputs['W0'];
$h = $inputs['H0'];
$qty = $inputs['QTY'];
$thickness = $inputs['THICKNESS'];
// 파생 계산값
$outputs = [];
// 실제 크기 (용접 마진 포함)
$outputs['W1'] = $this->formulaEvaluator->evaluate('W0 + 50', ['W0' => $w]);
$outputs['H1'] = $this->formulaEvaluator->evaluate('H0 + 50', ['H0' => $h]);
// 면적 (m²)
$outputs['AREA'] = $this->formulaEvaluator->evaluate('(W1 * H1) / 1000000', [
'W1' => $outputs['W1'],
'H1' => $outputs['H1'],
]);
// 중량 (kg) - 재질별 밀도 적용
$density = $this->formulaEvaluator->evaluateMapping($inputs['MATERIAL'], [
['source' => 'ss304', 'result' => 7.93],
['source' => 'ss316', 'result' => 8.0],
['source' => 'galvanized', 'result' => 7.85],
], 7.85);
$outputs['WEIGHT'] = $this->formulaEvaluator->evaluate('AREA * THICKNESS * DENSITY', [
'AREA' => $outputs['AREA'],
'THICKNESS' => $thickness / 1000, // mm to m
'DENSITY' => $density * 1000, // kg/m³
]);
// 품목 생성
$items = $this->generateSteelItems($inputs, $outputs, $qty);
// 비용 계산
$costs = $this->calculateCosts($items);
return [
'outputs' => $outputs,
'items' => $items,
'costs' => $costs,
];
}
/**
* 스크린 품목 생성
*/
private function generateScreenItems(array $inputs, array $outputs, int $qty): array
{
$items = [];
// 1. 스크린 원단
$items[] = [
'item_code' => 'SCR-FABRIC-001',
'item_name' => '스크린 원단',
'specification' => sprintf('%.0f x %.0f mm', $outputs['W1'], $outputs['H1']),
'unit' => 'm²',
'base_quantity' => 1,
'calculated_quantity' => $outputs['AREA'] * $qty,
'unit_price' => 25000,
'total_price' => $outputs['AREA'] * $qty * 25000,
'formula' => 'AREA * QTY',
'formula_category' => 'material',
];
// 2. 케이스
$items[] = [
'item_code' => 'SCR-CASE-001',
'item_name' => '알루미늄 케이스',
'specification' => sprintf('%.0f mm', $outputs['W1']),
'unit' => 'EA',
'base_quantity' => 1,
'calculated_quantity' => $qty,
'unit_price' => 85000,
'total_price' => $qty * 85000,
'formula' => 'QTY',
'formula_category' => 'material',
];
// 3. 모터
$motorPrice = $this->getMotorPrice($outputs['MOTOR_CAPACITY']);
$items[] = [
'item_code' => 'SCR-MOTOR-001',
'item_name' => '튜블러 모터',
'specification' => $outputs['MOTOR_CAPACITY'],
'unit' => 'EA',
'base_quantity' => 1,
'calculated_quantity' => $qty,
'unit_price' => $motorPrice,
'total_price' => $qty * $motorPrice,
'formula' => 'QTY',
'formula_category' => 'material',
];
// 4. 브라켓
$items[] = [
'item_code' => 'SCR-BRACKET-001',
'item_name' => '설치 브라켓',
'specification' => $inputs['INSTALL_TYPE'],
'unit' => 'SET',
'base_quantity' => 2,
'calculated_quantity' => 2 * $qty,
'unit_price' => 15000,
'total_price' => 2 * $qty * 15000,
'formula' => '2 * QTY',
'formula_category' => 'material',
];
// 5. 인건비
$laborHours = $this->formulaEvaluator->evaluateRange($outputs['AREA'], [
['min' => 0, 'max' => 5, 'result' => 2],
['min' => 5, 'max' => 10, 'result' => 3],
['min' => 10, 'max' => null, 'result' => 4],
], 2);
$items[] = [
'item_code' => 'LAB-INSTALL-001',
'item_name' => '설치 인건비',
'specification' => sprintf('%.1f시간', $laborHours * $qty),
'unit' => 'HR',
'base_quantity' => $laborHours,
'calculated_quantity' => $laborHours * $qty,
'unit_price' => 50000,
'total_price' => $laborHours * $qty * 50000,
'formula' => 'LABOR_HOURS * QTY',
'formula_category' => 'labor',
];
return $items;
}
/**
* 철재 품목 생성
*/
private function generateSteelItems(array $inputs, array $outputs, int $qty): array
{
$items = [];
// 재질별 단가
$materialPrice = $this->formulaEvaluator->evaluateMapping($inputs['MATERIAL'], [
['source' => 'ss304', 'result' => 4500],
['source' => 'ss316', 'result' => 6500],
['source' => 'galvanized', 'result' => 3000],
], 4500);
// 1. 철판
$items[] = [
'item_code' => 'STL-PLATE-001',
'item_name' => '철판 ('.$inputs['MATERIAL'].')',
'specification' => sprintf('%.0f x %.0f x %.1f mm', $outputs['W1'], $outputs['H1'], $inputs['THICKNESS']),
'unit' => 'kg',
'base_quantity' => $outputs['WEIGHT'],
'calculated_quantity' => $outputs['WEIGHT'] * $qty,
'unit_price' => $materialPrice,
'total_price' => $outputs['WEIGHT'] * $qty * $materialPrice,
'formula' => 'WEIGHT * QTY * MATERIAL_PRICE',
'formula_category' => 'material',
];
// 2. 용접
$weldLength = ($outputs['W1'] + $outputs['H1']) * 2 / 1000; // m
$items[] = [
'item_code' => 'STL-WELD-001',
'item_name' => '용접 ('.$inputs['WELDING'].')',
'specification' => sprintf('%.2f m', $weldLength * $qty),
'unit' => 'm',
'base_quantity' => $weldLength,
'calculated_quantity' => $weldLength * $qty,
'unit_price' => 15000,
'total_price' => $weldLength * $qty * 15000,
'formula' => 'WELD_LENGTH * QTY',
'formula_category' => 'labor',
];
// 3. 표면처리
$finishPrice = $this->formulaEvaluator->evaluateMapping($inputs['FINISH'], [
['source' => 'hairline', 'result' => 8000],
['source' => 'mirror', 'result' => 15000],
['source' => 'matte', 'result' => 5000],
], 8000);
$items[] = [
'item_code' => 'STL-FINISH-001',
'item_name' => '표면처리 ('.$inputs['FINISH'].')',
'specification' => sprintf('%.2f m²', $outputs['AREA'] * $qty),
'unit' => 'm²',
'base_quantity' => $outputs['AREA'],
'calculated_quantity' => $outputs['AREA'] * $qty,
'unit_price' => $finishPrice,
'total_price' => $outputs['AREA'] * $qty * $finishPrice,
'formula' => 'AREA * QTY',
'formula_category' => 'labor',
];
// 4. 가공비
$items[] = [
'item_code' => 'STL-PROCESS-001',
'item_name' => '가공비',
'specification' => '절단, 벤딩, 천공',
'unit' => 'EA',
'base_quantity' => 1,
'calculated_quantity' => $qty,
'unit_price' => 50000,
'total_price' => $qty * 50000,
'formula' => 'QTY',
'formula_category' => 'labor',
];
return $items;
}
/**
* 비용 계산
*/
private function calculateCosts(array $items): array
{
$materialCost = 0;
$laborCost = 0;
$installCost = 0;
foreach ($items as $item) {
$category = $item['formula_category'] ?? 'material';
$price = (float) ($item['total_price'] ?? 0);
match ($category) {
'material' => $materialCost += $price,
'labor' => $laborCost += $price,
'install' => $installCost += $price,
default => $materialCost += $price,
};
}
$subtotal = $materialCost + $laborCost + $installCost;
return [
'material_cost' => round($materialCost, 2),
'labor_cost' => round($laborCost, 2),
'install_cost' => round($installCost, 2),
'subtotal' => round($subtotal, 2),
];
}
/**
* 모터 단가 조회
*/
private function getMotorPrice(string $capacity): int
{
return match ($capacity) {
'50W' => 120000,
'100W' => 150000,
'200W' => 200000,
'300W' => 280000,
default => 150000,
};
}
/**
* 입력 스키마 반환 (프론트엔드용)
*/
public function getInputSchema(?string $productCategory = null): array
{
$category = $productCategory ?? Quote::CATEGORY_SCREEN;
$commonSchema = [
'W0' => [
'label' => '개구부 폭',
'type' => 'number',
'unit' => 'mm',
'required' => true,
'min' => 100,
'max' => 10000,
],
'H0' => [
'label' => '개구부 높이',
'type' => 'number',
'unit' => 'mm',
'required' => true,
'min' => 100,
'max' => 10000,
],
'QTY' => [
'label' => '수량',
'type' => 'integer',
'required' => true,
'min' => 1,
'default' => 1,
],
];
if ($category === Quote::CATEGORY_SCREEN) {
return array_merge($commonSchema, [
'INSTALL_TYPE' => [
'label' => '설치 유형',
'type' => 'select',
'options' => [
['value' => 'wall', 'label' => '벽면'],
['value' => 'ceiling', 'label' => '천장'],
['value' => 'floor', 'label' => '바닥'],
],
'default' => 'wall',
],
'MOTOR_TYPE' => [
'label' => '모터 유형',
'type' => 'select',
'options' => [
['value' => 'standard', 'label' => '일반형'],
['value' => 'heavy', 'label' => '고하중형'],
],
'default' => 'standard',
],
'CONTROL_TYPE' => [
'label' => '제어 방식',
'type' => 'select',
'options' => [
['value' => 'switch', 'label' => '스위치'],
['value' => 'remote', 'label' => '리모컨'],
['value' => 'smart', 'label' => '스마트'],
],
'default' => 'switch',
],
'CHAIN_SIDE' => [
'label' => '체인 위치',
'type' => 'select',
'options' => [
['value' => 'left', 'label' => '좌측'],
['value' => 'right', 'label' => '우측'],
],
'default' => 'left',
],
]);
}
if ($category === Quote::CATEGORY_STEEL) {
return array_merge($commonSchema, [
'MATERIAL' => [
'label' => '재질',
'type' => 'select',
'options' => [
['value' => 'ss304', 'label' => 'SUS304'],
['value' => 'ss316', 'label' => 'SUS316'],
['value' => 'galvanized', 'label' => '아연도금'],
],
'default' => 'ss304',
],
'THICKNESS' => [
'label' => '두께',
'type' => 'number',
'unit' => 'mm',
'min' => 0.5,
'max' => 10,
'step' => 0.1,
'default' => 1.5,
],
'FINISH' => [
'label' => '표면처리',
'type' => 'select',
'options' => [
['value' => 'hairline', 'label' => '헤어라인'],
['value' => 'mirror', 'label' => '미러'],
['value' => 'matte', 'label' => '무광'],
],
'default' => 'hairline',
],
'WELDING' => [
'label' => '용접 방식',
'type' => 'select',
'options' => [
['value' => 'tig', 'label' => 'TIG'],
['value' => 'mig', 'label' => 'MIG'],
['value' => 'spot', 'label' => '스팟'],
],
'default' => 'tig',
],
]);
}
return $commonSchema;
}
}

View File

@@ -0,0 +1,388 @@
<?php
namespace App\Services\Quote;
use App\Models\Quote\Quote;
use App\Services\Service;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* 견적 문서 서비스
*
* 견적서 PDF 생성 및 발송(이메일, 카카오톡)을 담당합니다.
*/
class QuoteDocumentService extends Service
{
/**
* 발송 채널 상수
*/
public const CHANNEL_EMAIL = 'email';
public const CHANNEL_KAKAO = 'kakao';
public const CHANNEL_FAX = 'fax';
/**
* 문서 유형 상수
*/
public const DOC_TYPE_QUOTE = 'quote';
public const DOC_TYPE_ORDER = 'order';
public const DOC_TYPE_INVOICE = 'invoice';
/**
* 발송 상태 상수
*/
public const STATUS_PENDING = 'pending';
public const STATUS_SENT = 'sent';
public const STATUS_FAILED = 'failed';
public const STATUS_DELIVERED = 'delivered';
public const STATUS_READ = 'read';
/**
* 견적서 PDF 생성
*/
public function generatePdf(int $quoteId): array
{
$tenantId = $this->tenantId();
$quote = Quote::with(['items', 'client'])
->where('tenant_id', $tenantId)
->find($quoteId);
if (! $quote) {
throw new NotFoundHttpException(__('error.quote_not_found'));
}
// PDF 생성 데이터 준비
$data = $this->preparePdfData($quote);
// TODO: 실제 PDF 생성 로직 구현 (barryvdh/laravel-dompdf 등 사용)
// 현재는 기본 구조만 제공
$filename = $this->generateFilename($quote);
$path = "quotes/{$tenantId}/{$filename}";
// 임시: PDF 생성 시뮬레이션
// Storage::disk('local')->put($path, $this->generatePdfContent($data));
return [
'quote_id' => $quoteId,
'quote_number' => $quote->quote_number,
'filename' => $filename,
'path' => $path,
'generated_at' => now()->toDateTimeString(),
'data' => $data, // 개발 단계에서 확인용
];
}
/**
* 견적서 이메일 발송
*/
public function sendEmail(int $quoteId, array $options = []): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$quote = Quote::with(['items', 'client'])
->where('tenant_id', $tenantId)
->find($quoteId);
if (! $quote) {
throw new NotFoundHttpException(__('error.quote_not_found'));
}
// 수신자 정보
$recipientEmail = $options['email'] ?? $quote->client?->email ?? null;
$recipientName = $options['name'] ?? $quote->client_name ?? $quote->client?->name ?? null;
if (! $recipientEmail) {
throw new BadRequestHttpException(__('error.quote_email_not_found'));
}
// 이메일 옵션
$subject = $options['subject'] ?? $this->getDefaultEmailSubject($quote);
$message = $options['message'] ?? $this->getDefaultEmailMessage($quote);
$cc = $options['cc'] ?? [];
$attachPdf = $options['attach_pdf'] ?? true;
// PDF 생성 (첨부 시)
$pdfInfo = null;
if ($attachPdf) {
$pdfInfo = $this->generatePdf($quoteId);
}
// TODO: 실제 이메일 발송 로직 구현 (Laravel Mail 사용)
// Mail::to($recipientEmail)->send(new QuoteMail($quote, $pdfInfo));
// 발송 이력 기록
$sendLog = $this->createSendLog($quote, [
'channel' => self::CHANNEL_EMAIL,
'recipient_email' => $recipientEmail,
'recipient_name' => $recipientName,
'subject' => $subject,
'message' => $message,
'cc' => $cc,
'pdf_path' => $pdfInfo['path'] ?? null,
'sent_by' => $userId,
]);
// 견적 상태 업데이트 (draft → sent)
if ($quote->status === Quote::STATUS_DRAFT) {
$quote->update([
'status' => Quote::STATUS_SENT,
'updated_by' => $userId,
]);
}
return [
'success' => true,
'message' => __('message.quote_email_sent'),
'send_log' => $sendLog,
'quote_status' => $quote->fresh()->status,
];
}
/**
* 견적서 카카오톡 발송
*/
public function sendKakao(int $quoteId, array $options = []): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$quote = Quote::with(['items', 'client'])
->where('tenant_id', $tenantId)
->find($quoteId);
if (! $quote) {
throw new NotFoundHttpException(__('error.quote_not_found'));
}
// 수신자 정보
$recipientPhone = $options['phone'] ?? $quote->contact ?? $quote->client?->phone ?? null;
$recipientName = $options['name'] ?? $quote->client_name ?? $quote->client?->name ?? null;
if (! $recipientPhone) {
throw new BadRequestHttpException(__('error.quote_phone_not_found'));
}
// 알림톡 템플릿 데이터
$templateCode = $options['template_code'] ?? 'QUOTE_SEND';
$templateData = $this->prepareKakaoTemplateData($quote, $options);
// TODO: 실제 카카오 알림톡 발송 로직 구현
// 외부 API 연동 필요 (NHN Cloud, Solapi 등)
// 발송 이력 기록
$sendLog = $this->createSendLog($quote, [
'channel' => self::CHANNEL_KAKAO,
'recipient_phone' => $recipientPhone,
'recipient_name' => $recipientName,
'template_code' => $templateCode,
'template_data' => $templateData,
'sent_by' => $userId,
]);
// 견적 상태 업데이트 (draft → sent)
if ($quote->status === Quote::STATUS_DRAFT) {
$quote->update([
'status' => Quote::STATUS_SENT,
'updated_by' => $userId,
]);
}
return [
'success' => true,
'message' => __('message.quote_kakao_sent'),
'send_log' => $sendLog,
'quote_status' => $quote->fresh()->status,
];
}
/**
* 발송 이력 조회
*/
public function getSendHistory(int $quoteId): array
{
$tenantId = $this->tenantId();
$quote = Quote::where('tenant_id', $tenantId)->find($quoteId);
if (! $quote) {
throw new NotFoundHttpException(__('error.quote_not_found'));
}
// TODO: 발송 이력 테이블 조회
// QuoteSendLog::where('quote_id', $quoteId)->orderByDesc('created_at')->get();
return [
'quote_id' => $quoteId,
'quote_number' => $quote->quote_number,
'history' => [], // 발송 이력 배열
];
}
/**
* PDF 데이터 준비
*/
private function preparePdfData(Quote $quote): array
{
// 회사 정보 (테넌트 설정에서)
$companyInfo = [
'name' => config('app.company_name', 'KD 건축자재'),
'address' => config('app.company_address', ''),
'phone' => config('app.company_phone', ''),
'email' => config('app.company_email', ''),
'registration_number' => config('app.company_registration_number', ''),
];
// 거래처 정보
$clientInfo = [
'name' => $quote->client_name ?? $quote->client?->name ?? '',
'manager' => $quote->manager ?? '',
'contact' => $quote->contact ?? '',
'address' => $quote->client?->address ?? '',
];
// 견적 기본 정보
$quoteInfo = [
'quote_number' => $quote->quote_number,
'registration_date' => $quote->registration_date?->format('Y-m-d'),
'completion_date' => $quote->completion_date?->format('Y-m-d'),
'product_category' => $quote->product_category,
'product_name' => $quote->product_name,
'site_name' => $quote->site_name,
'author' => $quote->author,
];
// 규격 정보
$specInfo = [
'open_size_width' => $quote->open_size_width,
'open_size_height' => $quote->open_size_height,
'quantity' => $quote->quantity,
'unit_symbol' => $quote->unit_symbol,
'floors' => $quote->floors,
];
// 품목 목록
$items = $quote->items->map(fn ($item) => [
'item_code' => $item->item_code,
'item_name' => $item->item_name,
'specification' => $item->specification,
'unit' => $item->unit,
'quantity' => $item->calculated_quantity,
'unit_price' => $item->unit_price,
'total_price' => $item->total_price,
'note' => $item->note,
])->toArray();
// 금액 정보
$amounts = [
'material_cost' => $quote->material_cost,
'labor_cost' => $quote->labor_cost,
'install_cost' => $quote->install_cost,
'subtotal' => $quote->subtotal,
'discount_rate' => $quote->discount_rate,
'discount_amount' => $quote->discount_amount,
'total_amount' => $quote->total_amount,
];
return [
'company' => $companyInfo,
'client' => $clientInfo,
'quote' => $quoteInfo,
'spec' => $specInfo,
'items' => $items,
'amounts' => $amounts,
'remarks' => $quote->remarks,
'notes' => $quote->notes,
];
}
/**
* 파일명 생성
*/
private function generateFilename(Quote $quote): string
{
$date = now()->format('Ymd');
return "quote_{$quote->quote_number}_{$date}.pdf";
}
/**
* 기본 이메일 제목
*/
private function getDefaultEmailSubject(Quote $quote): string
{
return "[견적서] {$quote->quote_number} - {$quote->product_name}";
}
/**
* 기본 이메일 본문
*/
private function getDefaultEmailMessage(Quote $quote): string
{
$clientName = $quote->client_name ?? '고객';
return <<<MESSAGE
안녕하세요, {$clientName} 담당자님.
요청하신 견적서를 송부드립니다.
[견적 정보]
- 견적번호: {$quote->quote_number}
- 제품명: {$quote->product_name}
- 현장명: {$quote->site_name}
- 견적금액: ₩ {$quote->total_amount}
첨부된 견적서를 확인해 주시기 바랍니다.
문의사항이 있으시면 언제든 연락 주세요.
감사합니다.
MESSAGE;
}
/**
* 카카오 알림톡 템플릿 데이터 준비
*/
private function prepareKakaoTemplateData(Quote $quote, array $options = []): array
{
return [
'quote_number' => $quote->quote_number,
'client_name' => $quote->client_name ?? '',
'product_name' => $quote->product_name ?? '',
'site_name' => $quote->site_name ?? '',
'total_amount' => number_format((float) $quote->total_amount),
'registration_date' => $quote->registration_date?->format('Y-m-d'),
'view_url' => $options['view_url'] ?? '', // 견적서 조회 URL
];
}
/**
* 발송 이력 생성
*/
private function createSendLog(Quote $quote, array $data): array
{
// TODO: QuoteSendLog 모델 생성 후 실제 DB 저장 구현
// QuoteSendLog::create([
// 'tenant_id' => $quote->tenant_id,
// 'quote_id' => $quote->id,
// 'channel' => $data['channel'],
// ...
// ]);
return array_merge([
'quote_id' => $quote->id,
'quote_number' => $quote->quote_number,
'status' => self::STATUS_PENDING,
'sent_at' => now()->toDateTimeString(),
], $data);
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Services\Quote;
use App\Models\Quote\Quote;
use App\Services\Service;
class QuoteNumberService extends Service
{
/**
* 견적번호 생성
*
* 형식: KD-{PREFIX}-{YYMMDD}-{SEQ}
* 예시: KD-SC-251204-01 (스크린), KD-ST-251204-01 (철재)
*/
public function generate(?string $productCategory = null): string
{
$tenantId = $this->tenantId();
// 제품 카테고리에 따른 접두어
$prefix = match ($productCategory) {
Quote::CATEGORY_SCREEN => 'SC',
Quote::CATEGORY_STEEL => 'ST',
default => 'SC',
};
// 날짜 부분 (YYMMDD)
$dateStr = now()->format('ymd');
// 오늘 날짜 기준으로 마지막 견적번호 조회
$pattern = "KD-{$prefix}-{$dateStr}-%";
$lastQuote = Quote::withTrashed()
->where('tenant_id', $tenantId)
->where('quote_number', 'like', $pattern)
->orderBy('quote_number', 'desc')
->first();
// 순번 계산
$sequence = 1;
if ($lastQuote) {
// KD-SC-251204-01 에서 마지막 숫자 추출
$parts = explode('-', $lastQuote->quote_number);
if (count($parts) >= 4) {
$lastSeq = (int) end($parts);
$sequence = $lastSeq + 1;
}
}
// 2자리 순번 (01, 02, ...)
$seqStr = str_pad((string) $sequence, 2, '0', STR_PAD_LEFT);
return "KD-{$prefix}-{$dateStr}-{$seqStr}";
}
/**
* 견적번호 미리보기
*/
public function preview(?string $productCategory = null): array
{
$quoteNumber = $this->generate($productCategory);
return [
'quote_number' => $quoteNumber,
'product_category' => $productCategory ?? Quote::CATEGORY_SCREEN,
'generated_at' => now()->toDateTimeString(),
];
}
/**
* 견적번호 형식 검증
*/
public function validate(string $quoteNumber): bool
{
// 형식: KD-XX-YYMMDD-NN
return (bool) preg_match('/^KD-[A-Z]{2}-\d{6}-\d{2,}$/', $quoteNumber);
}
/**
* 견적번호 파싱
*/
public function parse(string $quoteNumber): ?array
{
if (! $this->validate($quoteNumber)) {
return null;
}
$parts = explode('-', $quoteNumber);
return [
'prefix' => $parts[0], // KD
'category_code' => $parts[1], // SC or ST
'date' => $parts[2], // YYMMDD
'sequence' => (int) $parts[3], // 순번
];
}
/**
* 견적번호 중복 체크
*/
public function isUnique(string $quoteNumber, ?int $excludeId = null): bool
{
$tenantId = $this->tenantId();
$query = Quote::withTrashed()
->where('tenant_id', $tenantId)
->where('quote_number', $quoteNumber);
if ($excludeId) {
$query->where('id', '!=', $excludeId);
}
return ! $query->exists();
}
}

View File

@@ -0,0 +1,448 @@
<?php
namespace App\Services\Quote;
use App\Models\Quote\Quote;
use App\Models\Quote\QuoteItem;
use App\Models\Quote\QuoteRevision;
use App\Services\Service;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class QuoteService extends Service
{
public function __construct(
private QuoteNumberService $numberService
) {}
/**
* 견적 목록 조회
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$page = (int) ($params['page'] ?? 1);
$size = (int) ($params['size'] ?? 20);
$q = trim((string) ($params['q'] ?? ''));
$status = $params['status'] ?? null;
$productCategory = $params['product_category'] ?? null;
$clientId = $params['client_id'] ?? null;
$dateFrom = $params['date_from'] ?? null;
$dateTo = $params['date_to'] ?? null;
$sortBy = $params['sort_by'] ?? 'registration_date';
$sortOrder = $params['sort_order'] ?? 'desc';
$query = Quote::query()->where('tenant_id', $tenantId);
// 검색어
if ($q !== '') {
$query->search($q);
}
// 상태 필터
if ($status) {
$query->where('status', $status);
}
// 제품 카테고리 필터
if ($productCategory) {
$query->where('product_category', $productCategory);
}
// 발주처 필터
if ($clientId) {
$query->where('client_id', $clientId);
}
// 날짜 범위
$query->dateRange($dateFrom, $dateTo);
// 정렬
$allowedSortColumns = ['registration_date', 'quote_number', 'client_name', 'total_amount', 'status', 'created_at'];
if (in_array($sortBy, $allowedSortColumns)) {
$query->orderBy($sortBy, $sortOrder === 'asc' ? 'asc' : 'desc');
} else {
$query->orderBy('registration_date', 'desc');
}
$query->orderBy('id', 'desc');
return $query->paginate($size, ['*'], 'page', $page);
}
/**
* 견적 단건 조회
*/
public function show(int $id): Quote
{
$tenantId = $this->tenantId();
$quote = Quote::with(['items', 'revisions', 'client', 'creator', 'updater', 'finalizer'])
->where('tenant_id', $tenantId)
->find($id);
if (! $quote) {
throw new NotFoundHttpException(__('error.quote_not_found'));
}
return $quote;
}
/**
* 견적 생성
*/
public function store(array $data): Quote
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($data, $tenantId, $userId) {
// 견적번호 생성
$quoteNumber = $data['quote_number'] ?? $this->numberService->generate($data['product_category'] ?? 'SCREEN');
// 금액 계산
$materialCost = (float) ($data['material_cost'] ?? 0);
$laborCost = (float) ($data['labor_cost'] ?? 0);
$installCost = (float) ($data['install_cost'] ?? 0);
$subtotal = $materialCost + $laborCost + $installCost;
$discountRate = (float) ($data['discount_rate'] ?? 0);
$discountAmount = $subtotal * ($discountRate / 100);
$totalAmount = $subtotal - $discountAmount;
// 견적 생성
$quote = Quote::create([
'tenant_id' => $tenantId,
'quote_number' => $quoteNumber,
'registration_date' => $data['registration_date'] ?? now()->toDateString(),
'receipt_date' => $data['receipt_date'] ?? null,
'author' => $data['author'] ?? null,
// 발주처 정보
'client_id' => $data['client_id'] ?? null,
'client_name' => $data['client_name'] ?? null,
'manager' => $data['manager'] ?? null,
'contact' => $data['contact'] ?? null,
// 현장 정보
'site_id' => $data['site_id'] ?? null,
'site_name' => $data['site_name'] ?? null,
'site_code' => $data['site_code'] ?? null,
// 제품 정보
'product_category' => $data['product_category'] ?? Quote::CATEGORY_SCREEN,
'product_id' => $data['product_id'] ?? null,
'product_code' => $data['product_code'] ?? null,
'product_name' => $data['product_name'] ?? null,
// 규격 정보
'open_size_width' => $data['open_size_width'] ?? null,
'open_size_height' => $data['open_size_height'] ?? null,
'quantity' => $data['quantity'] ?? 1,
'unit_symbol' => $data['unit_symbol'] ?? null,
'floors' => $data['floors'] ?? null,
// 금액 정보
'material_cost' => $materialCost,
'labor_cost' => $laborCost,
'install_cost' => $installCost,
'subtotal' => $subtotal,
'discount_rate' => $discountRate,
'discount_amount' => $discountAmount,
'total_amount' => $data['total_amount'] ?? $totalAmount,
// 상태 관리
'status' => Quote::STATUS_DRAFT,
'current_revision' => 0,
'is_final' => false,
// 기타 정보
'completion_date' => $data['completion_date'] ?? null,
'remarks' => $data['remarks'] ?? null,
'memo' => $data['memo'] ?? null,
'notes' => $data['notes'] ?? null,
// 자동산출 입력값
'calculation_inputs' => $data['calculation_inputs'] ?? null,
// 감사
'created_by' => $userId,
]);
// 견적 품목 생성
if (! empty($data['items']) && is_array($data['items'])) {
$this->createItems($quote, $data['items'], $tenantId);
}
return $quote->load(['items', 'client']);
});
}
/**
* 견적 수정
*/
public function update(int $id, array $data): Quote
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$quote = Quote::where('tenant_id', $tenantId)->find($id);
if (! $quote) {
throw new NotFoundHttpException(__('error.quote_not_found'));
}
if (! $quote->isEditable()) {
throw new BadRequestHttpException(__('error.quote_not_editable'));
}
return DB::transaction(function () use ($quote, $data, $tenantId, $userId) {
// 수정 이력 생성
$this->createRevision($quote, $userId);
// 금액 재계산
$materialCost = (float) ($data['material_cost'] ?? $quote->material_cost);
$laborCost = (float) ($data['labor_cost'] ?? $quote->labor_cost);
$installCost = (float) ($data['install_cost'] ?? $quote->install_cost);
$subtotal = $materialCost + $laborCost + $installCost;
$discountRate = (float) ($data['discount_rate'] ?? $quote->discount_rate);
$discountAmount = $subtotal * ($discountRate / 100);
$totalAmount = $subtotal - $discountAmount;
// 업데이트
$quote->update([
'receipt_date' => $data['receipt_date'] ?? $quote->receipt_date,
'author' => $data['author'] ?? $quote->author,
// 발주처 정보
'client_id' => $data['client_id'] ?? $quote->client_id,
'client_name' => $data['client_name'] ?? $quote->client_name,
'manager' => $data['manager'] ?? $quote->manager,
'contact' => $data['contact'] ?? $quote->contact,
// 현장 정보
'site_id' => $data['site_id'] ?? $quote->site_id,
'site_name' => $data['site_name'] ?? $quote->site_name,
'site_code' => $data['site_code'] ?? $quote->site_code,
// 제품 정보
'product_category' => $data['product_category'] ?? $quote->product_category,
'product_id' => $data['product_id'] ?? $quote->product_id,
'product_code' => $data['product_code'] ?? $quote->product_code,
'product_name' => $data['product_name'] ?? $quote->product_name,
// 규격 정보
'open_size_width' => $data['open_size_width'] ?? $quote->open_size_width,
'open_size_height' => $data['open_size_height'] ?? $quote->open_size_height,
'quantity' => $data['quantity'] ?? $quote->quantity,
'unit_symbol' => $data['unit_symbol'] ?? $quote->unit_symbol,
'floors' => $data['floors'] ?? $quote->floors,
// 금액 정보
'material_cost' => $materialCost,
'labor_cost' => $laborCost,
'install_cost' => $installCost,
'subtotal' => $subtotal,
'discount_rate' => $discountRate,
'discount_amount' => $discountAmount,
'total_amount' => $data['total_amount'] ?? $totalAmount,
// 기타 정보
'completion_date' => $data['completion_date'] ?? $quote->completion_date,
'remarks' => $data['remarks'] ?? $quote->remarks,
'memo' => $data['memo'] ?? $quote->memo,
'notes' => $data['notes'] ?? $quote->notes,
// 자동산출 입력값
'calculation_inputs' => $data['calculation_inputs'] ?? $quote->calculation_inputs,
// 감사
'updated_by' => $userId,
'current_revision' => $quote->current_revision + 1,
]);
// 품목 업데이트 (전체 교체)
if (isset($data['items']) && is_array($data['items'])) {
$quote->items()->delete();
$this->createItems($quote, $data['items'], $tenantId);
}
return $quote->refresh()->load(['items', 'revisions', 'client']);
});
}
/**
* 견적 삭제 (Soft Delete)
*/
public function destroy(int $id): bool
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$quote = Quote::where('tenant_id', $tenantId)->find($id);
if (! $quote) {
throw new NotFoundHttpException(__('error.quote_not_found'));
}
if (! $quote->isDeletable()) {
throw new BadRequestHttpException(__('error.quote_not_deletable'));
}
$quote->deleted_by = $userId;
$quote->save();
$quote->delete();
return true;
}
/**
* 견적 일괄 삭제
*/
public function bulkDestroy(array $ids): int
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$deletedCount = 0;
foreach ($ids as $id) {
$quote = Quote::where('tenant_id', $tenantId)->find($id);
if ($quote && $quote->isDeletable()) {
$quote->deleted_by = $userId;
$quote->save();
$quote->delete();
$deletedCount++;
}
}
return $deletedCount;
}
/**
* 최종 확정
*/
public function finalize(int $id): Quote
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$quote = Quote::where('tenant_id', $tenantId)->find($id);
if (! $quote) {
throw new NotFoundHttpException(__('error.quote_not_found'));
}
if (! $quote->isFinalizable()) {
throw new BadRequestHttpException(__('error.quote_not_finalizable'));
}
$quote->update([
'status' => Quote::STATUS_FINALIZED,
'is_final' => true,
'finalized_at' => now(),
'finalized_by' => $userId,
'updated_by' => $userId,
]);
return $quote->refresh()->load(['items', 'client', 'finalizer']);
}
/**
* 확정 취소
*/
public function cancelFinalize(int $id): Quote
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$quote = Quote::where('tenant_id', $tenantId)->find($id);
if (! $quote) {
throw new NotFoundHttpException(__('error.quote_not_found'));
}
if ($quote->status !== Quote::STATUS_FINALIZED) {
throw new BadRequestHttpException(__('error.quote_not_finalized'));
}
if ($quote->status === Quote::STATUS_CONVERTED) {
throw new BadRequestHttpException(__('error.quote_already_converted'));
}
$quote->update([
'status' => Quote::STATUS_DRAFT,
'is_final' => false,
'finalized_at' => null,
'finalized_by' => null,
'updated_by' => $userId,
]);
return $quote->refresh()->load(['items', 'client']);
}
/**
* 수주 전환
*/
public function convertToOrder(int $id): Quote
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$quote = Quote::where('tenant_id', $tenantId)->find($id);
if (! $quote) {
throw new NotFoundHttpException(__('error.quote_not_found'));
}
if (! $quote->isConvertible()) {
throw new BadRequestHttpException(__('error.quote_not_convertible'));
}
return DB::transaction(function () use ($quote, $userId) {
// TODO: 수주(Order) 생성 로직 구현
// $order = $this->orderService->createFromQuote($quote);
$quote->update([
'status' => Quote::STATUS_CONVERTED,
'updated_by' => $userId,
]);
return $quote->refresh()->load(['items', 'client']);
});
}
/**
* 견적 품목 생성
*/
private function createItems(Quote $quote, array $items, int $tenantId): void
{
foreach ($items as $index => $item) {
$quantity = (float) ($item['calculated_quantity'] ?? $item['base_quantity'] ?? 1);
$unitPrice = (float) ($item['unit_price'] ?? 0);
$totalPrice = $quantity * $unitPrice;
QuoteItem::create([
'quote_id' => $quote->id,
'tenant_id' => $tenantId,
'item_id' => $item['item_id'] ?? null,
'item_code' => $item['item_code'] ?? '',
'item_name' => $item['item_name'] ?? '',
'specification' => $item['specification'] ?? null,
'unit' => $item['unit'] ?? 'EA',
'base_quantity' => $item['base_quantity'] ?? 1,
'calculated_quantity' => $quantity,
'unit_price' => $unitPrice,
'total_price' => $item['total_price'] ?? $totalPrice,
'formula' => $item['formula'] ?? null,
'formula_result' => $item['formula_result'] ?? null,
'formula_source' => $item['formula_source'] ?? null,
'formula_category' => $item['formula_category'] ?? null,
'data_source' => $item['data_source'] ?? null,
'delivery_date' => $item['delivery_date'] ?? null,
'note' => $item['note'] ?? null,
'sort_order' => $item['sort_order'] ?? $index,
]);
}
}
/**
* 수정 이력 생성
*/
private function createRevision(Quote $quote, int $userId): QuoteRevision
{
// 현재 견적 데이터 스냅샷
$previousData = $quote->toArray();
$previousData['items'] = $quote->items->toArray();
return QuoteRevision::create([
'quote_id' => $quote->id,
'tenant_id' => $quote->tenant_id,
'revision_number' => $quote->current_revision + 1,
'revision_date' => now()->toDateString(),
'revision_by' => $userId,
'revision_by_name' => auth()->user()?->name ?? 'Unknown',
'revision_reason' => null, // 별도 입력 받지 않음
'previous_data' => $previousData,
]);
}
}

539
app/Swagger/v1/QuoteApi.php Normal file
View File

@@ -0,0 +1,539 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Quote", description="견적 관리")
*
* @OA\Schema(
* schema="Quote",
* type="object",
* required={"id","quote_number"},
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="quote_number", type="string", example="KD-SC-251204-01", description="견적번호"),
* @OA\Property(property="registration_date", type="string", format="date", example="2025-12-04", description="등록일"),
* @OA\Property(property="receipt_date", type="string", format="date", nullable=true, example="2025-12-05", description="접수일"),
* @OA\Property(property="author", type="string", nullable=true, example="홍길동", description="작성자"),
* @OA\Property(property="client_id", type="integer", nullable=true, example=1, description="거래처 ID"),
* @OA\Property(property="client_name", type="string", nullable=true, example="ABC건설", description="발주처명"),
* @OA\Property(property="manager", type="string", nullable=true, example="김담당", description="담당자"),
* @OA\Property(property="contact", type="string", nullable=true, example="010-1234-5678", description="연락처"),
* @OA\Property(property="site_id", type="integer", nullable=true, example=1, description="현장 ID"),
* @OA\Property(property="site_name", type="string", nullable=true, example="강남현장", description="현장명"),
* @OA\Property(property="site_code", type="string", nullable=true, example="SITE-001", description="현장코드"),
* @OA\Property(property="product_category", type="string", enum={"SCREEN","STEEL"}, example="SCREEN", description="제품 카테고리"),
* @OA\Property(property="product_id", type="integer", nullable=true, example=1, description="제품 ID"),
* @OA\Property(property="product_code", type="string", nullable=true, example="SCR-001", description="제품코드"),
* @OA\Property(property="product_name", type="string", nullable=true, example="전동스크린", description="제품명"),
* @OA\Property(property="open_size_width", type="number", format="float", nullable=true, example=3000, description="개구부 폭(mm)"),
* @OA\Property(property="open_size_height", type="number", format="float", nullable=true, example=2500, description="개구부 높이(mm)"),
* @OA\Property(property="quantity", type="integer", example=1, description="수량"),
* @OA\Property(property="unit_symbol", type="string", nullable=true, example="EA", description="단위"),
* @OA\Property(property="floors", type="string", nullable=true, example="1F~3F", description="층수"),
* @OA\Property(property="material_cost", type="number", format="float", example=500000, description="자재비"),
* @OA\Property(property="labor_cost", type="number", format="float", example=100000, description="인건비"),
* @OA\Property(property="install_cost", type="number", format="float", example=50000, description="설치비"),
* @OA\Property(property="subtotal", type="number", format="float", example=650000, description="소계"),
* @OA\Property(property="discount_rate", type="number", format="float", example=10, description="할인율(%)"),
* @OA\Property(property="discount_amount", type="number", format="float", example=65000, description="할인금액"),
* @OA\Property(property="total_amount", type="number", format="float", example=585000, description="합계금액"),
* @OA\Property(property="status", type="string", enum={"draft","sent","approved","rejected","finalized","converted"}, example="draft", description="상태"),
* @OA\Property(property="current_revision", type="integer", example=0, description="현재 리비전"),
* @OA\Property(property="is_final", type="boolean", example=false, description="확정 여부"),
* @OA\Property(property="finalized_at", type="string", format="date-time", nullable=true, description="확정일시"),
* @OA\Property(property="finalized_by", type="integer", nullable=true, description="확정자 ID"),
* @OA\Property(property="completion_date", type="string", format="date", nullable=true, description="완료예정일"),
* @OA\Property(property="remarks", type="string", nullable=true, description="비고"),
* @OA\Property(property="memo", type="string", nullable=true, description="메모"),
* @OA\Property(property="notes", type="string", nullable=true, description="참고사항"),
* @OA\Property(property="calculation_inputs", type="object", nullable=true, description="자동산출 입력값"),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-12-04 10:00:00"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-12-04 10:00:00"),
* @OA\Property(property="items", type="array", @OA\Items(ref="#/components/schemas/QuoteItem"), description="견적 품목")
* )
*
* @OA\Schema(
* schema="QuoteItem",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="quote_id", type="integer", example=1),
* @OA\Property(property="item_id", type="integer", nullable=true, example=1, description="품목 ID"),
* @OA\Property(property="item_code", type="string", example="SCR-FABRIC-001", description="품목코드"),
* @OA\Property(property="item_name", type="string", example="스크린 원단", description="품목명"),
* @OA\Property(property="specification", type="string", nullable=true, example="3100 x 2650 mm", description="규격"),
* @OA\Property(property="unit", type="string", example="m²", description="단위"),
* @OA\Property(property="base_quantity", type="number", format="float", example=1, description="기본수량"),
* @OA\Property(property="calculated_quantity", type="number", format="float", example=8.215, description="산출수량"),
* @OA\Property(property="unit_price", type="number", format="float", example=25000, description="단가"),
* @OA\Property(property="total_price", type="number", format="float", example=205375, description="금액"),
* @OA\Property(property="formula", type="string", nullable=true, example="AREA * QTY", description="수량 수식"),
* @OA\Property(property="formula_result", type="string", nullable=true, description="수식 결과"),
* @OA\Property(property="formula_source", type="string", nullable=true, description="수식 출처"),
* @OA\Property(property="formula_category", type="string", nullable=true, example="material", description="비용 카테고리"),
* @OA\Property(property="note", type="string", nullable=true, description="비고"),
* @OA\Property(property="sort_order", type="integer", example=0, description="정렬순서")
* )
*
* @OA\Schema(
* schema="QuotePagination",
* type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Quote")),
* @OA\Property(property="first_page_url", type="string", example="/api/v1/quotes?page=1"),
* @OA\Property(property="from", type="integer", example=1),
* @OA\Property(property="last_page", type="integer", example=3),
* @OA\Property(property="last_page_url", type="string", example="/api/v1/quotes?page=3"),
* @OA\Property(property="next_page_url", type="string", nullable=true, example="/api/v1/quotes?page=2"),
* @OA\Property(property="path", type="string", example="/api/v1/quotes"),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="prev_page_url", type="string", nullable=true, example=null),
* @OA\Property(property="to", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=50)
* )
*
* @OA\Schema(
* schema="QuoteCreateRequest",
* type="object",
* @OA\Property(property="quote_number", type="string", nullable=true, maxLength=50, description="견적번호(미입력시 자동생성)"),
* @OA\Property(property="registration_date", type="string", format="date", nullable=true, example="2025-12-04"),
* @OA\Property(property="receipt_date", type="string", format="date", nullable=true),
* @OA\Property(property="author", type="string", nullable=true, maxLength=50),
* @OA\Property(property="client_id", type="integer", nullable=true),
* @OA\Property(property="client_name", type="string", nullable=true, maxLength=100),
* @OA\Property(property="manager", type="string", nullable=true, maxLength=50),
* @OA\Property(property="contact", type="string", nullable=true, maxLength=50),
* @OA\Property(property="site_id", type="integer", nullable=true),
* @OA\Property(property="site_name", type="string", nullable=true, maxLength=100),
* @OA\Property(property="site_code", type="string", nullable=true, maxLength=50),
* @OA\Property(property="product_category", type="string", enum={"SCREEN","STEEL"}, nullable=true),
* @OA\Property(property="product_id", type="integer", nullable=true),
* @OA\Property(property="product_code", type="string", nullable=true, maxLength=50),
* @OA\Property(property="product_name", type="string", nullable=true, maxLength=100),
* @OA\Property(property="open_size_width", type="number", format="float", nullable=true),
* @OA\Property(property="open_size_height", type="number", format="float", nullable=true),
* @OA\Property(property="quantity", type="integer", nullable=true, minimum=1),
* @OA\Property(property="unit_symbol", type="string", nullable=true, maxLength=10),
* @OA\Property(property="floors", type="string", nullable=true, maxLength=50),
* @OA\Property(property="material_cost", type="number", format="float", nullable=true),
* @OA\Property(property="labor_cost", type="number", format="float", nullable=true),
* @OA\Property(property="install_cost", type="number", format="float", nullable=true),
* @OA\Property(property="discount_rate", type="number", format="float", nullable=true, minimum=0, maximum=100),
* @OA\Property(property="total_amount", type="number", format="float", nullable=true),
* @OA\Property(property="completion_date", type="string", format="date", nullable=true),
* @OA\Property(property="remarks", type="string", nullable=true, maxLength=500),
* @OA\Property(property="memo", type="string", nullable=true),
* @OA\Property(property="notes", type="string", nullable=true),
* @OA\Property(property="calculation_inputs", type="object", nullable=true),
* @OA\Property(property="items", type="array", nullable=true, @OA\Items(ref="#/components/schemas/QuoteItemRequest"))
* )
*
* @OA\Schema(
* schema="QuoteUpdateRequest",
* type="object",
* @OA\Property(property="receipt_date", type="string", format="date", nullable=true),
* @OA\Property(property="author", type="string", nullable=true, maxLength=50),
* @OA\Property(property="client_id", type="integer", nullable=true),
* @OA\Property(property="client_name", type="string", nullable=true, maxLength=100),
* @OA\Property(property="manager", type="string", nullable=true, maxLength=50),
* @OA\Property(property="contact", type="string", nullable=true, maxLength=50),
* @OA\Property(property="site_id", type="integer", nullable=true),
* @OA\Property(property="site_name", type="string", nullable=true, maxLength=100),
* @OA\Property(property="site_code", type="string", nullable=true, maxLength=50),
* @OA\Property(property="product_category", type="string", enum={"SCREEN","STEEL"}, nullable=true),
* @OA\Property(property="product_id", type="integer", nullable=true),
* @OA\Property(property="product_code", type="string", nullable=true, maxLength=50),
* @OA\Property(property="product_name", type="string", nullable=true, maxLength=100),
* @OA\Property(property="open_size_width", type="number", format="float", nullable=true),
* @OA\Property(property="open_size_height", type="number", format="float", nullable=true),
* @OA\Property(property="quantity", type="integer", nullable=true, minimum=1),
* @OA\Property(property="unit_symbol", type="string", nullable=true, maxLength=10),
* @OA\Property(property="floors", type="string", nullable=true, maxLength=50),
* @OA\Property(property="material_cost", type="number", format="float", nullable=true),
* @OA\Property(property="labor_cost", type="number", format="float", nullable=true),
* @OA\Property(property="install_cost", type="number", format="float", nullable=true),
* @OA\Property(property="discount_rate", type="number", format="float", nullable=true, minimum=0, maximum=100),
* @OA\Property(property="total_amount", type="number", format="float", nullable=true),
* @OA\Property(property="completion_date", type="string", format="date", nullable=true),
* @OA\Property(property="remarks", type="string", nullable=true, maxLength=500),
* @OA\Property(property="memo", type="string", nullable=true),
* @OA\Property(property="notes", type="string", nullable=true),
* @OA\Property(property="calculation_inputs", type="object", nullable=true),
* @OA\Property(property="items", type="array", nullable=true, @OA\Items(ref="#/components/schemas/QuoteItemRequest"))
* )
*
* @OA\Schema(
* schema="QuoteItemRequest",
* type="object",
* @OA\Property(property="item_id", type="integer", nullable=true),
* @OA\Property(property="item_code", type="string", nullable=true, maxLength=50),
* @OA\Property(property="item_name", type="string", nullable=true, maxLength=100),
* @OA\Property(property="specification", type="string", nullable=true, maxLength=200),
* @OA\Property(property="unit", type="string", nullable=true, maxLength=20),
* @OA\Property(property="base_quantity", type="number", format="float", nullable=true),
* @OA\Property(property="calculated_quantity", type="number", format="float", nullable=true),
* @OA\Property(property="unit_price", type="number", format="float", nullable=true),
* @OA\Property(property="total_price", type="number", format="float", nullable=true),
* @OA\Property(property="formula", type="string", nullable=true, maxLength=500),
* @OA\Property(property="formula_result", type="string", nullable=true),
* @OA\Property(property="formula_source", type="string", nullable=true),
* @OA\Property(property="formula_category", type="string", nullable=true, maxLength=50),
* @OA\Property(property="note", type="string", nullable=true, maxLength=500),
* @OA\Property(property="sort_order", type="integer", nullable=true)
* )
*
* @OA\Schema(
* schema="QuoteCalculateRequest",
* type="object",
* @OA\Property(property="product_category", type="string", enum={"SCREEN","STEEL"}, nullable=true, description="제품 카테고리"),
* @OA\Property(property="W0", type="number", format="float", example=3000, description="개구부 폭(mm)"),
* @OA\Property(property="H0", type="number", format="float", example=2500, description="개구부 높이(mm)"),
* @OA\Property(property="QTY", type="integer", example=1, description="수량"),
* @OA\Property(property="INSTALL_TYPE", type="string", enum={"wall","ceiling","floor"}, nullable=true, description="설치유형(스크린)"),
* @OA\Property(property="MOTOR_TYPE", type="string", enum={"standard","heavy"}, nullable=true, description="모터유형(스크린)"),
* @OA\Property(property="CONTROL_TYPE", type="string", enum={"switch","remote","smart"}, nullable=true, description="제어방식(스크린)"),
* @OA\Property(property="MATERIAL", type="string", enum={"ss304","ss316","galvanized"}, nullable=true, description="재질(철재)"),
* @OA\Property(property="THICKNESS", type="number", format="float", nullable=true, description="두께(철재)"),
* @OA\Property(property="FINISH", type="string", enum={"hairline","mirror","matte"}, nullable=true, description="표면처리(철재)"),
* @OA\Property(property="WELDING", type="string", enum={"tig","mig","spot"}, nullable=true, description="용접방식(철재)")
* )
*
* @OA\Schema(
* schema="QuoteCalculationResult",
* type="object",
* @OA\Property(property="inputs", type="object", description="입력 파라미터"),
* @OA\Property(property="outputs", type="object", description="산출값 (W1, H1, AREA, WEIGHT 등)"),
* @OA\Property(property="items", type="array", @OA\Items(ref="#/components/schemas/QuoteItem"), description="산출된 품목"),
* @OA\Property(property="costs", type="object",
* @OA\Property(property="material_cost", type="number", format="float"),
* @OA\Property(property="labor_cost", type="number", format="float"),
* @OA\Property(property="install_cost", type="number", format="float"),
* @OA\Property(property="subtotal", type="number", format="float")
* ),
* @OA\Property(property="errors", type="array", @OA\Items(type="string"))
* )
*
* @OA\Schema(
* schema="QuoteSendEmailRequest",
* type="object",
* @OA\Property(property="email", type="string", format="email", nullable=true, description="수신자 이메일"),
* @OA\Property(property="name", type="string", nullable=true, maxLength=100, description="수신자명"),
* @OA\Property(property="subject", type="string", nullable=true, maxLength=200, description="제목"),
* @OA\Property(property="message", type="string", nullable=true, maxLength=2000, description="본문"),
* @OA\Property(property="cc", type="array", @OA\Items(type="string", format="email"), nullable=true, description="참조"),
* @OA\Property(property="attach_pdf", type="boolean", example=true, description="PDF 첨부 여부")
* )
*
* @OA\Schema(
* schema="QuoteSendKakaoRequest",
* type="object",
* @OA\Property(property="phone", type="string", nullable=true, maxLength=20, description="수신자 전화번호"),
* @OA\Property(property="name", type="string", nullable=true, maxLength=100, description="수신자명"),
* @OA\Property(property="template_code", type="string", nullable=true, maxLength=50, description="템플릿 코드"),
* @OA\Property(property="view_url", type="string", format="uri", nullable=true, description="조회 URL")
* )
*
* @OA\Schema(
* schema="QuoteNumberPreview",
* type="object",
* @OA\Property(property="quote_number", type="string", example="KD-SC-251204-01"),
* @OA\Property(property="product_category", type="string", example="SCREEN"),
* @OA\Property(property="generated_at", type="string", format="date-time")
* )
*/
class QuoteApi
{
/**
* @OA\Get(
* path="/api/v1/quotes",
* tags={"Quote"},
* summary="견적 목록 조회",
* security={{"BearerAuth":{}}},
* @OA\Parameter(name="page", in="query", @OA\Schema(type="integer")),
* @OA\Parameter(name="size", in="query", @OA\Schema(type="integer")),
* @OA\Parameter(name="q", in="query", description="검색어", @OA\Schema(type="string")),
* @OA\Parameter(name="status", in="query", @OA\Schema(type="string", enum={"draft","sent","approved","rejected","finalized","converted"})),
* @OA\Parameter(name="product_category", in="query", @OA\Schema(type="string", enum={"SCREEN","STEEL"})),
* @OA\Parameter(name="client_id", in="query", @OA\Schema(type="integer")),
* @OA\Parameter(name="date_from", in="query", @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="date_to", in="query", @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="sort_by", in="query", @OA\Schema(type="string")),
* @OA\Parameter(name="sort_order", in="query", @OA\Schema(type="string", enum={"asc","desc"})),
* @OA\Response(response=200, description="성공", @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/QuotePagination")
* ))
* )
*/
public function index() {}
/**
* @OA\Post(
* path="/api/v1/quotes",
* tags={"Quote"},
* summary="견적 생성",
* security={{"BearerAuth":{}}},
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/QuoteCreateRequest")),
* @OA\Response(response=200, description="성공", @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/Quote")
* ))
* )
*/
public function store() {}
/**
* @OA\Get(
* path="/api/v1/quotes/{id}",
* tags={"Quote"},
* summary="견적 상세 조회",
* security={{"BearerAuth":{}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(response=200, description="성공", @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/Quote")
* )),
* @OA\Response(response=404, description="견적 없음")
* )
*/
public function show() {}
/**
* @OA\Put(
* path="/api/v1/quotes/{id}",
* tags={"Quote"},
* summary="견적 수정",
* security={{"BearerAuth":{}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/QuoteUpdateRequest")),
* @OA\Response(response=200, description="성공", @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/Quote")
* )),
* @OA\Response(response=400, description="수정 불가 상태"),
* @OA\Response(response=404, description="견적 없음")
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/quotes/{id}",
* tags={"Quote"},
* summary="견적 삭제",
* security={{"BearerAuth":{}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(response=200, description="성공"),
* @OA\Response(response=400, description="삭제 불가 상태"),
* @OA\Response(response=404, description="견적 없음")
* )
*/
public function destroy() {}
/**
* @OA\Delete(
* path="/api/v1/quotes/bulk",
* tags={"Quote"},
* summary="견적 일괄 삭제",
* security={{"BearerAuth":{}}},
* @OA\RequestBody(required=true, @OA\JsonContent(
* @OA\Property(property="ids", type="array", @OA\Items(type="integer"), example={1,2,3})
* )),
* @OA\Response(response=200, description="성공", @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object", @OA\Property(property="deleted_count", type="integer", example=3))
* ))
* )
*/
public function bulkDestroy() {}
/**
* @OA\Post(
* path="/api/v1/quotes/{id}/finalize",
* tags={"Quote"},
* summary="견적 확정",
* security={{"BearerAuth":{}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(response=200, description="성공", @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/Quote")
* )),
* @OA\Response(response=400, description="확정 불가 상태")
* )
*/
public function finalize() {}
/**
* @OA\Post(
* path="/api/v1/quotes/{id}/cancel-finalize",
* tags={"Quote"},
* summary="견적 확정 취소",
* security={{"BearerAuth":{}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(response=200, description="성공", @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/Quote")
* )),
* @OA\Response(response=400, description="취소 불가 상태")
* )
*/
public function cancelFinalize() {}
/**
* @OA\Post(
* path="/api/v1/quotes/{id}/convert",
* tags={"Quote"},
* summary="수주 전환",
* security={{"BearerAuth":{}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(response=200, description="성공", @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/Quote")
* )),
* @OA\Response(response=400, description="전환 불가 상태")
* )
*/
public function convertToOrder() {}
/**
* @OA\Get(
* path="/api/v1/quotes/number/preview",
* tags={"Quote"},
* summary="견적번호 미리보기",
* security={{"BearerAuth":{}}},
* @OA\Parameter(name="product_category", in="query", @OA\Schema(type="string", enum={"SCREEN","STEEL"})),
* @OA\Response(response=200, description="성공", @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/QuoteNumberPreview")
* ))
* )
*/
public function previewNumber() {}
/**
* @OA\Get(
* path="/api/v1/quotes/calculation/schema",
* tags={"Quote"},
* summary="자동산출 입력 스키마 조회",
* security={{"BearerAuth":{}}},
* @OA\Parameter(name="product_category", in="query", @OA\Schema(type="string", enum={"SCREEN","STEEL"})),
* @OA\Response(response=200, description="성공", @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object", description="제품별 입력 스키마")
* ))
* )
*/
public function calculationSchema() {}
/**
* @OA\Post(
* path="/api/v1/quotes/calculate",
* tags={"Quote"},
* summary="자동산출 실행",
* security={{"BearerAuth":{}}},
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/QuoteCalculateRequest")),
* @OA\Response(response=200, description="성공", @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/QuoteCalculationResult")
* ))
* )
*/
public function calculate() {}
/**
* @OA\Post(
* path="/api/v1/quotes/{id}/pdf",
* tags={"Quote"},
* summary="견적서 PDF 생성",
* security={{"BearerAuth":{}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(response=200, description="성공", @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="quote_id", type="integer"),
* @OA\Property(property="quote_number", type="string"),
* @OA\Property(property="filename", type="string"),
* @OA\Property(property="path", type="string"),
* @OA\Property(property="generated_at", type="string")
* )
* ))
* )
*/
public function generatePdf() {}
/**
* @OA\Post(
* path="/api/v1/quotes/{id}/send/email",
* tags={"Quote"},
* summary="견적서 이메일 발송",
* security={{"BearerAuth":{}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\RequestBody(required=false, @OA\JsonContent(ref="#/components/schemas/QuoteSendEmailRequest")),
* @OA\Response(response=200, description="성공", @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="success", type="boolean"),
* @OA\Property(property="send_log", type="object"),
* @OA\Property(property="quote_status", type="string")
* )
* )),
* @OA\Response(response=400, description="수신자 정보 없음")
* )
*/
public function sendEmail() {}
/**
* @OA\Post(
* path="/api/v1/quotes/{id}/send/kakao",
* tags={"Quote"},
* summary="견적서 카카오톡 발송",
* security={{"BearerAuth":{}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\RequestBody(required=false, @OA\JsonContent(ref="#/components/schemas/QuoteSendKakaoRequest")),
* @OA\Response(response=200, description="성공", @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="success", type="boolean"),
* @OA\Property(property="send_log", type="object"),
* @OA\Property(property="quote_status", type="string")
* )
* )),
* @OA\Response(response=400, description="수신자 정보 없음")
* )
*/
public function sendKakao() {}
/**
* @OA\Get(
* path="/api/v1/quotes/{id}/send/history",
* tags={"Quote"},
* summary="발송 이력 조회",
* security={{"BearerAuth":{}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(response=200, description="성공", @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="quote_id", type="integer"),
* @OA\Property(property="quote_number", type="string"),
* @OA\Property(property="history", type="array", @OA\Items(type="object"))
* )
* ))
* )
*/
public function sendHistory() {}
}

View File

@@ -125,4 +125,21 @@
'entity_protected_by_locked_relationship' => '잠금된 연결로 보호된 항목은 삭제할 수 없습니다.',
'page_has_locked_children' => '잠금된 자식 연결이 있어 페이지를 삭제할 수 없습니다.',
'section_has_locked_children' => '잠금된 자식 연결이 있어 섹션을 삭제할 수 없습니다.',
// 견적 관련 (Quote)
'quote_not_found' => '견적 정보를 찾을 수 없습니다.',
'quote_not_editable' => '현재 상태에서는 견적을 수정할 수 없습니다.',
'quote_not_deletable' => '현재 상태에서는 견적을 삭제할 수 없습니다.',
'quote_not_finalizable' => '현재 상태에서는 견적을 확정할 수 없습니다.',
'quote_not_finalized' => '확정되지 않은 견적입니다.',
'quote_already_converted' => '이미 수주 전환된 견적입니다.',
'quote_not_convertible' => '현재 상태에서는 수주 전환할 수 없습니다.',
'quote_email_not_found' => '수신자 이메일 정보가 없습니다.',
'quote_phone_not_found' => '수신자 연락처 정보가 없습니다.',
// 수식 평가 관련 (Formula)
'formula_empty' => '수식이 비어있습니다.',
'formula_parentheses_mismatch' => '괄호가 올바르게 닫히지 않았습니다.',
'formula_unsupported_function' => '지원하지 않는 함수입니다: :function',
'formula_calculation_error' => '계산 오류: :expression',
];

View File

@@ -180,4 +180,20 @@
'folder_updated' => '폴더가 수정되었습니다.',
'folder_deleted' => '폴더가 비활성화되었습니다.',
'folders_reordered' => '폴더 순서가 변경되었습니다.',
// 견적 관리
'quote' => [
'fetched' => '견적을 조회했습니다.',
'created' => '견적이 등록되었습니다.',
'updated' => '견적이 수정되었습니다.',
'deleted' => '견적이 삭제되었습니다.',
'bulk_deleted' => '견적이 일괄 삭제되었습니다.',
'finalized' => '견적이 확정되었습니다.',
'finalize_cancelled' => '견적 확정이 취소되었습니다.',
'converted' => '견적이 수주로 전환되었습니다.',
'calculated' => '견적 자동산출이 완료되었습니다.',
'pdf_generated' => '견적서 PDF가 생성되었습니다.',
],
'quote_email_sent' => '견적서가 이메일로 발송되었습니다.',
'quote_kakao_sent' => '견적서가 카카오톡으로 발송되었습니다.',
];

View File

@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Api\Admin\GlobalMenuController;
use App\Http\Controllers\Api\V1\AdminController;
use App\Http\Controllers\Api\V1\ApiController;
use App\Http\Controllers\Api\V1\BoardController;
@@ -11,7 +12,6 @@
use App\Http\Controllers\Api\V1\ClientController;
use App\Http\Controllers\Api\V1\ClientGroupController;
use App\Http\Controllers\Api\V1\CommonController;
use App\Http\Controllers\Api\Admin\GlobalMenuController;
use App\Http\Controllers\Api\V1\DepartmentController;
use App\Http\Controllers\Api\V1\Design\AuditLogController as DesignAuditLogController;
use App\Http\Controllers\Api\V1\Design\BomCalculationController;
@@ -38,19 +38,20 @@
use App\Http\Controllers\Api\V1\PricingController;
use App\Http\Controllers\Api\V1\ProductBomItemController;
use App\Http\Controllers\Api\V1\ProductController;
use App\Http\Controllers\Api\V1\QuoteController;
use App\Http\Controllers\Api\V1\RefreshController;
use App\Http\Controllers\Api\V1\RegisterController;
use App\Http\Controllers\Api\V1\RoleController;
use App\Http\Controllers\Api\V1\RolePermissionController;
use App\Http\Controllers\Api\V1\TenantController;
use App\Http\Controllers\Api\V1\TenantFieldSettingController;
// 설계 전용 (디자인 네임스페이스)
use App\Http\Controllers\Api\V1\TenantFieldSettingController;
use App\Http\Controllers\Api\V1\TenantOptionGroupController;
use App\Http\Controllers\Api\V1\TenantOptionValueController;
use App\Http\Controllers\Api\V1\TenantStatFieldController;
use App\Http\Controllers\Api\V1\TenantUserProfileController;
use App\Http\Controllers\Api\V1\UserController;
// 모델셋 관리 (견적 시스템)
use App\Http\Controllers\Api\V1\UserController;
use App\Http\Controllers\Api\V1\UserRoleController;
use Illuminate\Support\Facades\Route;
@@ -333,6 +334,37 @@
Route::patch('/{id}/toggle', [ClientGroupController::class, 'toggle'])->whereNumber('id')->name('v1.client-groups.toggle'); // 활성/비활성
});
// Quotes (견적 관리)
Route::prefix('quotes')->group(function () {
// 기본 CRUD
Route::get('', [QuoteController::class, 'index'])->name('v1.quotes.index'); // 목록
Route::post('', [QuoteController::class, 'store'])->name('v1.quotes.store'); // 생성
Route::get('/{id}', [QuoteController::class, 'show'])->whereNumber('id')->name('v1.quotes.show'); // 단건
Route::put('/{id}', [QuoteController::class, 'update'])->whereNumber('id')->name('v1.quotes.update'); // 수정
Route::delete('/{id}', [QuoteController::class, 'destroy'])->whereNumber('id')->name('v1.quotes.destroy'); // 삭제
// 일괄 삭제
Route::delete('/bulk', [QuoteController::class, 'bulkDestroy'])->name('v1.quotes.bulk-destroy'); // 일괄 삭제
// 상태 관리
Route::post('/{id}/finalize', [QuoteController::class, 'finalize'])->whereNumber('id')->name('v1.quotes.finalize'); // 확정
Route::post('/{id}/cancel-finalize', [QuoteController::class, 'cancelFinalize'])->whereNumber('id')->name('v1.quotes.cancel-finalize'); // 확정 취소
Route::post('/{id}/convert', [QuoteController::class, 'convertToOrder'])->whereNumber('id')->name('v1.quotes.convert'); // 수주 전환
// 견적번호 미리보기
Route::get('/number/preview', [QuoteController::class, 'previewNumber'])->name('v1.quotes.number-preview'); // 번호 미리보기
// 자동산출
Route::get('/calculation/schema', [QuoteController::class, 'calculationSchema'])->name('v1.quotes.calculation-schema'); // 입력 스키마
Route::post('/calculate', [QuoteController::class, 'calculate'])->name('v1.quotes.calculate'); // 자동산출 실행
// 문서 관리
Route::post('/{id}/pdf', [QuoteController::class, 'generatePdf'])->whereNumber('id')->name('v1.quotes.pdf'); // PDF 생성
Route::post('/{id}/send/email', [QuoteController::class, 'sendEmail'])->whereNumber('id')->name('v1.quotes.send-email'); // 이메일 발송
Route::post('/{id}/send/kakao', [QuoteController::class, 'sendKakao'])->whereNumber('id')->name('v1.quotes.send-kakao'); // 카카오톡 발송
Route::get('/{id}/send/history', [QuoteController::class, 'sendHistory'])->whereNumber('id')->name('v1.quotes.send-history'); // 발송 이력
});
// Pricing (가격 이력 관리)
Route::prefix('pricing')->group(function () {
Route::get('', [PricingController::class, 'index'])->name('v1.pricing.index'); // 목록