feat:경동기업 견적/수주 전환 로직 개선

- KyungdongFormulaHandler: 수식 계산 로직 리팩토링 및 확장
- OrderService: 수주 전환 시 BOM 품목 매핑 로직 추가
- QuoteService: 견적 상태 처리 개선
- FormulaEvaluatorService: 디버그 로깅 추가
- Quote 모델: 캐스팅 타입 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 21:58:49 +09:00
parent 9f2b1cf44a
commit f640a837e9
7 changed files with 386 additions and 85 deletions

View File

@@ -31,6 +31,7 @@ public function index(Request $request)
'group_id' => $request->input('group_id'),
'active' => $request->input('is_active') ?? $request->input('active'),
'has_bom' => $request->input('has_bom'),
'exclude_process_id' => $request->input('exclude_process_id'),
];
return $this->service->index($params);

View File

@@ -331,10 +331,12 @@ public function scopeSearch($query, ?string $keyword)
/**
* 수정 가능 여부 확인
* - 모든 상태에서 수정 가능 (finalized, converted 포함)
* - 수주 전환된 견적 수정 시 연결된 수주도 함께 동기화됨
*/
public function isEditable(): bool
{
return ! in_array($this->status, [self::STATUS_FINALIZED, self::STATUS_CONVERTED]);
return true;
}
/**

View File

@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\Orders\Order;
use App\Models\Orders\OrderHistory;
use App\Models\Production\WorkOrder;
use App\Models\Quote\Quote;
use App\Models\Tenants\Sale;
@@ -470,19 +471,45 @@ public function createFromQuote(int $quoteId, array $data = [])
$order->save();
// calculation_inputs에서 제품 정보 추출 (floor, code)
// 단일 제품인 경우 모든 BOM 품목에 동일한 floor_code/symbol_code 적용
$calculationInputs = $quote->calculation_inputs ?? [];
$productItems = $calculationInputs['items'] ?? [];
// 견적 품목을 수주 품목으로 변환
foreach ($quote->items as $index => $quoteItem) {
// floor_code/symbol_code 추출:
// 1순위: quoteItem->note에서 파싱 (형식: "4F DS-01" → floor=4F, symbol=DS-01)
// 2순위: NULL
// 1순위: calculation_inputs.items[].floor, code (제품 정보)
// 2순위: quoteItem->note에서 파싱 (형식: "4F DS-01" → floor=4F, symbol=DS-01)
// 3순위: NULL
$floorCode = null;
$symbolCode = null;
$note = trim($quoteItem->note ?? '');
if ($note !== '') {
$parts = preg_split('/\s+/', $note, 2);
$floorCode = $parts[0] ?? null;
$symbolCode = $parts[1] ?? null;
// formula_source에서 제품 인덱스 추출 시도 (예: "product_0" → 0)
$productIndex = 0;
$formulaSource = $quoteItem->formula_source ?? '';
if (preg_match('/product_(\d+)/', $formulaSource, $matches)) {
$productIndex = (int) $matches[1];
}
// calculation_inputs에서 floor/code 가져오기
if (isset($productItems[$productIndex])) {
$floorCode = $productItems[$productIndex]['floor'] ?? null;
$symbolCode = $productItems[$productIndex]['code'] ?? null;
} elseif (count($productItems) === 1) {
// 단일 제품인 경우 첫 번째 제품 사용
$floorCode = $productItems[0]['floor'] ?? null;
$symbolCode = $productItems[0]['code'] ?? null;
}
// calculation_inputs에서 못 찾은 경우 note에서 파싱 시도
if (empty($floorCode) && empty($symbolCode)) {
$note = trim($quoteItem->note ?? '');
if ($note !== '') {
$parts = preg_split('/\s+/', $note, 2);
$floorCode = $parts[0] ?? null;
$symbolCode = $parts[1] ?? null;
}
}
$order->items()->create([
@@ -520,6 +547,149 @@ public function createFromQuote(int $quoteId, array $data = [])
});
}
/**
* 견적 변경사항을 수주에 동기화
*
* 견적이 수정되면 연결된 수주도 함께 업데이트하고 히스토리를 생성합니다.
*
* @param Quote $quote 수정된 견적
* @param int $revision 견적 수정 차수
* @return Order|null 업데이트된 수주 또는 null (연결된 수주가 없는 경우)
*/
public function syncFromQuote(Quote $quote, int $revision): ?Order
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 연결된 수주 확인
$order = Order::where('tenant_id', $tenantId)
->where('quote_id', $quote->id)
->first();
if (! $order) {
return null;
}
// 생산 진행 이상의 상태면 동기화 불가 (DRAFT, CONFIRMED, IN_PROGRESS만 허용)
$allowedStatuses = [
Order::STATUS_DRAFT,
Order::STATUS_CONFIRMED,
Order::STATUS_IN_PROGRESS,
];
if (! in_array($order->status_code, $allowedStatuses)) {
throw new BadRequestHttpException(__('error.order.cannot_sync_after_production'));
}
return DB::transaction(function () use ($order, $quote, $tenantId, $userId, $revision) {
// 변경 전 데이터 스냅샷 (히스토리용)
$beforeData = [
'site_name' => $order->site_name,
'client_name' => $order->client_name,
'total_amount' => $order->total_amount,
'items_count' => $order->items()->count(),
];
// 수주 기본 정보 업데이트
$order->update([
'site_name' => $quote->site_name,
'client_id' => $quote->client_id,
'client_name' => $quote->client_name,
'discount_rate' => $quote->discount_rate ?? 0,
'discount_amount' => $quote->discount_amount ?? 0,
'total_amount' => $quote->total_amount,
'updated_by' => $userId,
]);
// 기존 품목 삭제 후 새로 생성
$order->items()->delete();
// calculation_inputs에서 제품 정보 추출
$calculationInputs = $quote->calculation_inputs ?? [];
$productItems = $calculationInputs['items'] ?? [];
// 견적 품목을 수주 품목으로 변환
foreach ($quote->items as $index => $quoteItem) {
$floorCode = null;
$symbolCode = null;
// formula_source에서 제품 인덱스 추출
$productIndex = 0;
$formulaSource = $quoteItem->formula_source ?? '';
if (preg_match('/product_(\d+)/', $formulaSource, $matches)) {
$productIndex = (int) $matches[1];
}
// calculation_inputs에서 floor/code 가져오기
if (isset($productItems[$productIndex])) {
$floorCode = $productItems[$productIndex]['floor'] ?? null;
$symbolCode = $productItems[$productIndex]['code'] ?? null;
} elseif (count($productItems) === 1) {
$floorCode = $productItems[0]['floor'] ?? null;
$symbolCode = $productItems[0]['code'] ?? null;
}
// note에서 파싱 시도
if (empty($floorCode) && empty($symbolCode)) {
$note = trim($quoteItem->note ?? '');
if ($note !== '') {
$parts = preg_split('/\s+/', $note, 2);
$floorCode = $parts[0] ?? null;
$symbolCode = $parts[1] ?? null;
}
}
$order->items()->create([
'tenant_id' => $tenantId,
'serial_no' => $index + 1,
'item_id' => $quoteItem->item_id,
'item_code' => $quoteItem->item_code,
'item_name' => $quoteItem->item_name,
'specification' => $quoteItem->specification,
'floor_code' => $floorCode,
'symbol_code' => $symbolCode,
'quantity' => $quoteItem->calculated_quantity,
'unit' => $quoteItem->unit,
'unit_price' => $quoteItem->unit_price,
'supply_amount' => $quoteItem->total_price,
'tax_amount' => round($quoteItem->total_price * 0.1, 2),
'total_amount' => round($quoteItem->total_price * 1.1, 2),
'note' => $quoteItem->formula_category,
'sort_order' => $index,
]);
}
// 합계 재계산
$order->refresh();
$order->recalculateTotals()->save();
// 변경 후 데이터 스냅샷
$afterData = [
'site_name' => $order->site_name,
'client_name' => $order->client_name,
'total_amount' => $order->total_amount,
'items_count' => $order->items()->count(),
];
// 히스토리 생성
OrderHistory::create([
'tenant_id' => $tenantId,
'order_id' => $order->id,
'history_type' => 'quote_updated',
'content' => json_encode([
'message' => "견적 {$revision}차 수정으로 수주 정보가 업데이트되었습니다.",
'quote_id' => $quote->id,
'quote_number' => $quote->quote_number,
'revision' => $revision,
'before' => $beforeData,
'after' => $afterData,
], JSON_UNESCAPED_UNICODE),
'created_by' => $userId,
]);
return $order->load(['client:id,name', 'items', 'quote:id,quote_number']);
});
}
/**
* 생산지시 생성 (공정별 작업지시 다중 생성)
*/

