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.* 성공 메시지
This commit is contained in:
2025-12-04 22:03:40 +09:00
parent d164bb4c4a
commit 40ca8b8697
18 changed files with 3264 additions and 3 deletions

View File

@@ -0,0 +1,398 @@
<?php
namespace App\Services\Quote;
use App\Services\Service;
/**
* 수식 평가 서비스
*
* 견적 자동산출을 위한 수식 검증 및 평가 엔진
* 지원 함수: SUM, ROUND, CEIL, FLOOR, ABS, MIN, MAX, IF, AND, OR, NOT
*/
class FormulaEvaluatorService extends Service
{
/**
* 지원 함수 목록
*/
public const SUPPORTED_FUNCTIONS = [
'SUM', 'ROUND', 'CEIL', 'FLOOR', 'ABS', 'MIN', 'MAX', 'IF', 'AND', 'OR', 'NOT',
];
private array $variables = [];
private array $errors = [];
/**
* 수식 검증
*/
public function validateFormula(string $formula): array
{
$errors = [];
// 기본 문법 검증
if (empty(trim($formula))) {
return [
'success' => false,
'errors' => [__('error.formula_empty')],
];
}
// 괄호 매칭 검증
if (! $this->validateParentheses($formula)) {
$errors[] = __('error.formula_parentheses_mismatch');
}
// 변수 추출
$variables = $this->extractVariables($formula);
// 지원 함수 검증
$functions = $this->extractFunctions($formula);
foreach ($functions as $func) {
if (! in_array(strtoupper($func), self::SUPPORTED_FUNCTIONS)) {
$errors[] = __('error.formula_unsupported_function', ['function' => $func]);
}
}
return [
'success' => empty($errors),
'errors' => $errors,
'variables' => $variables,
'functions' => $functions,
];
}
/**
* 수식 평가
*
* @param string $formula 수식 문자열
* @param array $variables 변수 배열 [변수명 => 값]
*/
public function evaluate(string $formula, array $variables = []): mixed
{
$this->variables = array_merge($this->variables, $variables);
$this->errors = [];
try {
// 변수 치환
$expression = $this->substituteVariables($formula);
// 함수 처리
$expression = $this->processFunctions($expression);
// 최종 계산
return $this->calculateExpression($expression);
} catch (\Exception $e) {
$this->errors[] = $e->getMessage();
return null;
}
}
/**
* 다중 수식 일괄 평가
*
* @param array $formulas [변수명 => 수식] 배열
* @param array $inputVariables 입력 변수
* @return array [변수명 => 결과값]
*/
public function evaluateMultiple(array $formulas, array $inputVariables = []): array
{
$this->variables = $inputVariables;
$results = [];
foreach ($formulas as $variable => $formula) {
$result = $this->evaluate($formula);
$this->variables[$variable] = $result;
$results[$variable] = $result;
}
return [
'results' => $results,
'errors' => $this->errors,
];
}
/**
* 범위 기반 값 결정
*
* @param float $value 검사할 값
* @param array $ranges 범위 배열 [['min' => 0, 'max' => 100, 'result' => 값], ...]
* @param mixed $default 기본값
*/
public function evaluateRange(float $value, array $ranges, mixed $default = null): mixed
{
foreach ($ranges as $range) {
$min = $range['min'] ?? null;
$max = $range['max'] ?? null;
$inRange = true;
if ($min !== null && $value < $min) {
$inRange = false;
}
if ($max !== null && $value > $max) {
$inRange = false;
}
if ($inRange) {
$result = $range['result'] ?? $default;
// result가 수식인 경우 평가
if (is_string($result) && $this->isFormula($result)) {
return $this->evaluate($result);
}
return $result;
}
}
return $default;
}
/**
* 매핑 기반 값 결정
*
* @param mixed $sourceValue 소스 값
* @param array $mappings 매핑 배열 [['source' => 값, 'result' => 결과], ...]
* @param mixed $default 기본값
*/
public function evaluateMapping(mixed $sourceValue, array $mappings, mixed $default = null): mixed
{
foreach ($mappings as $mapping) {
$source = $mapping['source'] ?? null;
$result = $mapping['result'] ?? $default;
if ($sourceValue == $source) {
// result가 수식인 경우 평가
if (is_string($result) && $this->isFormula($result)) {
return $this->evaluate($result);
}
return $result;
}
}
return $default;
}
/**
* 괄호 매칭 검증
*/
private function validateParentheses(string $formula): bool
{
$count = 0;
foreach (str_split($formula) as $char) {
if ($char === '(') {
$count++;
}
if ($char === ')') {
$count--;
}
if ($count < 0) {
return false;
}
}
return $count === 0;
}
/**
* 수식에서 변수 추출
*/
private function extractVariables(string $formula): array
{
preg_match_all('/\b([A-Z][A-Z0-9_]*)\b/', $formula, $matches);
$variables = array_unique($matches[1] ?? []);
// 함수명 제외
return array_values(array_diff($variables, self::SUPPORTED_FUNCTIONS));
}
/**
* 수식에서 함수 추출
*/
private function extractFunctions(string $formula): array
{
preg_match_all('/\b([A-Za-z_]+)\s*\(/', $formula, $matches);
return array_unique($matches[1] ?? []);
}
/**
* 변수 치환
*/
private function substituteVariables(string $formula): string
{
foreach ($this->variables as $var => $value) {
$formula = preg_replace('/\b'.preg_quote($var, '/').'\b/', (string) $value, $formula);
}
return $formula;
}
/**
* 함수 처리
*/
private function processFunctions(string $expression): string
{
// ROUND(value, decimals)
$expression = preg_replace_callback(
'/ROUND\s*\(\s*([^,]+)\s*,\s*(\d+)\s*\)/i',
fn ($m) => round((float) $this->calculateExpression($m[1]), (int) $m[2]),
$expression
);
// SUM(a, b, c, ...)
$expression = preg_replace_callback(
'/SUM\s*\(([^)]+)\)/i',
fn ($m) => array_sum(array_map('floatval', explode(',', $m[1]))),
$expression
);
// MIN, MAX
$expression = preg_replace_callback(
'/MIN\s*\(([^)]+)\)/i',
fn ($m) => min(array_map('floatval', explode(',', $m[1]))),
$expression
);
$expression = preg_replace_callback(
'/MAX\s*\(([^)]+)\)/i',
fn ($m) => max(array_map('floatval', explode(',', $m[1]))),
$expression
);
// IF(condition, true_val, false_val)
$expression = preg_replace_callback(
'/IF\s*\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^)]+)\s*\)/i',
function ($m) {
$condition = $this->evaluateCondition($m[1]);
return $condition ? $this->calculateExpression($m[2]) : $this->calculateExpression($m[3]);
},
$expression
);
// ABS, CEIL, FLOOR
$expression = preg_replace_callback(
'/ABS\s*\(([^)]+)\)/i',
fn ($m) => abs((float) $this->calculateExpression($m[1])),
$expression
);
$expression = preg_replace_callback(
'/CEIL\s*\(([^)]+)\)/i',
fn ($m) => ceil((float) $this->calculateExpression($m[1])),
$expression
);
$expression = preg_replace_callback(
'/FLOOR\s*\(([^)]+)\)/i',
fn ($m) => floor((float) $this->calculateExpression($m[1])),
$expression
);
return $expression;
}
/**
* 수식 계산 (안전한 평가)
*/
private function calculateExpression(string $expression): float
{
// 안전한 수식 평가 (숫자, 연산자, 괄호만 허용)
$expression = preg_replace('/[^0-9+\-*\/().%\s]/', '', $expression);
if (empty(trim($expression))) {
return 0;
}
try {
// TODO: 프로덕션에서는 symfony/expression-language 등 안전한 라이브러리 사용 권장
return (float) eval("return {$expression};");
} catch (\Throwable $e) {
$this->errors[] = __('error.formula_calculation_error', ['expression' => $expression]);
return 0;
}
}
/**
* 조건식 평가
*/
private function evaluateCondition(string $condition): bool
{
// 비교 연산자 처리
if (preg_match('/(.+)(>=|<=|>|<|==|!=)(.+)/', $condition, $m)) {
$left = (float) $this->calculateExpression(trim($m[1]));
$right = (float) $this->calculateExpression(trim($m[3]));
$op = $m[2];
return match ($op) {
'>=' => $left >= $right,
'<=' => $left <= $right,
'>' => $left > $right,
'<' => $left < $right,
'==' => $left == $right,
'!=' => $left != $right,
default => false,
};
}
return (bool) $this->calculateExpression($condition);
}
/**
* 문자열이 수식인지 확인
*/
private function isFormula(string $value): bool
{
// 연산자나 함수가 포함되어 있으면 수식으로 판단
return preg_match('/[+\-*\/()]|[A-Z]+\s*\(/', $value) === 1;
}
/**
* 에러 목록 반환
*/
public function getErrors(): array
{
return $this->errors;
}
/**
* 현재 변수 상태 반환
*/
public function getVariables(): array
{
return $this->variables;
}
/**
* 변수 설정
*/
public function setVariables(array $variables): self
{
$this->variables = $variables;
return $this;
}
/**
* 변수 추가
*/
public function addVariable(string $name, mixed $value): self
{
$this->variables[$name] = $value;
return $this;
}
/**
* 변수 및 에러 초기화
*/
public function reset(): void
{
$this->variables = [];
$this->errors = [];
}
}

