Files
sam-api/app/Services/Quote/QuoteService.php
kent 8a5c7b5298 feat(API): Service 로직 개선
- EstimateService, ItemService 기능 추가
- OrderService 공정 연동 개선
- SalaryService, ReceivablesService 수정
- HandoverReportService, SiteBriefingService 추가
- Pricing 서비스 추가

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-13 19:49:06 +09:00

693 lines
25 KiB
PHP

<?php
namespace App\Services\Quote;
use App\Models\Orders\Order;
use App\Models\Orders\OrderItem;
use App\Models\Quote\Quote;
use App\Models\Quote\QuoteItem;
use App\Models\Quote\QuoteRevision;
use App\Models\Tenants\SiteBriefing;
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,
private QuoteCalculationService $calculationService
) {}
/**
* 견적 목록 조회
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$page = (int) ($params['page'] ?? 1);
$size = (int) ($params['size'] ?? 20);
$q = trim((string) ($params['q'] ?? ''));
$quoteType = $params['quote_type'] ?? null;
$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';
$withItems = filter_var($params['with_items'] ?? false, FILTER_VALIDATE_BOOLEAN);
$query = Quote::query()->where('tenant_id', $tenantId);
// items 포함 (수주 전환용)
if ($withItems) {
$query->with(['items', 'client:id,name,contact_person,phone']);
}
// 검색어
if ($q !== '') {
$query->search($q);
}
// 견적 유형 필터
if ($quoteType) {
$query->where('quote_type', $quoteType);
}
// 상태 필터
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', 'siteBriefing.partner'])
->where('tenant_id', $tenantId)
->find($id);
if (! $quote) {
throw new NotFoundHttpException(__('error.quote_not_found'));
}
// BOM 자재 데이터 계산 및 추가
$bomMaterials = $this->calculateBomMaterials($quote);
if (! empty($bomMaterials)) {
$quote->setAttribute('bom_materials', $bomMaterials);
}
return $quote;
}
/**
* 저장된 calculation_inputs를 기반으로 BOM 원자재(leaf nodes) 목록 조회
*
* 세부산출내역과 달리, BOM 트리에서 실제 원자재만 추출합니다:
* - 세부산출내역: BOM 계산 결과 (수식 기반 산출 품목)
* - 소요자재내역: BOM 트리 leaf nodes (실제 구매 필요한 원자재)
*/
private function calculateBomMaterials(Quote $quote): array
{
$calculationInputs = $quote->calculation_inputs;
// calculation_inputs가 없거나 items가 없으면 빈 배열 반환
if (empty($calculationInputs) || empty($calculationInputs['items'])) {
return [];
}
$tenantId = $this->tenantId();
$inputItems = $calculationInputs['items'];
$allMaterials = [];
foreach ($inputItems as $index => $input) {
// 완제품 코드 찾기 (productName에 저장됨)
$finishedGoodsCode = $input['productName'] ?? null;
if (! $finishedGoodsCode) {
continue;
}
// 주문 수량
$orderQuantity = (float) ($input['quantity'] ?? 1);
// BOM 계산을 위한 입력 변수 구성
$variables = [
'W0' => (float) ($input['openWidth'] ?? 0),
'H0' => (float) ($input['openHeight'] ?? 0),
'QTY' => $orderQuantity,
'PC' => $input['productCategory'] ?? 'SCREEN',
'GT' => $input['guideRailType'] ?? 'wall',
'MP' => $input['motorPower'] ?? 'single',
'CT' => $input['controller'] ?? 'basic',
'WS' => (float) ($input['wingSize'] ?? 50),
'INSP' => (float) ($input['inspectionFee'] ?? 50000),
];
try {
// BOM 트리에서 원자재(leaf nodes)만 추출
$leafMaterials = $this->calculationService->formulaEvaluator->getBomLeafMaterials(
$finishedGoodsCode,
$orderQuantity,
$variables,
$tenantId
);
// 각 자재 항목에 인덱스 정보 추가
foreach ($leafMaterials as $material) {
$allMaterials[] = [
'item_index' => $index,
'finished_goods_code' => $finishedGoodsCode,
'item_code' => $material['item_code'] ?? '',
'item_name' => $material['item_name'] ?? '',
'item_type' => $material['item_type'] ?? '',
'item_category' => $material['item_category'] ?? '',
'specification' => $material['specification'] ?? '',
'unit' => $material['unit'] ?? 'EA',
'quantity' => $material['quantity'] ?? 0,
'unit_price' => $material['unit_price'] ?? 0,
'total_price' => $material['total_price'] ?? 0,
'process_type' => $material['process_type'] ?? '',
];
}
} catch (\Throwable) {
// BOM 조회 실패 시 해당 품목은 스킵
continue;
}
}
return $allMaterials;
}
/**
* 견적 생성
*/
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)
->with(['items', 'client'])
->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, $tenantId) {
// 수주번호 생성
$orderNo = $this->generateOrderNumber($tenantId);
// 수주 마스터 생성
$order = Order::createFromQuote($quote, $orderNo);
$order->created_by = $userId;
$order->save();
// 수주 상세 품목 생성
$serialIndex = 1;
foreach ($quote->items as $quoteItem) {
$orderItem = OrderItem::createFromQuoteItem($quoteItem, $order->id, $serialIndex);
$orderItem->created_by = $userId;
$orderItem->save();
$serialIndex++;
}
// 수주 합계 재계산
$order->load('items');
$order->recalculateTotals();
$order->save();
// 견적 상태 변경
$quote->update([
'status' => Quote::STATUS_CONVERTED,
'order_id' => $order->id,
'updated_by' => $userId,
]);
return $quote->refresh()->load(['items', 'client', 'order']);
});
}
/**
* 수주번호 생성
* 형식: ORD-YYMMDD-NNN (예: ORD-260105-001)
*/
private function generateOrderNumber(int $tenantId): string
{
$dateStr = now()->format('ymd');
$prefix = "ORD-{$dateStr}-";
$lastOrder = Order::withTrashed()
->where('tenant_id', $tenantId)
->where('order_no', 'like', $prefix.'%')
->orderBy('order_no', 'desc')
->first();
$sequence = 1;
if ($lastOrder) {
$parts = explode('-', $lastOrder->order_no);
if (count($parts) >= 3) {
$lastSeq = (int) end($parts);
$sequence = $lastSeq + 1;
}
}
$seqStr = str_pad((string) $sequence, 3, '0', STR_PAD_LEFT);
return "{$prefix}{$seqStr}";
}
/**
* 견적 품목 생성
*/
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,
]);
}
/**
* 현장설명회에서 견적 Upsert (생성 또는 업데이트)
*
* 참석완료 상태일 때 견적을 자동 생성하거나 업데이트합니다.
* - 견적이 없으면: 견적대기(pending) 상태로 신규 생성
* - 견적이 있으면: 현장설명회 정보로 업데이트 (거래처, 현장 등)
*/
public function upsertFromSiteBriefing(SiteBriefing $siteBriefing): Quote
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 기존 견적 조회
$existingQuote = Quote::where('tenant_id', $tenantId)
->where('site_briefing_id', $siteBriefing->id)
->first();
return DB::transaction(function () use ($siteBriefing, $tenantId, $userId, $existingQuote) {
if ($existingQuote) {
// 기존 견적 업데이트 (현장설명회 정보 동기화)
$existingQuote->update([
// 발주처 정보 동기화
'client_id' => $siteBriefing->partner_id,
'client_name' => $siteBriefing->partner?->name,
// 현장 정보 동기화
'site_id' => $siteBriefing->site_id,
'site_name' => $siteBriefing->title,
// 감사
'updated_by' => $userId,
]);
return $existingQuote->refresh();
}
// 신규 견적 생성
$quoteNumber = $this->numberService->generate('SCREEN');
return Quote::create([
'tenant_id' => $tenantId,
'quote_type' => Quote::TYPE_CONSTRUCTION,
'site_briefing_id' => $siteBriefing->id,
'quote_number' => $quoteNumber,
'registration_date' => now()->toDateString(),
// 발주처 정보 (현장설명회에서 복사)
'client_id' => $siteBriefing->partner_id,
'client_name' => $siteBriefing->partner?->name,
// 현장 정보 (현장설명회에서 복사)
'site_id' => $siteBriefing->site_id,
'site_name' => $siteBriefing->title,
// 제품 카테고리 없음 (pending 상태이므로)
'product_category' => null,
// 금액 정보 (빈 값)
'material_cost' => 0,
'labor_cost' => 0,
'install_cost' => 0,
'subtotal' => 0,
'discount_rate' => 0,
'discount_amount' => 0,
'total_amount' => 0,
// 상태 관리 (견적대기)
'status' => Quote::STATUS_PENDING,
'current_revision' => 0,
'is_final' => false,
// 비고 (현장설명회 정보 기록)
'remarks' => "현장설명회 참석완료로 자동생성 (현설번호: {$siteBriefing->briefing_code})",
// 감사
'created_by' => $userId,
]);
});
}
/**
* @deprecated Use upsertFromSiteBriefing() instead
*/
public function createFromSiteBriefing(SiteBriefing $siteBriefing): ?Quote
{
return $this->upsertFromSiteBriefing($siteBriefing);
}
/**
* 현장설명회 ID로 연결된 견적 조회
*/
public function findBySiteBriefingId(int $siteBriefingId): ?Quote
{
$tenantId = $this->tenantId();
return Quote::where('tenant_id', $tenantId)
->where('site_briefing_id', $siteBriefingId)
->first();
}
}