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