View File

@@ -0,0 +1,538 @@
<?php
namespace App\Services\Quote;
use App\Models\Quote\Quote;
use App\Services\Service;
/**
* 견적 자동산출 서비스
*
* 입력 파라미터(W0, H0 등)를 기반으로 견적 품목과 금액을 자동 계산합니다.
* 제품 카테고리(스크린/철재)별 계산 로직을 지원합니다.
*/
class QuoteCalculationService extends Service
{
public function __construct(
private FormulaEvaluatorService $formulaEvaluator
) {}
/**
* 견적 자동산출 실행
*
* @param array $inputs 입력 파라미터
* @param string|null $productCategory 제품 카테고리
* @return array 산출 결과
*/
public function calculate(array $inputs, ?string $productCategory = null): array
{
$category = $productCategory ?? Quote::CATEGORY_SCREEN;
// 기본 변수 초기화
$this->formulaEvaluator->reset();
// 입력값 검증 및 변수 설정
$validatedInputs = $this->validateInputs($inputs, $category);
$this->formulaEvaluator->setVariables($validatedInputs);
// 카테고리별 산출 로직 실행
$result = match ($category) {
Quote::CATEGORY_SCREEN => $this->calculateScreen($validatedInputs),
Quote::CATEGORY_STEEL => $this->calculateSteel($validatedInputs),
default => $this->calculateScreen($validatedInputs),
};
return [
'inputs' => $validatedInputs,
'outputs' => $result['outputs'],
'items' => $result['items'],
'costs' => $result['costs'],
'errors' => $this->formulaEvaluator->getErrors(),
];
}
/**
* 견적 미리보기 (저장 없이 계산만)
*/
public function preview(array $inputs, ?string $productCategory = null): array
{
return $this->calculate($inputs, $productCategory);
}
/**
* 견적 품목 재계산 (기존 견적 기준)
*/
public function recalculate(Quote $quote): array
{
$inputs = $quote->calculation_inputs ?? [];
$category = $quote->product_category;
return $this->calculate($inputs, $category);
}
/**
* 입력값 검증 및 기본값 설정
*/
private function validateInputs(array $inputs, string $category): array
{
// 공통 입력값
$validated = [
'W0' => (float) ($inputs['W0'] ?? $inputs['open_size_width'] ?? 0),
'H0' => (float) ($inputs['H0'] ?? $inputs['open_size_height'] ?? 0),
'QTY' => (int) ($inputs['QTY'] ?? $inputs['quantity'] ?? 1),
];
// 카테고리별 추가 입력값
if ($category === Quote::CATEGORY_SCREEN) {
$validated = array_merge($validated, [
'INSTALL_TYPE' => $inputs['INSTALL_TYPE'] ?? 'wall', // wall, ceiling, floor
'MOTOR_TYPE' => $inputs['MOTOR_TYPE'] ?? 'standard', // standard, heavy
'CONTROL_TYPE' => $inputs['CONTROL_TYPE'] ?? 'switch', // switch, remote, smart
'CHAIN_SIDE' => $inputs['CHAIN_SIDE'] ?? 'left', // left, right
]);
} elseif ($category === Quote::CATEGORY_STEEL) {
$validated = array_merge($validated, [
'MATERIAL' => $inputs['MATERIAL'] ?? 'ss304', // ss304, ss316, galvanized
'THICKNESS' => (float) ($inputs['THICKNESS'] ?? 1.5),
'FINISH' => $inputs['FINISH'] ?? 'hairline', // hairline, mirror, matte
'WELDING' => $inputs['WELDING'] ?? 'tig', // tig, mig, spot
]);
}
return $validated;
}
/**
* 스크린 제품 산출
*/
private function calculateScreen(array $inputs): array
{
$w = $inputs['W0'];
$h = $inputs['H0'];
$qty = $inputs['QTY'];
// 파생 계산값
$outputs = [];
// W1: 실제 폭 (케이스 마진 포함)
$outputs['W1'] = $this->formulaEvaluator->evaluate('W0 + 100', ['W0' => $w]);
// H1: 실제 높이 (브라켓 마진 포함)
$outputs['H1'] = $this->formulaEvaluator->evaluate('H0 + 150', ['H0' => $h]);
// 면적 (m²)
$outputs['AREA'] = $this->formulaEvaluator->evaluate('(W1 * H1) / 1000000', [
'W1' => $outputs['W1'],
'H1' => $outputs['H1'],
]);
// 무게 (kg) - 대략 계산
$outputs['WEIGHT'] = $this->formulaEvaluator->evaluate('AREA * 5', [
'AREA' => $outputs['AREA'],
]);
// 모터 용량 결정 (면적 기준)
$outputs['MOTOR_CAPACITY'] = $this->formulaEvaluator->evaluateRange($outputs['AREA'], [
['min' => 0, 'max' => 5, 'result' => '50W'],
['min' => 5, 'max' => 10, 'result' => '100W'],
['min' => 10, 'max' => 20, 'result' => '200W'],
['min' => 20, 'max' => null, 'result' => '300W'],
], '100W');
// 품목 생성
$items = $this->generateScreenItems($inputs, $outputs, $qty);
// 비용 계산
$costs = $this->calculateCosts($items);
return [
'outputs' => $outputs,
'items' => $items,
'costs' => $costs,
];
}
/**
* 철재 제품 산출
*/
private function calculateSteel(array $inputs): array
{
$w = $inputs['W0'];
$h = $inputs['H0'];
$qty = $inputs['QTY'];
$thickness = $inputs['THICKNESS'];
// 파생 계산값
$outputs = [];
// 실제 크기 (용접 마진 포함)
$outputs['W1'] = $this->formulaEvaluator->evaluate('W0 + 50', ['W0' => $w]);
$outputs['H1'] = $this->formulaEvaluator->evaluate('H0 + 50', ['H0' => $h]);
// 면적 (m²)
$outputs['AREA'] = $this->formulaEvaluator->evaluate('(W1 * H1) / 1000000', [
'W1' => $outputs['W1'],
'H1' => $outputs['H1'],
]);
// 중량 (kg) - 재질별 밀도 적용
$density = $this->formulaEvaluator->evaluateMapping($inputs['MATERIAL'], [
['source' => 'ss304', 'result' => 7.93],
['source' => 'ss316', 'result' => 8.0],
['source' => 'galvanized', 'result' => 7.85],
], 7.85);
$outputs['WEIGHT'] = $this->formulaEvaluator->evaluate('AREA * THICKNESS * DENSITY', [
'AREA' => $outputs['AREA'],
'THICKNESS' => $thickness / 1000, // mm to m
'DENSITY' => $density * 1000, // kg/m³
]);
// 품목 생성
$items = $this->generateSteelItems($inputs, $outputs, $qty);
// 비용 계산
$costs = $this->calculateCosts($items);
return [
'outputs' => $outputs,
'items' => $items,
'costs' => $costs,
];
}
/**
* 스크린 품목 생성
*/
private function generateScreenItems(array $inputs, array $outputs, int $qty): array
{
$items = [];
// 1. 스크린 원단
$items[] = [
'item_code' => 'SCR-FABRIC-001',
'item_name' => '스크린 원단',
'specification' => sprintf('%.0f x %.0f mm', $outputs['W1'], $outputs['H1']),
'unit' => 'm²',
'base_quantity' => 1,
'calculated_quantity' => $outputs['AREA'] * $qty,
'unit_price' => 25000,
'total_price' => $outputs['AREA'] * $qty * 25000,
'formula' => 'AREA * QTY',
'formula_category' => 'material',
];
// 2. 케이스
$items[] = [
'item_code' => 'SCR-CASE-001',
'item_name' => '알루미늄 케이스',
'specification' => sprintf('%.0f mm', $outputs['W1']),
'unit' => 'EA',
'base_quantity' => 1,
'calculated_quantity' => $qty,
'unit_price' => 85000,
'total_price' => $qty * 85000,
'formula' => 'QTY',
'formula_category' => 'material',
];
// 3. 모터
$motorPrice = $this->getMotorPrice($outputs['MOTOR_CAPACITY']);
$items[] = [
'item_code' => 'SCR-MOTOR-001',
'item_name' => '튜블러 모터',
'specification' => $outputs['MOTOR_CAPACITY'],
'unit' => 'EA',
'base_quantity' => 1,
'calculated_quantity' => $qty,
'unit_price' => $motorPrice,
'total_price' => $qty * $motorPrice,
'formula' => 'QTY',
'formula_category' => 'material',
];
// 4. 브라켓
$items[] = [
'item_code' => 'SCR-BRACKET-001',
'item_name' => '설치 브라켓',
'specification' => $inputs['INSTALL_TYPE'],
'unit' => 'SET',
'base_quantity' => 2,
'calculated_quantity' => 2 * $qty,
'unit_price' => 15000,
'total_price' => 2 * $qty * 15000,
'formula' => '2 * QTY',
'formula_category' => 'material',
];
// 5. 인건비
$laborHours = $this->formulaEvaluator->evaluateRange($outputs['AREA'], [
['min' => 0, 'max' => 5, 'result' => 2],
['min' => 5, 'max' => 10, 'result' => 3],
['min' => 10, 'max' => null, 'result' => 4],
], 2);
$items[] = [
'item_code' => 'LAB-INSTALL-001',
'item_name' => '설치 인건비',
'specification' => sprintf('%.1f시간', $laborHours * $qty),
'unit' => 'HR',
'base_quantity' => $laborHours,
'calculated_quantity' => $laborHours * $qty,
'unit_price' => 50000,
'total_price' => $laborHours * $qty * 50000,
'formula' => 'LABOR_HOURS * QTY',
'formula_category' => 'labor',
];
return $items;
}
/**
* 철재 품목 생성
*/
private function generateSteelItems(array $inputs, array $outputs, int $qty): array
{
$items = [];
// 재질별 단가
$materialPrice = $this->formulaEvaluator->evaluateMapping($inputs['MATERIAL'], [
['source' => 'ss304', 'result' => 4500],
['source' => 'ss316', 'result' => 6500],
['source' => 'galvanized', 'result' => 3000],
], 4500);
// 1. 철판
$items[] = [
'item_code' => 'STL-PLATE-001',
'item_name' => '철판 ('.$inputs['MATERIAL'].')',
'specification' => sprintf('%.0f x %.0f x %.1f mm', $outputs['W1'], $outputs['H1'], $inputs['THICKNESS']),
'unit' => 'kg',
'base_quantity' => $outputs['WEIGHT'],
'calculated_quantity' => $outputs['WEIGHT'] * $qty,
'unit_price' => $materialPrice,
'total_price' => $outputs['WEIGHT'] * $qty * $materialPrice,
'formula' => 'WEIGHT * QTY * MATERIAL_PRICE',
'formula_category' => 'material',
];
// 2. 용접
$weldLength = ($outputs['W1'] + $outputs['H1']) * 2 / 1000; // m
$items[] = [
'item_code' => 'STL-WELD-001',
'item_name' => '용접 ('.$inputs['WELDING'].')',
'specification' => sprintf('%.2f m', $weldLength * $qty),
'unit' => 'm',
'base_quantity' => $weldLength,
'calculated_quantity' => $weldLength * $qty,
'unit_price' => 15000,
'total_price' => $weldLength * $qty * 15000,
'formula' => 'WELD_LENGTH * QTY',
'formula_category' => 'labor',
];
// 3. 표면처리
$finishPrice = $this->formulaEvaluator->evaluateMapping($inputs['FINISH'], [
['source' => 'hairline', 'result' => 8000],
['source' => 'mirror', 'result' => 15000],
['source' => 'matte', 'result' => 5000],
], 8000);
$items[] = [
'item_code' => 'STL-FINISH-001',
'item_name' => '표면처리 ('.$inputs['FINISH'].')',
'specification' => sprintf('%.2f m²', $outputs['AREA'] * $qty),
'unit' => 'm²',
'base_quantity' => $outputs['AREA'],
'calculated_quantity' => $outputs['AREA'] * $qty,
'unit_price' => $finishPrice,
'total_price' => $outputs['AREA'] * $qty * $finishPrice,
'formula' => 'AREA * QTY',
'formula_category' => 'labor',
];
// 4. 가공비
$items[] = [
'item_code' => 'STL-PROCESS-001',
'item_name' => '가공비',
'specification' => '절단, 벤딩, 천공',
'unit' => 'EA',
'base_quantity' => 1,
'calculated_quantity' => $qty,
'unit_price' => 50000,
'total_price' => $qty * 50000,
'formula' => 'QTY',
'formula_category' => 'labor',
];
return $items;
}
/**
* 비용 계산
*/
private function calculateCosts(array $items): array
{
$materialCost = 0;
$laborCost = 0;
$installCost = 0;
foreach ($items as $item) {
$category = $item['formula_category'] ?? 'material';
$price = (float) ($item['total_price'] ?? 0);
match ($category) {
'material' => $materialCost += $price,
'labor' => $laborCost += $price,
'install' => $installCost += $price,
default => $materialCost += $price,
};
}
$subtotal = $materialCost + $laborCost + $installCost;
return [
'material_cost' => round($materialCost, 2),
'labor_cost' => round($laborCost, 2),
'install_cost' => round($installCost, 2),
'subtotal' => round($subtotal, 2),
];
}
/**
* 모터 단가 조회
*/
private function getMotorPrice(string $capacity): int
{
return match ($capacity) {
'50W' => 120000,
'100W' => 150000,
'200W' => 200000,
'300W' => 280000,
default => 150000,
};
}
/**
* 입력 스키마 반환 (프론트엔드용)
*/
public function getInputSchema(?string $productCategory = null): array
{
$category = $productCategory ?? Quote::CATEGORY_SCREEN;
$commonSchema = [
'W0' => [
'label' => '개구부 폭',
'type' => 'number',
'unit' => 'mm',
'required' => true,
'min' => 100,
'max' => 10000,
],
'H0' => [
'label' => '개구부 높이',
'type' => 'number',
'unit' => 'mm',
'required' => true,
'min' => 100,
'max' => 10000,
],
'QTY' => [
'label' => '수량',
'type' => 'integer',
'required' => true,
'min' => 1,
'default' => 1,
],
];
if ($category === Quote::CATEGORY_SCREEN) {
return array_merge($commonSchema, [
'INSTALL_TYPE' => [
'label' => '설치 유형',
'type' => 'select',
'options' => [
['value' => 'wall', 'label' => '벽면'],
['value' => 'ceiling', 'label' => '천장'],
['value' => 'floor', 'label' => '바닥'],
],
'default' => 'wall',
],
'MOTOR_TYPE' => [
'label' => '모터 유형',
'type' => 'select',
'options' => [
['value' => 'standard', 'label' => '일반형'],
['value' => 'heavy', 'label' => '고하중형'],
],
'default' => 'standard',
],
'CONTROL_TYPE' => [
'label' => '제어 방식',
'type' => 'select',
'options' => [
['value' => 'switch', 'label' => '스위치'],
['value' => 'remote', 'label' => '리모컨'],
['value' => 'smart', 'label' => '스마트'],
],
'default' => 'switch',
],
'CHAIN_SIDE' => [
'label' => '체인 위치',
'type' => 'select',
'options' => [
['value' => 'left', 'label' => '좌측'],
['value' => 'right', 'label' => '우측'],
],
'default' => 'left',
],
]);
}
if ($category === Quote::CATEGORY_STEEL) {
return array_merge($commonSchema, [
'MATERIAL' => [
'label' => '재질',
'type' => 'select',
'options' => [
['value' => 'ss304', 'label' => 'SUS304'],
['value' => 'ss316', 'label' => 'SUS316'],
['value' => 'galvanized', 'label' => '아연도금'],
],
'default' => 'ss304',
],
'THICKNESS' => [
'label' => '두께',
'type' => 'number',
'unit' => 'mm',
'min' => 0.5,
'max' => 10,
'step' => 0.1,
'default' => 1.5,
],
'FINISH' => [
'label' => '표면처리',
'type' => 'select',
'options' => [
['value' => 'hairline', 'label' => '헤어라인'],
['value' => 'mirror', 'label' => '미러'],
['value' => 'matte', 'label' => '무광'],
],
'default' => 'hairline',
],
'WELDING' => [
'label' => '용접 방식',
'type' => 'select',
'options' => [
['value' => 'tig', 'label' => 'TIG'],
['value' => 'mig', 'label' => 'MIG'],
['value' => 'spot', 'label' => '스팟'],
],
'default' => 'tig',
],
]);
}
return $commonSchema;
}
}

