feat: 견적 시스템 API

- 5130의 71개 하드코딩 컬럼을 동적 카테고리 필드 시스템으로 전환
- 모터 브라켓 계산 등 핵심 비즈니스 로직 FormulaParser에 통합
- 파라미터 기반 동적 견적 폼 시스템 구축
- 견적 상태 워크플로 (DRAFT → SENT → APPROVED/REJECTED/EXPIRED)
- 모델셋 관리 API: 카테고리+제품+BOM 통합 관리
- 견적 관리 API: 생성/수정/복제/상태변경/미리보기 기능

주요 구현 사항:
- EstimateController/EstimateService: 견적 비즈니스 로직
- ModelSetController/ModelSetService: 모델셋 관리 로직
- Estimate/EstimateItem 모델: 견적 데이터 구조
- 동적 견적 필드 마이그레이션: 스크린/철재 제품 구조
- API 라우트 17개 엔드포인트 추가
- 다국어 메시지 지원 (성공/에러 메시지)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-24 17:41:26 +09:00
parent eb42d11f5e
commit 2d9217c9b4
14 changed files with 2021 additions and 0 deletions

View File

@@ -0,0 +1,346 @@
<?php
namespace App\Services\Estimate;
use App\Models\Commons\Category;
use App\Models\Estimate\Estimate;
use App\Models\Estimate\EstimateItem;
use App\Services\ModelSet\ModelSetService;
use App\Services\Service;
use App\Services\Calculation\CalculationEngine;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Pagination\LengthAwarePaginator;
class EstimateService extends Service
{
protected ModelSetService $modelSetService;
protected CalculationEngine $calculationEngine;
public function __construct(
ModelSetService $modelSetService,
CalculationEngine $calculationEngine
) {
parent::__construct();
$this->modelSetService = $modelSetService;
$this->calculationEngine = $calculationEngine;
}
/**
* 견적 목록 조회
*/
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' => $bomCalculation['total_amount'] ?? 0,
'notes' => $data['notes'] ?? null,
'valid_until' => now()->addDays(30), // 기본 30일 유효
'created_by' => $this->apiUserId(),
]);
// 견적 항목 생성 (BOM 기반)
if (!empty($bomCalculation['bom_items'])) {
$this->createEstimateItems($estimate, $bomCalculation['bom_items']);
}
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;
$data['total_amount'] = $bomCalculation['total_amount'] ?? 0;
// 기존 견적 항목 삭제 후 재생성
$estimate->items()->delete();
if (!empty($bomCalculation['bom_items'])) {
$this->createEstimateItems($estimate, $bomCalculation['bom_items']);
}
}
$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);
}
/**
* 견적 항목 생성
*/
protected function createEstimateItems(Estimate $estimate, array $bomItems): void
{
foreach ($bomItems as $index => $bomItem) {
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' => $bomItem['unit_price'] ?? 0,
'quantity' => $bomItem['quantity'] ?? 1,
'bom_components' => $bomItem['components'] ?? [],
'created_by' => $this->apiUserId(),
]);
}
}
/**
* 계산 결과 요약
*/
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;
}
}