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

@@ -1221,4 +1221,194 @@ private function evaluateQuantityFormula(string $formula, array $variables): flo
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);
}
}