fix(API): 견적 관리 필드 저장/조회 개선

- QuoteStoreRequest/UpdateRequest: manager, contact, remarks 필드 추가
- QuoteController: store에서 validated 데이터 로깅 추가 (디버깅용)
- QuoteService: manager, contact, remarks 필드 저장/조회 로직 추가
- FormulaEvaluatorService: BOM 계산 서비스 개선

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-06 20:57:51 +09:00
parent 494bdd19da
commit d6783b4a15
5 changed files with 282 additions and 23 deletions

View File

@@ -17,6 +17,7 @@
use App\Services\Quote\QuoteDocumentService; use App\Services\Quote\QuoteDocumentService;
use App\Services\Quote\QuoteNumberService; use App\Services\Quote\QuoteNumberService;
use App\Services\Quote\QuoteService; use App\Services\Quote\QuoteService;
use Illuminate\Support\Facades\Log;
class QuoteController extends Controller class QuoteController extends Controller
{ {
@@ -52,8 +53,26 @@ public function show(int $id)
*/ */
public function store(QuoteStoreRequest $request) public function store(QuoteStoreRequest $request)
{ {
return ApiResponse::handle(function () use ($request) { // DEBUG: 요청 데이터 로깅
return $this->quoteService->store($request->validated()); Log::info('[QuoteController::store] 원본 요청:', [
'author' => $request->input('author'),
'manager' => $request->input('manager'),
'contact' => $request->input('contact'),
'remarks' => $request->input('remarks'),
]);
$validated = $request->validated();
// DEBUG: validated 데이터 로깅
Log::info('[QuoteController::store] validated 데이터:', [
'author' => $validated['author'] ?? 'NOT_SET',
'manager' => $validated['manager'] ?? 'NOT_SET',
'contact' => $validated['contact'] ?? 'NOT_SET',
'remarks' => $validated['remarks'] ?? 'NOT_SET',
]);
return ApiResponse::handle(function () use ($validated) {
return $this->quoteService->store($validated);
}, __('message.quote.created')); }, __('message.quote.created'));
} }

View File

@@ -12,6 +12,24 @@ public function authorize(): bool
return true; return true;
} }
/**
* 프론트엔드 필드명을 백엔드 필드명으로 정규화
* manager_name → manager, manager_contact → contact
*/
protected function prepareForValidation(): void
{
$mappings = [
'manager_name' => 'manager',
'manager_contact' => 'contact',
];
foreach ($mappings as $from => $to) {
if ($this->has($from) && ! $this->has($to)) {
$this->merge([$to => $this->input($from)]);
}
}
}
public function rules(): array public function rules(): array
{ {
return [ return [

View File

@@ -12,6 +12,24 @@ public function authorize(): bool
return true; return true;
} }
/**
* 프론트엔드 필드명을 백엔드 필드명으로 정규화
* manager_name → manager, manager_contact → contact
*/
protected function prepareForValidation(): void
{
$mappings = [
'manager_name' => 'manager',
'manager_contact' => 'contact',
];
foreach ($mappings as $from => $to) {
if ($this->has($from) && ! $this->has($to)) {
$this->merge([$to => $this->input($from)]);
}
}
}
public function rules(): array public function rules(): array
{ {
return [ return [

View File

@@ -1221,4 +1221,194 @@ private function evaluateQuantityFormula(string $formula, array $variables): flo
return 1; return 1;
} }
} }
// =========================================================================
// BOM 원자재(Leaf Node) 조회 - 소요자재내역용
// =========================================================================
/**
* BOM 트리에서 원자재(leaf nodes)만 추출
*
* 완제품의 BOM을 재귀적으로 탐색하여 실제 구매해야 할 원자재 목록을 반환합니다.
* - Leaf node: BOM이 없는 품목 또는 item_type이 RM(원자재), SM(부자재), CS(소모품)인 품목
* - 수량은 상위 구조의 수량을 누적하여 계산
*
* @param string $finishedGoodsCode 완제품 코드
* @param float $orderQuantity 주문 수량 (QTY)
* @param array $variables 변수 배열 (W0, H0 등)
* @param int|null $tenantId 테넌트 ID
* @return array 원자재 목록 (leaf nodes)
*/
public function getBomLeafMaterials(
string $finishedGoodsCode,
float $orderQuantity,
array $variables,
?int $tenantId = null
): array {
$tenantId = $tenantId ?? $this->tenantId();
if (! $tenantId) {
return [];
}
// 완제품 조회
$finishedGoods = DB::table('items')
->where('tenant_id', $tenantId)
->where('code', $finishedGoodsCode)
->whereNull('deleted_at')
->first();
if (! $finishedGoods || empty($finishedGoods->bom)) {
return [];
}
$bomData = json_decode($finishedGoods->bom, true);
if (! is_array($bomData) || empty($bomData)) {
return [];
}
// 재귀적으로 leaf nodes 수집
$leafMaterials = [];
$this->collectLeafMaterials(
$bomData,
$tenantId,
$orderQuantity,
$variables,
$leafMaterials
);
// 동일 품목 코드 병합 (수량 합산)
return $this->mergeLeafMaterials($leafMaterials, $tenantId);
}
/**
* 재귀적으로 BOM 트리를 탐색하여 leaf materials 수집
*
* @param array $bomData BOM 데이터 배열
* @param int $tenantId 테넌트 ID
* @param float $parentQuantity 상위 품목 수량 (누적)
* @param array $variables 변수 배열
* @param array &$leafMaterials 결과 배열 (참조)
* @param int $depth 재귀 깊이 (무한루프 방지)
*/
private function collectLeafMaterials(
array $bomData,
int $tenantId,
float $parentQuantity,
array $variables,
array &$leafMaterials,
int $depth = 0
): void {
// 무한 루프 방지
if ($depth > 10) {
return;
}
foreach ($bomData as $bomEntry) {
$childItemId = $bomEntry['child_item_id'] ?? null;
$childItemCode = $bomEntry['item_code'] ?? $bomEntry['childItemCode'] ?? null;
$quantityFormula = $bomEntry['quantityFormula'] ?? $bomEntry['quantity'] ?? '1';
// 수량 계산
$itemQuantity = $this->evaluateQuantityFormula((string) $quantityFormula, $variables);
$totalQuantity = $parentQuantity * $itemQuantity;
// 자식 품목 조회
$childItem = null;
if ($childItemId) {
$childItem = DB::table('items')
->leftJoin('item_details', 'items.id', '=', 'item_details.item_id')
->where('items.tenant_id', $tenantId)
->where('items.id', $childItemId)
->whereNull('items.deleted_at')
->select('items.*', 'item_details.specification')
->first();
} elseif ($childItemCode) {
$childItem = DB::table('items')
->leftJoin('item_details', 'items.id', '=', 'item_details.item_id')
->where('items.tenant_id', $tenantId)
->where('items.code', $childItemCode)
->whereNull('items.deleted_at')
->select('items.*', 'item_details.specification')
->first();
}
if (! $childItem) {
continue;
}
// 자식의 BOM 확인
$childBomData = json_decode($childItem->bom ?? '[]', true);
$hasChildBom = ! empty($childBomData) && is_array($childBomData);
// Leaf node 판단 조건:
// 1. BOM이 없는 품목
// 2. 또는 item_type이 원자재(RM), 부자재(SM), 소모품(CS)인 품목
$isLeafType = in_array($childItem->item_type, ['RM', 'SM', 'CS']);
if (! $hasChildBom || $isLeafType) {
// Leaf node - 결과에 추가
$leafMaterials[] = [
'item_id' => $childItem->id,
'item_code' => $childItem->code,
'item_name' => $childItem->name,
'item_type' => $childItem->item_type,
'item_category' => $childItem->item_category,
'specification' => $childItem->specification,
'unit' => $childItem->unit ?? 'EA',
'quantity' => $totalQuantity,
'process_type' => $childItem->process_type,
];
} else {
// 중간 노드 (부품/반제품) - 재귀 탐색
$this->collectLeafMaterials(
$childBomData,
$tenantId,
$totalQuantity,
$variables,
$leafMaterials,
$depth + 1
);
}
}
}
/**
* 동일 품목 코드의 leaf materials 병합 (수량 합산)
*
* @param array $leafMaterials 원자재 목록
* @param int $tenantId 테넌트 ID
* @return array 병합된 원자재 목록
*/
private function mergeLeafMaterials(array $leafMaterials, int $tenantId): array
{
$merged = [];
foreach ($leafMaterials as $material) {
$code = $material['item_code'];
if (isset($merged[$code])) {
// 동일 품목 - 수량 합산
$merged[$code]['quantity'] += $material['quantity'];
} else {
// 새 품목 추가
$merged[$code] = $material;
}
}
// 단가 조회 및 금액 계산
$result = [];
foreach ($merged as $material) {
$unitPrice = $this->getItemPrice($material['item_code']);
$totalPrice = $material['quantity'] * $unitPrice;
$result[] = array_merge($material, [
'unit_price' => $unitPrice,
'total_price' => round($totalPrice, 2),
]);
}
return array_values($result);
}
} }

