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:
245
CURRENT_WORKS.md
245
CURRENT_WORKS.md
@@ -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)
|
||||
|
||||
### 작업 목표
|
||||
|
||||
196
app/Http/Controllers/Api/V1/QuoteController.php
Normal file
196
app/Http/Controllers/Api/V1/QuoteController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
21
app/Http/Requests/Quote/QuoteBulkDeleteRequest.php
Normal file
21
app/Http/Requests/Quote/QuoteBulkDeleteRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
52
app/Http/Requests/Quote/QuoteCalculateRequest.php
Normal file
52
app/Http/Requests/Quote/QuoteCalculateRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
37
app/Http/Requests/Quote/QuoteIndexRequest.php
Normal file
37
app/Http/Requests/Quote/QuoteIndexRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Http/Requests/Quote/QuoteSendEmailRequest.php
Normal file
26
app/Http/Requests/Quote/QuoteSendEmailRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/Http/Requests/Quote/QuoteSendKakaoRequest.php
Normal file
23
app/Http/Requests/Quote/QuoteSendKakaoRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
86
app/Http/Requests/Quote/QuoteStoreRequest.php
Normal file
86
app/Http/Requests/Quote/QuoteStoreRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
84
app/Http/Requests/Quote/QuoteUpdateRequest.php
Normal file
84
app/Http/Requests/Quote/QuoteUpdateRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
398
app/Services/Quote/FormulaEvaluatorService.php
Normal file
398
app/Services/Quote/FormulaEvaluatorService.php
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
538
app/Services/Quote/QuoteCalculationService.php
Normal file
538
app/Services/Quote/QuoteCalculationService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
388
app/Services/Quote/QuoteDocumentService.php
Normal file
388
app/Services/Quote/QuoteDocumentService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
115
app/Services/Quote/QuoteNumberService.php
Normal file
115
app/Services/Quote/QuoteNumberService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
448
app/Services/Quote/QuoteService.php
Normal file
448
app/Services/Quote/QuoteService.php
Normal 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
539
app/Swagger/v1/QuoteApi.php
Normal 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() {}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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' => '견적서가 카카오톡으로 발송되었습니다.',
|
||||
];
|
||||
|
||||
@@ -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'); // 목록
|
||||
|
||||
Reference in New Issue
Block a user