feat:경동기업 견적/수주 전환 로직 개선
- KyungdongFormulaHandler: 수식 계산 로직 리팩토링 및 확장 - OrderService: 수주 전환 시 BOM 품목 매핑 로직 추가 - QuoteService: 견적 상태 처리 개선 - FormulaEvaluatorService: 디버그 로깅 추가 - Quote 모델: 캐스팅 타입 수정 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,20 +471,46 @@ 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;
|
||||
|
||||
// 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([
|
||||
'tenant_id' => $tenantId,
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 생산지시 생성 (공정별 작업지시 다중 생성)
|
||||
*/
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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-뒷박스');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -405,6 +405,7 @@
|
||||
'production_order_already_exists' => '이미 생산지시가 존재합니다.',
|
||||
'cannot_revert_completed' => '완료된 수주의 생산지시는 되돌릴 수 없습니다.',
|
||||
'cannot_revert_not_confirmed' => '수주확정 상태에서만 되돌리기가 가능합니다.',
|
||||
'cannot_sync_after_production' => '생산지시 이후의 수주는 견적에서 자동 동기화할 수 없습니다.',
|
||||
],
|
||||
|
||||
// 견적 관련
|
||||
|
||||
Reference in New Issue
Block a user