View File

@@ -101,7 +101,11 @@ public function show(int $id): Quote
} }
/** /**
* 저장된 calculation_inputs를 기반으로 BOM 자재 목록 계산 * 저장된 calculation_inputs를 기반으로 BOM 자재(leaf nodes) 목록 조회
*
* 세부산출내역과 달리, BOM 트리에서 실제 원자재만 추출합니다:
* - 세부산출내역: BOM 계산 결과 (수식 기반 산출 품목)
* - 소요자재내역: BOM 트리 leaf nodes (실제 구매 필요한 원자재)
*/ */
private function calculateBomMaterials(Quote $quote): array private function calculateBomMaterials(Quote $quote): array
{ {
@@ -112,6 +116,7 @@ private function calculateBomMaterials(Quote $quote): array
return []; return [];
} }
$tenantId = $this->tenantId();
$inputItems = $calculationInputs['items']; $inputItems = $calculationInputs['items'];
$allMaterials = []; $allMaterials = [];
@@ -122,11 +127,14 @@ private function calculateBomMaterials(Quote $quote): array
continue; continue;
} }
// 주문 수량
$orderQuantity = (float) ($input['quantity'] ?? 1);
// BOM 계산을 위한 입력 변수 구성 // BOM 계산을 위한 입력 변수 구성
$bomInputs = [ $variables = [
'W0' => (float) ($input['openWidth'] ?? 0), 'W0' => (float) ($input['openWidth'] ?? 0),
'H0' => (float) ($input['openHeight'] ?? 0), 'H0' => (float) ($input['openHeight'] ?? 0),
'QTY' => (float) ($input['quantity'] ?? 1), 'QTY' => $orderQuantity,
'PC' => $input['productCategory'] ?? 'SCREEN', 'PC' => $input['productCategory'] ?? 'SCREEN',
'GT' => $input['guideRailType'] ?? 'wall', 'GT' => $input['guideRailType'] ?? 'wall',
'MP' => $input['motorPower'] ?? 'single', 'MP' => $input['motorPower'] ?? 'single',
@@ -136,27 +144,33 @@ private function calculateBomMaterials(Quote $quote): array
]; ];
try { try {
$result = $this->calculationService->calculateBom($finishedGoodsCode, $bomInputs, false); // BOM 트리에서 원자재(leaf nodes)만 추출
$leafMaterials = $this->calculationService->formulaEvaluator->getBomLeafMaterials(
$finishedGoodsCode,
$orderQuantity,
$variables,
$tenantId
);
if (($result['success'] ?? false) && ! empty($result['items'])) { // 각 자재 항목에 인덱스 정보 추가
// 각 자재 항목에 인덱스 정보 추가 foreach ($leafMaterials as $material) {
foreach ($result['items'] as $material) { $allMaterials[] = [
$allMaterials[] = [ 'item_index' => $index,
'item_index' => $index, 'finished_goods_code' => $finishedGoodsCode,
'finished_goods_code' => $finishedGoodsCode, 'item_code' => $material['item_code'] ?? '',
'item_code' => $material['item_code'] ?? '', 'item_name' => $material['item_name'] ?? '',
'item_name' => $material['item_name'] ?? '', 'item_type' => $material['item_type'] ?? '',
'specification' => $material['specification'] ?? '', 'item_category' => $material['item_category'] ?? '',
'unit' => $material['unit'] ?? 'EA', 'specification' => $material['specification'] ?? '',
'quantity' => $material['quantity'] ?? 0, 'unit' => $material['unit'] ?? 'EA',
'unit_price' => $material['unit_price'] ?? 0, 'quantity' => $material['quantity'] ?? 0,
'total_price' => $material['total_price'] ?? 0, 'unit_price' => $material['unit_price'] ?? 0,
'formula_category' => $material['formula_category'] ?? '', 'total_price' => $material['total_price'] ?? 0,
]; 'process_type' => $material['process_type'] ?? '',
} ];
} }
} catch (\Throwable) { } catch (\Throwable) {
// BOM 계산 실패 시 해당 품목은 스킵 // BOM 조회 실패 시 해당 품목은 스킵
continue; continue;
} }
} }