From 40ca8b8697dfef9f6738bbd4fefce7047e9c5cb1 Mon Sep 17 00:00:00 2001 From: hskwon Date: Thu, 4 Dec 2025 22:03:40 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[quote]=20=EA=B2=AC=EC=A0=81=20API=20Ph?= =?UTF-8?q?ase=202-3=20=EC=99=84=EB=A3=8C=20(Service=20+=20Controller=20La?= =?UTF-8?q?yer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.* 성공 메시지 --- CURRENT_WORKS.md | 245 ++++++++ .../Controllers/Api/V1/QuoteController.php | 196 +++++++ .../Requests/Quote/QuoteBulkDeleteRequest.php | 21 + .../Requests/Quote/QuoteCalculateRequest.php | 52 ++ app/Http/Requests/Quote/QuoteIndexRequest.php | 37 ++ .../Requests/Quote/QuoteSendEmailRequest.php | 26 + .../Requests/Quote/QuoteSendKakaoRequest.php | 23 + app/Http/Requests/Quote/QuoteStoreRequest.php | 86 +++ .../Requests/Quote/QuoteUpdateRequest.php | 84 +++ .../Quote/FormulaEvaluatorService.php | 398 +++++++++++++ .../Quote/QuoteCalculationService.php | 538 +++++++++++++++++ app/Services/Quote/QuoteDocumentService.php | 388 +++++++++++++ app/Services/Quote/QuoteNumberService.php | 115 ++++ app/Services/Quote/QuoteService.php | 448 +++++++++++++++ app/Swagger/v1/QuoteApi.php | 539 ++++++++++++++++++ lang/ko/error.php | 17 + lang/ko/message.php | 16 + routes/api.php | 38 +- 18 files changed, 3264 insertions(+), 3 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/QuoteController.php create mode 100644 app/Http/Requests/Quote/QuoteBulkDeleteRequest.php create mode 100644 app/Http/Requests/Quote/QuoteCalculateRequest.php create mode 100644 app/Http/Requests/Quote/QuoteIndexRequest.php create mode 100644 app/Http/Requests/Quote/QuoteSendEmailRequest.php create mode 100644 app/Http/Requests/Quote/QuoteSendKakaoRequest.php create mode 100644 app/Http/Requests/Quote/QuoteStoreRequest.php create mode 100644 app/Http/Requests/Quote/QuoteUpdateRequest.php create mode 100644 app/Services/Quote/FormulaEvaluatorService.php create mode 100644 app/Services/Quote/QuoteCalculationService.php create mode 100644 app/Services/Quote/QuoteDocumentService.php create mode 100644 app/Services/Quote/QuoteNumberService.php create mode 100644 app/Services/Quote/QuoteService.php create mode 100644 app/Swagger/v1/QuoteApi.php diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index 1bf7e9f..f229361 100644 --- a/CURRENT_WORKS.md +++ b/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) ### 작업 목표 diff --git a/app/Http/Controllers/Api/V1/QuoteController.php b/app/Http/Controllers/Api/V1/QuoteController.php new file mode 100644 index 0000000..462df18 --- /dev/null +++ b/app/Http/Controllers/Api/V1/QuoteController.php @@ -0,0 +1,196 @@ +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')); + } +} diff --git a/app/Http/Requests/Quote/QuoteBulkDeleteRequest.php b/app/Http/Requests/Quote/QuoteBulkDeleteRequest.php new file mode 100644 index 0000000..ae911b9 --- /dev/null +++ b/app/Http/Requests/Quote/QuoteBulkDeleteRequest.php @@ -0,0 +1,21 @@ + 'required|array|min:1', + 'ids.*' => 'required|integer', + ]; + } +} diff --git a/app/Http/Requests/Quote/QuoteCalculateRequest.php b/app/Http/Requests/Quote/QuoteCalculateRequest.php new file mode 100644 index 0000000..3f05200 --- /dev/null +++ b/app/Http/Requests/Quote/QuoteCalculateRequest.php @@ -0,0 +1,52 @@ + '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', + ]; + } +} diff --git a/app/Http/Requests/Quote/QuoteIndexRequest.php b/app/Http/Requests/Quote/QuoteIndexRequest.php new file mode 100644 index 0000000..4ffb5fe --- /dev/null +++ b/app/Http/Requests/Quote/QuoteIndexRequest.php @@ -0,0 +1,37 @@ + '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', + ]; + } +} diff --git a/app/Http/Requests/Quote/QuoteSendEmailRequest.php b/app/Http/Requests/Quote/QuoteSendEmailRequest.php new file mode 100644 index 0000000..478c6dc --- /dev/null +++ b/app/Http/Requests/Quote/QuoteSendEmailRequest.php @@ -0,0 +1,26 @@ + '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', + ]; + } +} diff --git a/app/Http/Requests/Quote/QuoteSendKakaoRequest.php b/app/Http/Requests/Quote/QuoteSendKakaoRequest.php new file mode 100644 index 0000000..0c1fbaa --- /dev/null +++ b/app/Http/Requests/Quote/QuoteSendKakaoRequest.php @@ -0,0 +1,23 @@ + 'nullable|string|max:20', + 'name' => 'nullable|string|max:100', + 'template_code' => 'nullable|string|max:50', + 'view_url' => 'nullable|url|max:500', + ]; + } +} diff --git a/app/Http/Requests/Quote/QuoteStoreRequest.php b/app/Http/Requests/Quote/QuoteStoreRequest.php new file mode 100644 index 0000000..acd2864 --- /dev/null +++ b/app/Http/Requests/Quote/QuoteStoreRequest.php @@ -0,0 +1,86 @@ + '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', + ]; + } +} diff --git a/app/Http/Requests/Quote/QuoteUpdateRequest.php b/app/Http/Requests/Quote/QuoteUpdateRequest.php new file mode 100644 index 0000000..7b70e65 --- /dev/null +++ b/app/Http/Requests/Quote/QuoteUpdateRequest.php @@ -0,0 +1,84 @@ + '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', + ]; + } +} diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php new file mode 100644 index 0000000..904d987 --- /dev/null +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -0,0 +1,398 @@ + 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 = []; + } +} diff --git a/app/Services/Quote/QuoteCalculationService.php b/app/Services/Quote/QuoteCalculationService.php new file mode 100644 index 0000000..3e44935 --- /dev/null +++ b/app/Services/Quote/QuoteCalculationService.php @@ -0,0 +1,538 @@ +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; + } +} diff --git a/app/Services/Quote/QuoteDocumentService.php b/app/Services/Quote/QuoteDocumentService.php new file mode 100644 index 0000000..f829564 --- /dev/null +++ b/app/Services/Quote/QuoteDocumentService.php @@ -0,0 +1,388 @@ +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 <<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); + } +} diff --git a/app/Services/Quote/QuoteNumberService.php b/app/Services/Quote/QuoteNumberService.php new file mode 100644 index 0000000..dc18dcc --- /dev/null +++ b/app/Services/Quote/QuoteNumberService.php @@ -0,0 +1,115 @@ +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(); + } +} diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php new file mode 100644 index 0000000..c9af7e2 --- /dev/null +++ b/app/Services/Quote/QuoteService.php @@ -0,0 +1,448 @@ +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, + ]); + } +} diff --git a/app/Swagger/v1/QuoteApi.php b/app/Swagger/v1/QuoteApi.php new file mode 100644 index 0000000..cce4a07 --- /dev/null +++ b/app/Swagger/v1/QuoteApi.php @@ -0,0 +1,539 @@ + '잠금된 연결로 보호된 항목은 삭제할 수 없습니다.', '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', ]; diff --git a/lang/ko/message.php b/lang/ko/message.php index 994c5d2..afd016d 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -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' => '견적서가 카카오톡으로 발송되었습니다.', ]; diff --git a/routes/api.php b/routes/api.php index c0847a6..c0751a9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,6 @@ 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'); // 목록