diff --git a/app/Http/Controllers/Api/Admin/ItemManagementApiController.php b/app/Http/Controllers/Api/Admin/ItemManagementApiController.php new file mode 100644 index 00000000..ace828a7 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ItemManagementApiController.php @@ -0,0 +1,85 @@ +service->getItemList([ + 'search' => $request->input('search'), + 'item_type' => $request->input('item_type'), + 'per_page' => $request->input('per_page', 50), + ]); + + return view('item-management.partials.item-list', compact('items')); + } + + /** + * BOM 재귀 트리 (JSON - 중앙 패널, JS 렌더링) + */ + public function bomTree(int $id, Request $request): JsonResponse + { + $maxDepth = $request->input('max_depth', 10); + $tree = $this->service->getBomTree($id, $maxDepth); + + return response()->json($tree); + } + + /** + * 품목 상세 (HTML partial - 우측 패널) + */ + public function detail(int $id): View + { + $data = $this->service->getItemDetail($id); + + return view('item-management.partials.item-detail', [ + 'item' => $data['item'], + 'bomChildren' => $data['bom_children'], + ]); + } + + /** + * 수식 기반 BOM 산출 (API 서버의 FormulaEvaluatorService HTTP 호출) + */ + public function calculateFormula(Request $request, int $id): JsonResponse + { + $item = Item::withoutGlobalScopes() + ->where('tenant_id', session('selected_tenant_id')) + ->findOrFail($id); + + $width = (int) $request->input('width', 1000); + $height = (int) $request->input('height', 1000); + $qty = (int) $request->input('qty', 1); + + $variables = [ + 'W0' => $width, + 'H0' => $height, + 'QTY' => $qty, + ]; + + $formulaService = new FormulaApiService(); + $result = $formulaService->calculateBom( + $item->code, + $variables, + (int) session('selected_tenant_id') + ); + + return response()->json($result); + } +} diff --git a/app/Http/Controllers/ItemManagementController.php b/app/Http/Controllers/ItemManagementController.php new file mode 100644 index 00000000..f58ae604 --- /dev/null +++ b/app/Http/Controllers/ItemManagementController.php @@ -0,0 +1,23 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('item-management.index')); + } + + return view('item-management.index'); + } +} diff --git a/app/Models/Commons/File.php b/app/Models/Commons/File.php new file mode 100644 index 00000000..fe3ce2bd --- /dev/null +++ b/app/Models/Commons/File.php @@ -0,0 +1,21 @@ + 'boolean', + 'bom' => 'array', 'attributes' => 'array', + 'attributes_archive' => 'array', + 'options' => 'array', + 'is_active' => 'boolean', ]; + + // 유형 상수 + const TYPE_FG = 'FG'; // 완제품 + const TYPE_PT = 'PT'; // 부품 + const TYPE_SM = 'SM'; // 부자재 + const TYPE_RM = 'RM'; // 원자재 + const TYPE_CS = 'CS'; // 소모품 + + const PRODUCT_TYPES = ['FG', 'PT']; + const MATERIAL_TYPES = ['SM', 'RM', 'CS']; + + // ── 관계 ── + + public function details() + { + return $this->hasOne(ItemDetail::class, 'item_id'); + } + + public function category() + { + return $this->belongsTo(Category::class, 'category_id'); + } + + /** + * 파일 (document_id/document_type 기반) + * document_id = items.id, document_type = '1' (ITEM_GROUP_ID) + */ + public function files() + { + return $this->hasMany(\App\Models\Commons\File::class, 'document_id') + ->where('document_type', '1'); + } + + // ── 스코프 ── + + public function scopeType($query, string $type) + { + return $query->where('items.item_type', strtoupper($type)); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeSearch($query, ?string $search) + { + if (!$search) return $query; + return $query->where(function ($q) use ($search) { + $q->where('code', 'like', "%{$search}%") + ->orWhere('name', 'like', "%{$search}%"); + }); + } + + // ── 헬퍼 ── + + public function isProduct(): bool + { + return in_array($this->item_type, self::PRODUCT_TYPES); + } + + public function isMaterial(): bool + { + return in_array($this->item_type, self::MATERIAL_TYPES); + } + + public function getBomChildIds(): array + { + return collect($this->bom ?? [])->pluck('child_item_id')->filter()->toArray(); + } } diff --git a/app/Models/Items/ItemDetail.php b/app/Models/Items/ItemDetail.php new file mode 100644 index 00000000..abf5a2c3 --- /dev/null +++ b/app/Models/Items/ItemDetail.php @@ -0,0 +1,39 @@ + 'boolean', + 'is_purchasable' => 'boolean', + 'is_producible' => 'boolean', + 'is_variable_size' => 'boolean', + 'bending_details' => 'array', + 'certification_start_date' => 'date', + 'certification_end_date' => 'date', + ]; + + public function item() + { + return $this->belongsTo(Item::class); + } +} diff --git a/app/Services/FormulaApiService.php b/app/Services/FormulaApiService.php new file mode 100644 index 00000000..16810fe6 --- /dev/null +++ b/app/Services/FormulaApiService.php @@ -0,0 +1,83 @@ + 3000, 'H0' => 3000, 'QTY' => 1] + * @param int $tenantId 테넌트 ID + * @return array 성공 시 API 응답, 실패 시 ['success' => false, 'error' => '...'] + */ + public function calculateBom(string $finishedGoodsCode, array $variables, int $tenantId): array + { + try { + $apiKey = config('api-explorer.default_environments.0.api_key') + ?: env('FLOW_TESTER_API_KEY', ''); + + // Bearer token: 세션에 저장된 API 토큰 사용 (헤더 인증 UI에서 발급) + $bearerToken = session('api_explorer_token'); + + $headers = [ + 'Host' => 'api.sam.kr', + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'X-API-KEY' => $apiKey, + 'X-TENANT-ID' => (string) $tenantId, + ]; + + $http = Http::timeout(30)->withoutVerifying()->withHeaders($headers); + + if ($bearerToken) { + $http = $http->withToken($bearerToken); + } + + // API의 QuoteBomCalculateRequest는 W0, H0, QTY 등을 최상위 레벨에서 기대 + $payload = array_merge( + ['finished_goods_code' => $finishedGoodsCode], + $variables // W0, H0, QTY 등을 풀어서 전송 + ); + + $response = $http->post('https://nginx/api/v1/quotes/calculate/bom', $payload); + + if ($response->successful()) { + $json = $response->json(); + // ApiResponse::handle()는 {success, message, data} 구조로 래핑 + return $json['data'] ?? $json; + } + + Log::warning('FormulaApiService: API 호출 실패', [ + 'status' => $response->status(), + 'body' => $response->body(), + 'code' => $finishedGoodsCode, + ]); + + return [ + 'success' => false, + 'error' => 'API 응답 오류: HTTP ' . $response->status(), + ]; + } catch (\Exception $e) { + Log::error('FormulaApiService: 예외 발생', [ + 'message' => $e->getMessage(), + 'code' => $finishedGoodsCode, + ]); + + return [ + 'success' => false, + 'error' => '수식 계산 서버 연결 실패: ' . $e->getMessage(), + ]; + } + } +} diff --git a/app/Services/ItemManagementService.php b/app/Services/ItemManagementService.php new file mode 100644 index 00000000..cc7bc861 --- /dev/null +++ b/app/Services/ItemManagementService.php @@ -0,0 +1,136 @@ +where('tenant_id', $tenantId) + ->search($search) + ->active() + ->when($itemType, function ($query, $types) { + $typeList = explode(',', $types); + $query->whereIn('item_type', $typeList); + }) + ->orderBy('code') + ->paginate($perPage); + } + + /** + * BOM 재귀 트리 조회 + */ + public function getBomTree(int $itemId, int $maxDepth = 10): array + { + $item = Item::withoutGlobalScopes() + ->where('tenant_id', session('selected_tenant_id')) + ->with('details') + ->findOrFail($itemId); + + return $this->buildBomNode($item, 0, $maxDepth, []); + } + + /** + * 품목 상세 조회 (1depth BOM + 파일 + 절곡정보) + */ + public function getItemDetail(int $itemId): array + { + $tenantId = session('selected_tenant_id'); + + $item = Item::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->with(['details', 'category', 'files']) + ->findOrFail($itemId); + + // BOM 1depth: 직접 연결된 자식 품목만 + $bomChildren = []; + $bomData = $item->bom ?? []; + if (!empty($bomData)) { + $childIds = array_column($bomData, 'child_item_id'); + $children = Item::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->whereIn('id', $childIds) + ->get(['id', 'code', 'name', 'item_type', 'unit']) + ->keyBy('id'); + + foreach ($bomData as $bom) { + $child = $children->get($bom['child_item_id']); + if ($child) { + $bomChildren[] = [ + 'id' => $child->id, + 'code' => $child->code, + 'name' => $child->name, + 'item_type' => $child->item_type, + 'unit' => $child->unit, + 'quantity' => $bom['quantity'] ?? 1, + ]; + } + } + } + + return [ + 'item' => $item, + 'bom_children' => $bomChildren, + ]; + } + + // ── Private ── + + private function buildBomNode(Item $item, int $depth, int $maxDepth, array $visited): array + { + // 순환 참조 방지: visited 배열 + maxDepth 이중 안전장치 + if (in_array($item->id, $visited) || $depth >= $maxDepth) { + return $this->formatNode($item, $depth, []); + } + + $visited[] = $item->id; + $children = []; + + $bomData = $item->bom ?? []; + if (!empty($bomData)) { + $childIds = array_column($bomData, 'child_item_id'); + $childItems = Item::withoutGlobalScopes() + ->where('tenant_id', session('selected_tenant_id')) + ->whereIn('id', $childIds) + ->get() + ->keyBy('id'); + + foreach ($bomData as $bom) { + $childItem = $childItems->get($bom['child_item_id']); + if ($childItem) { + $childNode = $this->buildBomNode($childItem, $depth + 1, $maxDepth, $visited); + $childNode['quantity'] = $bom['quantity'] ?? 1; + $children[] = $childNode; + } + } + } + + return $this->formatNode($item, $depth, $children); + } + + private function formatNode(Item $item, int $depth, array $children): array + { + return [ + 'id' => $item->id, + 'code' => $item->code, + 'name' => $item->name, + 'item_type' => $item->item_type, + 'unit' => $item->unit, + 'depth' => $depth, + 'has_children' => count($children) > 0, + 'children' => $children, + ]; + } +} diff --git a/resources/views/dev-tools/api-explorer/index.blade.php b/resources/views/dev-tools/api-explorer/index.blade.php index 6fa503d9..fbbfcf09 100644 --- a/resources/views/dev-tools/api-explorer/index.blade.php +++ b/resources/views/dev-tools/api-explorer/index.blade.php @@ -293,8 +293,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline- - - @include('dev-tools.partials.auth-modal') + {{-- 인증 모달: 전역 레이아웃(app.blade.php)에서 포함 --}}
좌측에서 품목을 선택하세요.
+| 코드 | +{{ $item->code }} | +
| 품목명 | +{{ $item->name }} | +
| 유형 | ++ @php + $badgeColors = [ + 'FG' => 'bg-blue-100 text-blue-800', + 'PT' => 'bg-green-100 text-green-800', + 'SM' => 'bg-yellow-100 text-yellow-800', + 'RM' => 'bg-orange-100 text-orange-800', + 'CS' => 'bg-gray-100 text-gray-800', + ]; + $typeLabels = [ + 'FG' => '완제품', + 'PT' => '부품', + 'SM' => '부자재', + 'RM' => '원자재', + 'CS' => '소모품', + ]; + $color = $badgeColors[$item->item_type] ?? 'bg-gray-100 text-gray-800'; + @endphp + + {{ $item->item_type }} ({{ $typeLabels[$item->item_type] ?? $item->item_type }}) + + | +
| 카테고리 | +{{ $item->item_category }} | +
| 단위 | +{{ $item->unit ?? '-' }} | +
| 분류 | +{{ $item->category->name }} | +
| 상태 | ++ @if($item->is_active) + 활성 + @else + 비활성 + @endif + | +
| 설명 | +{{ $item->description }} | +
| LOT 관리 | +{{ $item->options['lot_managed'] ? '예' : '아니오' }} | +
| 소진방식 | +{{ $item->options['consumption_method'] }} | +
| 조달구분 | +{{ $item->options['production_source'] }} | +
BOM 구성이 없습니다.
+ @endif +{{ json_encode($item->details->bending_details, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}
+ | 판매가능 | +{{ $item->details->is_sellable ? '예' : '아니오' }} | +
| 구매가능 | +{{ $item->details->is_purchasable ? '예' : '아니오' }} | +
| 생산가능 | +{{ $item->details->is_producible ? '예' : '아니오' }} | +
| 안전재고 | +{{ $item->details->safety_stock }} | +
| 리드타임 | +{{ $item->details->lead_time }}일 | +
| 제품분류 | +{{ $item->details->product_category }} | +
| 부품유형 | +{{ $item->details->part_type }} | +
| 규격 | +{{ $item->details->specification }} | +
| 검사여부 | +{{ $item->details->is_inspection === 'Y' ? '예' : '아니오' }} | +
| 비고 | +{{ $item->details->remarks }} | +
| 인증번호 | +{{ $item->details->certification_number }} | +
| 유효기간 | ++ {{ $item->details->certification_start_date->format('Y-m-d') }} + ~ {{ $item->details->certification_end_date?->format('Y-m-d') ?? '미정' }} + | +
{{ $item->name }}
+{{ $item->code }}
+