Files
TK-BOM-Project/backend/app/routers/tubing.py
Hyungi Ahn 4f8e395f87
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
feat: SWG 가스켓 전체 구성 정보 표시 개선
- H/F/I/O SS304/GRAPHITE/CS/CS 패턴에서 4개 구성요소 모두 표시
- 기존 SS304 + GRAPHITE → SS304/GRAPHITE/CS/CS로 완전한 구성 표시
- 외부링/필러/내부링/추가구성 모든 정보 포함
- 구매수량 계산 모달에서 정확한 재질 정보 확인 가능
2025-08-30 14:23:01 +09:00

538 lines
20 KiB
Python

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)}")