View File

@@ -1770,6 +1770,7 @@ private function calculateKyungdongBom(
];
$calculatedItems[] = [
'item_id' => $item['item_id'] ?? null,
'item_code' => $item['item_code'] ?? '',
'item_name' => $item['item_name'],
'item_category' => $item['category'],

View File

@@ -21,6 +21,105 @@ public function __construct(?EstimatePriceService $priceService = null)
$this->priceService = $priceService ?? new EstimatePriceService(self::TENANT_ID);
}
// =========================================================================
// 아이템 매핑 헬퍼 메서드 및 상수
// =========================================================================
/**
* 고정 매핑 아이템 코드 → ID 테이블
* items master에서 조회한 고정 값들
*/
private const FIXED_ITEM_MAPPINGS = [
// 연기차단재
'EST-SMOKE-케이스용' => 14912,
'EST-SMOKE-레일용' => 14911,
// 하장바
'00035' => 14158, // SUS
'00036' => 14159, // EGI
// 보강평철
'BD-보강평철-50' => 14790,
// 무게평철12T (평철12T와 동일)
'00021' => 14147,
// 환봉 (파이별)
'90201' => 14407, // 30파이
'90202' => 14408, // 35파이
'90203' => 14409, // 45파이
'90204' => 14410, // 50파이
// 조인트바
'800361' => 14733,
// 검사비
'EST-INSPECTION' => 14913,
// 제어기
'EST-CTRL-노출형' => 14861,
'EST-CTRL-매립형' => 14862,
'EST-CTRL-뒷박스' => 14863,
// 파이프
'EST-PIPE-1.4-3000' => 14900,
'EST-PIPE-1.4-6000' => 14901,
// 모터받침 앵글
'EST-ANGLE-BRACKET-스크린용' => 14907,
'EST-ANGLE-BRACKET-철제300K' => 14908,
'EST-ANGLE-BRACKET-철제400K' => 14909,
'EST-ANGLE-BRACKET-철제800K' => 14910,
];
/**
* items master에서 코드로 아이템 ID 조회 (캐싱 적용)
*
* @param string $code 아이템 코드
* @return int|null 아이템 ID (없으면 null)
*/
private function lookupItemId(string $code): ?int
{
// 1. 고정 매핑 먼저 확인
if (isset(self::FIXED_ITEM_MAPPINGS[$code])) {
return self::FIXED_ITEM_MAPPINGS[$code];
}
// 2. DB에서 동적 조회 (BD-*, EST-* 패턴)
static $cache = [];
if (isset($cache[$code])) {
return $cache[$code];
}
$item = \App\Models\Items\Item::where('tenant_id', self::TENANT_ID)
->where('code', $code)
->first(['id']);
$cache[$code] = $item?->id;
return $cache[$code];
}
/**
* 아이템 배열에 item_code/item_id 매핑 추가
*
* @param array $item 아이템 배열
* @param string $code 아이템 코드
* @return array 매핑이 추가된 아이템 배열
*/
private function withItemMapping(array $item, string $code): array
{
return array_merge($item, [
'item_code' => $code,
'item_id' => $this->lookupItemId($code),
]);
}
/**
* 모터 용량에 따른 기본 전압 결정
* 800K 이상은 380V, 그 외는 220V
*
* @param string $motorCapacity 모터 용량 (예: '300K', '800K')
* @return string 전압 (220V 또는 380V)
*/
private function getMotorVoltage(string $motorCapacity): string
{
$capacity = (int) str_replace(['K', '(S)'], '', $motorCapacity);
return $capacity >= 800 ? '380V' : '220V';
}
// =========================================================================
// 모터 용량 계산
// =========================================================================
@@ -366,15 +465,6 @@ public function getMainAnglePrice(string $angleType, string $size): float
// 절곡품 계산 (10종)
// =========================================================================
/**
* 절곡품 항목 계산 (10종)
*
* 케이스, 케이스용 연기차단재, 케이스 마구리, 가이드레일,
* 레일용 연기차단재, 하장바, L바, 보강평철, 무게평철12T, 환봉
*
* @param array $params 입력 파라미터
* @return array 절곡품 항목 배열
*/
public function calculateSteelItems(array $params): array
{
$items = [];
@@ -421,7 +511,8 @@ public function calculateSteelItems(array $params): array
if ($casePrice > 0 && $caseLength > 0) {
// 5130: round($shutter_price * $total_length * 1000) * $su → 단건 반올림 후 × QTY
$perUnitPrice = round(($casePrice / 1000) * $caseLength);
$items[] = [
$itemCode = "BD-케이스-{$caseSpec}";
$items[] = $this->withItemMapping([
'category' => 'steel',
'item_name' => '케이스',
'specification' => "{$caseSpec} {$caseLength}mm",
@@ -429,7 +520,7 @@ public function calculateSteelItems(array $params): array
'quantity' => $caseLength / 1000 * $quantity,
'unit_price' => $casePrice,
'total_price' => $perUnitPrice * $quantity,
];
], $itemCode);
}
// 2. 케이스용 연기차단재 - 5130: round(단가 × 길이m) × QTY
@@ -437,7 +528,7 @@ public function calculateSteelItems(array $params): array
if ($caseSmokePrice > 0 && $caseLength > 0) {
$lengthM = $caseLength / 1000;
$perUnitSmoke = round($caseSmokePrice * $lengthM);
$items[] = [
$items[] = $this->withItemMapping([
'category' => 'steel',
'item_name' => '케이스용 연기차단재',
'specification' => "{$lengthM}m",
@@ -445,7 +536,7 @@ public function calculateSteelItems(array $params): array
'quantity' => $lengthM * $quantity,
'unit_price' => $caseSmokePrice,
'total_price' => $perUnitSmoke * $quantity,
];
], 'EST-SMOKE-케이스용');
}
// 3. 케이스 마구리 - 5130: round(단가 × QTY)
@@ -453,7 +544,8 @@ public function calculateSteelItems(array $params): array
$caseCapPrice = $this->priceService->getCaseCapPrice($caseCapSpec);
if ($caseCapPrice > 0) {
$capQty = $quantity;
$items[] = [
$itemCode = "BD-마구리-{$caseCapSpec}";
$items[] = $this->withItemMapping([
'category' => 'steel',
'item_name' => '케이스 마구리',
'specification' => $caseCapSpec,
@@ -461,7 +553,7 @@ public function calculateSteelItems(array $params): array
'quantity' => $capQty,
'unit_price' => $caseCapPrice,
'total_price' => round($caseCapPrice * $capQty),
];
], $itemCode);
}
// 4. 가이드레일 (단가 × 길이m × 수량) - 타입별 처리
@@ -474,7 +566,7 @@ public function calculateSteelItems(array $params): array
$railSmokeMultiplier = ($productType === 'slat') ? 1 : 2;
$railSmokeQty = $railSmokeMultiplier * $quantity;
$perUnitRailSmoke = round($railSmokePrice * $guideLength);
$items[] = [
$items[] = $this->withItemMapping([
'category' => 'steel',
'item_name' => '레일용 연기차단재',
'specification' => ($railSmokeMultiplier > 1) ? "{$guideLength}m × 2" : "{$guideLength}m",
@@ -482,13 +574,15 @@ public function calculateSteelItems(array $params): array
'quantity' => $guideLength * $railSmokeQty,
'unit_price' => $railSmokePrice,
'total_price' => $perUnitRailSmoke * $railSmokeQty,
];
], 'EST-SMOKE-레일용');
}
// 6. 하장바 (단가 × 길이m × 수량)
$bottomBarPrice = $this->priceService->getBottomBarPrice($modelName, $finishingType);
if ($bottomBarPrice > 0 && $bottomBarLength > 0) {
$items[] = [
// 하장바 코드: SUS→00035, EGI→00036
$bottomBarCode = ($finishingType === 'EGI') ? '00036' : '00035';
$items[] = $this->withItemMapping([
'category' => 'steel',
'item_name' => '하장바',
'specification' => "{$modelName} {$finishingType} {$bottomBarLength}m",
@@ -496,13 +590,17 @@ public function calculateSteelItems(array $params): array
'quantity' => $bottomBarLength * $quantity,
'unit_price' => $bottomBarPrice,
'total_price' => round($bottomBarPrice * $bottomBarLength * $quantity),
];
], $bottomBarCode);
}
// 7. L바 (단가 × 길이m × 수량) - 스크린 전용, 슬랫 미사용
$lbarPrice = ($productType !== 'slat') ? $this->priceService->getLBarPrice($modelName) : 0;
if ($lbarPrice > 0 && $lbarLength > 0) {
$items[] = [
// L바 코드: BD-L-BAR-{모델}-{규격} (예: BD-L-BAR-KSS01-17*60)
// L바 규격은 모델별로 다르지만 대부분 17*60 또는 17*100
$lbarSpec = (str_contains($modelName, 'KDSS') || str_contains($modelName, 'KQT')) ? '17*100' : '17*60';
$itemCode = "BD-L-BAR-{$modelName}-{$lbarSpec}";
$items[] = $this->withItemMapping([
'category' => 'steel',
'item_name' => 'L바',
'specification' => "{$modelName} {$lbarLength}m",
@@ -510,13 +608,13 @@ public function calculateSteelItems(array $params): array
'quantity' => $lbarLength * $quantity,
'unit_price' => $lbarPrice,
'total_price' => round($lbarPrice * $lbarLength * $quantity),
];
], $itemCode);
}
// 8. 보강평철 (단가 × 길이m × 수량) - 스크린 전용, 슬랫 미사용
$flatBarPrice = ($productType !== 'slat') ? $this->priceService->getFlatBarPrice() : 0;
if ($flatBarPrice > 0 && $flatBarLength > 0) {
$items[] = [
$items[] = $this->withItemMapping([
'category' => 'steel',
'item_name' => '보강평철',
'specification' => "{$flatBarLength}m",
@@ -524,13 +622,13 @@ public function calculateSteelItems(array $params): array
'quantity' => $flatBarLength * $quantity,
'unit_price' => $flatBarPrice,
'total_price' => round($flatBarPrice * $flatBarLength * $quantity),
];
], 'BD-보강평철-50');
}
// 9. 무게평철12T (고정 12,000원 × 수량)
if ($weightPlateQty > 0) {
$weightPlatePrice = 12000;
$items[] = [
$items[] = $this->withItemMapping([
'category' => 'steel',
'item_name' => '무게평철12T',
'specification' => '12T',
@@ -538,21 +636,29 @@ public function calculateSteelItems(array $params): array
'quantity' => $weightPlateQty * $quantity,
'unit_price' => $weightPlatePrice,
'total_price' => $weightPlatePrice * $weightPlateQty * $quantity,
];
], '00021');
}
// 10. 환봉 (고정 2,000원 × 수량) - 스크린 전용, 슬랫 미사용
if ($roundBarQty > 0 && $productType !== 'slat') {
$roundBarPrice = 2000;
$items[] = [
// 환봉 코드: 파이 규격에 따라 분기 (기본 30파이)
$roundBarPhi = (int) ($params['round_bar_phi'] ?? 30);
$roundBarCode = match ($roundBarPhi) {
35 => '90202',
45 => '90203',
50 => '90204',
default => '90201', // 30파이
};
$items[] = $this->withItemMapping([
'category' => 'steel',
'item_name' => '환봉',
'specification' => '',
'specification' => "{$roundBarPhi}파이",
'unit' => 'EA',
'quantity' => $roundBarQty,
'unit_price' => $roundBarPrice,
'total_price' => $roundBarPrice * $roundBarQty,
];
], $roundBarCode);
}
return $items;
@@ -611,7 +717,8 @@ private function calculateGuideRails(
if ($price > 0) {
$setPrice = $price * 2; // 5130: 2개 세트 가격
$perSetTotal = round($setPrice * $guideLength);
$items[] = [
$itemCode = "BD-가이드레일-{$modelName}-{$finishingType}-{$wallSpec}";
$items[] = $this->withItemMapping([
'category' => 'steel',
'item_name' => '가이드레일',
'specification' => "{$modelName} {$finishingType} {$wallSpec} {$guideLength}m × 2",
@@ -619,7 +726,7 @@ private function calculateGuideRails(
'quantity' => $guideLength * 2 * $quantity,
'unit_price' => $price,
'total_price' => $perSetTotal * $quantity,
];
], $itemCode);
}
break;
@@ -628,7 +735,8 @@ private function calculateGuideRails(
if ($price > 0) {
$setPrice = $price * 2; // 5130: 2개 세트 가격
$perSetTotal = round($setPrice * $guideLength);
$items[] = [
$itemCode = "BD-가이드레일-{$modelName}-{$finishingType}-{$sideSpec}";
$items[] = $this->withItemMapping([
'category' => 'steel',
'item_name' => '가이드레일',
'specification' => "{$modelName} {$finishingType} {$sideSpec} {$guideLength}m × 2",
@@ -636,7 +744,7 @@ private function calculateGuideRails(
'quantity' => $guideLength * 2 * $quantity,
'unit_price' => $price,
'total_price' => $perSetTotal * $quantity,
];
], $itemCode);
}
break;
@@ -649,7 +757,9 @@ private function calculateGuideRails(
if ($setPrice > 0) {
$perSetTotal = round($setPrice * $guideLength);
$spec = "{$modelName} {$finishingType} {$wallSpec}/{$sideSpec} {$guideLength}m";
$items[] = [
// 혼합형은 벽면형 코드 사용 (주 가이드레일)
$itemCode = "BD-가이드레일-{$modelName}-{$finishingType}-{$wallSpec}";
$items[] = $this->withItemMapping([
'category' => 'steel',
'item_name' => '가이드레일',
'specification' => $spec,
@@ -657,7 +767,7 @@ private function calculateGuideRails(
'quantity' => $guideLength * 2 * $quantity,
'unit_price' => $setPrice,
'total_price' => $perSetTotal * $quantity,
];
], $itemCode);
}
break;
}
@@ -685,12 +795,6 @@ private function normalizeGuideType(string $type): string
// 부자재 계산 (3종)
// =========================================================================
/**
* 부자재 항목 계산
*
* @param array $params 입력 파라미터
* @return array 부자재 항목 배열
*/
public function calculatePartItems(array $params): array
{
$items = [];
@@ -708,7 +812,8 @@ public function calculatePartItems(array $params): array
$shaftLength = $this->mapShaftToFixedProduct($shaftSize, $shaftLengthMm);
$shaftPrice = $shaftLength > 0 ? $this->getShaftPrice($shaftSize, $shaftLength) : 0;
if ($shaftPrice > 0) {
$items[] = [
$itemCode = "EST-SHAFT-{$shaftSize}-{$shaftLength}";
$items[] = $this->withItemMapping([
'category' => 'parts',
'item_name' => "감기샤프트 {$shaftSize}인치",
'specification' => "{$shaftLength}m",
@@ -716,7 +821,7 @@ public function calculatePartItems(array $params): array
'quantity' => $quantity,
'unit_price' => $shaftPrice,
'total_price' => $shaftPrice * $quantity,
];
], $itemCode);
}
// 2. 각파이프 (5130: col67 = col37 + 3000 × col66, col68/col69 자동계산)
@@ -758,7 +863,7 @@ public function calculatePartItems(array $params): array
if ($pipe3000Qty > 0) {
$pipe3000Price = $this->getPipePrice($pipeThickness, 3000);
if ($pipe3000Price > 0) {
$items[] = [
$items[] = $this->withItemMapping([
'category' => 'parts',
'item_name' => '각파이프',
'specification' => "{$pipeThickness}T 3000mm",
@@ -766,13 +871,13 @@ public function calculatePartItems(array $params): array
'quantity' => $pipe3000Qty,
'unit_price' => $pipe3000Price,
'total_price' => $pipe3000Price * $pipe3000Qty,
];
], 'EST-PIPE-1.4-3000');
}
}
if ($pipe6000Qty > 0) {
$pipe6000Price = $this->getPipePrice($pipeThickness, 6000);
if ($pipe6000Price > 0) {
$items[] = [
$items[] = $this->withItemMapping([
'category' => 'parts',
'item_name' => '각파이프',
'specification' => "{$pipeThickness}T 6000mm",
@@ -780,7 +885,7 @@ public function calculatePartItems(array $params): array
'quantity' => $pipe6000Qty,
'unit_price' => $pipe6000Price,
'total_price' => $pipe6000Price * $pipe6000Qty,
];
], 'EST-PIPE-1.4-6000');
}
}
@@ -803,7 +908,8 @@ public function calculatePartItems(array $params): array
$anglePrice = $bracketAngleEnabled ? $this->getAnglePrice($angleSearchOption) : 0;
if ($anglePrice > 0) {
$angleQty = 4 * $quantity; // 5130: $su * 4
$items[] = [
$itemCode = "EST-ANGLE-BRACKET-{$angleSearchOption}";
$items[] = $this->withItemMapping([
'category' => 'parts',
'item_name' => '모터 받침용 앵글',
'specification' => $angleSearchOption,
@@ -811,7 +917,7 @@ public function calculatePartItems(array $params): array
'quantity' => $angleQty,
'unit_price' => $anglePrice,
'total_price' => $anglePrice * $angleQty,
];
], $itemCode);
}
// 4. 부자재 앵글 (main angle)
@@ -822,7 +928,9 @@ public function calculatePartItems(array $params): array
$mainAngleQty = (int) ($params['main_angle_qty'] ?? 2); // col71/col77, default 2 (좌우)
$mainAnglePrice = $this->getMainAnglePrice($mainAngleType, $mainAngleSize);
if ($mainAnglePrice > 0 && $mainAngleQty > 0) {
$items[] = [
// 앵글 코드: EST-ANGLE-MAIN-{타입}-{길이} (예: EST-ANGLE-MAIN-앵글3T-2.5)
$itemCode = "EST-ANGLE-MAIN-{$mainAngleType}-{$mainAngleSize}";
$items[] = $this->withItemMapping([
'category' => 'parts',
'item_name' => "앵글 {$mainAngleType}",
'specification' => "{$mainAngleSize}m",
@@ -830,7 +938,7 @@ public function calculatePartItems(array $params): array
'quantity' => $mainAngleQty,
'unit_price' => $mainAnglePrice,
'total_price' => $mainAnglePrice * $mainAngleQty,
];
], $itemCode);
}
// 5. 조인트바 (슬랫 전용, 5130: price × col76, QTY 미적용)
@@ -839,7 +947,7 @@ public function calculatePartItems(array $params): array
if ($jointBarQty > 0) {
$jointBarPrice = $this->getRawMaterialPrice('조인트바');
if ($jointBarPrice > 0) {
$items[] = [
$items[] = $this->withItemMapping([
'category' => 'parts',
'item_name' => '조인트바',
'specification' => '',
@@ -847,7 +955,7 @@ public function calculatePartItems(array $params): array
'quantity' => $jointBarQty,
'unit_price' => $jointBarPrice,
'total_price' => round($jointBarPrice * $jointBarQty),
];
], '800361');
}
}
}
@@ -859,12 +967,6 @@ public function calculatePartItems(array $params): array
// 전체 동적 항목 계산
// =========================================================================
/**
* 동적 항목 전체 계산
*
* @param array $inputs 입력 파라미터
* @return array 계산된 항목 배열
*/
public function calculateDynamicItems(array $inputs): array
{
$items = [];
@@ -904,51 +1006,57 @@ public function calculateDynamicItems(array $inputs): array
// 0. 검사비 (5130: inspectionFee × col14, 기본 50,000원)
$inspectionFee = (int) ($inputs['inspection_fee'] ?? 50000);
if ($inspectionFee > 0) {
$items[] = [
$items[] = $this->withItemMapping([
'category' => 'inspection',
'item_code' => 'KD-INSPECTION',
'item_name' => '검사비',
'specification' => '',
'unit' => 'EA',
'quantity' => $quantity,
'unit_price' => $inspectionFee,
'total_price' => $inspectionFee * $quantity,
];
], 'EST-INSPECTION');
}
// 1. 주자재 (스크린 또는 슬랫)
if ($productType === 'slat') {
$materialResult = $this->calculateSlatPrice($width, $height);
$materialName = '주자재(슬랫)';
$materialCode = 'KD-SLAT';
// 슬랫 타입에 따른 코드 (기본: 방화)
$slatType = $inputs['slat_type'] ?? '방화';
$materialCode = "EST-RAW-슬랫-{$slatType}";
} else {
$materialResult = $this->calculateScreenPrice($width, $height);
$materialName = '주자재(스크린)';
$materialCode = 'KD-SCREEN';
// 스크린 타입에 따른 코드 (기본: 실리카)
$screenType = $inputs['screen_type'] ?? '실리카';
$materialCode = "EST-RAW-스크린-{$screenType}";
}
$items[] = [
$items[] = $this->withItemMapping([
'category' => 'material',
'item_code' => $materialCode,
'item_name' => $materialName,
'specification' => "면적 {$materialResult['area']}",
'unit' => '㎡',
'quantity' => $materialResult['area'] * $quantity,
'unit_price' => $materialResult['unit_price'],
'total_price' => $materialResult['total_price'] * $quantity,
];
], $materialCode);
// 2. 모터
$motorPrice = $this->getMotorPrice($motorCapacity);
$items[] = [
// 모터 전압 (기본: 220V, 대용량은 380V)
$motorVoltage = $inputs['motor_voltage'] ?? $this->getMotorVoltage($motorCapacity);
// 모터 코드: 150K는 150K(S)만 존재
$motorCapacityCode = ($motorCapacity === '150K') ? '150K(S)' : $motorCapacity;
$motorCode = "EST-MOTOR-{$motorVoltage}-{$motorCapacityCode}";
$items[] = $this->withItemMapping([
'category' => 'motor',
'item_code' => "KD-MOTOR-{$motorCapacity}",
'item_name' => "모터 {$motorCapacity}",
'specification' => $motorCapacity,
'unit' => 'EA',
'quantity' => $quantity,
'unit_price' => $motorPrice,
'total_price' => $motorPrice * $quantity,
];
], $motorCode);
// 3. 제어기 (5130: 매립형×col15 + 노출형×col16 + 뒷박스×col17)
// 5130: 제어기 = price_매립 × col15 + price_노출 × col16 + price_뒷박스 × col17
@@ -957,16 +1065,16 @@ public function calculateDynamicItems(array $inputs): array
$controllerQty = (int) ($inputs['controller_qty'] ?? 1);
$controllerPrice = $this->getControllerPrice($controllerType);
if ($controllerPrice > 0 && $controllerQty > 0) {
$items[] = [
$ctrlCode = "EST-CTRL-{$controllerType}";
$items[] = $this->withItemMapping([
'category' => 'controller',
'item_code' => 'KD-CTRL-'.strtoupper($controllerType),
'item_name' => "제어기 {$controllerType}",
'specification' => $controllerType,
'unit' => 'EA',
'quantity' => $controllerQty,
'unit_price' => $controllerPrice,
'total_price' => $controllerPrice * $controllerQty,
];
], $ctrlCode);
}
// 뒷박스 (5130: col17 수량, QTY와 무관)
@@ -974,16 +1082,15 @@ public function calculateDynamicItems(array $inputs): array
if ($backboxQty > 0) {
$backboxPrice = $this->getControllerPrice('뒷박스');
if ($backboxPrice > 0) {
$items[] = [
$items[] = $this->withItemMapping([
'category' => 'controller',
'item_code' => 'KD-CTRL-BACKBOX',
'item_name' => '뒷박스',
'specification' => '',
'unit' => 'EA',
'quantity' => $backboxQty,
'unit_price' => $backboxPrice,
'total_price' => $backboxPrice * $backboxQty,
];
], 'EST-CTRL-뒷박스');
}
}

View File

@@ -10,6 +10,7 @@
use App\Models\Quote\QuoteItem;
use App\Models\Quote\QuoteRevision;
use App\Models\Tenants\SiteBriefing;
use App\Services\OrderService;
use App\Services\Service;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
@@ -21,7 +22,8 @@ class QuoteService extends Service
{
public function __construct(
private QuoteNumberService $numberService,
private QuoteCalculationService $calculationService
private QuoteCalculationService $calculationService,
private OrderService $orderService
) {}
/**
@@ -439,7 +441,24 @@ public function update(int $id, array $data): Quote
$this->createItems($quote, $data['items'], $tenantId);
}
return $quote->refresh()->load(['items', 'revisions', 'client']);
$quote->refresh()->load(['items', 'revisions', 'client']);
// 연결된 수주가 있으면 동기화
if ($quote->order_id) {
try {
$this->orderService->setContext($tenantId, $userId);
$this->orderService->syncFromQuote($quote, $quote->current_revision);
} catch (\Exception $e) {
// 수주 동기화 실패는 로그만 남기고 견적 수정은 성공 처리
Log::warning('Failed to sync order from quote', [
'quote_id' => $quote->id,
'order_id' => $quote->order_id,
'error' => $e->getMessage(),
]);
}
}
return $quote;
});
}

View File

@@ -405,6 +405,7 @@
'production_order_already_exists' => '이미 생산지시가 존재합니다.',
'cannot_revert_completed' => '완료된 수주의 생산지시는 되돌릴 수 없습니다.',
'cannot_revert_not_confirmed' => '수주확정 상태에서만 되돌리기가 가능합니다.',
'cannot_sync_after_production' => '생산지시 이후의 수주는 견적에서 자동 동기화할 수 없습니다.',
],
// 견적 관련