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.* 성공 메시지
389 lines
12 KiB
PHP
389 lines
12 KiB
PHP
<?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);
|
|
}
|
|
}
|