Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- H/F/I/O SS304/GRAPHITE/CS/CS 패턴에서 4개 구성요소 모두 표시 - 기존 SS304 + GRAPHITE → SS304/GRAPHITE/CS/CS로 완전한 구성 표시 - 외부링/필러/내부링/추가구성 모든 정보 포함 - 구매수량 계산 모달에서 정확한 재질 정보 확인 가능
538 lines
20 KiB
Python
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)}") |