Files
sam-api/app/Services/Quote/QuoteDocumentService.php
hskwon 40ca8b8697 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.* 성공 메시지
2025-12-04 22:03:40 +09:00

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