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:
@@ -17,6 +17,7 @@
|
||||
use App\Services\Quote\QuoteDocumentService;
|
||||
use App\Services\Quote\QuoteNumberService;
|
||||
use App\Services\Quote\QuoteService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class QuoteController extends Controller
|
||||
{
|
||||
@@ -52,8 +53,26 @@ public function show(int $id)
|
||||
*/
|
||||
public function store(QuoteStoreRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->quoteService->store($request->validated());
|
||||
// DEBUG: 요청 데이터 로깅
|
||||
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'));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,24 @@ public function authorize(): bool
|
||||
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
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -12,6 +12,24 @@ public function authorize(): bool
|
||||
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
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -112,6 +116,7 @@ private function calculateBomMaterials(Quote $quote): array
|
||||
return [];
|
||||
}
|
||||
|
||||
$tenantId = $this->tenantId();
|
||||
$inputItems = $calculationInputs['items'];
|
||||
$allMaterials = [];
|
||||
|
||||
@@ -122,11 +127,14 @@ private function calculateBomMaterials(Quote $quote): array
|
||||
continue;
|
||||
}
|
||||
|
||||
// 주문 수량
|
||||
$orderQuantity = (float) ($input['quantity'] ?? 1);
|
||||
|
||||
// BOM 계산을 위한 입력 변수 구성
|
||||
$bomInputs = [
|
||||
$variables = [
|
||||
'W0' => (float) ($input['openWidth'] ?? 0),
|
||||
'H0' => (float) ($input['openHeight'] ?? 0),
|
||||
'QTY' => (float) ($input['quantity'] ?? 1),
|
||||
'QTY' => $orderQuantity,
|
||||
'PC' => $input['productCategory'] ?? 'SCREEN',
|
||||
'GT' => $input['guideRailType'] ?? 'wall',
|
||||
'MP' => $input['motorPower'] ?? 'single',
|
||||
@@ -136,27 +144,33 @@ private function calculateBomMaterials(Quote $quote): array
|
||||
];
|
||||
|
||||
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 ($result['items'] as $material) {
|
||||
$allMaterials[] = [
|
||||
'item_index' => $index,
|
||||
'finished_goods_code' => $finishedGoodsCode,
|
||||
'item_code' => $material['item_code'] ?? '',
|
||||
'item_name' => $material['item_name'] ?? '',
|
||||
'specification' => $material['specification'] ?? '',
|
||||
'unit' => $material['unit'] ?? 'EA',
|
||||
'quantity' => $material['quantity'] ?? 0,
|
||||
'unit_price' => $material['unit_price'] ?? 0,
|
||||
'total_price' => $material['total_price'] ?? 0,
|
||||
'formula_category' => $material['formula_category'] ?? '',
|
||||
];
|
||||
}
|
||||
// 각 자재 항목에 인덱스 정보 추가
|
||||
foreach ($leafMaterials as $material) {
|
||||
$allMaterials[] = [
|
||||
'item_index' => $index,
|
||||
'finished_goods_code' => $finishedGoodsCode,
|
||||
'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 계산 실패 시 해당 품목은 스킵
|
||||
// BOM 조회 실패 시 해당 품목은 스킵
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user