Files
sam-api/app/Services/Estimate/EstimateService.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

430 lines
15 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Estimate;
use App\Models\Estimate\Estimate;
use App\Models\Estimate\EstimateItem;
use App\Services\Calculation\CalculationEngine;
use App\Services\ModelSet\ModelSetService;
use App\Services\Pricing\PricingService;
use App\Services\Service;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class EstimateService extends Service
{
protected ModelSetService $modelSetService;
protected CalculationEngine $calculationEngine;
protected PricingService $pricingService;
public function __construct(
ModelSetService $modelSetService,
CalculationEngine $calculationEngine,
PricingService $pricingService
) {
$this->modelSetService = $modelSetService;
$this->calculationEngine = $calculationEngine;
$this->pricingService = $pricingService;
}
/**
* 견적 목록 조회
*/
public function getEstimates(array $filters = []): LengthAwarePaginator
{
$query = Estimate::with(['modelSet', 'items'])
->where('tenant_id', $this->tenantId());
// 필터링
if (! empty($filters['status'])) {
$query->where('status', $filters['status']);
}
if (! empty($filters['customer_name'])) {
$query->where('customer_name', 'like', '%'.$filters['customer_name'].'%');
}
if (! empty($filters['model_set_id'])) {
$query->where('model_set_id', $filters['model_set_id']);
}
if (! empty($filters['date_from'])) {
$query->whereDate('created_at', '>=', $filters['date_from']);
}
if (! empty($filters['date_to'])) {
$query->whereDate('created_at', '<=', $filters['date_to']);
}
if (! empty($filters['search'])) {
$searchTerm = $filters['search'];
$query->where(function ($q) use ($searchTerm) {
$q->where('estimate_name', 'like', '%'.$searchTerm.'%')
->orWhere('estimate_no', 'like', '%'.$searchTerm.'%')
->orWhere('project_name', 'like', '%'.$searchTerm.'%');
});
}
return $query->orderBy('created_at', 'desc')
->paginate($filters['per_page'] ?? 20);
}
/**
* 견적 상세 조회
*/
public function getEstimateDetail($estimateId): array
{
$estimate = Estimate::with(['modelSet.fields', 'items'])
->where('tenant_id', $this->tenantId())
->findOrFail($estimateId);
return [
'estimate' => $estimate,
'model_set_schema' => $this->modelSetService->getModelSetCategoryFields($estimate->model_set_id),
'calculation_summary' => $this->summarizeCalculations($estimate),
];
}
/**
* 견적 생성
*/
public function createEstimate(array $data): array
{
return DB::transaction(function () use ($data) {
// 견적번호 생성
$estimateNo = Estimate::generateEstimateNo($this->tenantId());
// 모델셋 기반 BOM 계산
$bomCalculation = $this->modelSetService->calculateModelSetBom(
$data['model_set_id'],
$data['parameters']
);
// 견적 생성
$estimate = Estimate::create([
'tenant_id' => $this->tenantId(),
'model_set_id' => $data['model_set_id'],
'estimate_no' => $estimateNo,
'estimate_name' => $data['estimate_name'],
'customer_name' => $data['customer_name'] ?? null,
'project_name' => $data['project_name'] ?? null,
'parameters' => $data['parameters'],
'calculated_results' => $bomCalculation['calculated_values'] ?? [],
'bom_data' => $bomCalculation,
'total_amount' => 0, // 항목 생성 후 재계산
'notes' => $data['notes'] ?? null,
'valid_until' => now()->addDays(30), // 기본 30일 유효
'created_by' => $this->apiUserId(),
]);
// 견적 항목 생성 (BOM 기반) + 가격 계산
if (! empty($bomCalculation['bom_items'])) {
$totalAmount = $this->createEstimateItems(
$estimate,
$bomCalculation['bom_items'],
$data['client_id'] ?? null
);
// 총액 업데이트
$estimate->update(['total_amount' => $totalAmount]);
}
return $this->getEstimateDetail($estimate->id);
});
}
/**
* 견적 수정
*/
public function updateEstimate($estimateId, array $data): array
{
return DB::transaction(function () use ($estimateId, $data) {
$estimate = Estimate::where('tenant_id', $this->tenantId())
->findOrFail($estimateId);
// 파라미터가 변경되면 재계산
if (isset($data['parameters'])) {
$bomCalculation = $this->modelSetService->calculateModelSetBom(
$estimate->model_set_id,
$data['parameters']
);
$data['calculated_results'] = $bomCalculation['calculated_values'] ?? [];
$data['bom_data'] = $bomCalculation;
// 기존 견적 항목 삭제 후 재생성
$estimate->items()->delete();
if (! empty($bomCalculation['bom_items'])) {
$totalAmount = $this->createEstimateItems(
$estimate,
$bomCalculation['bom_items'],
$data['client_id'] ?? null
);
$data['total_amount'] = $totalAmount;
}
}
$estimate->update([
...$data,
'updated_by' => $this->apiUserId(),
]);
return $this->getEstimateDetail($estimate->id);
});
}
/**
* 견적 삭제
*/
public function deleteEstimate($estimateId): void
{
DB::transaction(function () use ($estimateId) {
$estimate = Estimate::where('tenant_id', $this->tenantId())
->findOrFail($estimateId);
// 진행 중인 견적은 삭제 불가
if (in_array($estimate->status, ['SENT', 'APPROVED'])) {
throw new \Exception(__('error.estimate.cannot_delete_sent_or_approved'));
}
$estimate->update(['deleted_by' => $this->apiUserId()]);
$estimate->delete();
});
}
/**
* 견적 복제
*/
public function cloneEstimate($estimateId, array $data): array
{
return DB::transaction(function () use ($estimateId, $data) {
$originalEstimate = Estimate::with('items')
->where('tenant_id', $this->tenantId())
->findOrFail($estimateId);
// 새 견적번호 생성
$newEstimateNo = Estimate::generateEstimateNo($this->tenantId());
// 견적 복제
$newEstimate = Estimate::create([
'tenant_id' => $this->tenantId(),
'model_set_id' => $originalEstimate->model_set_id,
'estimate_no' => $newEstimateNo,
'estimate_name' => $data['estimate_name'],
'customer_name' => $data['customer_name'] ?? $originalEstimate->customer_name,
'project_name' => $data['project_name'] ?? $originalEstimate->project_name,
'parameters' => $originalEstimate->parameters,
'calculated_results' => $originalEstimate->calculated_results,
'bom_data' => $originalEstimate->bom_data,
'total_amount' => $originalEstimate->total_amount,
'notes' => $data['notes'] ?? $originalEstimate->notes,
'valid_until' => now()->addDays(30),
'created_by' => $this->apiUserId(),
]);
// 견적 항목 복제
foreach ($originalEstimate->items as $item) {
EstimateItem::create([
'tenant_id' => $this->tenantId(),
'estimate_id' => $newEstimate->id,
'sequence' => $item->sequence,
'item_name' => $item->item_name,
'item_description' => $item->item_description,
'parameters' => $item->parameters,
'calculated_values' => $item->calculated_values,
'unit_price' => $item->unit_price,
'quantity' => $item->quantity,
'total_price' => $item->total_price,
'bom_components' => $item->bom_components,
'notes' => $item->notes,
'created_by' => $this->apiUserId(),
]);
}
return $this->getEstimateDetail($newEstimate->id);
});
}
/**
* 견적 상태 변경
*/
public function changeEstimateStatus($estimateId, string $status, ?string $notes = null): array
{
$estimate = Estimate::where('tenant_id', $this->tenantId())
->findOrFail($estimateId);
$validTransitions = [
'DRAFT' => ['SENT', 'REJECTED'],
'SENT' => ['APPROVED', 'REJECTED', 'EXPIRED'],
'APPROVED' => ['EXPIRED'],
'REJECTED' => ['DRAFT'],
'EXPIRED' => ['DRAFT'],
];
if (! in_array($status, $validTransitions[$estimate->status] ?? [])) {
throw new \Exception(__('error.estimate.invalid_status_transition'));
}
$estimate->update([
'status' => $status,
'notes' => $notes ? ($estimate->notes."\n\n".$notes) : $estimate->notes,
'updated_by' => $this->apiUserId(),
]);
return $this->getEstimateDetail($estimate->id);
}
/**
* 동적 견적 폼 스키마 조회
*/
public function getEstimateFormSchema($modelSetId): array
{
$parameters = $this->modelSetService->getEstimateParameters($modelSetId);
return [
'model_set' => $parameters['category'],
'form_schema' => [
'input_fields' => $parameters['input_fields'],
'calculated_fields' => $parameters['calculated_fields'],
],
'calculation_schema' => $parameters['calculation_schema'],
];
}
/**
* 견적 파라미터 미리보기 계산
*/
public function previewCalculation($modelSetId, array $parameters): array
{
return $this->modelSetService->calculateModelSetBom($modelSetId, $parameters);
}
/**
* 견적 항목 생성 (가격 계산 포함)
*
* @return float 총 견적 금액
*/
protected function createEstimateItems(Estimate $estimate, array $bomItems, ?int $clientId = null): float
{
$totalAmount = 0;
$warnings = [];
foreach ($bomItems as $index => $bomItem) {
$quantity = $bomItem['quantity'] ?? 1;
$unitPrice = 0;
// 가격 조회 (item_id와 item_type이 있는 경우)
if (isset($bomItem['item_id']) && isset($bomItem['item_type'])) {
$priceResult = $this->pricingService->getItemPrice(
$bomItem['item_type'], // 'PRODUCT' or 'MATERIAL'
$bomItem['item_id'],
$clientId,
now()->format('Y-m-d')
);
$unitPrice = $priceResult['price'] ?? 0;
if ($priceResult['warning']) {
$warnings[] = $priceResult['warning'];
}
}
$totalPrice = $unitPrice * $quantity;
$totalAmount += $totalPrice;
EstimateItem::create([
'tenant_id' => $this->tenantId(),
'estimate_id' => $estimate->id,
'sequence' => $index + 1,
'item_name' => $bomItem['name'] ?? '견적 항목 '.($index + 1),
'item_description' => $bomItem['description'] ?? '',
'parameters' => $bomItem['parameters'] ?? [],
'calculated_values' => $bomItem['calculated_values'] ?? [],
'unit_price' => $unitPrice,
'quantity' => $quantity,
'total_price' => $totalPrice,
'bom_components' => $bomItem['components'] ?? [],
'created_by' => $this->apiUserId(),
]);
}
// 가격 경고가 있으면 로그 기록
if (! empty($warnings)) {
\Log::warning('견적 가격 조회 경고', [
'estimate_id' => $estimate->id,
'warnings' => $warnings,
]);
}
return $totalAmount;
}
/**
* 견적 통계 조회
*/
public function stats(): array
{
$tenantId = $this->tenantId();
$stats = Estimate::query()
->where('tenant_id', $tenantId)
->selectRaw("
COUNT(*) as total,
SUM(CASE WHEN status = 'DRAFT' THEN 1 ELSE 0 END) as draft,
SUM(CASE WHEN status = 'SENT' THEN 1 ELSE 0 END) as sent,
SUM(CASE WHEN status = 'APPROVED' THEN 1 ELSE 0 END) as approved,
SUM(CASE WHEN status = 'REJECTED' THEN 1 ELSE 0 END) as rejected,
SUM(CASE WHEN status = 'EXPIRED' THEN 1 ELSE 0 END) as expired,
SUM(total_amount) as total_amount
")
->first();
return [
'total' => (int) $stats->total,
'draft' => (int) $stats->draft,
'sent' => (int) $stats->sent,
'approved' => (int) $stats->approved,
'rejected' => (int) $stats->rejected,
'expired' => (int) $stats->expired,
'total_amount' => (float) ($stats->total_amount ?? 0),
];
}
/**
* 계산 결과 요약
*/
protected function summarizeCalculations(Estimate $estimate): array
{
$summary = [
'total_items' => $estimate->items->count(),
'total_amount' => $estimate->total_amount,
'key_calculations' => [],
];
// 주요 계산 결과 추출
if (! empty($estimate->calculated_results)) {
$results = $estimate->calculated_results;
if (isset($results['W1'], $results['H1'])) {
$summary['key_calculations']['제작사이즈'] = $results['W1'].' × '.$results['H1'].' mm';
}
if (isset($results['weight'])) {
$summary['key_calculations']['중량'] = $results['weight'].' kg';
}
if (isset($results['area'])) {
$summary['key_calculations']['면적'] = $results['area'].' ㎡';
}
if (isset($results['bracket_size'])) {
$summary['key_calculations']['모터브라켓'] = $results['bracket_size'];
}
}
return $summary;
}
}