View File

@@ -0,0 +1,388 @@
<?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);
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Services\Quote;
use App\Models\Quote\Quote;
use App\Services\Service;
class QuoteNumberService extends Service
{
/**
* 견적번호 생성
*
* 형식: KD-{PREFIX}-{YYMMDD}-{SEQ}
* 예시: KD-SC-251204-01 (스크린), KD-ST-251204-01 (철재)
*/
public function generate(?string $productCategory = null): string
{
$tenantId = $this->tenantId();
// 제품 카테고리에 따른 접두어
$prefix = match ($productCategory) {
Quote::CATEGORY_SCREEN => 'SC',
Quote::CATEGORY_STEEL => 'ST',
default => 'SC',
};
// 날짜 부분 (YYMMDD)
$dateStr = now()->format('ymd');
// 오늘 날짜 기준으로 마지막 견적번호 조회
$pattern = "KD-{$prefix}-{$dateStr}-%";
$lastQuote = Quote::withTrashed()
->where('tenant_id', $tenantId)
->where('quote_number', 'like', $pattern)
->orderBy('quote_number', 'desc')
->first();
// 순번 계산
$sequence = 1;
if ($lastQuote) {
// KD-SC-251204-01 에서 마지막 숫자 추출
$parts = explode('-', $lastQuote->quote_number);
if (count($parts) >= 4) {
$lastSeq = (int) end($parts);
$sequence = $lastSeq + 1;
}
}
// 2자리 순번 (01, 02, ...)
$seqStr = str_pad((string) $sequence, 2, '0', STR_PAD_LEFT);
return "KD-{$prefix}-{$dateStr}-{$seqStr}";
}
/**
* 견적번호 미리보기
*/
public function preview(?string $productCategory = null): array
{
$quoteNumber = $this->generate($productCategory);
return [
'quote_number' => $quoteNumber,
'product_category' => $productCategory ?? Quote::CATEGORY_SCREEN,
'generated_at' => now()->toDateTimeString(),
];
}
/**
* 견적번호 형식 검증
*/
public function validate(string $quoteNumber): bool
{
// 형식: KD-XX-YYMMDD-NN
return (bool) preg_match('/^KD-[A-Z]{2}-\d{6}-\d{2,}$/', $quoteNumber);
}
/**
* 견적번호 파싱
*/
public function parse(string $quoteNumber): ?array
{
if (! $this->validate($quoteNumber)) {
return null;
}
$parts = explode('-', $quoteNumber);
return [
'prefix' => $parts[0], // KD
'category_code' => $parts[1], // SC or ST
'date' => $parts[2], // YYMMDD
'sequence' => (int) $parts[3], // 순번
];
}
/**
* 견적번호 중복 체크
*/
public function isUnique(string $quoteNumber, ?int $excludeId = null): bool
{
$tenantId = $this->tenantId();
$query = Quote::withTrashed()
->where('tenant_id', $tenantId)
->where('quote_number', $quoteNumber);
if ($excludeId) {
$query->where('id', '!=', $excludeId);
}
return ! $query->exists();
}
}

View File

@@ -0,0 +1,448 @@
<?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,
]);
}
}