from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session, joinedload from sqlalchemy import text from typing import Optional, List from datetime import datetime, date from pydantic import BaseModel from decimal import Decimal from ..database import get_db from ..models import ( TubingCategory, TubingSpecification, TubingManufacturer, TubingProduct, MaterialTubingMapping, Material ) router = APIRouter() # ================================ # Pydantic 모델들 # ================================ class TubingCategoryResponse(BaseModel): id: int category_code: str category_name: str description: Optional[str] = None is_active: bool created_at: datetime class Config: from_attributes = True class TubingManufacturerResponse(BaseModel): id: int manufacturer_code: str manufacturer_name: str country: Optional[str] = None website: Optional[str] = None is_active: bool class Config: from_attributes = True class TubingSpecificationResponse(BaseModel): id: int spec_code: str spec_name: str category_name: Optional[str] = None outer_diameter_mm: Optional[float] = None wall_thickness_mm: Optional[float] = None inner_diameter_mm: Optional[float] = None material_grade: Optional[str] = None material_standard: Optional[str] = None max_pressure_bar: Optional[float] = None max_temperature_c: Optional[float] = None min_temperature_c: Optional[float] = None standard_length_m: Optional[float] = None surface_finish: Optional[str] = None is_active: bool class Config: from_attributes = True class TubingProductResponse(BaseModel): id: int specification_id: int manufacturer_id: int manufacturer_part_number: str manufacturer_product_name: Optional[str] = None spec_name: Optional[str] = None manufacturer_name: Optional[str] = None list_price: Optional[float] = None currency: Optional[str] = 'KRW' lead_time_days: Optional[int] = None availability_status: Optional[str] = None datasheet_url: Optional[str] = None notes: Optional[str] = None is_active: bool class Config: from_attributes = True class TubingProductCreate(BaseModel): specification_id: int manufacturer_id: int manufacturer_part_number: str manufacturer_product_name: Optional[str] = None list_price: Optional[float] = None currency: str = 'KRW' lead_time_days: Optional[int] = None minimum_order_qty: Optional[float] = None standard_packaging_qty: Optional[float] = None availability_status: Optional[str] = None datasheet_url: Optional[str] = None catalog_page: Optional[str] = None notes: Optional[str] = None class MaterialTubingMappingCreate(BaseModel): material_id: int tubing_product_id: int confidence_score: Optional[float] = None mapping_method: str = 'manual' required_length_m: Optional[float] = None calculated_quantity: Optional[float] = None notes: Optional[str] = None # ================================ # API 엔드포인트들 # ================================ @router.get("/categories", response_model=List[TubingCategoryResponse]) async def get_tubing_categories( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=1000), db: Session = Depends(get_db) ): """Tubing 카테고리 목록 조회""" try: categories = db.query(TubingCategory)\ .filter(TubingCategory.is_active == True)\ .offset(skip)\ .limit(limit)\ .all() return categories except Exception as e: raise HTTPException(status_code=500, detail=f"카테고리 조회 실패: {str(e)}") @router.get("/manufacturers", response_model=List[TubingManufacturerResponse]) async def get_tubing_manufacturers( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=1000), search: Optional[str] = Query(None), country: Optional[str] = Query(None), db: Session = Depends(get_db) ): """Tubing 제조사 목록 조회""" try: query = db.query(TubingManufacturer)\ .filter(TubingManufacturer.is_active == True) if search: query = query.filter( TubingManufacturer.manufacturer_name.ilike(f"%{search}%") | TubingManufacturer.manufacturer_code.ilike(f"%{search}%") ) if country: query = query.filter(TubingManufacturer.country.ilike(f"%{country}%")) manufacturers = query.offset(skip).limit(limit).all() return manufacturers except Exception as e: raise HTTPException(status_code=500, detail=f"제조사 조회 실패: {str(e)}") @router.get("/specifications", response_model=List[TubingSpecificationResponse]) async def get_tubing_specifications( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=1000), category_id: Optional[int] = Query(None), material_grade: Optional[str] = Query(None), outer_diameter_min: Optional[float] = Query(None), outer_diameter_max: Optional[float] = Query(None), search: Optional[str] = Query(None), db: Session = Depends(get_db) ): """Tubing 규격 목록 조회""" try: query = db.query(TubingSpecification)\ .options(joinedload(TubingSpecification.category))\ .filter(TubingSpecification.is_active == True) if category_id: query = query.filter(TubingSpecification.category_id == category_id) if material_grade: query = query.filter(TubingSpecification.material_grade.ilike(f"%{material_grade}%")) if outer_diameter_min: query = query.filter(TubingSpecification.outer_diameter_mm >= outer_diameter_min) if outer_diameter_max: query = query.filter(TubingSpecification.outer_diameter_mm <= outer_diameter_max) if search: query = query.filter( TubingSpecification.spec_name.ilike(f"%{search}%") | TubingSpecification.spec_code.ilike(f"%{search}%") | TubingSpecification.material_grade.ilike(f"%{search}%") ) specifications = query.offset(skip).limit(limit).all() # 응답 데이터 변환 result = [] for spec in specifications: spec_dict = { "id": spec.id, "spec_code": spec.spec_code, "spec_name": spec.spec_name, "category_name": spec.category.category_name if spec.category else None, "outer_diameter_mm": float(spec.outer_diameter_mm) if spec.outer_diameter_mm else None, "wall_thickness_mm": float(spec.wall_thickness_mm) if spec.wall_thickness_mm else None, "inner_diameter_mm": float(spec.inner_diameter_mm) if spec.inner_diameter_mm else None, "material_grade": spec.material_grade, "material_standard": spec.material_standard, "max_pressure_bar": float(spec.max_pressure_bar) if spec.max_pressure_bar else None, "max_temperature_c": float(spec.max_temperature_c) if spec.max_temperature_c else None, "min_temperature_c": float(spec.min_temperature_c) if spec.min_temperature_c else None, "standard_length_m": float(spec.standard_length_m) if spec.standard_length_m else None, "surface_finish": spec.surface_finish, "is_active": spec.is_active } result.append(spec_dict) return result except Exception as e: raise HTTPException(status_code=500, detail=f"규격 조회 실패: {str(e)}") @router.get("/products", response_model=List[TubingProductResponse]) async def get_tubing_products( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=1000), specification_id: Optional[int] = Query(None), manufacturer_id: Optional[int] = Query(None), search: Optional[str] = Query(None), db: Session = Depends(get_db) ): """Tubing 제품 목록 조회""" try: query = db.query(TubingProduct)\ .options( joinedload(TubingProduct.specification), joinedload(TubingProduct.manufacturer) )\ .filter(TubingProduct.is_active == True) if specification_id: query = query.filter(TubingProduct.specification_id == specification_id) if manufacturer_id: query = query.filter(TubingProduct.manufacturer_id == manufacturer_id) if search: query = query.filter( TubingProduct.manufacturer_part_number.ilike(f"%{search}%") | TubingProduct.manufacturer_product_name.ilike(f"%{search}%") ) products = query.offset(skip).limit(limit).all() # 응답 데이터 변환 result = [] for product in products: product_dict = { "id": product.id, "specification_id": product.specification_id, "manufacturer_id": product.manufacturer_id, "manufacturer_part_number": product.manufacturer_part_number, "manufacturer_product_name": product.manufacturer_product_name, "spec_name": product.specification.spec_name if product.specification else None, "manufacturer_name": product.manufacturer.manufacturer_name if product.manufacturer else None, "list_price": float(product.list_price) if product.list_price else None, "currency": product.currency, "lead_time_days": product.lead_time_days, "availability_status": product.availability_status, "datasheet_url": product.datasheet_url, "notes": product.notes, "is_active": product.is_active } result.append(product_dict) return result except Exception as e: raise HTTPException(status_code=500, detail=f"제품 조회 실패: {str(e)}") @router.post("/products", response_model=TubingProductResponse) async def create_tubing_product( product_data: TubingProductCreate, db: Session = Depends(get_db) ): """새 Tubing 제품 등록""" try: # 중복 확인 existing = db.query(TubingProduct)\ .filter( TubingProduct.specification_id == product_data.specification_id, TubingProduct.manufacturer_id == product_data.manufacturer_id, TubingProduct.manufacturer_part_number == product_data.manufacturer_part_number ).first() if existing: raise HTTPException( status_code=400, detail=f"동일한 제품이 이미 등록되어 있습니다: {product_data.manufacturer_part_number}" ) # 새 제품 생성 new_product = TubingProduct(**product_data.dict()) db.add(new_product) db.commit() db.refresh(new_product) # 관련 정보와 함께 조회 product_with_relations = db.query(TubingProduct)\ .options( joinedload(TubingProduct.specification), joinedload(TubingProduct.manufacturer) )\ .filter(TubingProduct.id == new_product.id)\ .first() return { "id": product_with_relations.id, "specification_id": product_with_relations.specification_id, "manufacturer_id": product_with_relations.manufacturer_id, "manufacturer_part_number": product_with_relations.manufacturer_part_number, "manufacturer_product_name": product_with_relations.manufacturer_product_name, "spec_name": product_with_relations.specification.spec_name if product_with_relations.specification else None, "manufacturer_name": product_with_relations.manufacturer.manufacturer_name if product_with_relations.manufacturer else None, "list_price": float(product_with_relations.list_price) if product_with_relations.list_price else None, "currency": product_with_relations.currency, "lead_time_days": product_with_relations.lead_time_days, "availability_status": product_with_relations.availability_status, "datasheet_url": product_with_relations.datasheet_url, "notes": product_with_relations.notes, "is_active": product_with_relations.is_active } except HTTPException: db.rollback() raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=f"제품 등록 실패: {str(e)}") @router.post("/material-mapping") async def create_material_tubing_mapping( mapping_data: MaterialTubingMappingCreate, mapped_by: str = "admin", db: Session = Depends(get_db) ): """BOM 자재와 Tubing 제품 매핑 생성""" try: # 기존 매핑 확인 existing = db.query(MaterialTubingMapping)\ .filter( MaterialTubingMapping.material_id == mapping_data.material_id, MaterialTubingMapping.tubing_product_id == mapping_data.tubing_product_id ).first() if existing: raise HTTPException( status_code=400, detail="이미 매핑된 자재와 제품입니다" ) # 새 매핑 생성 new_mapping = MaterialTubingMapping( **mapping_data.dict(), mapped_by=mapped_by ) db.add(new_mapping) db.commit() db.refresh(new_mapping) return { "success": True, "message": "매핑이 성공적으로 생성되었습니다", "mapping_id": new_mapping.id } except HTTPException: db.rollback() raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=f"매핑 생성 실패: {str(e)}") @router.get("/material-mappings/{material_id}") async def get_material_tubing_mappings( material_id: int, db: Session = Depends(get_db) ): """특정 자재의 Tubing 매핑 조회""" try: mappings = db.query(MaterialTubingMapping)\ .options( joinedload(MaterialTubingMapping.tubing_product) .joinedload(TubingProduct.specification), joinedload(MaterialTubingMapping.tubing_product) .joinedload(TubingProduct.manufacturer) )\ .filter(MaterialTubingMapping.material_id == material_id)\ .all() result = [] for mapping in mappings: product = mapping.tubing_product mapping_dict = { "mapping_id": mapping.id, "confidence_score": float(mapping.confidence_score) if mapping.confidence_score else None, "mapping_method": mapping.mapping_method, "mapped_by": mapping.mapped_by, "mapped_at": mapping.mapped_at, "required_length_m": float(mapping.required_length_m) if mapping.required_length_m else None, "calculated_quantity": float(mapping.calculated_quantity) if mapping.calculated_quantity else None, "is_verified": mapping.is_verified, "tubing_product": { "id": product.id, "manufacturer_part_number": product.manufacturer_part_number, "manufacturer_product_name": product.manufacturer_product_name, "spec_name": product.specification.spec_name if product.specification else None, "manufacturer_name": product.manufacturer.manufacturer_name if product.manufacturer else None, "list_price": float(product.list_price) if product.list_price else None, "currency": product.currency, "availability_status": product.availability_status } } result.append(mapping_dict) return { "success": True, "material_id": material_id, "mappings": result } except Exception as e: raise HTTPException(status_code=500, detail=f"매핑 조회 실패: {str(e)}") @router.get("/search") async def search_tubing_products( query: str = Query(..., min_length=2), category: Optional[str] = Query(None), manufacturer: Optional[str] = Query(None), min_diameter: Optional[float] = Query(None), max_diameter: Optional[float] = Query(None), material_grade: Optional[str] = Query(None), limit: int = Query(20, ge=1, le=100), db: Session = Depends(get_db) ): """통합 Tubing 검색 (규격, 제품, 제조사)""" try: # SQL 쿼리로 복합 검색 sql_query = """ SELECT DISTINCT tp.id as product_id, tp.manufacturer_part_number, tp.manufacturer_product_name, tp.list_price, tp.currency, tp.availability_status, ts.spec_code, ts.spec_name, ts.outer_diameter_mm, ts.wall_thickness_mm, ts.material_grade, tc.category_name, tm.manufacturer_name, tm.country FROM tubing_products tp JOIN tubing_specifications ts ON tp.specification_id = ts.id JOIN tubing_categories tc ON ts.category_id = tc.id JOIN tubing_manufacturers tm ON tp.manufacturer_id = tm.id WHERE tp.is_active = true AND ts.is_active = true AND tc.is_active = true AND tm.is_active = true AND ( tp.manufacturer_part_number ILIKE :query OR tp.manufacturer_product_name ILIKE :query OR ts.spec_name ILIKE :query OR ts.spec_code ILIKE :query OR ts.material_grade ILIKE :query OR tm.manufacturer_name ILIKE :query ) """ params = {"query": f"%{query}%"} # 필터 조건 추가 if category: sql_query += " AND tc.category_code = :category" params["category"] = category if manufacturer: sql_query += " AND tm.manufacturer_code = :manufacturer" params["manufacturer"] = manufacturer if min_diameter: sql_query += " AND ts.outer_diameter_mm >= :min_diameter" params["min_diameter"] = min_diameter if max_diameter: sql_query += " AND ts.outer_diameter_mm <= :max_diameter" params["max_diameter"] = max_diameter if material_grade: sql_query += " AND ts.material_grade ILIKE :material_grade" params["material_grade"] = f"%{material_grade}%" sql_query += " ORDER BY tp.manufacturer_part_number LIMIT :limit" params["limit"] = limit result = db.execute(text(sql_query), params) products = result.fetchall() search_results = [] for product in products: product_dict = { "product_id": product.product_id, "manufacturer_part_number": product.manufacturer_part_number, "manufacturer_product_name": product.manufacturer_product_name, "list_price": float(product.list_price) if product.list_price else None, "currency": product.currency, "availability_status": product.availability_status, "spec_code": product.spec_code, "spec_name": product.spec_name, "outer_diameter_mm": float(product.outer_diameter_mm) if product.outer_diameter_mm else None, "wall_thickness_mm": float(product.wall_thickness_mm) if product.wall_thickness_mm else None, "material_grade": product.material_grade, "category_name": product.category_name, "manufacturer_name": product.manufacturer_name, "country": product.country } search_results.append(product_dict) return { "success": True, "query": query, "total_results": len(search_results), "results": search_results } except Exception as e: raise HTTPException(status_code=500, detail=f"검색 실패: {str(e)}")