Files
sam-api/app/Services/Quote/QuoteService.php
권혁성 e5da452fde fix: [quote] QA 견적 관련 백엔드 버그 수정
- Quote.isEditable() 생산지시 존재 시 수정 차단
- BOM 탭 순서 정렬 + inspection→검사비 매핑 추가
- 제어기 수량 계산 오류 수정 (1개소 고정 → 수량 반영)
- QuoteService for_order/status 필터 조건 수정
2026-03-17 13:55:18 +09:00

1156 lines
44 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

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

<?php
namespace App\Services\Quote;
use App\Models\Bidding\Bidding;
use App\Models\Items\Item;
use App\Models\Orders\Order;
use App\Models\Orders\OrderItem;
use App\Models\Orders\OrderNode;
use App\Models\Quote\Quote;
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;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class QuoteService extends Service
{
public function __construct(
private QuoteNumberService $numberService,
private QuoteCalculationService $calculationService,
private OrderService $orderService
) {}
/**
* 견적 목록 조회
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$page = (int) ($params['page'] ?? 1);
$size = (int) ($params['size'] ?? 20);
$q = trim((string) ($params['q'] ?? ''));
$quoteType = $params['quote_type'] ?? null;
$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';
$withItems = filter_var($params['with_items'] ?? false, FILTER_VALIDATE_BOOLEAN);
$forOrder = filter_var($params['for_order'] ?? false, FILTER_VALIDATE_BOOLEAN);
$query = Quote::query()->where('tenant_id', $tenantId);
// 수주 전환용 조회: 아직 수주가 생성되지 않은 견적만
if ($forOrder) {
$query->whereNull('order_id');
}
// items 포함 (수주 전환용)
if ($withItems) {
$query->with(['items', 'client:id,name,contact_person,phone']);
}
// 검색어
if ($q !== '') {
$query->search($q);
}
// 견적 유형 필터
if ($quoteType) {
$query->where('quote_type', $quoteType);
}
// 상태 필터 (converted는 order_id 기반으로 판별)
if ($status === Quote::STATUS_CONVERTED) {
$query->whereNotNull('order_id');
} elseif ($status) {
$query->where('status', $status);
if (! $forOrder) {
$query->whereNull('order_id');
}
}
// 제품 카테고리 필터
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);
}
/**
* 견적 참조 데이터 조회 (현장명, 부호 목록)
*
* 기존 견적/수주에서 사용된 현장명과 부호를 DISTINCT로 조회합니다.
*/
public function referenceData(): array
{
$tenantId = $this->tenantId();
// 현장명: 견적 테이블에서 DISTINCT
$siteNames = Quote::where('tenant_id', $tenantId)
->whereNotNull('site_name')
->where('site_name', '!=', '')
->distinct()
->orderBy('site_name')
->pluck('site_name')
->toArray();
// 부호(개소코드): calculation_inputs JSON 내 items[].code (예: FSS-01, SD-02)
$locationCodes = collect();
// calculation_inputs JSON에서 items[].code 추출
$quotesWithInputs = Quote::where('tenant_id', $tenantId)
->whereNotNull('calculation_inputs')
->select('calculation_inputs')
->get();
foreach ($quotesWithInputs as $quote) {
$inputs = is_string($quote->calculation_inputs)
? json_decode($quote->calculation_inputs, true)
: $quote->calculation_inputs;
if (! is_array($inputs)) {
continue;
}
$items = $inputs['items'] ?? $inputs['locations'] ?? [];
foreach ($items as $item) {
$code = $item['code'] ?? null;
if ($code && trim($code) !== '') {
$locationCodes->push(trim($code));
}
}
}
// 중복 제거, 정렬
$locationCodes = $locationCodes
->unique()
->sort()
->values()
->toArray();
return [
'site_names' => $siteNames,
'location_codes' => $locationCodes,
];
}
/**
* 견적 단건 조회
*/
public function show(int $id): Quote
{
$tenantId = $this->tenantId();
$quote = Quote::with(['items', 'revisions', 'client', 'creator', 'updater', 'finalizer', 'siteBriefing.partner'])
->where('tenant_id', $tenantId)
->find($id);
if (! $quote) {
throw new NotFoundHttpException(__('error.quote_not_found'));
}
// 역방향 참조 보정: order_id가 null인데 Order.quote_id로 연결된 수주가 있는 경우
if (! $quote->order_id) {
$linkedOrder = Order::where('quote_id', $quote->id)
->where('tenant_id', $tenantId)
->first();
if ($linkedOrder) {
$quote->update(['order_id' => $linkedOrder->id]);
$quote->refresh()->load(['items', 'revisions', 'client', 'creator', 'updater', 'finalizer', 'siteBriefing.partner']);
}
}
// BOM 자재 데이터 계산 및 추가
$bomMaterials = $this->calculateBomMaterials($quote);
if (! empty($bomMaterials)) {
$quote->setAttribute('bom_materials', $bomMaterials);
}
// 프론트 제어용 플래그
$quote->setAttribute('is_editable', $quote->isEditable());
$quote->setAttribute('has_work_orders', $quote->order_id
? Order::where('id', $quote->order_id)->whereHas('workOrders')->exists()
: false
);
return $quote;
}
/**
* 저장된 calculation_inputs를 기반으로 BOM 원자재(leaf nodes) 목록 조회
*
* 세부산출내역과 달리, BOM 트리에서 실제 원자재만 추출합니다:
* - 세부산출내역: BOM 계산 결과 (수식 기반 산출 품목)
* - 소요자재내역: BOM 트리 leaf nodes (실제 구매 필요한 원자재)
*/
private function calculateBomMaterials(Quote $quote): array
{
$calculationInputs = $quote->calculation_inputs;
// calculation_inputs가 없거나 items가 없으면 빈 배열 반환
if (empty($calculationInputs) || empty($calculationInputs['items'])) {
return [];
}
$tenantId = $this->tenantId();
$inputItems = $calculationInputs['items'];
$allMaterials = [];
foreach ($inputItems as $index => $input) {
// 완제품 코드 찾기 (productName에 저장됨)
$finishedGoodsCode = $input['productName'] ?? null;
if (! $finishedGoodsCode) {
continue;
}
// 주문 수량
$orderQuantity = (float) ($input['quantity'] ?? 1);
// BOM 계산을 위한 입력 변수 구성
$variables = [
'W0' => (float) ($input['openWidth'] ?? 0),
'H0' => (float) ($input['openHeight'] ?? 0),
'QTY' => $orderQuantity,
'PC' => $input['productCategory'] ?? 'SCREEN',
'GT' => $input['guideRailType'] ?? 'wall',
'MP' => $input['motorPower'] ?? 'single',
'CT' => $input['controller'] ?? 'exposed',
'WS' => (float) ($input['wingSize'] ?? 50),
'INSP' => (float) ($input['inspectionFee'] ?? 50000),
];
try {
// BOM 트리에서 원자재(leaf nodes)만 추출
$leafMaterials = $this->calculationService->formulaEvaluator->getBomLeafMaterials(
$finishedGoodsCode,
$orderQuantity,
$variables,
$tenantId
);
// 각 자재 항목에 인덱스 정보 추가
foreach ($leafMaterials as $material) {
$allMaterials[] = [
'item_index' => $index,
'finished_goods_code' => $finishedGoodsCode,
'item_id' => $material['item_id'] ?? null,
'item_code' => $material['item_code'] ?? '',
'item_name' => $material['item_name'] ?? '',
'item_type' => $material['item_type'] ?? '',
'item_category' => $material['item_category'] ?? '',
'specification' => $material['specification'] ?? '',
'unit' => $material['unit'] ?? 'EA',
'quantity' => $material['quantity'] ?? 0,
'unit_price' => $material['unit_price'] ?? 0,
'total_price' => $material['total_price'] ?? 0,
'process_type' => $material['process_type'] ?? '',
];
}
} catch (\Throwable) {
// BOM 조회 실패 시 해당 품목은 스킵
continue;
}
}
return $allMaterials;
}
/**
* 견적 생성
*/
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);
// 프론트엔드에서 직접 계산한 할인금액이 있으면 사용, 없으면 subtotal에서 계산
$discountAmount = isset($data['discount_amount'])
? (float) $data['discount_amount']
: $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'] ?? $this->extractProductCodeFromInputs($data),
'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);
// 상태 변경: pending(견적대기) → draft(작성중)
// 현장설명회에서 자동 생성된 견적을 처음 수정하면 작성중 상태로 변경
$newStatus = $quote->status;
if ($quote->status === Quote::STATUS_PENDING) {
$newStatus = Quote::STATUS_DRAFT;
}
// 금액 재계산
$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);
// 프론트엔드에서 직접 계산한 할인금액이 있으면 사용, 없으면 subtotal에서 계산
$discountAmount = isset($data['discount_amount'])
? (float) $data['discount_amount']
: $subtotal * ($discountRate / 100);
$totalAmount = $subtotal - $discountAmount;
// 업데이트
$quote->update([
'status' => $newStatus,
'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'] ?? $this->extractProductCodeFromInputs($data) ?? $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,
// 견적 옵션 (summary_items, expense_items, price_adjustments, detail_items, price_adjustment_data)
// 기존 options와 새 options를 병합 (새 데이터가 기존 데이터를 덮어씀)
'options' => $this->mergeOptions($quote->options, $data['options'] ?? null),
// 감사
'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);
}
$quote->refresh()->load(['items', 'revisions', 'client']);
// 연결된 수주가 있으면 동기화 (역방향 참조도 포함)
$syncOrderId = $quote->order_id;
if (! $syncOrderId) {
$linkedOrder = Order::where('quote_id', $quote->id)
->where('tenant_id', $tenantId)
->first();
if ($linkedOrder) {
$quote->update(['order_id' => $linkedOrder->id]);
$quote->refresh()->load(['items', 'revisions', 'client']);
$syncOrderId = $linkedOrder->id;
}
}
if ($syncOrderId) {
try {
$this->orderService->setContext($tenantId, $userId);
$this->orderService->syncFromQuote($quote, $quote->current_revision);
} catch (\Throwable $e) {
// 수주 동기화 실패는 로그만 남기고 견적 수정은 성공 처리
Log::warning('Failed to sync order from quote', [
'quote_id' => $quote->id,
'order_id' => $syncOrderId,
'error' => $e->getMessage(),
]);
}
}
return $quote;
});
}
/**
* 견적 삭제 (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'));
}
// 확정 시 필수 필드 검증 (업체명, 현장명, 담당자, 연락처)
$missing = [];
if (empty($quote->client_name)) {
$missing[] = '업체명';
}
if (empty($quote->site_name)) {
$missing[] = '현장명';
}
if (empty($quote->manager)) {
$missing[] = '담당자';
}
if (empty($quote->contact)) {
$missing[] = '연락처';
}
if (! empty($missing)) {
throw new BadRequestHttpException(
__('error.quote_finalize_missing_fields', ['fields' => implode(', ', $missing)])
);
}
$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->order_id) {
throw new BadRequestHttpException(__('error.quote_already_converted'));
}
if ($quote->getRawOriginal('status') !== Quote::STATUS_FINALIZED) {
throw new BadRequestHttpException(__('error.quote_not_finalized'));
}
$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)
->with(['items', 'client'])
->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, $tenantId) {
// calculation_inputs에서 개소(제품) 정보 추출
$calculationInputs = $quote->calculation_inputs ?? [];
$productItems = $calculationInputs['items'] ?? [];
$bomResults = $calculationInputs['bomResults'] ?? [];
$locationCount = count($productItems);
// 품목→개소 매핑 사전 계산
$hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source));
$itemsPerLocation = (! $hasFormulaSource && $locationCount > 1)
? intdiv($quote->items->count(), $locationCount)
: 0;
// 견적 품목을 개소별로 그룹핑
$itemsByLocation = [];
foreach ($quote->items as $index => $quoteItem) {
$locIdx = $this->resolveLocationIndex($quoteItem, $productItems);
// sort_order 기반 분배 (formula_source/note 매칭 모두 실패 시)
if ($locIdx === 0 && $itemsPerLocation > 0) {
$locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1);
}
$itemsByLocation[$locIdx][] = [
'quoteItem' => $quoteItem,
'mapping' => $this->resolveLocationMapping($quoteItem, $productItems),
];
}
// 개소(items) × 수량(quantity) = 총 수주 건수 계산
// 예: items 1건(qty=10) → 10건, items 3건(각 qty=1) → 3건
$expandedLocations = [];
foreach ($productItems as $idx => $locItem) {
$bomResult = $bomResults[$idx] ?? null;
$qty = (int) ($locItem['quantity'] ?? 1);
for ($q = 0; $q < $qty; $q++) {
$expandedLocations[] = [
'locItem' => $locItem,
'bomResult' => $bomResult,
'origIdx' => $idx,
'unitIndex' => $q,
];
}
}
$totalOrders = count($expandedLocations);
$orderNumbers = $this->generateOrderNumbers($tenantId, max($totalOrders, 1));
// 개소×수량별로 독립 수주 생성
$firstOrderId = null;
foreach ($expandedLocations as $orderIdx => $expanded) {
$locItem = $expanded['locItem'];
$bomResult = $expanded['bomResult'];
$origIdx = $expanded['origIdx'];
$grandTotal = $bomResult['grand_total'] ?? 0;
$floor = $locItem['floor'] ?? '';
$symbol = $locItem['code'] ?? '';
// 수주 마스터 생성 (qty=1 단위)
$unitLocItem = array_merge($locItem, ['quantity' => 1]);
$order = Order::createFromQuoteLocation($quote, $orderNumbers[$orderIdx], $unitLocItem, $bomResult);
$order->created_by = $userId;
$order->save();
if ($firstOrderId === null) {
$firstOrderId = $order->id;
}
// OrderNode 생성 (1수주 = 1노드)
$node = OrderNode::create([
'tenant_id' => $tenantId,
'order_id' => $order->id,
'parent_id' => null,
'node_type' => 'location',
'code' => trim("{$floor}-{$symbol}", '-') ?: "LOC-{$orderIdx}",
'name' => trim("{$floor} {$symbol}") ?: '개소 '.($orderIdx + 1),
'status_code' => OrderNode::STATUS_PENDING,
'quantity' => 1,
'unit_price' => $grandTotal,
'total_price' => $grandTotal,
'options' => [
'floor' => $floor,
'symbol' => $symbol,
'product_code' => $locItem['productCode'] ?? null,
'product_name' => $locItem['productName'] ?? null,
'open_width' => $locItem['openWidth'] ?? null,
'open_height' => $locItem['openHeight'] ?? null,
'guide_rail_type' => $locItem['guideRailType'] ?? null,
'motor_power' => $locItem['motorPower'] ?? null,
'controller' => $locItem['controller'] ?? null,
'wing_size' => $locItem['wingSize'] ?? null,
'inspection_fee' => $locItem['inspectionFee'] ?? null,
'bom_result' => $bomResult,
],
'depth' => 0,
'sort_order' => 0,
'created_by' => $userId,
]);
// 해당 개소 소속 품목 → OrderItem 복제 (모든 수량 분할 건에 동일 품목)
$serialIndex = 1;
foreach ($itemsByLocation[$origIdx] ?? [] as $entry) {
$mapping = $entry['mapping'];
$mapping['order_node_id'] = $node->id;
$orderItem = OrderItem::createFromQuoteItem($entry['quoteItem'], $order->id, $serialIndex, $mapping);
$orderItem->created_by = $userId;
$orderItem->save();
$serialIndex++;
}
// 수주 합계 재계산
$order->load('items');
$order->recalculateTotals();
$order->save();
}
// 견적에 수주 연결 (status는 accessor가 자동으로 'converted' 반환)
$quote->update([
'order_id' => $firstOrderId,
'updated_by' => $userId,
]);
return $quote->refresh()->load(['items', 'client', 'orders']);
});
}
/**
* 견적 품목에서 개소(층/부호) 매핑 정보 추출
*
* 1순위: note 필드 파싱 ("4F FSS-01" → floor_code:"4F", symbol_code:"FSS-01")
* 2순위: formula_source → calculation_inputs.items[] 매칭
*/
private function resolveLocationMapping(QuoteItem $quoteItem, array $productItems): array
{
$floorCode = null;
$symbolCode = null;
// 1순위: note에서 파싱
$note = trim($quoteItem->note ?? '');
if ($note !== '') {
$parts = preg_split('/\s+/', $note, 2);
$floorCode = $parts[0] ?? null;
$symbolCode = $parts[1] ?? null;
}
// 2순위: formula_source → calculation_inputs
if (empty($floorCode) && empty($symbolCode)) {
$productIndex = 0;
$formulaSource = $quoteItem->formula_source ?? '';
if (preg_match('/product_(\d+)/', $formulaSource, $matches)) {
$productIndex = (int) $matches[1];
}
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;
}
}
return ['floor_code' => $floorCode, 'symbol_code' => $symbolCode];
}
/**
* 견적 품목이 속하는 개소(productItems) 인덱스 반환
*
* 1순위: formula_source에서 product_N 패턴 추출
* 2순위: note 파싱 후 productItems에서 floor/code 매칭
* 매칭 실패 시 0 반환
*/
private function resolveLocationIndex(QuoteItem $quoteItem, array $productItems): int
{
// 1순위: formula_source
$formulaSource = $quoteItem->formula_source ?? '';
if (preg_match('/product_(\d+)/', $formulaSource, $matches)) {
return (int) $matches[1];
}
// 2순위: note에서 floor/code 매칭
$note = trim($quoteItem->note ?? '');
if ($note !== '') {
$parts = preg_split('/\s+/', $note, 2);
$floor = $parts[0] ?? '';
$code = $parts[1] ?? '';
foreach ($productItems as $idx => $item) {
if (($item['floor'] ?? '') === $floor && ($item['code'] ?? '') === $code) {
return $idx;
}
}
}
return 0;
}
/**
* calculation_inputs에서 첫 번째 개소의 productCode 추출
* 다중 개소 시 첫 번째를 대표값으로 사용
*/
private function extractProductCodeFromInputs(array $data): ?string
{
$inputs = $data['calculation_inputs'] ?? null;
if (! $inputs || ! is_array($inputs)) {
return null;
}
$items = $inputs['items'] ?? [];
return $items[0]['productCode'] ?? null;
}
/**
* 수주번호 N개 연속 생성
* 형식: ORD-YYMMDD-NNN (예: ORD-260105-001, -002, -003)
*/
private function generateOrderNumbers(int $tenantId, int $count = 1): array
{
$dateStr = now()->format('ymd');
$prefix = "ORD-{$dateStr}-";
$lastOrder = Order::withTrashed()
->where('tenant_id', $tenantId)
->where('order_no', 'like', $prefix.'%')
->orderBy('order_no', 'desc')
->first();
$sequence = 1;
if ($lastOrder) {
$parts = explode('-', $lastOrder->order_no);
if (count($parts) >= 3) {
$lastSeq = (int) end($parts);
$sequence = $lastSeq + 1;
}
}
$numbers = [];
for ($i = 0; $i < $count; $i++) {
$seqStr = str_pad((string) ($sequence + $i), 3, '0', STR_PAD_LEFT);
$numbers[] = "{$prefix}{$seqStr}";
}
return $numbers;
}
/**
* 견적 품목 생성
*/
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;
// item_type: 전달된 값 또는 items 테이블에서 조회
$itemType = $item['item_type'] ?? null;
if ($itemType === null && isset($item['item_id'])) {
$itemType = Item::where('id', $item['item_id'])->value('item_type');
}
QuoteItem::create([
'quote_id' => $quote->id,
'tenant_id' => $tenantId,
'item_id' => $item['item_id'] ?? null,
'item_type' => $itemType,
'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,
]);
}
/**
* 현장설명회에서 견적 Upsert (생성 또는 업데이트)
*
* 참석완료 상태일 때 견적을 자동 생성하거나 업데이트합니다.
* - 견적이 없으면: 견적대기(pending) 상태로 신규 생성
* - 견적이 있으면: 현장설명회 정보로 업데이트 (거래처, 현장 등)
*/
public function upsertFromSiteBriefing(SiteBriefing $siteBriefing): Quote
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 기존 견적 조회
$existingQuote = Quote::where('tenant_id', $tenantId)
->where('site_briefing_id', $siteBriefing->id)
->first();
return DB::transaction(function () use ($siteBriefing, $tenantId, $userId, $existingQuote) {
if ($existingQuote) {
// 기존 견적 업데이트 (현장설명회 정보 동기화)
$existingQuote->update([
// 발주처 정보 동기화
'client_id' => $siteBriefing->partner_id,
'client_name' => $siteBriefing->partner?->name,
// 현장 정보 동기화
'site_id' => $siteBriefing->site_id,
'site_name' => $siteBriefing->title,
// 감사
'updated_by' => $userId,
]);
return $existingQuote->refresh();
}
// 신규 견적 생성
$quoteNumber = $this->numberService->generate('SCREEN');
return Quote::create([
'tenant_id' => $tenantId,
'quote_type' => Quote::TYPE_CONSTRUCTION,
'site_briefing_id' => $siteBriefing->id,
'quote_number' => $quoteNumber,
'registration_date' => now()->toDateString(),
// 발주처 정보 (현장설명회에서 복사)
'client_id' => $siteBriefing->partner_id,
'client_name' => $siteBriefing->partner?->name,
// 현장 정보 (현장설명회에서 복사)
'site_id' => $siteBriefing->site_id,
'site_name' => $siteBriefing->title,
// 제품 카테고리 없음 (pending 상태이므로)
'product_category' => null,
// 금액 정보 (빈 값)
'material_cost' => 0,
'labor_cost' => 0,
'install_cost' => 0,
'subtotal' => 0,
'discount_rate' => 0,
'discount_amount' => 0,
'total_amount' => 0,
// 상태 관리 (견적대기)
'status' => Quote::STATUS_PENDING,
'current_revision' => 0,
'is_final' => false,
// 비고 (현장설명회 정보 기록)
'remarks' => "현장설명회 참석완료로 자동생성 (현설번호: {$siteBriefing->briefing_code})",
// 감사
'created_by' => $userId,
]);
});
}
/**
* @deprecated Use upsertFromSiteBriefing() instead
*/
public function createFromSiteBriefing(SiteBriefing $siteBriefing): ?Quote
{
return $this->upsertFromSiteBriefing($siteBriefing);
}
/**
* 현장설명회 ID로 연결된 견적 조회
*/
public function findBySiteBriefingId(int $siteBriefingId): ?Quote
{
$tenantId = $this->tenantId();
return Quote::where('tenant_id', $tenantId)
->where('site_briefing_id', $siteBriefingId)
->first();
}
/**
* 견적 options 병합
* 기존 options와 새 options를 병합하여 반환
*/
private function mergeOptions(?array $existingOptions, ?array $newOptions): ?array
{
\Log::info('🔍 [QuoteService::mergeOptions] 시작', [
'existingOptions_keys' => $existingOptions ? array_keys($existingOptions) : null,
'newOptions_keys' => $newOptions ? array_keys($newOptions) : null,
'newOptions_detail_items_count' => isset($newOptions['detail_items']) ? count($newOptions['detail_items']) : 0,
'newOptions_price_adjustment_data' => isset($newOptions['price_adjustment_data']) ? 'exists' : 'null',
]);
if ($newOptions === null) {
return $existingOptions;
}
if ($existingOptions === null) {
\Log::info('✅ [QuoteService::mergeOptions] 기존 없음, 새 options 반환', [
'result_keys' => array_keys($newOptions),
]);
return $newOptions;
}
$merged = array_merge($existingOptions, $newOptions);
\Log::info('✅ [QuoteService::mergeOptions] 병합 완료', [
'merged_keys' => array_keys($merged),
'merged_detail_items_count' => isset($merged['detail_items']) ? count($merged['detail_items']) : 0,
]);
return $merged;
}
/**
* 견적을 입찰로 변환
* 시공 견적(finalized)만 입찰로 변환 가능
*
* @param int $quoteId 견적 ID
* @return Bidding 생성된 입찰 정보
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException 견적이 존재하지 않는 경우
* @throws \RuntimeException 이미 입찰로 변환된 경우 또는 시공 견적이 아닌 경우
*/
public function convertToBidding(int $quoteId): Bidding
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 1. 견적 조회 (시공 견적이어야 함)
$quote = Quote::where('tenant_id', $tenantId)
->where('id', $quoteId)
->where('type', Quote::TYPE_CONSTRUCTION) // 시공 견적만
->firstOrFail();
// 2. 이미 입찰로 변환되었는지 확인
$existingBidding = Bidding::where('tenant_id', $tenantId)
->where('quote_id', $quoteId)
->first();
if ($existingBidding) {
throw new \RuntimeException(__('error.bidding.already_converted'));
}
// 3. 입찰번호 생성 (BID-YYYYMMDD-XXXX)
$today = now()->format('Ymd');
$lastBidding = Bidding::where('tenant_id', $tenantId)
->where('bidding_code', 'like', "BID-{$today}-%")
->orderBy('bidding_code', 'desc')
->first();
$sequence = 1;
if ($lastBidding) {
$parts = explode('-', $lastBidding->bidding_code);
$sequence = (int) ($parts[2] ?? 0) + 1;
}
$biddingCode = sprintf('BID-%s-%04d', $today, $sequence);
// 4. 입찰 생성
$bidding = Bidding::create([
'tenant_id' => $tenantId,
'bidding_code' => $biddingCode,
'quote_id' => $quote->id,
// 거래처/현장 정보
'client_id' => $quote->client_id,
'client_name' => $quote->client_name ?? $quote->client?->name,
'project_name' => $quote->project_name,
// 입찰 정보 (초기값)
'bidding_date' => now()->toDateString(),
'total_count' => 1,
'bidding_amount' => $quote->total_amount ?? 0,
'status' => Bidding::STATUS_WAITING,
// 입찰자 정보 (현재 사용자)
'bidder_id' => $userId,
// VAT 유형
'vat_type' => $quote->vat_type ?? Bidding::VAT_INCLUDED,
// 견적 데이터 스냅샷
'expense_items' => $quote->options['expense_items'] ?? null,
'estimate_detail_items' => $quote->options['detail_items'] ?? null,
// 감사 필드
'created_by' => $userId,
]);
\Log::info('✅ [QuoteService::convertToBidding] 입찰 변환 완료', [
'quote_id' => $quoteId,
'bidding_id' => $bidding->id,
'bidding_code' => $biddingCode,
]);
return $bidding;
}
}