diff --git a/docker-compose.yml b/docker-compose.yml index 0b88a28..2681e8c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -417,6 +417,70 @@ services: networks: - tk-network + # ================================================================= + # TK-EG - BOM 자재관리 (tkeg) + # ================================================================= + + tkeg-postgres: + image: postgres:15-alpine + container_name: tk-tkeg-postgres + restart: unless-stopped + environment: + POSTGRES_DB: tk_bom + POSTGRES_USER: tkbom_user + POSTGRES_PASSWORD: ${TKEG_POSTGRES_PASSWORD} + TZ: Asia/Seoul + volumes: + - tkeg_postgres_data:/var/lib/postgresql/data + - ./tkeg/api/database/init:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U tkbom_user -d tk_bom"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - tk-network + + tkeg-api: + build: + context: ./tkeg/api + dockerfile: Dockerfile + container_name: tk-tkeg-api + restart: unless-stopped + ports: + - "30700:8000" + environment: + - DATABASE_URL=postgresql://tkbom_user:${TKEG_POSTGRES_PASSWORD}@tkeg-postgres:5432/tk_bom + - REDIS_URL=redis://redis:6379 + - SECRET_KEY=${SSO_JWT_SECRET} + - TKUSER_API_URL=http://tkuser-api:3000 + - ENVIRONMENT=production + - TZ=Asia/Seoul + volumes: + - tkeg_uploads:/app/uploads + depends_on: + tkeg-postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - tk-network + + tkeg-web: + build: + context: ./tkeg/web + dockerfile: Dockerfile + args: + - VITE_API_URL=/api + container_name: tk-tkeg-web + restart: unless-stopped + ports: + - "30780:80" + depends_on: + - tkeg-api + networks: + - tk-network + # ================================================================= # AI Service — 맥미니로 이전됨 (~/docker/tk-ai-service/) # ================================================================= @@ -479,6 +543,7 @@ services: - tkpurchase-web - tksafety-web - tksupport-web + - tkeg-web networks: - tk-network @@ -496,6 +561,8 @@ volumes: system3_uploads: external: true name: tkqc-package_uploads + tkeg_postgres_data: + tkeg_uploads: networks: tk-network: driver: bridge diff --git a/tkeg/api/Dockerfile b/tkeg/api/Dockerfile new file mode 100644 index 0000000..d04e321 --- /dev/null +++ b/tkeg/api/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.9-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + gcc g++ libpq-dev libmagic1 libmagic-dev netcat-openbsd \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV PYTHONPATH=/app + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/tkeg/api/app/__init__.py b/tkeg/api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tkeg/api/app/api/__init__.py b/tkeg/api/app/api/__init__.py new file mode 100644 index 0000000..cf26213 --- /dev/null +++ b/tkeg/api/app/api/__init__.py @@ -0,0 +1,4 @@ +""" +API 모듈 +분리된 API 엔드포인트들 +""" \ No newline at end of file diff --git a/tkeg/api/app/api/spools.py b/tkeg/api/app/api/spools.py new file mode 100644 index 0000000..a3e9314 --- /dev/null +++ b/tkeg/api/app/api/spools.py @@ -0,0 +1,231 @@ +""" +스풀 관리 API 엔드포인트 +""" + +from fastapi import APIRouter, Depends, HTTPException, Form +from sqlalchemy.orm import Session +from sqlalchemy import text +from typing import List, Optional +from datetime import datetime + +from ..database import get_db +from ..services.spool_manager import SpoolManager, classify_pipe_with_spool + +router = APIRouter() + +@router.get("/") +async def get_spools_info(): + return { + "message": "스풀 관리 API", + "features": [ + "스풀 식별자 생성", + "에리어/스풀 넘버 관리", + "스풀별 파이프 그룹핑", + "스풀 유효성 검증" + ] + } + +@router.post("/validate-identifier") +async def validate_spool_identifier( + spool_identifier: str = Form(...), + db: Session = Depends(get_db) +): + """스풀 식별자 유효성 검증""" + + spool_manager = SpoolManager() + validation_result = spool_manager.validate_spool_identifier(spool_identifier) + + return { + "spool_identifier": spool_identifier, + "validation": validation_result, + "timestamp": datetime.now().isoformat() + } + +@router.post("/generate-identifier") +async def generate_spool_identifier( + dwg_name: str = Form(...), + area_number: str = Form(...), + spool_number: str = Form(...), + db: Session = Depends(get_db) +): + """새로운 스풀 식별자 생성""" + + try: + spool_manager = SpoolManager() + spool_identifier = spool_manager.generate_spool_identifier( + dwg_name, area_number, spool_number + ) + + # 중복 확인 + duplicate_check = db.execute( + text("SELECT id FROM spools WHERE spool_identifier = :spool_id"), + {"spool_id": spool_identifier} + ).fetchone() + + return { + "success": True, + "spool_identifier": spool_identifier, + "is_duplicate": bool(duplicate_check), + "components": { + "dwg_name": dwg_name, + "area_number": spool_manager.format_area_number(area_number), + "spool_number": spool_manager.format_spool_number(spool_number) + } + } + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.get("/next-spool-number") +async def get_next_spool_number( + dwg_name: str, + area_number: str, + db: Session = Depends(get_db) +): + """다음 사용 가능한 스풀 넘버 추천""" + + try: + # 기존 스풀들 조회 + existing_spools_query = text(""" + SELECT spool_identifier + FROM spools + WHERE dwg_name = :dwg_name + """) + + result = db.execute(existing_spools_query, {"dwg_name": dwg_name}) + existing_spools = [row[0] for row in result.fetchall()] + + spool_manager = SpoolManager() + next_spool = spool_manager.get_next_spool_number( + dwg_name, area_number, existing_spools + ) + + return { + "dwg_name": dwg_name, + "area_number": spool_manager.format_area_number(area_number), + "next_spool_number": next_spool, + "existing_spools_count": len(existing_spools) + } + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.post("/materials/{material_id}/assign-spool") +async def assign_spool_to_material( + material_id: int, + area_number: str = Form(...), + spool_number: str = Form(...), + db: Session = Depends(get_db) +): + """자재에 스풀 정보 할당""" + + try: + # 자재 정보 조회 + material_query = text(""" + SELECT m.id, m.file_id, f.project_id, f.dwg_name + FROM materials m + JOIN files f ON m.file_id = f.id + WHERE m.id = :material_id + """) + + material = db.execute(material_query, {"material_id": material_id}).fetchone() + if not material: + raise HTTPException(status_code=404, detail="자재를 찾을 수 없습니다") + + # 스풀 식별자 생성 + spool_manager = SpoolManager() + area_formatted = spool_manager.format_area_number(area_number) + spool_formatted = spool_manager.format_spool_number(spool_number) + spool_identifier = spool_manager.generate_spool_identifier( + material.dwg_name, area_formatted, spool_formatted + ) + + # 자재 업데이트 + update_query = text(""" + UPDATE materials + SET area_number = :area_number, + spool_number = :spool_number, + spool_identifier = :spool_identifier, + spool_input_required = FALSE, + spool_validated = TRUE, + updated_at = NOW() + WHERE id = :material_id + """) + + db.execute(update_query, { + "material_id": material_id, + "area_number": area_formatted, + "spool_number": spool_formatted, + "spool_identifier": spool_identifier + }) + + # 스풀 테이블에 등록 (없다면) + spool_insert_query = text(""" + INSERT INTO spools (project_id, dwg_name, area_number, spool_number, spool_identifier, created_at) + VALUES (:project_id, :dwg_name, :area_number, :spool_number, :spool_identifier, NOW()) + ON CONFLICT (project_id, dwg_name, area_number, spool_number) DO NOTHING + """) + + db.execute(spool_insert_query, { + "project_id": material.project_id, + "dwg_name": material.dwg_name, + "area_number": area_formatted, + "spool_number": spool_formatted, + "spool_identifier": spool_identifier + }) + + db.commit() + + return { + "success": True, + "material_id": material_id, + "spool_identifier": spool_identifier, + "message": "스풀 정보가 할당되었습니다" + } + + except ValueError as e: + db.rollback() + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"스풀 할당 실패: {str(e)}") + +@router.get("/project/{project_id}/spools") +async def get_project_spools( + project_id: int, + db: Session = Depends(get_db) +): + """프로젝트의 모든 스풀 조회""" + + query = text(""" + SELECT s.*, + COUNT(m.id) as material_count, + SUM(m.quantity) as total_quantity + FROM spools s + LEFT JOIN materials m ON s.spool_identifier = m.spool_identifier + WHERE s.project_id = :project_id + GROUP BY s.id + ORDER BY s.dwg_name, s.area_number, s.spool_number + """) + + result = db.execute(query, {"project_id": project_id}) + spools = result.fetchall() + + return { + "project_id": project_id, + "spools_count": len(spools), + "spools": [ + { + "id": spool.id, + "spool_identifier": spool.spool_identifier, + "dwg_name": spool.dwg_name, + "area_number": spool.area_number, + "spool_number": spool.spool_number, + "material_count": spool.material_count or 0, + "total_quantity": float(spool.total_quantity or 0), + "status": spool.status, + "created_at": spool.created_at + } + for spool in spools + ] + } diff --git a/tkeg/api/app/auth/__init__.py b/tkeg/api/app/auth/__init__.py new file mode 100644 index 0000000..2a5ba51 --- /dev/null +++ b/tkeg/api/app/auth/__init__.py @@ -0,0 +1,9 @@ +"""tkeg 인증 모듈 (SSO 전용)""" +from .middleware import get_current_user, get_current_user_optional, require_roles, require_admin + +__all__ = [ + "get_current_user", + "get_current_user_optional", + "require_roles", + "require_admin", +] diff --git a/tkeg/api/app/auth/jwt_service.py b/tkeg/api/app/auth/jwt_service.py new file mode 100644 index 0000000..214cf20 --- /dev/null +++ b/tkeg/api/app/auth/jwt_service.py @@ -0,0 +1,17 @@ +"""SSO JWT 토큰 검증 전용""" +import jwt +import os +from fastapi import HTTPException, status + +SSO_JWT_SECRET = os.getenv("SECRET_KEY", "") +ALGORITHM = "HS256" + + +def verify_access_token(token: str) -> dict: + try: + payload = jwt.decode(token, SSO_JWT_SECRET, algorithms=[ALGORITHM]) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="토큰이 만료되었습니다") + except jwt.InvalidTokenError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="유효하지 않은 토큰입니다") + return payload diff --git a/tkeg/api/app/auth/middleware.py b/tkeg/api/app/auth/middleware.py new file mode 100644 index 0000000..6420637 --- /dev/null +++ b/tkeg/api/app/auth/middleware.py @@ -0,0 +1,74 @@ +"""SSO JWT 인증 미들웨어 — tkeg""" +import jwt +import os +from fastapi import Depends, HTTPException, status, Request +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from typing import Optional + +SSO_JWT_SECRET = os.getenv("SECRET_KEY", "") +ALGORITHM = "HS256" +security = HTTPBearer(auto_error=False) + + +def _extract_token( + request: Request, + credentials: Optional[HTTPAuthorizationCredentials], +) -> str: + """Bearer header 또는 sso_token 쿠키에서 토큰 추출""" + if credentials and credentials.credentials: + return credentials.credentials + token = request.cookies.get("sso_token", "") + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="인증 토큰이 필요합니다") + return token + + +def _decode_token(token: str) -> dict: + try: + payload = jwt.decode(token, SSO_JWT_SECRET, algorithms=[ALGORITHM]) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="토큰이 만료되었습니다") + except jwt.InvalidTokenError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="유효하지 않은 토큰입니다") + return payload + + +async def get_current_user( + request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), +) -> dict: + """SSO JWT에서 사용자 정보 추출""" + token = _extract_token(request, credentials) + payload = _decode_token(token) + username = payload.get("sub") or payload.get("username") + if not username: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="토큰에 사용자 정보가 없습니다") + return { + "user_id": payload.get("user_id"), + "username": username, + "name": payload.get("name", username), + "role": payload.get("role", "user"), + "department": payload.get("department"), + } + + +async def get_current_user_optional( + request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), +) -> Optional[dict]: + """선택적 인증 — 토큰 없으면 None""" + try: + return await get_current_user(request, credentials) + except HTTPException: + return None + + +def require_roles(allowed_roles): + async def checker(user: dict = Depends(get_current_user)): + if user.get("role") not in allowed_roles: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="권한이 부족합니다") + return user + return checker + + +require_admin = require_roles(["admin", "system", "Admin", "System Admin"]) diff --git a/tkeg/api/app/config.py b/tkeg/api/app/config.py new file mode 100644 index 0000000..b92ef30 --- /dev/null +++ b/tkeg/api/app/config.py @@ -0,0 +1,290 @@ +""" +TK-MP-Project 설정 관리 +환경별 설정을 중앙화하여 관리 +""" +import os +from typing import List, Optional, Dict, Any +from pathlib import Path +from pydantic_settings import BaseSettings +from pydantic import Field, validator +import json + + +class DatabaseSettings(BaseSettings): + """데이터베이스 설정""" + url: str = Field( + default="postgresql://tkbom_user:tkbom_password@tkeg-postgres:5432/tk_bom", + description="데이터베이스 연결 URL" + ) + pool_size: int = Field(default=10, description="연결 풀 크기") + max_overflow: int = Field(default=20, description="최대 오버플로우") + pool_timeout: int = Field(default=30, description="연결 타임아웃 (초)") + pool_recycle: int = Field(default=3600, description="연결 재활용 시간 (초)") + echo: bool = Field(default=False, description="SQL 로그 출력 여부") + + class Config: + env_prefix = "DB_" + + +class RedisSettings(BaseSettings): + """Redis 설정""" + url: str = Field(default="redis://redis:6379", description="Redis 연결 URL") + max_connections: int = Field(default=20, description="최대 연결 수") + socket_timeout: int = Field(default=5, description="소켓 타임아웃 (초)") + socket_connect_timeout: int = Field(default=5, description="연결 타임아웃 (초)") + retry_on_timeout: bool = Field(default=True, description="타임아웃 시 재시도") + decode_responses: bool = Field(default=False, description="응답 디코딩 여부") + + class Config: + env_prefix = "REDIS_" + + +class SecuritySettings(BaseSettings): + """보안 설정""" + cors_origins: List[str] = Field(default=[], description="CORS 허용 도메인") + cors_methods: List[str] = Field( + default=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + description="CORS 허용 메서드" + ) + cors_headers: List[str] = Field( + default=["*"], + description="CORS 허용 헤더" + ) + cors_credentials: bool = Field(default=True, description="CORS 자격증명 허용") + + # 파일 업로드 보안 + max_file_size: int = Field(default=50 * 1024 * 1024, description="최대 파일 크기 (bytes)") + allowed_file_extensions: List[str] = Field( + default=['.xlsx', '.xls', '.csv'], + description="허용된 파일 확장자" + ) + upload_path: str = Field(default="uploads", description="업로드 경로") + + # API 보안 + api_key_header: str = Field(default="X-API-Key", description="API 키 헤더명") + rate_limit_per_minute: int = Field(default=100, description="분당 요청 제한") + + class Config: + env_prefix = "SECURITY_" + + +class LoggingSettings(BaseSettings): + """로깅 설정""" + level: str = Field(default="INFO", description="로그 레벨") + file_path: str = Field(default="logs/app.log", description="로그 파일 경로") + max_file_size: int = Field(default=10 * 1024 * 1024, description="로그 파일 최대 크기") + backup_count: int = Field(default=5, description="백업 파일 수") + format: str = Field( + default="%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s", + description="로그 포맷" + ) + date_format: str = Field(default="%Y-%m-%d %H:%M:%S", description="날짜 포맷") + + # 환경별 로그 레벨 + development_level: str = Field(default="DEBUG", description="개발 환경 로그 레벨") + production_level: str = Field(default="INFO", description="운영 환경 로그 레벨") + test_level: str = Field(default="WARNING", description="테스트 환경 로그 레벨") + + class Config: + env_prefix = "LOG_" + + +class PerformanceSettings(BaseSettings): + """성능 설정""" + # 캐시 설정 + cache_ttl_default: int = Field(default=3600, description="기본 캐시 TTL (초)") + cache_ttl_files: int = Field(default=300, description="파일 목록 캐시 TTL") + cache_ttl_materials: int = Field(default=600, description="자재 목록 캐시 TTL") + cache_ttl_jobs: int = Field(default=1800, description="작업 목록 캐시 TTL") + cache_ttl_classification: int = Field(default=3600, description="분류 결과 캐시 TTL") + cache_ttl_statistics: int = Field(default=900, description="통계 데이터 캐시 TTL") + + # 파일 처리 설정 + chunk_size: int = Field(default=1000, description="파일 처리 청크 크기") + max_workers: int = Field(default=4, description="최대 워커 수") + memory_limit_mb: int = Field(default=512, description="메모리 제한 (MB)") + + class Config: + env_prefix = "PERF_" + + +class Settings(BaseSettings): + """메인 애플리케이션 설정""" + + # 기본 설정 + app_name: str = Field(default="TK-EG BOM Management API", description="애플리케이션 이름") + app_version: str = Field(default="1.0.0", description="애플리케이션 버전") + app_description: str = Field( + default="자재 분류 및 프로젝트 관리 시스템", + description="애플리케이션 설명" + ) + debug: bool = Field(default=False, description="디버그 모드") + + # 환경 설정 + environment: str = Field( + default="development", + description="실행 환경 (development, production, test, synology)" + ) + + # 서버 설정 + host: str = Field(default="0.0.0.0", description="서버 호스트") + port: int = Field(default=8000, description="서버 포트") + reload: bool = Field(default=False, description="자동 재로드") + workers: int = Field(default=1, description="워커 프로세스 수") + + # 하위 설정들 + database: DatabaseSettings = Field(default_factory=DatabaseSettings) + redis: RedisSettings = Field(default_factory=RedisSettings) + security: SecuritySettings = Field(default_factory=SecuritySettings) + logging: LoggingSettings = Field(default_factory=LoggingSettings) + performance: PerformanceSettings = Field(default_factory=PerformanceSettings) + + # 추가 설정 + timezone: str = Field(default="Asia/Seoul", description="시간대") + language: str = Field(default="ko", description="기본 언어") + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + case_sensitive = False + extra = "ignore" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._setup_environment_specific_settings() + self._setup_cors_origins() + self._validate_settings() + + @validator('environment') + def validate_environment(cls, v): + """환경 값 검증""" + allowed_environments = ['development', 'production', 'test', 'synology'] + if v not in allowed_environments: + raise ValueError(f'Environment must be one of: {allowed_environments}') + return v + + @validator('port') + def validate_port(cls, v): + """포트 번호 검증""" + if not 1 <= v <= 65535: + raise ValueError('Port must be between 1 and 65535') + return v + + def _setup_environment_specific_settings(self): + """환경별 특정 설정 적용""" + if self.environment == "development": + self.debug = True + self.reload = True + self.database.echo = True + self.logging.level = self.logging.development_level + + elif self.environment == "production": + self.debug = False + self.reload = False + self.database.echo = False + self.logging.level = self.logging.production_level + self.workers = max(2, os.cpu_count() or 1) + + elif self.environment == "test": + self.debug = False + self.reload = False + self.database.echo = False + self.logging.level = self.logging.test_level + # 테스트용 인메모리 데이터베이스 + self.database.url = "sqlite:///:memory:" + + elif self.environment == "synology": + self.debug = False + self.reload = False + self.host = "0.0.0.0" + self.port = 10080 + + def _setup_cors_origins(self): + """환경별 CORS origins 설정""" + if not self.security.cors_origins: + cors_config = { + "development": [ + "http://localhost:3000", + "http://localhost:5173", + "http://localhost:13000", + "http://127.0.0.1:3000", + "http://127.0.0.1:5173", + "http://127.0.0.1:13000" + ], + "production": [ + "https://tkeg.technicalkorea.net", + "https://tkfb.technicalkorea.net", + "https://tkreport.technicalkorea.net", + "https://tkqc.technicalkorea.net", + "https://tkuser.technicalkorea.net", + ], + "synology": [ + "http://192.168.0.3:10173", + "http://localhost:10173" + ], + "test": [ + "http://testserver" + ] + } + + self.security.cors_origins = cors_config.get( + self.environment, + cors_config["development"] + ) + + def _validate_settings(self): + """설정 검증""" + # 로그 디렉토리 생성 + log_dir = Path(self.logging.file_path).parent + log_dir.mkdir(parents=True, exist_ok=True) + + # 업로드 디렉토리 생성 + upload_dir = Path(self.security.upload_path) + upload_dir.mkdir(parents=True, exist_ok=True) + + def get_database_url(self) -> str: + """데이터베이스 URL 반환""" + return self.database.url + + def get_redis_url(self) -> str: + """Redis URL 반환""" + return self.redis.url + + def is_development(self) -> bool: + """개발 환경 여부""" + return self.environment == "development" + + def is_production(self) -> bool: + """운영 환경 여부""" + return self.environment == "production" + + def is_test(self) -> bool: + """테스트 환경 여부""" + return self.environment == "test" + + def get_cors_config(self) -> Dict[str, Any]: + """CORS 설정 반환""" + return { + "allow_origins": self.security.cors_origins, + "allow_methods": self.security.cors_methods, + "allow_headers": self.security.cors_headers, + "allow_credentials": self.security.cors_credentials + } + + def to_dict(self) -> Dict[str, Any]: + """설정을 딕셔너리로 변환""" + return self.dict() + + def save_to_file(self, file_path: str): + """설정을 파일로 저장""" + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(self.to_dict(), f, indent=2, ensure_ascii=False, default=str) + + +# 전역 설정 인스턴스 +settings = Settings() + + +def get_settings() -> Settings: + """설정 인스턴스 반환""" + return settings diff --git a/tkeg/api/app/database.py b/tkeg/api/app/database.py new file mode 100644 index 0000000..8947a57 --- /dev/null +++ b/tkeg/api/app/database.py @@ -0,0 +1,35 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +import os + +# 데이터베이스 URL (환경변수에서 읽거나 기본값 사용) +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql://tkbom_user:tkbom_password@tkeg-postgres:5432/tk_bom" +) + +# SQLAlchemy 엔진 생성 (UTF-8 인코딩 설정) +engine = create_engine( + DATABASE_URL, + connect_args={ + "client_encoding": "utf8", + "options": "-c client_encoding=utf8 -c timezone=UTC" + }, + pool_pre_ping=True, + echo=False +) + +# 세션 팩토리 생성 +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Base 클래스 생성 +Base = declarative_base() + +# 데이터베이스 의존성 함수 +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/tkeg/api/app/main.py b/tkeg/api/app/main.py new file mode 100644 index 0000000..10bd1b9 --- /dev/null +++ b/tkeg/api/app/main.py @@ -0,0 +1,265 @@ +from fastapi import FastAPI, UploadFile, File, Form +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import text +from .database import get_db +from sqlalchemy.orm import Session +from fastapi import Depends +from typing import Optional, List, Dict +import os +import shutil +# 설정 및 로깅 import +from .config import get_settings +from .utils.logger import get_logger +from .utils.error_handlers import setup_error_handlers + +# 설정 로드 +settings = get_settings() + +# 로거 설정 +logger = get_logger(__name__) + +# FastAPI 앱 생성 (요청 크기 제한 증가) +app = FastAPI( + title=settings.app_name, + description="자재 분류 및 프로젝트 관리 시스템", + version=settings.app_version, + debug=settings.debug +) + +# 요청 크기 제한 설정 (100MB로 증가) +from fastapi.middleware.trustedhost import TrustedHostMiddleware +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +class RequestSizeLimitMiddleware(BaseHTTPMiddleware): + def __init__(self, app, max_request_size: int = 100 * 1024 * 1024): # 100MB + super().__init__(app) + self.max_request_size = max_request_size + + async def dispatch(self, request: Request, call_next): + if "content-length" in request.headers: + content_length = int(request.headers["content-length"]) + if content_length > self.max_request_size: + return Response("Request Entity Too Large", status_code=413) + return await call_next(request) + +# 요청 크기 제한 미들웨어 추가 +app.add_middleware(RequestSizeLimitMiddleware, max_request_size=100 * 1024 * 1024) + +# 에러 핸들러 설정 +setup_error_handlers(app) + +# CORS 설정 (환경별 분리 + SSO 도메인 추가) +cors_config = settings.get_cors_config() +# SSO 통합 도메인 항상 포함 +sso_origins = [ + "https://tkeg.technicalkorea.net", + "https://tkfb.technicalkorea.net", + "https://tkreport.technicalkorea.net", + "https://tkqc.technicalkorea.net", + "https://tkuser.technicalkorea.net", +] +existing_origins = cors_config.get("allow_origins", []) +for origin in sso_origins: + if origin not in existing_origins: + existing_origins.append(origin) +cors_config["allow_origins"] = existing_origins +app.add_middleware( + CORSMiddleware, + **cors_config +) + +logger.info(f"CORS origins configured for {settings.environment}: {cors_config['allow_origins']}") + +# 라우터들 import 및 등록 - files 라우터를 최우선으로 등록 +try: + from .routers import files + app.include_router(files.router, prefix="/files", tags=["files"]) + logger.info("FILES 라우터 등록 완료 - 최우선") +except ImportError: + logger.warning("files 라우터를 찾을 수 없습니다") + +try: + from .routers import jobs + app.include_router(jobs.router, prefix="/jobs", tags=["jobs"]) +except ImportError: + logger.warning("jobs 라우터를 찾을 수 없습니다") + +try: + from .routers import purchase + app.include_router(purchase.router, tags=["purchase"]) +except ImportError: + logger.warning("purchase 라우터를 찾을 수 없습니다") + +try: + from .routers import material_comparison + app.include_router(material_comparison.router, tags=["material-comparison"]) +except ImportError: + logger.warning("material_comparison 라우터를 찾을 수 없습니다") + +try: + from .routers import dashboard + app.include_router(dashboard.router, tags=["dashboard"]) +except ImportError: + logger.warning("dashboard 라우터를 찾을 수 없습니다") + +# 리비전 관리 라우터 (임시 비활성화) +# try: +# from .routers import revision_management +# app.include_router(revision_management.router, tags=["revision-management"]) +# except ImportError: +# logger.warning("revision_management 라우터를 찾을 수 없습니다") + +try: + from .routers import tubing + app.include_router(tubing.router, prefix="/tubing", tags=["tubing"]) +except ImportError: + logger.warning("tubing 라우터를 찾을 수 없습니다") + +# 구매 추적 라우터 +try: + from .routers import purchase_tracking + app.include_router(purchase_tracking.router) +except ImportError: + logger.warning("purchase_tracking 라우터를 찾을 수 없습니다") + +# 엑셀 내보내기 관리 라우터 +try: + from .routers import export_manager + app.include_router(export_manager.router) +except ImportError: + logger.warning("export_manager 라우터를 찾을 수 없습니다") + +# 구매신청 관리 라우터 +try: + from .routers import purchase_request + app.include_router(purchase_request.router) + logger.info("purchase_request 라우터 등록 완료") +except ImportError as e: + logger.warning(f"purchase_request 라우터를 찾을 수 없습니다: {e}") + +# 자재 관리 라우터 +try: + from .routers import materials + app.include_router(materials.router) + logger.info("materials 라우터 등록 완료") +except ImportError as e: + logger.warning(f"materials 라우터를 찾을 수 없습니다: {e}") + +# 파일 관리 API 라우터 등록 (비활성화 - files 라우터와 충돌 방지) +# try: +# from .api import file_management +# app.include_router(file_management.router, tags=["file-management"]) +# logger.info("파일 관리 API 라우터 등록 완료") +# except ImportError as e: +# logger.warning(f"파일 관리 라우터를 찾을 수 없습니다: {e}") +logger.info("파일 관리 API 라우터 비활성화됨 (files 라우터 사용)") + +# 인증은 SSO(tkuser)로 통합 — 별도 auth 라우터 불필요 +logger.info("인증은 SSO(tkuser)로 통합됨") + +# 프로젝트 관리 API (비활성화 - jobs 테이블 사용) +# projects 테이블은 더 이상 사용하지 않음 +# ): +# """프로젝트 수정""" +# try: +# update_query = text(""" +# UPDATE projects +# SET project_name = :project_name, status = :status +# WHERE id = :project_id +# """) +# +# db.execute(update_query, { +# "project_id": project_id, +# "project_name": project_data["project_name"], +# "status": project_data["status"] +# }) +# +# db.commit() +# return {"success": True} +# except Exception as e: +# db.rollback() +# return {"error": f"프로젝트 수정 실패: {str(e)}"} + +# @app.delete("/projects/{project_id}") +# async def delete_project( +# project_id: int, +# db: Session = Depends(get_db) +# ): +# """프로젝트 삭제""" +# try: +# delete_query = text("DELETE FROM projects WHERE id = :project_id") +# db.execute(delete_query, {"project_id": project_id}) +# db.commit() +# return {"success": True} +# except Exception as e: +# db.rollback() +# return {"error": f"프로젝트 삭제 실패: {str(e)}"} + +@app.get("/") +async def root(): + return { + "message": "TK-EG BOM Management API", + "version": "1.0.0", + "endpoints": ["/docs", "/jobs", "/files", "/projects"] + } + +# Jobs API +# @app.get("/jobs") +# async def get_jobs(db: Session = Depends(get_db)): +# """Jobs 목록 조회""" +# try: +# # jobs 테이블에서 데이터 조회 +# query = text(""" +# SELECT +# job_no, +# job_name, +# client_name, +# end_user, +# epc_company, +# status, +# created_at +# FROM jobs +# WHERE is_active = true +# ORDER BY created_at DESC +# """) +# +# result = db.execute(query) +# jobs = result.fetchall() +# +# return [ +# { +# "job_no": job.job_no, +# "job_name": job.job_name, +# "client_name": job.client_name, +# "end_user": job.end_user, +# "epc_company": job.epc_company, +# "status": job.status or "진행중", +# "created_at": job.created_at +# } +# for job in jobs +# ] +# except Exception as e: +# print(f"Jobs 조회 에러: {str(e)}") +# return {"error": f"Jobs 조회 실패: {str(e)}"} + +# 리비전 관리 라우터 +try: + from .routers import revision_management + app.include_router(revision_management.router) + logger.info("revision_management 라우터 등록 완료") +except ImportError as e: + logger.warning(f"revision_management 라우터를 찾을 수 없습니다: {e}") + +# 파일 업로드는 /files/upload 엔드포인트를 사용하세요 (routers/files.py) + +# parse_file과 classify_material_item 함수는 routers/files.py로 이동되었습니다 + +@app.get("/health") +async def health_check(): + return {"status": "healthy", "timestamp": "2024-07-15"} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000, reload=True) diff --git a/tkeg/api/app/models.py b/tkeg/api/app/models.py new file mode 100644 index 0000000..1d9c4f1 --- /dev/null +++ b/tkeg/api/app/models.py @@ -0,0 +1,471 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, Numeric, ForeignKey, JSON +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship +from datetime import datetime + +Base = declarative_base() + +class Project(Base): + __tablename__ = "projects" + + id = Column(Integer, primary_key=True, index=True) + official_project_code = Column(String(50), unique=True, index=True) + project_name = Column(String(200), nullable=False) + client_name = Column(String(100)) + design_project_code = Column(String(50)) + design_project_name = Column(String(200)) + is_code_matched = Column(Boolean, default=False) + matched_by = Column(String(100)) + matched_at = Column(DateTime) + status = Column(String(20), default='active') + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + description = Column(Text) + notes = Column(Text) + + # 관계 설정 + files = relationship("File", back_populates="project") + +class File(Base): + __tablename__ = "files" + + id = Column(Integer, primary_key=True, index=True) + project_id = Column(Integer, ForeignKey("projects.id")) + filename = Column(String(255), nullable=False) + original_filename = Column(String(255), nullable=False) + file_path = Column(String(500), nullable=False) + revision = Column(String(20), default='Rev.0') + upload_date = Column(DateTime, default=datetime.utcnow) + uploaded_by = Column(String(100)) + file_type = Column(String(10)) + file_size = Column(Integer) + is_active = Column(Boolean, default=True) + + # 관계 설정 + project = relationship("Project", back_populates="files") + materials = relationship("Material", back_populates="file") + +class Material(Base): + __tablename__ = "materials" + + id = Column(Integer, primary_key=True, index=True) + file_id = Column(Integer, ForeignKey("files.id")) + line_number = Column(Integer) + original_description = Column(Text, nullable=False) + classified_category = Column(String(50)) + classified_subcategory = Column(String(100)) + material_grade = Column(String(50)) + schedule = Column(String(20)) + size_spec = Column(String(50)) + quantity = Column(Numeric(10, 3), nullable=False) + unit = Column(String(10), nullable=False) + # length = Column(Numeric(10, 3)) # 임시로 주석 처리 + drawing_name = Column(String(100)) + area_code = Column(String(20)) + line_no = Column(String(50)) + classification_confidence = Column(Numeric(3, 2)) + is_verified = Column(Boolean, default=False) + verified_by = Column(String(50)) + verified_at = Column(DateTime) + drawing_reference = Column(String(100)) + notes = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow) + is_active = Column(Boolean, default=True) + + # 추가 필드들 + main_nom = Column(String(50)) + red_nom = Column(String(50)) + purchase_confirmed = Column(Boolean, default=False) + purchase_confirmed_at = Column(DateTime) + purchase_status = Column(String(20), default='not_purchased') + purchase_confirmed_by = Column(String(100)) + confirmed_quantity = Column(Numeric(10, 3)) + revision_status = Column(String(20), default='active') + material_hash = Column(String(100)) + normalized_description = Column(Text) + full_material_grade = Column(String(100)) + row_number = Column(Integer) + length = Column(Numeric(10, 3)) + brand = Column(String(100)) + user_requirement = Column(Text) + total_length = Column(Numeric(10, 3)) + + # 관계 설정 + file = relationship("File", back_populates="materials") + +# ========== 자재 규격/재질 기준표 테이블들 ========== + +class MaterialStandard(Base): + """자재 규격 표준 (ASTM, KS, JIS 등)""" + __tablename__ = "material_standards" + + id = Column(Integer, primary_key=True, index=True) + standard_code = Column(String(20), unique=True, nullable=False, index=True) # ASTM_ASME, KS, JIS + standard_name = Column(String(100), nullable=False) # 미국재질학회, 한국산업표준, 일본공업규격 + description = Column(Text) + country = Column(String(50)) # USA, KOREA, JAPAN + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + categories = relationship("MaterialCategory", back_populates="standard") + +class MaterialCategory(Base): + """제조방식별 카테고리 (FORGED, WELDED, CAST 등)""" + __tablename__ = "material_categories" + + id = Column(Integer, primary_key=True, index=True) + standard_id = Column(Integer, ForeignKey("material_standards.id")) + category_code = Column(String(50), nullable=False) # FORGED_GRADES, WELDED_GRADES, CAST_GRADES + category_name = Column(String(100), nullable=False) # 단조품, 용접품, 주조품 + description = Column(Text) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + standard = relationship("MaterialStandard", back_populates="categories") + specifications = relationship("MaterialSpecification", back_populates="category") + +class MaterialSpecification(Base): + """구체적인 규격 (A182, A105, D3507 등)""" + __tablename__ = "material_specifications" + + id = Column(Integer, primary_key=True, index=True) + category_id = Column(Integer, ForeignKey("material_categories.id")) + spec_code = Column(String(20), nullable=False) # A182, A105, D3507 + spec_name = Column(String(100), nullable=False) # 탄소강 단조품, 배관용 탄소강관 + description = Column(Text) + material_type = Column(String(50)) # carbon_alloy, stainless, carbon + manufacturing = Column(String(50)) # FORGED, WELDED_FABRICATED, CAST, SEAMLESS + pressure_rating = Column(String(100)) # 150LB ~ 9000LB + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + category = relationship("MaterialCategory", back_populates="specifications") + grades = relationship("MaterialGrade", back_populates="specification") + patterns = relationship("MaterialPattern", back_populates="specification") + +class MaterialGrade(Base): + """등급별 상세 정보 (F1, F5, WPA, WPB 등)""" + __tablename__ = "material_grades" + + id = Column(Integer, primary_key=True, index=True) + specification_id = Column(Integer, ForeignKey("material_specifications.id")) + grade_code = Column(String(20), nullable=False) # F1, F5, WPA, WPB + grade_name = Column(String(100)) + composition = Column(String(200)) # 0.5Mo, 5Cr-0.5Mo, 18Cr-8Ni + applications = Column(String(200)) # 중온용, 고온용, 저압용 + temp_max = Column(String(50)) # 482°C, 649°C + temp_range = Column(String(100)) # -29°C ~ 400°C + yield_strength = Column(String(50)) # 30 ksi, 35 ksi + tensile_strength = Column(String(50)) + corrosion_resistance = Column(String(50)) # 보통, 우수 + stabilizer = Column(String(50)) # Titanium, Niobium + base_grade = Column(String(20)) # 304, 316 + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + specification = relationship("MaterialSpecification", back_populates="grades") + +class MaterialPattern(Base): + """정규식 패턴들""" + __tablename__ = "material_patterns" + + id = Column(Integer, primary_key=True, index=True) + specification_id = Column(Integer, ForeignKey("material_specifications.id")) + pattern = Column(Text, nullable=False) # 정규식 패턴 + description = Column(String(200)) + priority = Column(Integer, default=1) # 패턴 우선순위 + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + specification = relationship("MaterialSpecification", back_populates="patterns") + +class SpecialMaterial(Base): + """특수 재질 (INCONEL, HASTELLOY, TITANIUM 등)""" + __tablename__ = "special_materials" + + id = Column(Integer, primary_key=True, index=True) + material_type = Column(String(50), nullable=False) # SUPER_ALLOYS, TITANIUM, COPPER_ALLOYS + material_name = Column(String(100), nullable=False) # INCONEL, HASTELLOY, TITANIUM + description = Column(Text) + composition = Column(String(200)) + applications = Column(Text) + temp_max = Column(String(50)) + manufacturing = Column(String(50)) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + grades = relationship("SpecialMaterialGrade", back_populates="material") + patterns = relationship("SpecialMaterialPattern", back_populates="material") + +class SpecialMaterialGrade(Base): + """특수 재질 등급""" + __tablename__ = "special_material_grades" + + id = Column(Integer, primary_key=True, index=True) + material_id = Column(Integer, ForeignKey("special_materials.id")) + grade_code = Column(String(20), nullable=False) # 600, 625, C276 + composition = Column(String(200)) + applications = Column(String(200)) + temp_max = Column(String(50)) + strength = Column(String(50)) + purity = Column(String(100)) + corrosion = Column(String(50)) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + material = relationship("SpecialMaterial", back_populates="grades") + +class SpecialMaterialPattern(Base): + """특수 재질 정규식 패턴""" + __tablename__ = "special_material_patterns" + + id = Column(Integer, primary_key=True, index=True) + material_id = Column(Integer, ForeignKey("special_materials.id")) + pattern = Column(Text, nullable=False) + description = Column(String(200)) + priority = Column(Integer, default=1) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + material = relationship("SpecialMaterial", back_populates="patterns") + +# ========== 파이프 상세 정보 및 사용자 요구사항 테이블 ========== + +class PipeDetail(Base): + """파이프 상세 정보""" + __tablename__ = "pipe_details" + + id = Column(Integer, primary_key=True, index=True) + file_id = Column(Integer, ForeignKey("files.id"), nullable=False) + + # 재질 정보 + material_standard = Column(String(50)) # ASTM, KS, JIS 등 + material_grade = Column(String(50)) # A106, A53, STPG370 등 + material_type = Column(String(50)) # CARBON, STAINLESS 등 + + # 파이프 특화 정보 + manufacturing_method = Column(String(50)) # SEAMLESS, WELDED, CAST + end_preparation = Column(String(50)) # BOTH_ENDS_BEVELED, ONE_END_BEVELED, NO_BEVEL + schedule = Column(String(50)) # SCH 10, 20, 40, 80 등 + wall_thickness = Column(String(50)) # 벽두께 정보 + + # 치수 정보 + nominal_size = Column(String(50)) # MAIN_NOM (인치, 직경) + length_mm = Column(Numeric(10, 3)) # LENGTH (길이) + + # 신뢰도 + material_confidence = Column(Numeric(3, 2)) + manufacturing_confidence = Column(Numeric(3, 2)) + end_prep_confidence = Column(Numeric(3, 2)) + schedule_confidence = Column(Numeric(3, 2)) + + # 메타데이터 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + file = relationship("File", backref="pipe_details") + +class RequirementType(Base): + """요구사항 타입 마스터""" + __tablename__ = "requirement_types" + + id = Column(Integer, primary_key=True, index=True) + type_code = Column(String(50), unique=True, nullable=False) # 'IMPACT_TEST', 'HEAT_TREATMENT' 등 + type_name = Column(String(100), nullable=False) # '임팩테스트', '열처리' 등 + category = Column(String(50), nullable=False) # 'TEST', 'TREATMENT', 'CERTIFICATION', 'CUSTOM' 등 + description = Column(Text) # 타입 설명 + is_active = Column(Boolean, default=True) + + created_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정은 문자열 기반이므로 제거 + +class UserRequirement(Base): + """사용자 추가 요구사항""" + __tablename__ = "user_requirements" + + id = Column(Integer, primary_key=True, index=True) + file_id = Column(Integer, ForeignKey("files.id"), nullable=False) + material_id = Column(Integer, ForeignKey("materials.id"), nullable=True) # 자재 ID (개별 자재별 요구사항 연결) + + # 요구사항 타입 + requirement_type = Column(String(50), nullable=False) # 'IMPACT_TEST', 'HEAT_TREATMENT', 'CUSTOM_SPEC', 'CERTIFICATION' 등 + + # 요구사항 내용 + requirement_title = Column(String(200), nullable=False) # '임팩테스트', '열처리', '인증서' 등 + requirement_description = Column(Text) # 상세 설명 + requirement_spec = Column(Text) # 구체적 스펙 (예: "Charpy V-notch -20°C") + + # 상태 관리 + status = Column(String(20), default='PENDING') # 'PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED' + priority = Column(String(20), default='NORMAL') # 'LOW', 'NORMAL', 'HIGH', 'URGENT' + + # 담당자 정보 + assigned_to = Column(String(100)) # 담당자명 + due_date = Column(DateTime) # 완료 예정일 + + # 메타데이터 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + file = relationship("File", backref="user_requirements") + +# ========== Tubing 시스템 모델들 ========== + +class TubingCategory(Base): + """Tubing 카테고리 (일반, VCR, 위생용 등)""" + __tablename__ = "tubing_categories" + + id = Column(Integer, primary_key=True, index=True) + category_code = Column(String(20), unique=True, nullable=False) + category_name = Column(String(100), nullable=False) + description = Column(Text) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + specifications = relationship("TubingSpecification", back_populates="category") + +class TubingSpecification(Base): + """Tubing 규격 마스터""" + __tablename__ = "tubing_specifications" + + id = Column(Integer, primary_key=True, index=True) + category_id = Column(Integer, ForeignKey("tubing_categories.id")) + spec_code = Column(String(50), unique=True, nullable=False) + spec_name = Column(String(200), nullable=False) + + # 물리적 규격 + outer_diameter_mm = Column(Numeric(8, 3)) + wall_thickness_mm = Column(Numeric(6, 3)) + inner_diameter_mm = Column(Numeric(8, 3)) + + # 재질 정보 + material_grade = Column(String(100)) + material_standard = Column(String(100)) + + # 압력/온도 등급 + max_pressure_bar = Column(Numeric(8, 2)) + max_temperature_c = Column(Numeric(6, 2)) + min_temperature_c = Column(Numeric(6, 2)) + + # 표준 규격 + standard_length_m = Column(Numeric(8, 3)) + bend_radius_min_mm = Column(Numeric(8, 2)) + + # 기타 정보 + surface_finish = Column(String(100)) + hardness = Column(String(50)) + notes = Column(Text) + + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + category = relationship("TubingCategory", back_populates="specifications") + products = relationship("TubingProduct", back_populates="specification") + +class TubingManufacturer(Base): + """Tubing 제조사""" + __tablename__ = "tubing_manufacturers" + + id = Column(Integer, primary_key=True, index=True) + manufacturer_code = Column(String(20), unique=True, nullable=False) + manufacturer_name = Column(String(200), nullable=False) + country = Column(String(100)) + website = Column(String(500)) + contact_info = Column(JSON) # JSONB 타입 + quality_certs = Column(JSON) # JSONB 타입 + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + products = relationship("TubingProduct", back_populates="manufacturer") + +class TubingProduct(Base): + """제조사별 Tubing 제품 (품목번호 매핑)""" + __tablename__ = "tubing_products" + + id = Column(Integer, primary_key=True, index=True) + specification_id = Column(Integer, ForeignKey("tubing_specifications.id")) + manufacturer_id = Column(Integer, ForeignKey("tubing_manufacturers.id")) + + # 제조사 품목번호 정보 + manufacturer_part_number = Column(String(200), nullable=False) + manufacturer_product_name = Column(String(300)) + + # 가격/공급 정보 + list_price = Column(Numeric(12, 2)) + currency = Column(String(10), default='KRW') + lead_time_days = Column(Integer) + minimum_order_qty = Column(Numeric(10, 3)) + standard_packaging_qty = Column(Numeric(10, 3)) + + # 가용성 정보 + availability_status = Column(String(50)) + last_price_update = Column(DateTime) + + # 추가 정보 + datasheet_url = Column(String(500)) + catalog_page = Column(String(100)) + notes = Column(Text) + + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + specification = relationship("TubingSpecification", back_populates="products") + manufacturer = relationship("TubingManufacturer", back_populates="products") + material_mappings = relationship("MaterialTubingMapping", back_populates="tubing_product") + +class MaterialTubingMapping(Base): + """BOM 자재와 Tubing 제품 매핑""" + __tablename__ = "material_tubing_mapping" + + id = Column(Integer, primary_key=True, index=True) + material_id = Column(Integer, ForeignKey("materials.id", ondelete="CASCADE")) + tubing_product_id = Column(Integer, ForeignKey("tubing_products.id")) + + # 매핑 정보 + confidence_score = Column(Numeric(3, 2)) + mapping_method = Column(String(50)) + mapped_by = Column(String(100)) + mapped_at = Column(DateTime, default=datetime.utcnow) + + # 수량 정보 + required_length_m = Column(Numeric(10, 3)) + calculated_quantity = Column(Numeric(10, 3)) + + # 검증 정보 + is_verified = Column(Boolean, default=False) + verified_by = Column(String(100)) + verified_at = Column(DateTime) + + notes = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + material = relationship("Material", backref="tubing_mappings") + tubing_product = relationship("TubingProduct", back_populates="material_mappings") diff --git a/tkeg/api/app/routers/__init__.py b/tkeg/api/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tkeg/api/app/routers/dashboard.py b/tkeg/api/app/routers/dashboard.py new file mode 100644 index 0000000..17a3af8 --- /dev/null +++ b/tkeg/api/app/routers/dashboard.py @@ -0,0 +1,610 @@ +""" +대시보드 API +사용자별 맞춤형 대시보드 데이터 제공 +""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import text, func +from typing import Optional, Dict, Any, List +from datetime import datetime, timedelta + +from ..database import get_db +from ..auth.middleware import get_current_user +from ..services.activity_logger import ActivityLogger +from ..utils.logger import get_logger + +logger = get_logger(__name__) +router = APIRouter(prefix="/dashboard", tags=["dashboard"]) + + +@router.get("/stats") +async def get_dashboard_stats( + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 사용자별 맞춤형 대시보드 통계 데이터 조회 + + Returns: + dict: 사용자 역할에 맞는 통계 데이터 + """ + try: + username = current_user.get('username') + user_role = current_user.get('role', 'user') + + # 역할별 맞춤 통계 생성 + if user_role == 'admin': + stats = await get_admin_stats(db) + elif user_role == 'manager': + stats = await get_manager_stats(db, username) + elif user_role == 'designer': + stats = await get_designer_stats(db, username) + elif user_role == 'purchaser': + stats = await get_purchaser_stats(db, username) + else: + stats = await get_user_stats(db, username) + + return { + "success": True, + "user_role": user_role, + "stats": stats + } + + except Exception as e: + logger.error(f"Dashboard stats error: {str(e)}") + raise HTTPException(status_code=500, detail=f"대시보드 통계 조회 실패: {str(e)}") + + +@router.get("/activities") +async def get_user_activities( + current_user: dict = Depends(get_current_user), + limit: int = Query(10, ge=1, le=50), + db: Session = Depends(get_db) +): + """ + 사용자 활동 이력 조회 + + Args: + limit: 조회할 활동 수 (1-50) + + Returns: + dict: 사용자 활동 이력 + """ + try: + username = current_user.get('username') + + activity_logger = ActivityLogger(db) + activities = activity_logger.get_user_activities( + username=username, + limit=limit + ) + + return { + "success": True, + "activities": activities, + "total": len(activities) + } + + except Exception as e: + logger.error(f"User activities error: {str(e)}") + raise HTTPException(status_code=500, detail=f"활동 이력 조회 실패: {str(e)}") + + +@router.get("/recent-activities") +async def get_recent_activities( + current_user: dict = Depends(get_current_user), + days: int = Query(7, ge=1, le=30), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db) +): + """ + 최근 전체 활동 조회 (관리자/매니저용) + + Args: + days: 조회 기간 (일) + limit: 조회할 활동 수 + + Returns: + dict: 최근 활동 이력 + """ + try: + user_role = current_user.get('role', 'user') + + # 관리자와 매니저만 전체 활동 조회 가능 + if user_role not in ['admin', 'manager']: + raise HTTPException(status_code=403, detail="권한이 없습니다") + + activity_logger = ActivityLogger(db) + activities = activity_logger.get_recent_activities( + days=days, + limit=limit + ) + + return { + "success": True, + "activities": activities, + "period_days": days, + "total": len(activities) + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Recent activities error: {str(e)}") + raise HTTPException(status_code=500, detail=f"최근 활동 조회 실패: {str(e)}") + + +async def get_admin_stats(db: Session) -> Dict[str, Any]: + """관리자용 통계""" + try: + # 전체 프로젝트 수 + total_projects_query = text("SELECT COUNT(*) FROM jobs WHERE status != 'deleted'") + total_projects = db.execute(total_projects_query).scalar() + + # 활성 사용자 수 (최근 30일 로그인) + active_users_query = text(""" + SELECT COUNT(DISTINCT username) + FROM user_activity_logs + WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '30 days' + """) + active_users = db.execute(active_users_query).scalar() or 0 + + # 오늘 업로드된 파일 수 + today_uploads_query = text(""" + SELECT COUNT(*) + FROM files + WHERE DATE(upload_date) = CURRENT_DATE + """) + today_uploads = db.execute(today_uploads_query).scalar() or 0 + + # 전체 자재 수 + total_materials_query = text("SELECT COUNT(*) FROM materials") + total_materials = db.execute(total_materials_query).scalar() or 0 + + return { + "title": "시스템 관리자", + "subtitle": "전체 시스템을 관리하고 모니터링합니다", + "metrics": [ + {"label": "전체 프로젝트 수", "value": total_projects, "icon": "📋", "color": "#667eea"}, + {"label": "활성 사용자 수", "value": active_users, "icon": "👥", "color": "#48bb78"}, + {"label": "시스템 상태", "value": "정상", "icon": "🟢", "color": "#38b2ac"}, + {"label": "오늘 업로드", "value": today_uploads, "icon": "📤", "color": "#ed8936"} + ] + } + + except Exception as e: + logger.error(f"Admin stats error: {str(e)}") + raise + + +async def get_manager_stats(db: Session, username: str) -> Dict[str, Any]: + """매니저용 통계""" + try: + # 담당 프로젝트 수 (향후 assigned_to 필드 활용) + assigned_projects_query = text(""" + SELECT COUNT(*) + FROM jobs + WHERE (assigned_to = :username OR created_by = :username) + AND status != 'deleted' + """) + assigned_projects = db.execute(assigned_projects_query, {"username": username}).scalar() or 0 + + # 이번 주 완료된 작업 (활동 로그 기반) + week_completed_query = text(""" + SELECT COUNT(*) + FROM user_activity_logs + WHERE activity_type IN ('PROJECT_CREATE', 'PURCHASE_CONFIRM') + AND created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days' + """) + week_completed = db.execute(week_completed_query).scalar() or 0 + + # 승인 대기 (구매 확정 대기 등) + pending_approvals_query = text(""" + SELECT COUNT(*) + FROM material_purchase_tracking + WHERE purchase_status = 'PENDING' + OR purchase_status = 'REQUESTED' + """) + pending_approvals = db.execute(pending_approvals_query).scalar() or 0 + + return { + "title": "프로젝트 매니저", + "subtitle": "팀 프로젝트를 관리하고 진행상황을 모니터링합니다", + "metrics": [ + {"label": "담당 프로젝트", "value": assigned_projects, "icon": "📋", "color": "#667eea"}, + {"label": "팀 진행률", "value": "87%", "icon": "📈", "color": "#48bb78"}, + {"label": "승인 대기", "value": pending_approvals, "icon": "⏳", "color": "#ed8936"}, + {"label": "이번 주 완료", "value": week_completed, "icon": "✅", "color": "#38b2ac"} + ] + } + + except Exception as e: + logger.error(f"Manager stats error: {str(e)}") + raise + + +async def get_designer_stats(db: Session, username: str) -> Dict[str, Any]: + """설계자용 통계""" + try: + # 내가 업로드한 BOM 파일 수 + my_files_query = text(""" + SELECT COUNT(*) + FROM files + WHERE uploaded_by = :username + AND is_active = true + """) + my_files = db.execute(my_files_query, {"username": username}).scalar() or 0 + + # 분류된 자재 수 + classified_materials_query = text(""" + SELECT COUNT(*) + FROM materials m + JOIN files f ON m.file_id = f.id + WHERE f.uploaded_by = :username + AND m.classified_category IS NOT NULL + """) + classified_materials = db.execute(classified_materials_query, {"username": username}).scalar() or 0 + + # 검증 대기 자재 수 + pending_verification_query = text(""" + SELECT COUNT(*) + FROM materials m + JOIN files f ON m.file_id = f.id + WHERE f.uploaded_by = :username + AND m.is_verified = false + """) + pending_verification = db.execute(pending_verification_query, {"username": username}).scalar() or 0 + + # 이번 주 업로드 수 + week_uploads_query = text(""" + SELECT COUNT(*) + FROM files + WHERE uploaded_by = :username + AND upload_date >= CURRENT_TIMESTAMP - INTERVAL '7 days' + """) + week_uploads = db.execute(week_uploads_query, {"username": username}).scalar() or 0 + + # 분류 완료율 계산 + total_materials_query = text(""" + SELECT COUNT(*) + FROM materials m + JOIN files f ON m.file_id = f.id + WHERE f.uploaded_by = :username + """) + total_materials = db.execute(total_materials_query, {"username": username}).scalar() or 1 + + classification_rate = f"{(classified_materials / total_materials * 100):.0f}%" if total_materials > 0 else "0%" + + return { + "title": "설계 담당자", + "subtitle": "BOM 파일을 관리하고 자재를 분류합니다", + "metrics": [ + {"label": "내 BOM 파일", "value": my_files, "icon": "📄", "color": "#667eea"}, + {"label": "분류 완료율", "value": classification_rate, "icon": "🎯", "color": "#48bb78"}, + {"label": "검증 대기", "value": pending_verification, "icon": "⏳", "color": "#ed8936"}, + {"label": "이번 주 업로드", "value": week_uploads, "icon": "📤", "color": "#9f7aea"} + ] + } + + except Exception as e: + logger.error(f"Designer stats error: {str(e)}") + raise + + +async def get_purchaser_stats(db: Session, username: str) -> Dict[str, Any]: + """구매자용 통계""" + try: + # 구매 요청 수 + purchase_requests_query = text(""" + SELECT COUNT(*) + FROM material_purchase_tracking + WHERE purchase_status IN ('PENDING', 'REQUESTED') + """) + purchase_requests = db.execute(purchase_requests_query).scalar() or 0 + + # 발주 완료 수 + orders_completed_query = text(""" + SELECT COUNT(*) + FROM material_purchase_tracking + WHERE purchase_status = 'CONFIRMED' + AND confirmed_by = :username + """) + orders_completed = db.execute(orders_completed_query, {"username": username}).scalar() or 0 + + # 입고 대기 수 + receiving_pending_query = text(""" + SELECT COUNT(*) + FROM material_purchase_tracking + WHERE purchase_status = 'ORDERED' + """) + receiving_pending = db.execute(receiving_pending_query).scalar() or 0 + + # 이번 달 구매 금액 (임시 데이터) + monthly_amount = "₩2.3M" # 실제로는 계산 필요 + + return { + "title": "구매 담당자", + "subtitle": "구매 요청을 처리하고 발주를 관리합니다", + "metrics": [ + {"label": "구매 요청", "value": purchase_requests, "icon": "🛒", "color": "#667eea"}, + {"label": "발주 완료", "value": orders_completed, "icon": "✅", "color": "#48bb78"}, + {"label": "입고 대기", "value": receiving_pending, "icon": "📦", "color": "#ed8936"}, + {"label": "이번 달 금액", "value": monthly_amount, "icon": "💰", "color": "#9f7aea"} + ] + } + + except Exception as e: + logger.error(f"Purchaser stats error: {str(e)}") + raise + + +async def get_user_stats(db: Session, username: str) -> Dict[str, Any]: + """일반 사용자용 통계""" + try: + # 내 활동 수 (최근 7일) + my_activities_query = text(""" + SELECT COUNT(*) + FROM user_activity_logs + WHERE username = :username + AND created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days' + """) + my_activities = db.execute(my_activities_query, {"username": username}).scalar() or 0 + + # 접근 가능한 프로젝트 수 (임시) + accessible_projects = 5 + + return { + "title": "일반 사용자", + "subtitle": "할당된 업무를 수행하고 프로젝트에 참여합니다", + "metrics": [ + {"label": "내 업무", "value": 6, "icon": "📋", "color": "#667eea"}, + {"label": "완료율", "value": "75%", "icon": "📈", "color": "#48bb78"}, + {"label": "대기 중", "value": 2, "icon": "⏳", "color": "#ed8936"}, + {"label": "이번 주 활동", "value": my_activities, "icon": "🎯", "color": "#9f7aea"} + ] + } + + except Exception as e: + logger.error(f"User stats error: {str(e)}") + raise + + +@router.get("/quick-actions") +async def get_quick_actions( + current_user: dict = Depends(get_current_user) +): + """ + 사용자 역할별 빠른 작업 메뉴 조회 + + Returns: + dict: 역할별 빠른 작업 목록 + """ + try: + user_role = current_user.get('role', 'user') + + quick_actions = { + "admin": [ + {"title": "사용자 관리", "icon": "👤", "path": "/admin/users", "color": "#667eea"}, + {"title": "시스템 설정", "icon": "⚙️", "path": "/admin/settings", "color": "#48bb78"}, + {"title": "백업 관리", "icon": "💾", "path": "/admin/backup", "color": "#ed8936"}, + {"title": "활동 로그", "icon": "📊", "path": "/admin/logs", "color": "#9f7aea"} + ], + "manager": [ + {"title": "프로젝트 생성", "icon": "➕", "path": "/projects/new", "color": "#667eea"}, + {"title": "팀 관리", "icon": "👥", "path": "/team", "color": "#48bb78"}, + {"title": "진행 상황", "icon": "📊", "path": "/progress", "color": "#38b2ac"}, + {"title": "승인 처리", "icon": "✅", "path": "/approvals", "color": "#ed8936"} + ], + "designer": [ + {"title": "BOM 업로드", "icon": "📤", "path": "/upload", "color": "#667eea"}, + {"title": "자재 분류", "icon": "🔧", "path": "/materials", "color": "#48bb78"}, + {"title": "리비전 관리", "icon": "🔄", "path": "/revisions", "color": "#38b2ac"}, + {"title": "분류 검증", "icon": "✅", "path": "/verify", "color": "#ed8936"} + ], + "purchaser": [ + {"title": "구매 확정", "icon": "🛒", "path": "/purchase", "color": "#667eea"}, + {"title": "발주 관리", "icon": "📋", "path": "/orders", "color": "#48bb78"}, + {"title": "공급업체", "icon": "🏢", "path": "/suppliers", "color": "#38b2ac"}, + {"title": "입고 처리", "icon": "📦", "path": "/receiving", "color": "#ed8936"} + ], + "user": [ + {"title": "내 업무", "icon": "📋", "path": "/my-tasks", "color": "#667eea"}, + {"title": "프로젝트 보기", "icon": "👁️", "path": "/projects", "color": "#48bb78"}, + {"title": "리포트 다운로드", "icon": "📊", "path": "/reports", "color": "#38b2ac"}, + {"title": "도움말", "icon": "❓", "path": "/help", "color": "#9f7aea"} + ] + } + + return { + "success": True, + "user_role": user_role, + "quick_actions": quick_actions.get(user_role, quick_actions["user"]) + } + + except Exception as e: + logger.error(f"Quick actions error: {str(e)}") + raise HTTPException(status_code=500, detail=f"빠른 작업 조회 실패: {str(e)}") + + +@router.post("/projects") +async def create_project( + official_project_code: str = Query(..., description="프로젝트 코드"), + project_name: str = Query(..., description="프로젝트 이름"), + client_name: str = Query(None, description="고객사명"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 새 프로젝트 생성 + + Args: + official_project_code: 프로젝트 코드 (예: J24-001) + project_name: 프로젝트 이름 + client_name: 고객사명 (선택) + + Returns: + dict: 생성된 프로젝트 정보 + """ + try: + # 중복 확인 + check_query = text("SELECT id FROM projects WHERE official_project_code = :code") + existing = db.execute(check_query, {"code": official_project_code}).fetchone() + + if existing: + raise HTTPException(status_code=400, detail="이미 존재하는 프로젝트 코드입니다") + + # 프로젝트 생성 + insert_query = text(""" + INSERT INTO projects (official_project_code, project_name, client_name, status) + VALUES (:code, :name, :client, 'active') + RETURNING * + """) + + new_project = db.execute(insert_query, { + "code": official_project_code, + "name": project_name, + "client": client_name + }).fetchone() + + db.commit() + + # TODO: 활동 로그 기록 (추후 구현) + # ActivityLogger 사용법 확인 필요 + + return { + "success": True, + "message": "프로젝트가 생성되었습니다", + "project": { + "id": new_project.id, + "official_project_code": new_project.official_project_code, + "project_name": new_project.project_name, + "client_name": new_project.client_name, + "status": new_project.status + } + } + + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"프로젝트 생성 실패: {str(e)}") + raise HTTPException(status_code=500, detail=f"프로젝트 생성 실패: {str(e)}") + + +@router.get("/projects") +async def get_projects( + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 프로젝트 목록 조회 + + Returns: + dict: 프로젝트 목록 + """ + try: + query = text(""" + SELECT + id, + official_project_code, + project_name, + client_name, + design_project_code, + design_project_name, + status, + created_at, + updated_at + FROM projects + ORDER BY created_at DESC + """) + + results = db.execute(query).fetchall() + + projects = [] + for row in results: + projects.append({ + "id": row.id, + "official_project_code": row.official_project_code, + "job_no": row.official_project_code, # job_no 필드 추가 (프론트엔드 호환성) + "project_name": row.project_name, + "job_name": row.project_name, # 호환성을 위해 추가 + "client_name": row.client_name, + "design_project_code": row.design_project_code, + "design_project_name": row.design_project_name, + "status": row.status, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None + }) + + return { + "success": True, + "projects": projects, + "count": len(projects) + } + + except Exception as e: + logger.error(f"프로젝트 목록 조회 실패: {str(e)}") + raise HTTPException(status_code=500, detail=f"프로젝트 목록 조회 실패: {str(e)}") + + +@router.patch("/projects/{project_id}") +async def update_project_name( + project_id: int, + job_name: str = Query(..., description="새 프로젝트 이름"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 프로젝트 이름 수정 + + Args: + project_id: 프로젝트 ID + job_name: 새 프로젝트 이름 + + Returns: + dict: 수정 결과 + """ + try: + # 프로젝트 존재 확인 + query = text("SELECT * FROM projects WHERE id = :project_id") + result = db.execute(query, {"project_id": project_id}).fetchone() + + if not result: + raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다") + + # 프로젝트 이름 업데이트 + update_query = text(""" + UPDATE projects + SET project_name = :project_name, + updated_at = CURRENT_TIMESTAMP + WHERE id = :project_id + RETURNING * + """) + + updated = db.execute(update_query, { + "project_name": job_name, + "project_id": project_id + }).fetchone() + + db.commit() + + # TODO: 활동 로그 기록 (추후 구현) + + return { + "success": True, + "message": "프로젝트 이름이 수정되었습니다", + "project": { + "id": updated.id, + "official_project_code": updated.official_project_code, + "project_name": updated.project_name, + "job_name": updated.project_name # 호환성 + } + } + + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"프로젝트 수정 실패: {str(e)}") + raise HTTPException(status_code=500, detail=f"프로젝트 수정 실패: {str(e)}") diff --git a/tkeg/api/app/routers/export_manager.py b/tkeg/api/app/routers/export_manager.py new file mode 100644 index 0000000..5029552 --- /dev/null +++ b/tkeg/api/app/routers/export_manager.py @@ -0,0 +1,593 @@ +""" +엑셀 내보내기 및 구매 배치 관리 API +""" +from fastapi import APIRouter, Depends, HTTPException, status, Response +from fastapi.responses import FileResponse +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import Optional, List, Dict, Any +from datetime import datetime +import json +import os +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter +import uuid + +from ..database import get_db +from ..auth.middleware import get_current_user +from ..utils.logger import get_logger + +logger = get_logger(__name__) + +router = APIRouter(prefix="/export", tags=["Export Management"]) + +# 엑셀 파일 저장 경로 +EXPORT_DIR = "exports" +os.makedirs(EXPORT_DIR, exist_ok=True) + + +def create_excel_from_materials(materials: List[Dict], batch_info: Dict) -> str: + """ + 자재 목록으로 엑셀 파일 생성 + """ + wb = openpyxl.Workbook() + ws = wb.active + ws.title = batch_info.get("category", "자재목록") + + # 헤더 스타일 + header_fill = PatternFill(start_color="B8E6FF", end_color="B8E6FF", fill_type="solid") + header_font = Font(bold=True, size=11) + header_alignment = Alignment(horizontal="center", vertical="center") + thin_border = Border( + left=Side(style='thin'), + right=Side(style='thin'), + top=Side(style='thin'), + bottom=Side(style='thin') + ) + + # 배치 정보 추가 (상단 3줄) + ws.merge_cells('A1:J1') + ws['A1'] = f"구매 배치: {batch_info.get('batch_no', 'N/A')}" + ws['A1'].font = Font(bold=True, size=14) + + ws.merge_cells('A2:J2') + ws['A2'] = f"프로젝트: {batch_info.get('job_no', '')} - {batch_info.get('job_name', '')}" + + ws.merge_cells('A3:J3') + ws['A3'] = f"내보낸 날짜: {batch_info.get('export_date', datetime.now().strftime('%Y-%m-%d %H:%M'))}" + + # 빈 줄 + ws.append([]) + + # 헤더 행 + headers = [ + "No.", "카테고리", "자재 설명", "크기", "스케줄/등급", + "재질", "수량", "단위", "추가요구", "사용자요구", + "구매상태", "PR번호", "PO번호", "공급업체", "예정일" + ] + + for col, header in enumerate(headers, 1): + cell = ws.cell(row=5, column=col, value=header) + cell.fill = header_fill + cell.font = header_font + cell.alignment = header_alignment + cell.border = thin_border + + # 데이터 행 + row_num = 6 + for idx, material in enumerate(materials, 1): + row_data = [ + idx, + material.get("category", ""), + material.get("description", ""), + material.get("size", ""), + material.get("schedule", ""), + material.get("material_grade", ""), + material.get("quantity", ""), + material.get("unit", ""), + material.get("additional_req", ""), + material.get("user_requirement", ""), + material.get("purchase_status", "pending"), + material.get("purchase_request_no", ""), + material.get("purchase_order_no", ""), + material.get("vendor_name", ""), + material.get("expected_date", "") + ] + + for col, value in enumerate(row_data, 1): + cell = ws.cell(row=row_num, column=col, value=value) + cell.border = thin_border + if col == 11: # 구매상태 컬럼 + if value == "pending": + cell.fill = PatternFill(start_color="FFFFE0", end_color="FFFFE0", fill_type="solid") + elif value == "requested": + cell.fill = PatternFill(start_color="FFE4B5", end_color="FFE4B5", fill_type="solid") + elif value == "ordered": + cell.fill = PatternFill(start_color="ADD8E6", end_color="ADD8E6", fill_type="solid") + elif value == "received": + cell.fill = PatternFill(start_color="90EE90", end_color="90EE90", fill_type="solid") + + row_num += 1 + + # 열 너비 자동 조정 + for column in ws.columns: + max_length = 0 + column_letter = get_column_letter(column[0].column) + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + # 파일 저장 + file_name = f"batch_{batch_info.get('batch_no', uuid.uuid4().hex[:8])}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + file_path = os.path.join(EXPORT_DIR, file_name) + wb.save(file_path) + + return file_name + + +@router.post("/create-batch") +async def create_export_batch( + file_id: int, + job_no: Optional[str] = None, + category: Optional[str] = None, + materials: List[Dict] = [], + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 엑셀 내보내기 배치 생성 (자재 그룹화) + """ + try: + # 배치 번호 생성 (YYYYMMDD-XXX 형식) + batch_date = datetime.now().strftime('%Y%m%d') + + # 오늘 생성된 배치 수 확인 + count_query = text(""" + SELECT COUNT(*) as count + FROM excel_export_history + WHERE DATE(export_date) = CURRENT_DATE + """) + count_result = db.execute(count_query).fetchone() + batch_seq = (count_result.count + 1) if count_result else 1 + batch_no = f"{batch_date}-{str(batch_seq).zfill(3)}" + + # Job 정보 조회 + job_name = "" + if job_no: + job_query = text("SELECT job_name FROM jobs WHERE job_no = :job_no") + job_result = db.execute(job_query, {"job_no": job_no}).fetchone() + if job_result: + job_name = job_result.job_name + + # 배치 정보 + batch_info = { + "batch_no": batch_no, + "job_no": job_no, + "job_name": job_name, + "category": category, + "export_date": datetime.now().strftime('%Y-%m-%d %H:%M') + } + + # 엑셀 파일 생성 + excel_file_name = create_excel_from_materials(materials, batch_info) + + # 내보내기 이력 저장 + insert_history = text(""" + INSERT INTO excel_export_history ( + file_id, job_no, exported_by, export_type, + category, material_count, file_name, notes + ) VALUES ( + :file_id, :job_no, :exported_by, :export_type, + :category, :material_count, :file_name, :notes + ) RETURNING export_id + """) + + result = db.execute(insert_history, { + "file_id": file_id, + "job_no": job_no, + "exported_by": current_user.get("user_id"), + "export_type": "batch", + "category": category, + "material_count": len(materials), + "file_name": excel_file_name, + "notes": f"배치번호: {batch_no}" + }) + + export_id = result.fetchone().export_id + + # 자재별 내보내기 기록 + material_ids = [] + for material in materials: + material_id = material.get("id") + if material_id: + material_ids.append(material_id) + + insert_material = text(""" + INSERT INTO exported_materials ( + export_id, material_id, purchase_status, + quantity_exported + ) VALUES ( + :export_id, :material_id, 'pending', + :quantity + ) + """) + + db.execute(insert_material, { + "export_id": export_id, + "material_id": material_id, + "quantity": material.get("quantity", 0) + }) + + db.commit() + + logger.info(f"Export batch created: {batch_no} with {len(materials)} materials") + + return { + "success": True, + "batch_no": batch_no, + "export_id": export_id, + "file_name": excel_file_name, + "material_count": len(materials), + "message": f"배치 {batch_no}가 생성되었습니다" + } + + except Exception as e: + db.rollback() + logger.error(f"Failed to create export batch: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"배치 생성 실패: {str(e)}" + ) + + +@router.get("/batches") +async def get_export_batches( + file_id: Optional[int] = None, + job_no: Optional[str] = None, + status: Optional[str] = None, + limit: int = 50, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 내보내기 배치 목록 조회 + """ + try: + query = text(""" + SELECT + eeh.export_id, + eeh.file_id, + eeh.job_no, + eeh.export_date, + eeh.category, + eeh.material_count, + eeh.file_name, + eeh.notes, + u.name as exported_by, + j.job_name, + f.original_filename, + -- 상태별 집계 + COUNT(DISTINCT em.material_id) as total_materials, + COUNT(DISTINCT CASE WHEN em.purchase_status = 'pending' THEN em.material_id END) as pending_count, + COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) as requested_count, + COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) as ordered_count, + COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) as received_count, + -- 전체 상태 계산 + CASE + WHEN COUNT(DISTINCT em.material_id) = COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) + THEN 'completed' + WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) > 0 + THEN 'in_progress' + WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) > 0 + THEN 'requested' + ELSE 'pending' + END as batch_status + FROM excel_export_history eeh + LEFT JOIN users u ON eeh.exported_by = u.user_id + LEFT JOIN jobs j ON eeh.job_no = j.job_no + LEFT JOIN files f ON eeh.file_id = f.id + LEFT JOIN exported_materials em ON eeh.export_id = em.export_id + WHERE eeh.export_type = 'batch' + AND (:file_id IS NULL OR eeh.file_id = :file_id) + AND (:job_no IS NULL OR eeh.job_no = :job_no) + GROUP BY + eeh.export_id, eeh.file_id, eeh.job_no, eeh.export_date, + eeh.category, eeh.material_count, eeh.file_name, eeh.notes, + u.name, j.job_name, f.original_filename + HAVING (:status IS NULL OR + CASE + WHEN COUNT(DISTINCT em.material_id) = COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) + THEN 'completed' + WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) > 0 + THEN 'in_progress' + WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) > 0 + THEN 'requested' + ELSE 'pending' + END = :status) + ORDER BY eeh.export_date DESC + LIMIT :limit + """) + + results = db.execute(query, { + "file_id": file_id, + "job_no": job_no, + "status": status, + "limit": limit + }).fetchall() + + batches = [] + for row in results: + # 배치 번호 추출 (notes에서) + batch_no = "" + if row.notes and "배치번호:" in row.notes: + batch_no = row.notes.split("배치번호:")[1].strip() + + batches.append({ + "export_id": row.export_id, + "batch_no": batch_no, + "file_id": row.file_id, + "job_no": row.job_no, + "job_name": row.job_name, + "export_date": row.export_date.isoformat() if row.export_date else None, + "category": row.category, + "material_count": row.total_materials, + "file_name": row.file_name, + "exported_by": row.exported_by, + "source_file": row.original_filename, + "batch_status": row.batch_status, + "status_detail": { + "pending": row.pending_count, + "requested": row.requested_count, + "ordered": row.ordered_count, + "received": row.received_count, + "total": row.total_materials + } + }) + + return { + "success": True, + "batches": batches, + "count": len(batches) + } + + except Exception as e: + logger.error(f"Failed to get export batches: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"배치 목록 조회 실패: {str(e)}" + ) + + +@router.get("/batch/{export_id}/materials") +async def get_batch_materials( + export_id: int, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 배치에 포함된 자재 목록 조회 + """ + try: + query = text(""" + SELECT + em.id as exported_material_id, + em.material_id, + m.original_description, + m.classified_category, + m.size_inch, + m.schedule, + m.material_grade, + m.quantity, + m.unit, + em.purchase_status, + em.purchase_request_no, + em.purchase_order_no, + em.vendor_name, + em.expected_date, + em.quantity_ordered, + em.quantity_received, + em.unit_price, + em.total_price, + em.notes, + ur.requirement as user_requirement + FROM exported_materials em + JOIN materials m ON em.material_id = m.id + LEFT JOIN user_requirements ur ON m.id = ur.material_id + WHERE em.export_id = :export_id + ORDER BY m.classified_category, m.original_description + """) + + results = db.execute(query, {"export_id": export_id}).fetchall() + + materials = [] + for row in results: + materials.append({ + "exported_material_id": row.exported_material_id, + "material_id": row.material_id, + "description": row.original_description, + "category": row.classified_category, + "size": row.size_inch, + "schedule": row.schedule, + "material_grade": row.material_grade, + "quantity": row.quantity, + "unit": row.unit, + "user_requirement": row.user_requirement, + "purchase_status": row.purchase_status, + "purchase_request_no": row.purchase_request_no, + "purchase_order_no": row.purchase_order_no, + "vendor_name": row.vendor_name, + "expected_date": row.expected_date.isoformat() if row.expected_date else None, + "quantity_ordered": row.quantity_ordered, + "quantity_received": row.quantity_received, + "unit_price": float(row.unit_price) if row.unit_price else None, + "total_price": float(row.total_price) if row.total_price else None, + "notes": row.notes + }) + + return { + "success": True, + "materials": materials, + "count": len(materials) + } + + except Exception as e: + logger.error(f"Failed to get batch materials: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"배치 자재 조회 실패: {str(e)}" + ) + + +@router.get("/batch/{export_id}/download") +async def download_batch_excel( + export_id: int, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 저장된 배치 엑셀 파일 다운로드 + """ + try: + # 배치 정보 조회 + query = text(""" + SELECT file_name, notes + FROM excel_export_history + WHERE export_id = :export_id + """) + result = db.execute(query, {"export_id": export_id}).fetchone() + + if not result: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="배치를 찾을 수 없습니다" + ) + + file_path = os.path.join(EXPORT_DIR, result.file_name) + + if not os.path.exists(file_path): + # 파일이 없으면 재생성 + materials = await get_batch_materials(export_id, current_user, db) + + batch_no = "" + if result.notes and "배치번호:" in result.notes: + batch_no = result.notes.split("배치번호:")[1].strip() + + batch_info = { + "batch_no": batch_no, + "export_date": datetime.now().strftime('%Y-%m-%d %H:%M') + } + + file_name = create_excel_from_materials(materials["materials"], batch_info) + file_path = os.path.join(EXPORT_DIR, file_name) + + # DB 업데이트 + update_query = text(""" + UPDATE excel_export_history + SET file_name = :file_name + WHERE export_id = :export_id + """) + db.execute(update_query, { + "file_name": file_name, + "export_id": export_id + }) + db.commit() + + return FileResponse( + path=file_path, + filename=result.file_name, + media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to download batch excel: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"엑셀 다운로드 실패: {str(e)}" + ) + + +@router.patch("/batch/{export_id}/status") +async def update_batch_status( + export_id: int, + status: str, + purchase_request_no: Optional[str] = None, + purchase_order_no: Optional[str] = None, + vendor_name: Optional[str] = None, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 배치 전체 상태 일괄 업데이트 + """ + try: + # 배치의 모든 자재 상태 업데이트 + update_query = text(""" + UPDATE exported_materials + SET + purchase_status = :status, + purchase_request_no = COALESCE(:pr_no, purchase_request_no), + purchase_order_no = COALESCE(:po_no, purchase_order_no), + vendor_name = COALESCE(:vendor, vendor_name), + updated_by = :updated_by, + requested_date = CASE WHEN :status = 'requested' THEN CURRENT_TIMESTAMP ELSE requested_date END, + ordered_date = CASE WHEN :status = 'ordered' THEN CURRENT_TIMESTAMP ELSE ordered_date END, + received_date = CASE WHEN :status = 'received' THEN CURRENT_TIMESTAMP ELSE received_date END + WHERE export_id = :export_id + """) + + result = db.execute(update_query, { + "export_id": export_id, + "status": status, + "pr_no": purchase_request_no, + "po_no": purchase_order_no, + "vendor": vendor_name, + "updated_by": current_user.get("user_id") + }) + + # 이력 기록 + history_query = text(""" + INSERT INTO purchase_status_history ( + exported_material_id, material_id, + previous_status, new_status, + changed_by, reason + ) + SELECT + em.id, em.material_id, + em.purchase_status, :new_status, + :changed_by, :reason + FROM exported_materials em + WHERE em.export_id = :export_id + """) + + db.execute(history_query, { + "export_id": export_id, + "new_status": status, + "changed_by": current_user.get("user_id"), + "reason": f"배치 일괄 업데이트" + }) + + db.commit() + + logger.info(f"Batch {export_id} status updated to {status}") + + return { + "success": True, + "message": f"배치의 모든 자재가 {status} 상태로 변경되었습니다", + "updated_count": result.rowcount + } + + except Exception as e: + db.rollback() + logger.error(f"Failed to update batch status: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"배치 상태 업데이트 실패: {str(e)}" + ) diff --git a/tkeg/api/app/routers/files.py b/tkeg/api/app/routers/files.py new file mode 100644 index 0000000..b6c39d1 --- /dev/null +++ b/tkeg/api/app/routers/files.py @@ -0,0 +1,3074 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request, Query, Body +from sqlalchemy.orm import Session +from sqlalchemy import text +from typing import List, Optional, Dict +from pydantic import BaseModel +import os +import shutil +from datetime import datetime +import uuid +import pandas as pd +import re +from pathlib import Path +import json + +from ..database import get_db +from ..auth.middleware import get_current_user +from ..services.activity_logger import ActivityLogger, log_activity_from_request +from ..utils.logger import get_logger +from app.services.excel_parser import BOMParser +from app.services.material_service import MaterialService # [신규] 자재 서비스 임포트 + +# 로거 설정 +logger = get_logger(__name__) +# [REMOVED] classify_material_integrated 등 직접 임포트 제거 (서비스에서 사용) +from app.services.revision_comparator import get_revision_comparison + +router = APIRouter() + +class ExcelSaveRequest(BaseModel): + file_id: int + category: str + materials: List[Dict] + filename: str + user_id: Optional[int] = None + +def extract_enhanced_material_grade(description: str, original_grade: str, category: str) -> str: + """ + 원본 설명에서 개선된 재질 정보를 추출 + + Args: + description: 원본 설명 + original_grade: 기존 재질 정보 + category: 자재 카테고리 (PIPE, FITTING, FLANGE 등) + + Returns: + 개선된 재질 정보 + """ + if not description: + return original_grade or '-' + + desc_upper = description.upper() + + # PIPE 재질 패턴 + if category == 'PIPE': + pipe_patterns = [ + (r'A312\s*(TP\d+[A-Z]*)', lambda m: f'A312 {m.group(1)}'), + (r'A106\s*(GR\.?\s*[A-Z]+)', lambda m: f'A106 {m.group(1)}'), # A106 GR.B, A106 GR B 등 전체 보존 + (r'A106\s*([A-Z]+)', lambda m: f'A106 GR.{m.group(1)}'), # A106 B → A106 GR.B + (r'A333\s*(GR\.?\s*[A-Z0-9]+)', lambda m: f'A333 {m.group(1)}'), # A333 GR.6 등 전체 보존 + (r'A333\s*([A-Z0-9]+)', lambda m: f'A333 GR.{m.group(1)}'), # A333 6 → A333 GR.6 + (r'A53\s*(GR\.?\s*[A-Z]+)', lambda m: f'A53 {m.group(1)}'), # A53 GR.B 등 전체 보존 + (r'A53\s*([A-Z]+)', lambda m: f'A53 GR.{m.group(1)}'), # A53 B → A53 GR.B + (r'A335\s*(P\d+[A-Z]*)', lambda m: f'A335 {m.group(1)}'), + (r'STPG\s*(\d+)', lambda m: f'STPG {m.group(1)}'), + (r'STS\s*(\d+[A-Z]*)', lambda m: f'STS {m.group(1)}') + ] + + for pattern, formatter in pipe_patterns: + match = re.search(pattern, desc_upper) + if match: + return formatter(match) + + # FITTING 재질 패턴 + elif category == 'FITTING': + fitting_patterns = [ + (r'A403\s*(WP\d+[A-Z]*)', lambda m: f'A403 {m.group(1)}'), + (r'A234\s*(WP[A-Z]+)', lambda m: f'A234 {m.group(1)}'), + (r'A420\s*(WPL\d+)', lambda m: f'A420 {m.group(1)}'), + (r'A105\s*(GR\.?\s*[N])', lambda m: f'A105 {m.group(1)}'), # A105 GR.N 전체 보존 + (r'A105\s*([N])', lambda m: f'A105 GR.{m.group(1)}'), # A105 N → A105 GR.N + (r'A105(?!\s*[A-Z])', lambda m: f'A105'), # A105만 있는 경우 + (r'A106\s*(GR\.?\s*[A-Z]+)', lambda m: f'A106 {m.group(1)}'), # A106 GR.B 전체 보존 + (r'A106\s*([A-Z]+)', lambda m: f'A106 GR.{m.group(1)}'), # A106 B → A106 GR.B + (r'A182\s*(F\d+[A-Z]*)', lambda m: f'A182 {m.group(1)}'), + (r'A350\s*(LF\d+)', lambda m: f'A350 {m.group(1)}') + ] + + for pattern, formatter in fitting_patterns: + match = re.search(pattern, desc_upper) + if match: + return formatter(match) + + # FLANGE 재질 패턴 + elif category == 'FLANGE': + flange_patterns = [ + (r'A182\s*(F\d+[A-Z]*)', lambda m: f'A182 {m.group(1)}'), + (r'A105\s*(GR\.?\s*[N])', lambda m: f'A105 {m.group(1)}'), # A105 GR.N 전체 보존 + (r'A105\s*([N])', lambda m: f'A105 GR.{m.group(1)}'), # A105 N → A105 GR.N + (r'A105(?!\s*[A-Z])', lambda m: f'A105'), # A105만 있는 경우 + (r'A350\s*(LF\d+)', lambda m: f'A350 {m.group(1)}'), + (r'A694\s*(F\d+)', lambda m: f'A694 {m.group(1)}') + ] + + for pattern, formatter in flange_patterns: + match = re.search(pattern, desc_upper) + if match: + return formatter(match) + + # 패턴이 매치되지 않으면 기존 값 반환 + return original_grade or '-' + +def extract_enhanced_flange_type(description: str, original_type: str) -> str: + """ + FLANGE 타입에 PIPE측 연결면 정보 추가 + + Args: + description: 원본 설명 + original_type: 기존 플랜지 타입 + + Returns: + 개선된 플랜지 타입 (예: WN RF, SO FF 등) + """ + if not description: + return original_type or '-' + + desc_upper = description.upper() + + # 기본 플랜지 타입 매핑 + flange_type_map = { + 'WELD_NECK': 'WN', + 'SLIP_ON': 'SO', + 'BLIND': 'BL', + 'SOCKET_WELD': 'SW', + 'LAP_JOINT': 'LJ', + 'THREADED': 'TH', + 'ORIFICE': 'ORIFICE' + } + + display_type = flange_type_map.get(original_type, original_type) if original_type else '-' + + # PIPE측 연결면 타입 추출 + pipe_end_type = '' + if ' RF' in desc_upper or 'RAISED FACE' in desc_upper: + pipe_end_type = ' RF' + elif ' FF' in desc_upper or 'FLAT FACE' in desc_upper: + pipe_end_type = ' FF' + elif ' RTJ' in desc_upper or 'RING TYPE JOINT' in desc_upper: + pipe_end_type = ' RTJ' + elif ' MSF' in desc_upper or 'MALE AND FEMALE' in desc_upper: + pipe_end_type = ' MSF' + elif ' T&G' in desc_upper or 'TONGUE AND GROOVE' in desc_upper: + pipe_end_type = ' T&G' + + # 최종 타입 조합 + if pipe_end_type and display_type != '-': + return display_type + pipe_end_type + + return display_type + +UPLOAD_DIR = Path("uploads") +UPLOAD_DIR.mkdir(exist_ok=True) +ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"} + +# API 정보는 /info 엔드포인트로 이동됨 + +@router.get("/test") +async def test_endpoint(): + return {"status": "파일 API가 정상 작동합니다!"} + +@router.post("/add-missing-columns") +async def add_missing_columns(db: Session = Depends(get_db)): + """누락된 컬럼들 추가""" + try: + db.execute(text("ALTER TABLE files ADD COLUMN IF NOT EXISTS parsed_count INTEGER DEFAULT 0")) + db.execute(text("ALTER TABLE materials ADD COLUMN IF NOT EXISTS row_number INTEGER")) + db.commit() + + return { + "success": True, + "message": "누락된 컬럼들이 추가되었습니다", + "added_columns": ["files.parsed_count", "materials.row_number"] + } + except Exception as e: + db.rollback() + return {"success": False, "error": f"컬럼 추가 실패: {str(e)}"} + +# [REMOVED] 구형 파싱 함수들 (validate_file_extension, generate_unique_filename, parse_dataframe, parse_file_data) +# 이 기능들은 이제 app/services/excel_parser.py의 BOMParser 클래스로 이관되었습니다. + +@router.post("/upload") +async def upload_file( + request: Request, + file: UploadFile = File(...), + job_no: str = Form(...), + revision: str = Form("Rev.0"), # 기본값은 Rev.0 (새 BOM) + parent_file_id: Optional[int] = Form(None), # 리비전 업로드 시 부모 파일 ID + bom_name: Optional[str] = Form(None), # BOM 이름 (사용자 입력) + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + # 🎯 트랜잭션 오류 방지: 완전한 트랜잭션 초기화 + + # 🔍 디버깅: 업로드 파라미터 로깅 + print(f"🔍 [UPLOAD] job_no: {job_no}, revision: {revision}, parent_file_id: {parent_file_id}") + print(f"🔍 [UPLOAD] bom_name: {bom_name}, filename: {file.filename}") + print(f"🔍 [UPLOAD] revision != 'Rev.0': {revision != 'Rev.0'}") + + try: + # 1. 현재 트랜잭션 완전 롤백 + db.rollback() + print("🔄 1단계: 이전 트랜잭션 롤백 완료") + + # 2. 세션 상태 초기화 + db.close() + print("🔄 2단계: 세션 닫기 완료") + + # 3. 새 세션 생성 + from ..database import get_db + db = next(get_db()) + print("🔄 3단계: 새 세션 생성 완료") + + except Exception as e: + print(f"⚠️ 트랜잭션 초기화 중 오류: {e}") + # 오류 발생 시에도 계속 진행 + + # [변경] BOMParser 사용하여 확장자 검증 + if not BOMParser.validate_extension(file.filename): + raise HTTPException( + status_code=400, + detail=f"지원하지 않는 파일 형식입니다. 허용된 확장자: xlsx, xls, csv" + ) + + if file.size and file.size > 10 * 1024 * 1024: + raise HTTPException(status_code=400, detail="파일 크기는 10MB를 초과할 수 없습니다") + + # [변경] BOMParser 사용하여 유니크 파일명 생성 + unique_filename = BOMParser.generate_unique_filename(file.filename) + file_path = UPLOAD_DIR / unique_filename + + try: + with open(file_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + except Exception as e: + raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}") + + try: + # [변경] BOMParser 사용하여 파일 파싱 (자동 양식 감지 포함) + print(f"🚀 파일 파싱 시작: {file_path}") + materials_data = BOMParser.parse_file(str(file_path)) + parsed_count = len(materials_data) + print(f"✅ 파싱 완료: {parsed_count}개 자재 추출됨") + + # 신규 자재 카운트 초기화 + new_materials_count = 0 + existing_materials_descriptions = set() + + # 리비전 업로드인 경우만 자동 리비전 생성 및 기존 자재 조회 + if parent_file_id is not None: + print(f"🔄 리비전 업로드 감지: parent_file_id={parent_file_id}") + # 부모 파일의 정보 조회 + parent_query = text(""" + SELECT original_filename, revision, bom_name FROM files + WHERE id = :parent_file_id AND job_no = :job_no + """) + + parent_result = db.execute(parent_query, { + "parent_file_id": parent_file_id, + "job_no": job_no + }) + parent_file = parent_result.fetchone() + + if not parent_file: + raise HTTPException(status_code=404, detail="부모 파일을 찾을 수 없습니다.") + + # 해당 BOM의 최신 리비전 확인 (bom_name 기준) + bom_name_to_use = parent_file[2] or parent_file[0] # bom_name 우선, 없으면 original_filename + latest_revision_query = text(""" + SELECT revision FROM files + WHERE job_no = :job_no + AND (bom_name = :bom_name OR (bom_name IS NULL AND original_filename = :bom_name)) + ORDER BY revision DESC + LIMIT 1 + """) + + latest_result = db.execute(latest_revision_query, { + "job_no": job_no, + "bom_name": bom_name_to_use + }) + latest_revision = latest_result.fetchone() + + if latest_revision: + latest_rev = latest_revision[0] + if latest_rev.startswith("Rev."): + try: + rev_num = int(latest_rev.replace("Rev.", "")) + revision = f"Rev.{rev_num + 1}" + except ValueError: + revision = "Rev.1" + else: + revision = "Rev.1" + + print(f"리비전 업로드: {latest_rev} → {revision}") + else: + revision = "Rev.1" + print(f"첫 번째 리비전: {revision}") + + # 모든 이전 리비전의 누적 자재 목록 조회 (리비전 0부터 현재까지) + existing_materials_query = text(""" + SELECT m.original_description, m.size_spec, SUM(m.quantity) as total_quantity + FROM materials m + JOIN files f ON m.file_id = f.id + WHERE f.job_no = :job_no + AND f.id <= :parent_file_id + AND f.is_active = TRUE + GROUP BY m.original_description, m.size_spec + """) + + existing_result = db.execute(existing_materials_query, { + "job_no": job_no, + "parent_file_id": parent_file_id + }) + + existing_materials_with_quantity = {} + for row in existing_result: + # 설명과 사이즈를 조합하여 유니크 키 생성 + key = f"{row.original_description}|{row.size_spec or ''}" + existing_materials_descriptions.add(key) + existing_materials_with_quantity[key] = float(row.total_quantity or 0) + + print(f"📊 누적 자재 수 (Rev.0~현재): {len(existing_materials_descriptions)}") + print(f"📊 누적 자재 총 수량: {sum(existing_materials_with_quantity.values())}") + if len(existing_materials_descriptions) > 0: + print(f"📝 기존 자재 샘플 (처음 3개): {list(existing_materials_descriptions)[:3]}") + # 수량이 있는 자재들 확인 + quantity_samples = [(k, v) for k, v in list(existing_materials_with_quantity.items())[:3]] + print(f"📊 기존 자재 수량 샘플: {quantity_samples}") + else: + print(f"⚠️ 기존 자재가 없습니다! parent_file_id={parent_file_id}의 materials 테이블을 확인하세요.") + + # 파일명을 부모와 동일하게 유지 + file.filename = parent_file[0] + else: + # 일반 업로드 (새 BOM) + print(f"일반 업로드 모드: 새 BOM 파일 (Rev.0)") + + # 파일 정보 저장 (사용자 정보 포함) + print("DB 저장 시작") + username = current_user.get('username', 'unknown') + user_id = current_user.get('user_id') + + file_insert_query = text(""" + INSERT INTO files (filename, original_filename, file_path, job_no, revision, bom_name, description, file_size, parsed_count, is_active, uploaded_by) + VALUES (:filename, :original_filename, :file_path, :job_no, :revision, :bom_name, :description, :file_size, :parsed_count, :is_active, :uploaded_by) + RETURNING id + """) + + file_result = db.execute(file_insert_query, { + "filename": unique_filename, + "original_filename": file.filename, + "file_path": str(file_path), + "job_no": job_no, + "revision": revision, + "bom_name": bom_name or file.filename, # bom_name 우선, 없으면 파일명 + "description": f"BOM 파일 - {parsed_count}개 자재", + "file_size": file.size, + "parsed_count": parsed_count, + "is_active": True, + "uploaded_by": username + }) + + file_id = file_result.fetchone()[0] + db.commit() # 파일 레코드 즉시 커밋 + print(f"파일 저장 완료: file_id = {file_id}, uploaded_by = {username}") + + # 🔄 리비전 비교 수행 (RULES.md 코딩 컨벤션 준수) + revision_comparison = None + materials_to_classify = materials_data + purchased_materials_map = {} # 구매확정된 자재 매핑 (키 -> 구매확정 정보) + + if revision != "Rev.0": # 리비전 업로드인 경우만 비교 + print(f"🔍 [DEBUG] 리비전 비교 시작 - revision: {revision}, parent_file_id: {parent_file_id}") + try: + # 간단한 리비전 비교 로직 (purchase_confirmed 기반) + print(f"🔍 [DEBUG] perform_simple_revision_comparison 호출 중...") + revision_comparison = perform_simple_revision_comparison(db, job_no, parent_file_id, materials_data) + print(f"🔍 [DEBUG] 리비전 비교 완료: {revision_comparison.keys() if revision_comparison else 'None'}") + + if revision_comparison.get("has_purchased_materials", False): + print(f"📊 간단한 리비전 비교 결과:") + print(f" - 구매확정된 자재: {revision_comparison.get('purchased_count', 0)}개") + print(f" - 미구매 자재: {revision_comparison.get('unpurchased_count', 0)}개") + print(f" - 신규 자재: {revision_comparison.get('new_count', 0)}개") + print(f" - 제외된 구매확정 자재: {revision_comparison.get('excluded_purchased_count', 0)}개") + + # 신규 및 변경된 자재만 분류 + materials_to_classify = revision_comparison.get("materials_to_classify", []) + print(f" - 분류 필요: {len(materials_to_classify)}개") + + # 🔥 구매확정된 자재 매핑 정보 저장 + purchased_materials_map = revision_comparison.get("purchased_materials_map", {}) + print(f" - 구매확정 자재 매핑: {len(purchased_materials_map)}개") + else: + print("📝 이전 구매확정 자료 없음 - 전체 자재 분류") + + except Exception as e: + print(f"⚠️ 리비전 비교 실패, 전체 자재 분류로 진행: {str(e)}") + import traceback + traceback.print_exc() + + print(f"🔧 자재 분류 및 저장 시작: {len(materials_to_classify)}개 자재") + + # [변경] MaterialService를 사용하여 자재 처리 및 저장 + materials_inserted = MaterialService.process_and_save_materials( + db, file_id, materials_data, + revision_comparison=revision_comparison, + parent_file_id=parent_file_id, + purchased_materials_map=purchased_materials_map + ) + + db.commit() + print(f"자재 저장 완료: {materials_inserted}개") + + # [변경] MaterialService를 사용하여 구매신청 정보 상속 + if parent_file_id is not None: + MaterialService.inherit_purchase_requests(db, file_id, parent_file_id) + db.commit() + + # 활동 로그 기록 + try: + activity_logger = ActivityLogger(db) + activity_logger.log_file_upload( + username=username, + file_id=file_id, + filename=file.filename, + file_size=file.size or 0, + job_no=job_no, + revision=revision, + user_id=user_id, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get('user-agent') + ) + print(f"활동 로그 기록 완료: {username} - 파일 업로드") + except Exception as e: + print(f"활동 로그 기록 실패: {str(e)}") + # 로그 실패는 업로드 성공에 영향을 주지 않음 + + # 리비전 업로드인 경우 누락된 도면 감지 및 구매신청 여부 확인 + missing_drawings_info = None + has_previous_purchase = False + + if parent_file_id is not None: + try: + # 이전 리비전의 구매신청 여부 확인 + purchase_check_query = text(""" + SELECT COUNT(*) as purchase_count + FROM purchase_request_items pri + JOIN materials m ON pri.material_id = m.id + WHERE m.file_id = :parent_file_id + """) + purchase_result = db.execute(purchase_check_query, {"parent_file_id": parent_file_id}).fetchone() + has_previous_purchase = purchase_result.purchase_count > 0 + + print(f"📦 이전 리비전 구매신청 여부: {has_previous_purchase} ({purchase_result.purchase_count}개 자재)") + print(f"📂 parent_file_id: {parent_file_id}, new file_id: {file_id}") + + # 이전 리비전의 도면 목록 조회 + prev_drawings_query = text(""" + SELECT DISTINCT drawing_name, line_no, COUNT(*) as material_count + FROM materials + WHERE file_id = :parent_file_id + AND (drawing_name IS NOT NULL OR line_no IS NOT NULL) + GROUP BY drawing_name, line_no + """) + prev_drawings_result = db.execute(prev_drawings_query, {"parent_file_id": parent_file_id}).fetchall() + print(f"📋 이전 리비전 도면 수: {len(prev_drawings_result)}") + + # 새 리비전의 도면 목록 조회 + new_drawings_query = text(""" + SELECT DISTINCT drawing_name, line_no + FROM materials + WHERE file_id = :file_id + AND (drawing_name IS NOT NULL OR line_no IS NOT NULL) + """) + new_drawings_result = db.execute(new_drawings_query, {"file_id": file_id}).fetchall() + print(f"📋 새 리비전 도면 수: {len(new_drawings_result)}") + + prev_drawings = set() + for row in prev_drawings_result: + if row.drawing_name: + prev_drawings.add(row.drawing_name) + elif row.line_no: + prev_drawings.add(row.line_no) + + new_drawings = set() + for row in new_drawings_result: + if row.drawing_name: + new_drawings.add(row.drawing_name) + elif row.line_no: + new_drawings.add(row.line_no) + + missing_drawings = prev_drawings - new_drawings + + print(f"📊 이전 도면: {list(prev_drawings)[:5]}") + print(f"📊 새 도면: {list(new_drawings)[:5]}") + print(f"❌ 누락 도면: {len(missing_drawings)}개") + + if missing_drawings: + # 누락된 도면의 자재 상세 정보 + missing_materials = [] + for row in prev_drawings_result: + drawing = row.drawing_name or row.line_no + if drawing in missing_drawings: + missing_materials.append({ + "drawing_name": drawing, + "material_count": row.material_count + }) + + missing_drawings_info = { + "drawings": list(missing_drawings), + "materials": missing_materials, + "count": len(missing_drawings), + "requires_confirmation": True, + "has_previous_purchase": has_previous_purchase, + "action": "mark_as_inventory" if has_previous_purchase else "hide_materials" + } + + # 누락된 도면의 자재 상태 업데이트 + if missing_drawings: + for drawing in missing_drawings: + status_to_set = 'inventory' if has_previous_purchase else 'deleted_not_purchased' + + update_status_query = text(""" + UPDATE materials + SET revision_status = :status + WHERE file_id = :parent_file_id + AND (drawing_name = :drawing OR line_no = :drawing) + """) + + db.execute(update_status_query, { + "status": status_to_set, + "parent_file_id": parent_file_id, + "drawing": drawing + }) + + db.commit() + print(f"✅ 누락 도면 자재 상태 업데이트: {len(missing_drawings)}개 도면 → {status_to_set}") + except Exception as e: + print(f"누락 도면 감지 실패: {str(e)}") + db.rollback() + # 감지 실패는 업로드 성공에 영향 없음 + + # 리비전 업로드인 경우 메시지 다르게 표시 + if parent_file_id is not None: + message = f"리비전 업로드 성공! {new_materials_count}개의 신규 자재가 추가되었습니다." + else: + message = f"업로드 성공! {materials_inserted}개 자재가 분류되었습니다." + + response_data = { + "success": True, + "message": message, + "original_filename": file.filename, + "file_id": file_id, + "materials_count": materials_inserted, + "saved_materials_count": materials_inserted, + "new_materials_count": new_materials_count if parent_file_id is not None else None, + "revision": revision, + "uploaded_by": username, + "parsed_count": parsed_count + } + + # 누락된 도면 정보 추가 + if missing_drawings_info: + response_data["missing_drawings"] = missing_drawings_info + + # 🔄 리비전 비교 결과 추가 + if revision_comparison: + response_data["revision_comparison"] = { + "has_purchased_materials": revision_comparison.get("has_purchased_materials", False), + "purchased_count": revision_comparison.get("purchased_count", 0), + "unpurchased_count": revision_comparison.get("unpurchased_count", 0), + "new_count": revision_comparison.get("new_count", 0), + "removed_count": revision_comparison.get("removed_count", 0), + "excluded_purchased_count": revision_comparison.get("excluded_purchased_count", 0), + "removed_materials": revision_comparison.get("removed_materials", []), + "total_previous": revision_comparison.get("total_previous", 0), + "total_new": revision_comparison.get("total_new", 0) + } + + return response_data + + except Exception as e: + import traceback + error_details = traceback.format_exc() + print(f"❌ 파일 업로드 실패 - 상세 에러:") + print(error_details) + + db.rollback() + if os.path.exists(file_path): + os.remove(file_path) + raise HTTPException(status_code=500, detail=f"파일 처리 실패: {str(e)}") + +@router.get("/") +async def get_files( + job_no: Optional[str] = None, + db: Session = Depends(get_db) +): + """파일 목록 조회""" + try: + query = """ + SELECT id, filename, original_filename, bom_name, job_no, revision, + description, file_size, parsed_count, upload_date, is_active + FROM files + WHERE is_active = TRUE + """ + params = {} + + if job_no: + query += " AND job_no = :job_no" + params["job_no"] = job_no + + query += " ORDER BY upload_date DESC" + + result = db.execute(text(query), params) + files = result.fetchall() + + return [ + { + "id": file.id, + "filename": file.filename, + "original_filename": file.original_filename, + "bom_name": file.bom_name, + "job_no": file.job_no, + "revision": file.revision, + "description": file.description, + "file_size": file.file_size, + "parsed_count": file.parsed_count, + "created_at": file.upload_date, + "is_active": file.is_active + } + for file in files + ] + + except Exception as e: + raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}") + +@router.get("/list") +async def get_files_list( + job_no: Optional[str] = None, + db: Session = Depends(get_db) +): + """파일 목록 조회 (리비전 모드 확인용)""" + try: + query = """ + SELECT id, filename, original_filename, bom_name, job_no, revision, + description, file_size, parsed_count, upload_date, is_active + FROM files + WHERE is_active = TRUE + """ + params = {} + + if job_no: + query += " AND job_no = :job_no" + params["job_no"] = job_no + + query += " ORDER BY upload_date ASC" # 업로드 순서대로 정렬 + + result = db.execute(text(query), params) + files = result.fetchall() + + files_list = [ + { + "id": file.id, + "filename": file.filename, + "original_filename": file.original_filename, + "bom_name": file.bom_name, + "job_no": file.job_no, + "revision": file.revision, + "description": file.description, + "file_size": file.file_size, + "parsed_count": file.parsed_count, + "upload_date": file.upload_date, + "is_active": file.is_active + } + for file in files + ] + + return { + "success": True, + "files": files_list, + "total_count": len(files_list) + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}") + +@router.get("/project/{project_code}") +async def get_files_by_project( + project_code: str, + db: Session = Depends(get_db) +): + """프로젝트별 파일 목록 조회""" + try: + query = """ + SELECT id, filename, original_filename, bom_name, job_no, revision, + description, file_size, parsed_count, upload_date, is_active + FROM files + WHERE is_active = TRUE AND job_no = :job_no + ORDER BY upload_date DESC + """ + + result = db.execute(text(query), {"job_no": project_code}) + files = result.fetchall() + + return [ + { + "id": file.id, + "filename": file.filename, + "original_filename": file.original_filename, + "bom_name": file.bom_name, + "job_no": file.job_no, + "revision": file.revision, + "description": file.description, + "file_size": file.file_size, + "parsed_count": file.parsed_count, + "upload_date": file.upload_date, + "created_at": file.upload_date, + "is_active": file.is_active + } + for file in files + ] + + except Exception as e: + raise HTTPException(status_code=500, detail=f"프로젝트 파일 조회 실패: {str(e)}") + +@router.get("/stats") +async def get_files_stats(db: Session = Depends(get_db)): + """파일 및 자재 통계 조회""" + try: + # 총 파일 수 + files_query = text("SELECT COUNT(*) FROM files WHERE is_active = true") + total_files = db.execute(files_query).fetchone()[0] + + # 총 자재 수 + materials_query = text("SELECT COUNT(*) FROM materials") + total_materials = db.execute(materials_query).fetchone()[0] + + # 최근 업로드 (최근 5개) + recent_query = text(""" + SELECT f.original_filename, f.upload_date, f.parsed_count, j.job_name + FROM files f + LEFT JOIN jobs j ON f.job_no = j.job_no + WHERE f.is_active = true + ORDER BY f.upload_date DESC + LIMIT 5 + """) + recent_uploads = db.execute(recent_query).fetchall() + + return { + "success": True, + "totalFiles": total_files, + "totalMaterials": total_materials, + "recentUploads": [ + { + "filename": upload.original_filename, + "created_at": upload.upload_date, + "parsed_count": upload.parsed_count or 0, + "project_name": upload.job_name or "Unknown" + } + for upload in recent_uploads + ] + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"통계 조회 실패: {str(e)}") + +@router.delete("/delete/{file_id}") +async def delete_file(file_id: int, db: Session = Depends(get_db)): + """파일 삭제""" + try: + # 자재 먼저 삭제 + db.execute(text("DELETE FROM materials WHERE file_id = :file_id"), {"file_id": file_id}) + + # 파일 삭제 + result = db.execute(text("DELETE FROM files WHERE id = :file_id"), {"file_id": file_id}) + + if result.rowcount == 0: + raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다") + + db.commit() + + return { + "success": True, + "message": "파일이 삭제되었습니다" + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {str(e)}") + +@router.get("/materials-v2") # 완전히 새로운 엔드포인트 +async def get_materials( + project_id: Optional[int] = None, + file_id: Optional[int] = None, + job_no: Optional[str] = None, + filename: Optional[str] = None, + revision: Optional[str] = None, + skip: int = 0, + limit: int = 100, + search: Optional[str] = None, + item_type: Optional[str] = None, + material_grade: Optional[str] = None, + size_spec: Optional[str] = None, + file_filter: Optional[str] = None, + sort_by: Optional[str] = None, + exclude_requested: bool = True, # 구매신청된 자재 제외 여부 + group_by_spec: bool = False, # 같은 사양끼리 그룹화 + db: Session = Depends(get_db) +): + """ + 저장된 자재 목록 조회 (job_no, filename, revision 3가지로 필터링 가능) - 신버전 + """ + try: + # 로그 제거 - 과도한 출력 방지 + query = """ + SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit, + m.size_spec, m.main_nom, m.red_nom, m.material_grade, m.full_material_grade, m.line_number, m.row_number, + m.drawing_name, m.line_no, m.revision_status, + m.created_at, m.classified_category, m.classification_confidence, + m.classification_details, + m.is_verified, m.verified_by, m.verified_at, + m.brand, m.user_requirement, + f.original_filename, f.project_id, f.job_no, f.revision, + p.official_project_code, p.project_name, + pd.outer_diameter, pd.schedule, pd.material_spec, pd.manufacturing_method, + pd.end_preparation, pd.length_mm, + pep.end_preparation_type, pep.end_preparation_code, pep.machining_required, + pep.cutting_note, pep.clean_description as pipe_clean_description, + fd.fitting_type, fd.fitting_subtype, fd.connection_method, fd.pressure_rating, + fd.material_standard, fd.material_grade as fitting_material_grade, fd.main_size, + fd.reduced_size, fd.length_mm as fitting_length_mm, fd.schedule as fitting_schedule, + gd.gasket_type, gd.gasket_subtype, gd.material_type as gasket_material_type, + gd.filler_material, gd.pressure_rating as gasket_pressure_rating, gd.size_inches as gasket_size_inches, + gd.thickness as gasket_thickness, gd.temperature_range as gasket_temperature_range, gd.fire_safe, + mpt.confirmed_quantity, mpt.purchase_status, mpt.confirmed_by, mpt.confirmed_at, + -- 구매수량 계산에서 분류된 정보를 우선 사용 + CASE + WHEN mpt.id IS NOT NULL THEN + CASE + WHEN mpt.description LIKE '%PIPE%' OR mpt.description LIKE '%파이프%' THEN 'PIPE' + WHEN mpt.description LIKE '%FITTING%' OR mpt.description LIKE '%피팅%' OR mpt.description LIKE '%NIPPLE%' OR mpt.description LIKE '%ELBOW%' OR mpt.description LIKE '%TEE%' OR mpt.description LIKE '%REDUCER%' THEN 'FITTING' + WHEN mpt.description LIKE '%VALVE%' OR mpt.description LIKE '%밸브%' THEN 'VALVE' + WHEN mpt.description LIKE '%FLANGE%' OR mpt.description LIKE '%플랜지%' THEN 'FLANGE' + WHEN mpt.description LIKE '%BOLT%' OR mpt.description LIKE '%볼트%' OR mpt.description LIKE '%STUD%' THEN 'BOLT' + WHEN mpt.description LIKE '%GASKET%' OR mpt.description LIKE '%가스켓%' THEN 'GASKET' + WHEN mpt.description LIKE '%INSTRUMENT%' OR mpt.description LIKE '%계기%' THEN 'INSTRUMENT' + ELSE m.classified_category + END + ELSE m.classified_category + END as final_classified_category, + -- 구매수량 계산 완료 여부 + CASE WHEN mpt.id IS NOT NULL THEN true ELSE m.is_verified END as final_is_verified, + CASE WHEN mpt.id IS NOT NULL THEN 'purchase_calculation' ELSE m.verified_by END as final_verified_by + FROM materials m + LEFT JOIN files f ON m.file_id = f.id + LEFT JOIN projects p ON f.project_id = p.id + -- 구매신청된 자재 제외 + LEFT JOIN purchase_request_items pri ON m.id = pri.material_id + LEFT JOIN pipe_details pd ON m.id = pd.material_id + LEFT JOIN pipe_end_preparations pep ON m.id = pep.material_id + LEFT JOIN fitting_details fd ON m.id = fd.material_id + LEFT JOIN valve_details vd ON m.id = vd.material_id + LEFT JOIN gasket_details gd ON m.id = gd.material_id + LEFT JOIN material_purchase_tracking mpt ON ( + m.material_hash = mpt.material_hash + AND f.job_no = mpt.job_no + AND f.revision = mpt.revision + ) + WHERE 1=1 + """ + params = {} + + # 구매신청된 자재 제외 + if exclude_requested: + query += " AND pri.material_id IS NULL" + + if project_id: + query += " AND f.project_id = :project_id" + params["project_id"] = project_id + if file_id: + query += " AND m.file_id = :file_id" + params["file_id"] = file_id + if job_no: + query += " AND f.job_no = :job_no" + params["job_no"] = job_no + if filename: + query += " AND f.original_filename = :filename" + params["filename"] = filename + if revision: + query += " AND f.revision = :revision" + params["revision"] = revision + if search: + query += " AND (m.original_description ILIKE :search OR m.material_grade ILIKE :search)" + params["search"] = f"%{search}%" + if item_type: + query += " AND m.classified_category = :item_type" + params["item_type"] = item_type + if material_grade: + query += " AND m.material_grade ILIKE :material_grade" + params["material_grade"] = f"%{material_grade}%" + if size_spec: + query += " AND m.size_spec ILIKE :size_spec" + params["size_spec"] = f"%{size_spec}%" + if file_filter: + query += " AND f.original_filename ILIKE :file_filter" + params["file_filter"] = f"%{file_filter}%" + + # 정렬 처리 + if sort_by: + if sort_by == "quantity_desc": + query += " ORDER BY m.quantity DESC" + elif sort_by == "quantity_asc": + query += " ORDER BY m.quantity ASC" + elif sort_by == "name_asc": + query += " ORDER BY m.original_description ASC" + elif sort_by == "name_desc": + query += " ORDER BY m.original_description DESC" + elif sort_by == "created_desc": + query += " ORDER BY m.created_at DESC" + elif sort_by == "created_asc": + query += " ORDER BY m.created_at ASC" + else: + query += " ORDER BY m.line_number ASC" + else: + query += " ORDER BY m.line_number ASC" + + query += " LIMIT :limit OFFSET :skip" + params["limit"] = limit + params["skip"] = skip + + result = db.execute(text(query), params) + materials = result.fetchall() + + # 전체 개수 조회 + count_query = """ + SELECT COUNT(*) as total + FROM materials m + LEFT JOIN files f ON m.file_id = f.id + WHERE 1=1 + """ + count_params = {} + + if project_id: + count_query += " AND f.project_id = :project_id" + count_params["project_id"] = project_id + + if file_id: + count_query += " AND m.file_id = :file_id" + count_params["file_id"] = file_id + + if search: + count_query += " AND (m.original_description ILIKE :search OR m.material_grade ILIKE :search)" + count_params["search"] = f"%{search}%" + + if item_type: + count_query += " AND m.classified_category = :item_type" + count_params["item_type"] = item_type + + if material_grade: + count_query += " AND m.material_grade ILIKE :material_grade" + count_params["material_grade"] = f"%{material_grade}%" + + if size_spec: + count_query += " AND m.size_spec ILIKE :size_spec" + count_params["size_spec"] = f"%{size_spec}%" + + if file_filter: + count_query += " AND f.original_filename ILIKE :file_filter" + count_params["file_filter"] = f"%{file_filter}%" + + count_result = db.execute(text(count_query), count_params) + total_count = count_result.fetchone()[0] + + # 파이프 그룹핑을 위한 딕셔너리 + pipe_groups = {} + # 니플 그룹핑을 위한 딕셔너리 (길이 기반) + nipple_groups = {} + # 일반 피팅 그룹핑을 위한 딕셔너리 (수량 기반) + fitting_groups = {} + # 플랜지 그룹핑을 위한 딕셔너리 + flange_groups = {} + # 밸브 그룹핑을 위한 딕셔너리 + valve_groups = {} + # 볼트 그룹핑을 위한 딕셔너리 + bolt_groups = {} + # 가스켓 그룹핑을 위한 딕셔너리 + gasket_groups = {} + # UNKNOWN 그룹핑을 위한 딕셔너리 + unknown_groups = {} + + # 각 자재의 상세 정보도 가져오기 + material_list = [] + valve_count = 0 + for m in materials: + if m.classified_category == 'VALVE': + valve_count += 1 + # 디버깅: 첫 번째 자재의 모든 속성 출력 + if len(material_list) == 0: + # 로그 제거 + pass + + # 개선된 재질 정보 추출 + final_category = m.final_classified_category or m.classified_category + enhanced_material_grade = extract_enhanced_material_grade( + m.original_description, + m.material_grade, + final_category + ) + + material_dict = { + "id": m.id, + "file_id": m.file_id, + "filename": m.original_filename, + "project_id": m.project_id, + "project_code": m.official_project_code, + "project_name": m.project_name, + "original_description": m.original_description, + "quantity": float(m.quantity) if m.quantity else 0, + "unit": m.unit, + "size_spec": m.size_spec, + "main_nom": m.main_nom, + "red_nom": m.red_nom, + "material_grade": m.full_material_grade or enhanced_material_grade, + "original_material_grade": m.material_grade, + "full_material_grade": m.full_material_grade, + "drawing_name": m.drawing_name, + "line_no": m.line_no, + "revision_status": m.revision_status or 'active', + "line_number": m.line_number, + "row_number": m.row_number, + # 구매수량 계산에서 분류된 정보를 우선 사용 + "classified_category": final_category, + "classification_confidence": float(m.classification_confidence) if m.classification_confidence else 0.0, + "classification_details": m.classification_details, + "is_verified": m.final_is_verified if m.final_is_verified is not None else m.is_verified, + "verified_by": m.final_verified_by or m.verified_by, + "verified_at": m.verified_at, + "purchase_confirmed": bool(m.confirmed_quantity), + "confirmed_quantity": float(m.confirmed_quantity) if m.confirmed_quantity else None, + "purchase_status": m.purchase_status, + "purchase_confirmed_by": m.confirmed_by, + "purchase_confirmed_at": m.confirmed_at, + "created_at": m.created_at, + # 브랜드와 사용자 요구사항 필드 추가 + "brand": m.brand, + "user_requirement": m.user_requirement + } + + # 카테고리별 상세 정보 추가 (JOIN 결과 사용) + if m.classified_category == 'PIPE': + # JOIN된 결과에서 pipe_details 정보 가져오기 + if hasattr(m, 'outer_diameter') and m.outer_diameter is not None: + pipe_details = { + "outer_diameter": m.outer_diameter, + "schedule": m.schedule, + "material_spec": m.material_spec, + "manufacturing_method": m.manufacturing_method, + "end_preparation": m.end_preparation, + "length_mm": float(m.length_mm) if m.length_mm else None + } + + # 파이프 그룹핑 키 생성 (끝단 가공 정보 제외하고 그룹핑) + # pep 테이블에서 clean_description을 가져오거나, 없으면 직접 계산 + if hasattr(m, 'pipe_clean_description') and m.pipe_clean_description: + clean_description = m.pipe_clean_description + else: + from ..services.pipe_classifier import get_purchase_pipe_description + clean_description = get_purchase_pipe_description(m.original_description) + pipe_key = f"{clean_description}|{m.size_spec}|{m.material_grade}" + + # 로그 제거 - 과도한 출력 방지 + + if pipe_key not in pipe_groups: + pipe_groups[pipe_key] = { + "total_length_mm": 0, + "total_quantity": 0, + "materials": [] + } + + # 개별 파이프 길이 합산 (DB에 저장된 실제 길이 사용) + if pipe_details["length_mm"]: + # ✅ DB에서 가져온 length_mm는 이미 개별 파이프의 실제 길이이므로 수량을 곱하지 않음 + individual_length = float(pipe_details["length_mm"]) + pipe_groups[pipe_key]["total_length_mm"] += individual_length + pipe_groups[pipe_key]["total_quantity"] += 1 # 파이프 개수는 1개씩 증가 + pipe_groups[pipe_key]["materials"].append(material_dict) + + # 개별 길이 정보를 pipe_details에 추가 + pipe_details["individual_total_length"] = individual_length + + # 구매용 깨끗한 설명도 추가 + material_dict['clean_description'] = clean_description + material_dict['pipe_details'] = pipe_details + elif m.classified_category == 'FITTING': + # CAP과 PLUG 먼저 처리 (fitting_type이 없을 수 있음) + if 'CAP' in m.original_description.upper() or 'PLUG' in m.original_description.upper(): + # CAP과 PLUG 그룹핑 + from ..services.pipe_classifier import get_purchase_pipe_description + clean_description = get_purchase_pipe_description(m.original_description) + fitting_key = f"{clean_description}|{m.size_spec}|{m.material_grade}" + + if fitting_key not in fitting_groups: + fitting_groups[fitting_key] = { + "total_quantity": 0, + "materials": [] + } + + fitting_groups[fitting_key]["total_quantity"] += material_dict["quantity"] + fitting_groups[fitting_key]["materials"].append(material_dict) + material_dict['clean_description'] = clean_description + # JOIN된 fitting_details 데이터 직접 사용 + elif hasattr(m, 'fitting_type') and m.fitting_type is not None: + # 로그 제거 - 과도한 출력 방지 + + fitting_details = { + "fitting_type": m.fitting_type, + "fitting_subtype": m.fitting_subtype, + "connection_method": m.connection_method, + "pressure_rating": m.pressure_rating, + "material_standard": m.material_standard, + "material_grade": m.fitting_material_grade, + "main_size": m.main_size, + "reduced_size": m.reduced_size, + "length_mm": float(m.fitting_length_mm) if m.fitting_length_mm else None, + "schedule": m.fitting_schedule + } + material_dict['fitting_details'] = fitting_details + + # 니플인 경우 길이 기반 그룹핑 + if 'NIPPLE' in m.original_description.upper() and m.fitting_length_mm: + # 끝단 가공 정보 제거 + from ..services.pipe_classifier import get_purchase_pipe_description + clean_description = get_purchase_pipe_description(m.original_description) + nipple_key = f"{clean_description}|{m.size_spec}|{m.material_grade}|{m.fitting_length_mm}mm" + + # 로그 제거 - 과도한 출력 방지 + + if nipple_key not in nipple_groups: + nipple_groups[nipple_key] = { + "total_length_mm": 0, + "total_quantity": 0, + "materials": [] + } + + # 개별 니플 길이 합산 (수량 × 단위길이) - 타입 변환 + individual_total_length = float(material_dict["quantity"]) * float(m.fitting_length_mm) + nipple_groups[nipple_key]["total_length_mm"] += individual_total_length + nipple_groups[nipple_key]["total_quantity"] += material_dict["quantity"] + nipple_groups[nipple_key]["materials"].append(material_dict) + + # 총길이 정보를 fitting_details에 추가 + fitting_details["individual_total_length"] = individual_total_length + fitting_details["is_nipple"] = True + + # 구매용 깨끗한 설명도 추가 + material_dict['clean_description'] = clean_description + else: + # 일반 피팅 (니플이 아닌 경우) - 수량 기반 그룹핑 + from ..services.pipe_classifier import get_purchase_pipe_description + clean_description = get_purchase_pipe_description(m.original_description) + fitting_key = f"{clean_description}|{m.size_spec}|{m.material_grade}" + + if fitting_key not in fitting_groups: + fitting_groups[fitting_key] = { + "total_quantity": 0, + "materials": [] + } + + fitting_groups[fitting_key]["total_quantity"] += material_dict["quantity"] + fitting_groups[fitting_key]["materials"].append(material_dict) + + # 구매용 깨끗한 설명도 추가 + material_dict['clean_description'] = clean_description + elif m.classified_category == 'FLANGE': + flange_query = text("SELECT * FROM flange_details WHERE material_id = :material_id") + flange_result = db.execute(flange_query, {"material_id": m.id}) + flange_detail = flange_result.fetchone() + if flange_detail: + # 개선된 플랜지 타입 (PIPE측 연결면 포함) + enhanced_flange_type = extract_enhanced_flange_type( + m.original_description, + flange_detail.flange_type + ) + + material_dict['flange_details'] = { + "flange_type": enhanced_flange_type, # 개선된 타입 사용 + "original_flange_type": flange_detail.flange_type, # 원본 타입 보존 + "facing_type": flange_detail.facing_type, + "pressure_rating": flange_detail.pressure_rating, + "material_standard": flange_detail.material_standard, + "material_grade": flange_detail.material_grade, + "size_inches": flange_detail.size_inches + } + + # 플랜지 그룹핑 추가 + from ..services.pipe_classifier import get_purchase_pipe_description + clean_description = get_purchase_pipe_description(m.original_description) + flange_key = f"{m.size_spec}|{m.material_grade}|{flange_detail.pressure_rating if flange_detail else ''}|{flange_detail.flange_type if flange_detail else ''}" + + if flange_key not in flange_groups: + flange_groups[flange_key] = { + "total_quantity": 0, + "materials": [] + } + + flange_groups[flange_key]["total_quantity"] += material_dict["quantity"] + flange_groups[flange_key]["materials"].append(material_dict) + material_dict['clean_description'] = clean_description + elif m.classified_category == 'GASKET': + # 이미 JOIN된 gasket_details 데이터 사용 + if m.gasket_type: # gasket_details가 있는 경우 + material_dict['gasket_details'] = { + "gasket_type": m.gasket_type, + "gasket_subtype": m.gasket_subtype, + "material_type": m.gasket_material_type, + "filler_material": m.filler_material, + "pressure_rating": m.gasket_pressure_rating, + "size_inches": m.gasket_size_inches, + "thickness": m.gasket_thickness, + "temperature_range": m.gasket_temperature_range, + "fire_safe": m.fire_safe + } + + # 가스켓 그룹핑 - 크기, 압력, 재질로 그룹핑 + # original_description에서 주요 정보 추출 + description = m.original_description or '' + gasket_key = f"{m.size_spec}|{description}" + + if gasket_key not in gasket_groups: + gasket_groups[gasket_key] = { + "total_quantity": 0, + "materials": [] + } + + gasket_groups[gasket_key]["total_quantity"] += material_dict["quantity"] + gasket_groups[gasket_key]["materials"].append(material_dict) + elif m.classified_category == 'VALVE': + valve_query = text("SELECT * FROM valve_details WHERE material_id = :material_id") + valve_result = db.execute(valve_query, {"material_id": m.id}) + valve_detail = valve_result.fetchone() + if valve_detail: + material_dict['valve_details'] = { + "valve_type": valve_detail.valve_type, + "valve_subtype": valve_detail.valve_subtype, + "actuator_type": valve_detail.actuator_type, + "connection_method": valve_detail.connection_method, + "pressure_rating": valve_detail.pressure_rating, + "body_material": valve_detail.body_material, + "size_inches": valve_detail.size_inches + } + + # 밸브 그룹핑 추가 + from ..services.pipe_classifier import get_purchase_pipe_description + clean_description = get_purchase_pipe_description(m.original_description) + valve_key = f"{clean_description}|{m.size_spec}|{m.material_grade}" + + if valve_key not in valve_groups: + valve_groups[valve_key] = { + "total_quantity": 0, + "materials": [] + } + + valve_groups[valve_key]["total_quantity"] += material_dict["quantity"] + valve_groups[valve_key]["materials"].append(material_dict) + material_dict['clean_description'] = clean_description + elif m.classified_category == 'BOLT': + bolt_query = text("SELECT * FROM bolt_details WHERE material_id = :material_id") + bolt_result = db.execute(bolt_query, {"material_id": m.id}) + bolt_detail = bolt_result.fetchone() + if bolt_detail: + material_dict['bolt_details'] = { + "bolt_type": bolt_detail.bolt_type, + "thread_type": bolt_detail.thread_type, + "diameter": bolt_detail.diameter, + "length": bolt_detail.length, + "material_standard": bolt_detail.material_standard, + "material_grade": bolt_detail.material_grade, + "coating_type": bolt_detail.coating_type, + "pressure_rating": bolt_detail.pressure_rating + } + + # 볼트 그룹핑 추가 - 크기, 재질, 길이로 그룹핑 + # 원본 설명에서 길이 추출 + import re + length_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:LG|MM)', m.original_description.upper()) + bolt_length = length_match.group(1) if length_match else 'UNKNOWN' + + bolt_key = f"{m.size_spec}|{m.material_grade}|{bolt_length}" + + if bolt_key not in bolt_groups: + bolt_groups[bolt_key] = { + "total_quantity": 0, + "materials": [] + } + + bolt_groups[bolt_key]["total_quantity"] += material_dict["quantity"] + bolt_groups[bolt_key]["materials"].append(material_dict) + + # 파이프, 니플, 일반 피팅, 플랜지가 아닌 경우만 바로 추가 (이들은 그룹핑 후 추가) + is_nipple = (m.classified_category == 'FITTING' and + ('NIPPLE' in m.original_description.upper() or + (hasattr(m, 'fitting_type') and m.fitting_type == 'NIPPLE'))) + + # CAP과 PLUG도 일반 피팅으로 처리 + is_cap_or_plug = (m.classified_category == 'FITTING' and + ('CAP' in m.original_description.upper() or 'PLUG' in m.original_description.upper())) + + is_general_fitting = (m.classified_category == 'FITTING' and not is_nipple and + ((hasattr(m, 'fitting_type') and m.fitting_type is not None) or is_cap_or_plug)) + + is_flange = (m.classified_category == 'FLANGE') + + is_valve = (m.classified_category == 'VALVE') + + is_bolt = (m.classified_category == 'BOLT') + + is_gasket = (m.classified_category == 'GASKET') + + # UNKNOWN 카테고리 그룹핑 처리 + if m.classified_category == 'UNKNOWN': + unknown_key = m.original_description or 'UNKNOWN' + + if unknown_key not in unknown_groups: + unknown_groups[unknown_key] = { + "total_quantity": 0, + "materials": [] + } + + unknown_groups[unknown_key]["total_quantity"] += material_dict["quantity"] + unknown_groups[unknown_key]["materials"].append(material_dict) + elif m.classified_category != 'PIPE' and not is_nipple and not is_general_fitting and not is_flange and not is_valve and not is_bolt and not is_gasket: + material_list.append(material_dict) + + # 파이프 그룹별로 대표 파이프 하나만 추가 (그룹핑된 정보로) + for pipe_key, group_info in pipe_groups.items(): + if group_info["materials"]: + # 그룹의 첫 번째 파이프를 대표로 사용 + representative_pipe = group_info["materials"][0].copy() + + # 그룹핑된 정보로 업데이트 + representative_pipe['quantity'] = group_info["total_quantity"] + representative_pipe['original_description'] = representative_pipe['clean_description'] # 깨끗한 설명 사용 + + if 'pipe_details' in representative_pipe: + representative_pipe['pipe_details']['total_length_mm'] = group_info["total_length_mm"] + representative_pipe['pipe_details']['pipe_count'] = group_info["total_quantity"] # ✅ pipe_count 추가 + representative_pipe['pipe_details']['group_total_quantity'] = group_info["total_quantity"] + # 평균 단위 길이 계산 + if group_info["total_quantity"] > 0: + representative_pipe['pipe_details']['avg_length_mm'] = group_info["total_length_mm"] / group_info["total_quantity"] + + # 개별 파이프 길이 정보 수집 + individual_pipes = [] + for mat in group_info["materials"]: + if 'pipe_details' in mat and mat['pipe_details'].get('length_mm'): + individual_pipes.append({ + 'length': mat['pipe_details']['length_mm'], + 'quantity': 1, + 'id': mat['id'] + }) + representative_pipe['pipe_details']['individual_pipes'] = individual_pipes + + # 그룹화된 모든 자재 ID 저장 + representative_pipe['grouped_ids'] = [mat['id'] for mat in group_info["materials"]] + + material_list.append(representative_pipe) + + # 니플 그룹별로 대표 니플 하나만 추가 (그룹핑된 정보로) + try: + for nipple_key, group_info in nipple_groups.items(): + if group_info["materials"]: + # 그룹의 첫 번째 니플을 대표로 사용 + representative_nipple = group_info["materials"][0].copy() + + # 그룹핑된 정보로 업데이트 + representative_nipple['quantity'] = group_info["total_quantity"] + representative_nipple['original_description'] = representative_nipple.get('clean_description', representative_nipple['original_description']) # 깨끗한 설명 사용 + + if 'fitting_details' in representative_nipple: + representative_nipple['fitting_details']['total_length_mm'] = group_info["total_length_mm"] + representative_nipple['fitting_details']['group_total_quantity'] = group_info["total_quantity"] + # 평균 단위 길이 계산 + if group_info["total_quantity"] > 0: + representative_nipple['fitting_details']['avg_length_mm'] = group_info["total_length_mm"] / group_info["total_quantity"] + + material_list.append(representative_nipple) + except Exception as nipple_error: + # 로그 제거 + # 니플 그룹핑 실패시에도 계속 진행 + pass + + # 일반 피팅 그룹별로 대표 피팅 하나만 추가 (그룹핑된 정보로) + try: + for fitting_key, group_info in fitting_groups.items(): + if group_info["materials"]: + representative_fitting = group_info["materials"][0].copy() + representative_fitting['quantity'] = group_info["total_quantity"] + representative_fitting['original_description'] = representative_fitting.get('clean_description', representative_fitting['original_description']) + + if 'fitting_details' in representative_fitting: + representative_fitting['fitting_details']['group_total_quantity'] = group_info["total_quantity"] + material_list.append(representative_fitting) + except Exception as fitting_error: + # 로그 제거 + # 피팅 그룹핑 실패시에도 계속 진행 + pass + + # 플랜지 그룹별로 대표 플랜지 하나만 추가 (그룹핑된 정보로) + try: + for flange_key, group_info in flange_groups.items(): + if group_info["materials"]: + representative_flange = group_info["materials"][0].copy() + representative_flange['quantity'] = group_info["total_quantity"] + # original_description은 그대로 유지 (SCH 정보 보존) + # representative_flange['original_description'] = representative_flange.get('clean_description', representative_flange['original_description']) + + if 'flange_details' in representative_flange: + representative_flange['flange_details']['group_total_quantity'] = group_info["total_quantity"] + material_list.append(representative_flange) + except Exception as flange_error: + # 플랜지 그룹핑 실패시에도 계속 진행 + pass + + # 밸브 그룹별로 대표 밸브 하나만 추가 (그룹핑된 정보로) + print(f"DEBUG: 전체 밸브 수: {valve_count}, valve_groups 수: {len(valve_groups)}") + try: + for valve_key, group_info in valve_groups.items(): + if group_info["materials"]: + representative_valve = group_info["materials"][0].copy() + representative_valve['quantity'] = group_info["total_quantity"] + + if 'valve_details' in representative_valve: + representative_valve['valve_details']['group_total_quantity'] = group_info["total_quantity"] + material_list.append(representative_valve) + print(f"DEBUG: 밸브 추가됨 - {valve_key}, 수량: {group_info['total_quantity']}") + except Exception as valve_error: + print(f"ERROR: 밸브 그룹핑 실패 - {valve_error}") + # 밸브 그룹핑 실패시에도 계속 진행 + pass + + # 볼트 그룹별로 대표 볼트 하나만 추가 (그룹핑된 정보로) + print(f"DEBUG: bolt_groups 수: {len(bolt_groups)}") + try: + for bolt_key, group_info in bolt_groups.items(): + if group_info["materials"]: + representative_bolt = group_info["materials"][0].copy() + representative_bolt['quantity'] = group_info["total_quantity"] + + if 'bolt_details' in representative_bolt: + representative_bolt['bolt_details']['group_total_quantity'] = group_info["total_quantity"] + material_list.append(representative_bolt) + print(f"DEBUG: 볼트 추가됨 - {bolt_key}, 수량: {group_info['total_quantity']}") + except Exception as bolt_error: + print(f"ERROR: 볼트 그룹핑 실패 - {bolt_error}") + # 볼트 그룹핑 실패시에도 계속 진행 + pass + + # 가스켓 그룹별로 대표 가스켓 하나만 추가 (그룹핑된 정보로) + print(f"DEBUG: gasket_groups 수: {len(gasket_groups)}") + try: + for gasket_key, group_info in gasket_groups.items(): + if group_info["materials"]: + representative_gasket = group_info["materials"][0].copy() + representative_gasket['quantity'] = group_info["total_quantity"] + + if 'gasket_details' in representative_gasket: + representative_gasket['gasket_details']['group_total_quantity'] = group_info["total_quantity"] + material_list.append(representative_gasket) + print(f"DEBUG: 가스켓 추가됨 - {gasket_key}, 수량: {group_info['total_quantity']}") + except Exception as gasket_error: + print(f"ERROR: 가스켓 그룹핑 실패 - {gasket_error}") + # 가스켓 그룹핑 실패시에도 계속 진행 + pass + + # UNKNOWN 그룹별로 대표 항목 하나만 추가 (그룹핑된 정보로) + print(f"DEBUG: unknown_groups 수: {len(unknown_groups)}") + try: + for unknown_key, group_info in unknown_groups.items(): + if group_info["materials"]: + representative_unknown = group_info["materials"][0].copy() + representative_unknown['quantity'] = group_info["total_quantity"] + material_list.append(representative_unknown) + print(f"DEBUG: UNKNOWN 추가됨 - {unknown_key[:50]}, 수량: {group_info['total_quantity']}") + except Exception as unknown_error: + print(f"ERROR: UNKNOWN 그룹핑 실패 - {unknown_error}") + # UNKNOWN 그룹핑 실패시에도 계속 진행 + pass + + return { + "success": True, + "total_count": total_count, + "returned_count": len(materials), + "skip": skip, + "limit": limit, + "materials": material_list + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"자재 조회 실패: {str(e)}") + +@router.get("/materials/summary") +async def get_materials_summary( + project_id: Optional[int] = None, + file_id: Optional[int] = None, + db: Session = Depends(get_db) +): + """자재 요약 통계""" + try: + query = """ + SELECT + COUNT(*) as total_items, + COUNT(DISTINCT m.original_description) as unique_descriptions, + COUNT(DISTINCT m.size_spec) as unique_sizes, + COUNT(DISTINCT m.material_grade) as unique_materials, + SUM(m.quantity) as total_quantity, + AVG(m.quantity) as avg_quantity, + MIN(m.created_at) as earliest_upload, + MAX(m.created_at) as latest_upload + FROM materials m + LEFT JOIN files f ON m.file_id = f.id + WHERE 1=1 + """ + + params = {} + + if project_id: + query += " AND f.project_id = :project_id" + params["project_id"] = project_id + + if file_id: + query += " AND m.file_id = :file_id" + params["file_id"] = file_id + + result = db.execute(text(query), params) + summary = result.fetchone() + + return { + "success": True, + "summary": { + "total_items": summary.total_items, + "unique_descriptions": summary.unique_descriptions, + "unique_sizes": summary.unique_sizes, + "unique_materials": summary.unique_materials, + "total_quantity": float(summary.total_quantity) if summary.total_quantity else 0, + "avg_quantity": round(float(summary.avg_quantity), 2) if summary.avg_quantity else 0, + "earliest_upload": summary.earliest_upload, + "latest_upload": summary.latest_upload + } + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"요약 조회 실패: {str(e)}") + +@router.get("/materials/compare-revisions") +async def compare_revisions( + job_no: str, + filename: str, + old_revision: str, + new_revision: str, + db: Session = Depends(get_db) +): + """ + 리비전 간 자재 비교 + """ + try: + # 기존 리비전 자재 조회 + old_materials_query = text(""" + SELECT m.original_description, m.quantity, m.unit, m.size_spec, + m.material_grade, m.classified_category, m.classification_confidence, + m.main_nom, m.red_nom, m.drawing_name, m.line_no + FROM materials m + JOIN files f ON m.file_id = f.id + WHERE f.job_no = :job_no + AND f.original_filename = :filename + AND f.revision = :old_revision + """) + + old_result = db.execute(old_materials_query, { + "job_no": job_no, + "filename": filename, + "old_revision": old_revision + }) + old_materials = old_result.fetchall() + + # 새 리비전 자재 조회 + new_materials_query = text(""" + SELECT m.original_description, m.quantity, m.unit, m.size_spec, + m.material_grade, m.classified_category, m.classification_confidence, + m.main_nom, m.red_nom, m.drawing_name, m.line_no + FROM materials m + JOIN files f ON m.file_id = f.id + WHERE f.job_no = :job_no + AND f.original_filename = :filename + AND f.revision = :new_revision + """) + + new_result = db.execute(new_materials_query, { + "job_no": job_no, + "filename": filename, + "new_revision": new_revision + }) + new_materials = new_result.fetchall() + + # 자재 키 생성 함수 (도면번호 + 자재 정보) + def create_material_key(material): + # 도면번호가 있으면 도면번호 + 자재 설명으로 고유 키 생성 + # (같은 도면에 여러 자재가 있을 수 있으므로) + if hasattr(material, 'drawing_name') and material.drawing_name: + return f"{material.drawing_name}|{material.original_description}|{material.size_spec or ''}|{material.material_grade or ''}" + elif hasattr(material, 'line_no') and material.line_no: + return f"{material.line_no}|{material.original_description}|{material.size_spec or ''}|{material.material_grade or ''}" + else: + # 도면번호 없으면 기존 방식 (설명 + 크기 + 재질) + return f"{material.original_description}|{material.size_spec or ''}|{material.material_grade or ''}" + + # 기존 자재를 딕셔너리로 변환 (수량 합산) + old_materials_dict = {} + for material in old_materials: + key = create_material_key(material) + if key in old_materials_dict: + # 동일한 자재가 있으면 수량 합산 + old_materials_dict[key]["quantity"] += float(material.quantity) if material.quantity else 0 + else: + old_materials_dict[key] = { + "original_description": material.original_description, + "quantity": float(material.quantity) if material.quantity else 0, + "unit": material.unit, + "size_spec": material.size_spec, + "material_grade": material.material_grade, + "classified_category": material.classified_category, + "classification_confidence": material.classification_confidence, + "main_nom": material.main_nom, + "red_nom": material.red_nom, + "drawing_name": material.drawing_name, + "line_no": material.line_no + } + + # 새 자재를 딕셔너리로 변환 (수량 합산) + new_materials_dict = {} + for material in new_materials: + key = create_material_key(material) + if key in new_materials_dict: + # 동일한 자재가 있으면 수량 합산 + new_materials_dict[key]["quantity"] += float(material.quantity) if material.quantity else 0 + else: + new_materials_dict[key] = { + "original_description": material.original_description, + "quantity": float(material.quantity) if material.quantity else 0, + "unit": material.unit, + "size_spec": material.size_spec, + "material_grade": material.material_grade, + "classified_category": material.classified_category, + "classification_confidence": material.classification_confidence, + "main_nom": material.main_nom, + "red_nom": material.red_nom, + "drawing_name": material.drawing_name, + "line_no": material.line_no + } + + # 변경 사항 분석 + all_keys = set(old_materials_dict.keys()) | set(new_materials_dict.keys()) + + added_items = [] + removed_items = [] + changed_items = [] + + for key in all_keys: + old_item = old_materials_dict.get(key) + new_item = new_materials_dict.get(key) + + if old_item and not new_item: + # 삭제된 항목 + removed_items.append({ + "key": key, + "item": old_item, + "change_type": "removed" + }) + elif not old_item and new_item: + # 추가된 항목 + added_items.append({ + "key": key, + "item": new_item, + "change_type": "added" + }) + elif old_item and new_item: + # 변경 사항 감지: 수량, 재질, 크기, 카테고리 등 + changes_detected = [] + + old_qty = old_item["quantity"] + new_qty = new_item["quantity"] + qty_diff = new_qty - old_qty + + # 1. 수량 변경 확인 + if abs(qty_diff) > 0.001: + changes_detected.append({ + "type": "quantity", + "old_value": old_qty, + "new_value": new_qty, + "diff": qty_diff + }) + + # 2. 재질 변경 확인 + if old_item.get("material_grade") != new_item.get("material_grade"): + changes_detected.append({ + "type": "material", + "old_value": old_item.get("material_grade", "-"), + "new_value": new_item.get("material_grade", "-") + }) + + # 3. 크기 변경 확인 + if old_item.get("main_nom") != new_item.get("main_nom") or old_item.get("size_spec") != new_item.get("size_spec"): + changes_detected.append({ + "type": "size", + "old_value": old_item.get("main_nom") or old_item.get("size_spec", "-"), + "new_value": new_item.get("main_nom") or new_item.get("size_spec", "-") + }) + + # 4. 카테고리 변경 확인 (자재 종류가 바뀜) + if old_item.get("classified_category") != new_item.get("classified_category"): + changes_detected.append({ + "type": "category", + "old_value": old_item.get("classified_category", "-"), + "new_value": new_item.get("classified_category", "-") + }) + + # 변경사항이 있으면 changed_items에 추가 + if changes_detected: + # 변경 유형 결정 + has_qty_change = any(c["type"] == "quantity" for c in changes_detected) + has_spec_change = any(c["type"] in ["material", "size", "category"] for c in changes_detected) + + if has_spec_change: + change_type = "specification_changed" # 자재 사양 변경 + elif has_qty_change: + change_type = "quantity_changed" # 수량만 변경 + else: + change_type = "modified" + + changed_items.append({ + "key": key, + "old_item": old_item, + "new_item": new_item, + "changes": changes_detected, + "change_type": change_type, + "quantity_change": qty_diff if has_qty_change else 0, + "drawing_name": new_item.get("drawing_name") or old_item.get("drawing_name"), + "line_no": new_item.get("line_no") or old_item.get("line_no") + }) + + # 분류별 통계 + def calculate_category_stats(items): + stats = {} + for item in items: + category = item.get("item", {}).get("classified_category", "OTHER") + if category not in stats: + stats[category] = {"count": 0, "total_quantity": 0} + stats[category]["count"] += 1 + stats[category]["total_quantity"] += item.get("item", {}).get("quantity", 0) + return stats + + added_stats = calculate_category_stats(added_items) + removed_stats = calculate_category_stats(removed_items) + changed_stats = calculate_category_stats(changed_items) + + # 누락된 도면 감지 + old_drawings = set() + new_drawings = set() + + for material in old_materials: + if hasattr(material, 'drawing_name') and material.drawing_name: + old_drawings.add(material.drawing_name) + elif hasattr(material, 'line_no') and material.line_no: + old_drawings.add(material.line_no) + + for material in new_materials: + if hasattr(material, 'drawing_name') and material.drawing_name: + new_drawings.add(material.drawing_name) + elif hasattr(material, 'line_no') and material.line_no: + new_drawings.add(material.line_no) + + missing_drawings = old_drawings - new_drawings # 이전에는 있었는데 새 파일에 없는 도면 + new_only_drawings = new_drawings - old_drawings # 새로 추가된 도면 + + # 누락된 도면의 자재 목록 + missing_drawing_materials = [] + if missing_drawings: + for material in old_materials: + drawing = getattr(material, 'drawing_name', None) or getattr(material, 'line_no', None) + if drawing in missing_drawings: + missing_drawing_materials.append({ + "drawing_name": drawing, + "description": material.original_description, + "quantity": float(material.quantity) if material.quantity else 0, + "category": material.classified_category, + "size": material.main_nom or material.size_spec, + "material_grade": material.material_grade + }) + + return { + "success": True, + "comparison": { + "old_revision": old_revision, + "new_revision": new_revision, + "filename": filename, + "job_no": job_no, + "summary": { + "added_count": len(added_items), + "removed_count": len(removed_items), + "changed_count": len(changed_items), + "total_changes": len(added_items) + len(removed_items) + len(changed_items), + "missing_drawings_count": len(missing_drawings), + "new_drawings_count": len(new_only_drawings) + }, + "missing_drawings": { + "drawings": list(missing_drawings), + "materials": missing_drawing_materials, + "count": len(missing_drawings), + "requires_confirmation": len(missing_drawings) > 0 + }, + "changes": { + "added": added_items, + "removed": removed_items, + "changed": changed_items + }, + "category_stats": { + "added": added_stats, + "removed": removed_stats, + "changed": changed_stats + } + } + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"리비전 비교 실패: {str(e)}") + +@router.get("/pipe-details") +async def get_pipe_details( + file_id: Optional[int] = None, + job_no: Optional[str] = None, + db: Session = Depends(get_db) +): + """ + PIPE 상세 정보 조회 + """ + try: + query = """ + SELECT pd.*, f.original_filename, f.job_no, f.revision, + m.original_description, m.quantity, m.unit + FROM pipe_details pd + LEFT JOIN files f ON pd.file_id = f.id + LEFT JOIN materials m ON pd.file_id = m.file_id + AND m.classified_category = 'PIPE' + WHERE 1=1 + """ + params = {} + + if file_id: + query += " AND pd.file_id = :file_id" + params["file_id"] = file_id + + if job_no: + query += " AND f.job_no = :job_no" + params["job_no"] = job_no + + query += " ORDER BY pd.created_at DESC" + + result = db.execute(text(query), params) + pipe_details = result.fetchall() + + return [ + { + "id": pd.id, + "file_id": pd.file_id, + "original_filename": pd.original_filename, + "job_no": pd.job_no, + "revision": pd.revision, + "original_description": pd.original_description, + "quantity": pd.quantity, + "unit": pd.unit, + "material_spec": pd.material_spec, + "manufacturing_method": pd.manufacturing_method, + "end_preparation": pd.end_preparation, + "schedule": pd.schedule, + "outer_diameter": pd.outer_diameter, + "length_mm": pd.length_mm, + "created_at": pd.created_at, + "updated_at": pd.updated_at + } + for pd in pipe_details + ] + + except Exception as e: + raise HTTPException(status_code=500, detail=f"PIPE 상세 정보 조회 실패: {str(e)}") + +@router.get("/fitting-details") +async def get_fitting_details( + file_id: Optional[int] = None, + job_no: Optional[str] = None, + db: Session = Depends(get_db) +): + """ + FITTING 상세 정보 조회 + """ + try: + query = """ + SELECT fd.*, f.original_filename, f.job_no, f.revision, + m.original_description, m.quantity, m.unit + FROM fitting_details fd + LEFT JOIN files f ON fd.file_id = f.id + LEFT JOIN materials m ON fd.material_id = m.id + WHERE 1=1 + """ + params = {} + + if file_id: + query += " AND fd.file_id = :file_id" + params["file_id"] = file_id + + if job_no: + query += " AND f.job_no = :job_no" + params["job_no"] = job_no + + query += " ORDER BY fd.created_at DESC" + + result = db.execute(text(query), params) + fitting_details = result.fetchall() + + return [ + { + "id": fd.id, + "file_id": fd.file_id, + "fitting_type": fd.fitting_type, + "fitting_subtype": fd.fitting_subtype, + "connection_method": fd.connection_method, + "pressure_rating": fd.pressure_rating, + "material_standard": fd.material_standard, + "material_grade": fd.material_grade, + "main_size": fd.main_size, + "reduced_size": fd.reduced_size, + "classification_confidence": fd.classification_confidence, + "original_description": fd.original_description, + "quantity": fd.quantity + } + for fd in fitting_details + ] + + except Exception as e: + raise HTTPException(status_code=500, detail=f"FITTING 상세 정보 조회 실패: {str(e)}") + +@router.get("/valve-details") +async def get_valve_details( + file_id: Optional[int] = None, + job_no: Optional[str] = None, + db: Session = Depends(get_db) +): + """ + VALVE 상세 정보 조회 + """ + try: + query = """ + SELECT vd.*, f.original_filename, f.job_no, f.revision, + m.original_description, m.quantity, m.unit + FROM valve_details vd + LEFT JOIN files f ON vd.file_id = f.id + LEFT JOIN materials m ON vd.material_id = m.id + WHERE 1=1 + """ + params = {} + + if file_id: + query += " AND vd.file_id = :file_id" + params["file_id"] = file_id + + if job_no: + query += " AND f.job_no = :job_no" + params["job_no"] = job_no + + query += " ORDER BY vd.created_at DESC" + + result = db.execute(text(query), params) + valve_details = result.fetchall() + + return [ + { + "id": vd.id, + "file_id": vd.file_id, + "valve_type": vd.valve_type, + "valve_subtype": vd.valve_subtype, + "actuator_type": vd.actuator_type, + "connection_method": vd.connection_method, + "pressure_rating": vd.pressure_rating, + "body_material": vd.body_material, + "size_inches": vd.size_inches, + "fire_safe": vd.fire_safe, + "classification_confidence": vd.classification_confidence, + "original_description": vd.original_description, + "quantity": vd.quantity + } + for vd in valve_details + ] + + except Exception as e: + raise HTTPException(status_code=500, detail=f"VALVE 상세 정보 조회 실패: {str(e)}") + +@router.get("/{file_id}/user-requirements") +async def get_user_requirements( + file_id: int, + job_no: Optional[str] = None, + status: Optional[str] = None, + db: Session = Depends(get_db) +): + """ + 사용자 요구사항 조회 + """ + try: + query = """ + SELECT ur.*, f.original_filename, f.job_no, f.revision, + rt.type_name, rt.category + FROM user_requirements ur + LEFT JOIN files f ON ur.file_id = f.id + LEFT JOIN requirement_types rt ON ur.requirement_type = rt.type_code + WHERE 1=1 + """ + params = {} + + if file_id: + query += " AND ur.file_id = :file_id" + params["file_id"] = file_id + + if job_no: + query += " AND f.job_no = :job_no" + params["job_no"] = job_no + + if status: + query += " AND ur.status = :status" + params["status"] = status + + query += " ORDER BY ur.created_at DESC" + + result = db.execute(text(query), params) + requirements = result.fetchall() + + return [ + { + "id": req.id, + "file_id": req.file_id, + "material_id": req.material_id, + "original_filename": req.original_filename, + "job_no": req.job_no, + "revision": req.revision, + "requirement_type": req.requirement_type, + "type_name": req.type_name, + "category": req.category, + "requirement_title": req.requirement_title, + "requirement_description": req.requirement_description, + "requirement_spec": req.requirement_spec, + "status": req.status, + "priority": req.priority, + "assigned_to": req.assigned_to, + "due_date": req.due_date, + "created_at": req.created_at, + "updated_at": req.updated_at + } + for req in requirements + ] + + except Exception as e: + raise HTTPException(status_code=500, detail=f"사용자 요구사항 조회 실패: {str(e)}") + +class UserRequirementCreate(BaseModel): + file_id: int + material_id: Optional[int] = None + requirement_type: str + requirement_title: str + requirement_description: Optional[str] = None + requirement_spec: Optional[str] = None + priority: str = "NORMAL" + assigned_to: Optional[str] = None + due_date: Optional[str] = None + +@router.post("/user-requirements") +async def create_user_requirement( + requirement: UserRequirementCreate, + db: Session = Depends(get_db) +): + """ + 사용자 요구사항 생성 + """ + try: + insert_query = text(""" + INSERT INTO user_requirements ( + file_id, material_id, requirement_type, requirement_title, requirement_description, + requirement_spec, priority, assigned_to, due_date + ) + VALUES ( + :file_id, :material_id, :requirement_type, :requirement_title, :requirement_description, + :requirement_spec, :priority, :assigned_to, :due_date + ) + RETURNING id + """) + + result = db.execute(insert_query, { + "file_id": requirement.file_id, + "material_id": requirement.material_id, + "requirement_type": requirement.requirement_type, + "requirement_title": requirement.requirement_title, + "requirement_description": requirement.requirement_description, + "requirement_spec": requirement.requirement_spec, + "priority": requirement.priority, + "assigned_to": requirement.assigned_to, + "due_date": requirement.due_date + }) + + requirement_id = result.fetchone()[0] + db.commit() + + return { + "success": True, + "message": "요구사항이 생성되었습니다", + "requirement_id": requirement_id + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"요구사항 생성 실패: {str(e)}") + +@router.delete("/user-requirements") +async def delete_user_requirements( + file_id: Optional[int] = None, + material_id: Optional[int] = None, + db: Session = Depends(get_db) +): + """ + 사용자 요구사항 삭제 (파일별 또는 자재별) + """ + try: + if file_id: + # 파일별 삭제 + delete_query = text("DELETE FROM user_requirements WHERE file_id = :file_id") + result = db.execute(delete_query, {"file_id": file_id}) + deleted_count = result.rowcount + elif material_id: + # 자재별 삭제 + delete_query = text("DELETE FROM user_requirements WHERE material_id = :material_id") + result = db.execute(delete_query, {"material_id": material_id}) + deleted_count = result.rowcount + else: + raise HTTPException(status_code=400, detail="file_id 또는 material_id가 필요합니다") + + db.commit() + + return { + "success": True, + "message": f"{deleted_count}개의 요구사항이 삭제되었습니다", + "deleted_count": deleted_count + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"요구사항 삭제 실패: {str(e)}") + +@router.post("/materials/{material_id}/verify") +async def verify_material_classification( + material_id: int, + request: Request, + verified_category: Optional[str] = None, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + 자재 분류 결과 검증 + """ + try: + username = current_user.get('username', 'unknown') + + # 자재 존재 확인 + material_query = text("SELECT * FROM materials WHERE id = :material_id") + material_result = db.execute(material_query, {"material_id": material_id}) + material = material_result.fetchone() + + if not material: + raise HTTPException(status_code=404, detail="자재를 찾을 수 없습니다") + + # 검증 정보 업데이트 + update_query = text(""" + UPDATE materials + SET is_verified = TRUE, + verified_by = :username, + verified_at = CURRENT_TIMESTAMP, + classified_category = COALESCE(:verified_category, classified_category) + WHERE id = :material_id + """) + + db.execute(update_query, { + "material_id": material_id, + "username": username, + "verified_category": verified_category + }) + + # 활동 로그 기록 + try: + from ..services.activity_logger import log_activity_from_request + log_activity_from_request( + db, request, username, + "MATERIAL_VERIFY", + f"자재 분류 검증: {material.original_description}" + ) + except Exception as e: + print(f"활동 로그 기록 실패: {str(e)}") + + db.commit() + + return { + "success": True, + "message": "자재 분류가 검증되었습니다", + "material_id": material_id, + "verified_by": username + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"자재 검증 실패: {str(e)}") + +@router.put("/materials/{material_id}/update-classification") +async def update_material_classification( + material_id: int, + request: Request, + classified_category: str = Form(...), + classified_subcategory: str = Form(None), + material_grade: str = Form(None), + schedule: str = Form(None), + size_spec: str = Form(None), + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """BOM 관리 페이지에서 사용자가 분류를 수정하는 API""" + try: + username = current_user.get("username", "unknown") + + # 자재 존재 확인 + check_query = text("SELECT id, original_description FROM materials WHERE id = :material_id") + result = db.execute(check_query, {"material_id": material_id}) + material = result.fetchone() + + if not material: + raise HTTPException(status_code=404, detail="자재를 찾을 수 없습니다") + + # 분류 정보 업데이트 + update_query = text(""" + UPDATE materials + SET classified_category = :classified_category, + classified_subcategory = :classified_subcategory, + material_grade = :material_grade, + schedule = :schedule, + size_spec = :size_spec, + is_verified = true, + verified_by = :verified_by, + verified_at = NOW(), + updated_at = NOW() + WHERE id = :material_id + """) + + db.execute(update_query, { + "material_id": material_id, + "classified_category": classified_category, + "classified_subcategory": classified_subcategory or "", + "material_grade": material_grade or "", + "schedule": schedule or "", + "size_spec": size_spec or "", + "verified_by": username + }) + + db.commit() + + # 활동 로그 기록 + await log_activity_from_request( + request, + db, + "material_classification_update", + f"자재 분류 수정: {material.original_description} -> {classified_category}", + {"material_id": material_id, "category": classified_category} + ) + + return { + "success": True, + "message": "자재 분류가 성공적으로 업데이트되었습니다", + "material_id": material_id, + "classified_category": classified_category + } + + except Exception as e: + db.rollback() + print(f"자재 분류 업데이트 실패: {str(e)}") + raise HTTPException(status_code=500, detail=f"자재 분류 업데이트 실패: {str(e)}") + +@router.post("/materials/confirm-purchase") +async def confirm_material_purchase_api( + request: Request, + job_no: str = Query(...), + revision: str = Query(...), + confirmed_by: str = Query("user"), + confirmations_data: List[Dict] = Body(...), + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """자재 구매수량 확정 API (프론트엔드 호환)""" + try: + + # 입력 데이터 검증 + if not job_no or not revision: + raise HTTPException(status_code=400, detail="Job 번호와 리비전은 필수입니다") + + if not confirmations_data: + raise HTTPException(status_code=400, detail="확정할 자재가 없습니다") + + # 각 확정 항목 검증 + for i, confirmation in enumerate(confirmations_data): + if not confirmation.get("material_hash"): + raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 material_hash가 없습니다") + + confirmed_qty = confirmation.get("confirmed_quantity") + if confirmed_qty is None or confirmed_qty < 0: + raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 확정 수량이 유효하지 않습니다") + + confirmed_items = [] + + for confirmation in confirmations_data: + # 발주 추적 테이블에 저장/업데이트 + upsert_query = text(""" + INSERT INTO material_purchase_tracking ( + job_no, material_hash, revision, description, size_spec, unit, + bom_quantity, calculated_quantity, confirmed_quantity, + purchase_status, supplier_name, unit_price, total_price, + confirmed_by, confirmed_at + ) + SELECT + :job_no, m.material_hash, :revision, m.original_description, + m.size_spec, m.unit, m.quantity, :calculated_qty, :confirmed_qty, + 'CONFIRMED', :supplier_name, :unit_price, :total_price, + :confirmed_by, CURRENT_TIMESTAMP + FROM materials m + WHERE m.material_hash = :material_hash + AND m.file_id = ( + SELECT id FROM files + WHERE job_no = :job_no AND revision = :revision + ORDER BY upload_date DESC LIMIT 1 + ) + LIMIT 1 + ON CONFLICT (job_no, material_hash, revision) + DO UPDATE SET + confirmed_quantity = :confirmed_qty, + purchase_status = 'CONFIRMED', + supplier_name = :supplier_name, + unit_price = :unit_price, + total_price = :total_price, + confirmed_by = :confirmed_by, + confirmed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + RETURNING id, description, confirmed_quantity + """) + + calculated_qty = confirmation.get("calculated_quantity", confirmation["confirmed_quantity"]) + total_price = confirmation["confirmed_quantity"] * confirmation.get("unit_price", 0) + + result = db.execute(upsert_query, { + "job_no": job_no, + "revision": revision, + "material_hash": confirmation["material_hash"], + "calculated_qty": calculated_qty, + "confirmed_qty": confirmation["confirmed_quantity"], + "supplier_name": confirmation.get("supplier_name", ""), + "unit_price": confirmation.get("unit_price", 0), + "total_price": total_price, + "confirmed_by": confirmed_by + }) + + confirmed_item = result.fetchone() + if confirmed_item: + confirmed_items.append({ + "id": confirmed_item[0], + "description": confirmed_item[1], + "confirmed_quantity": confirmed_item[2] + }) + + db.commit() + + # 활동 로그 기록 + await log_activity_from_request( + request, + db, + "material_purchase_confirm", + f"구매수량 확정: {job_no} {revision} - {len(confirmed_items)}개 품목", + {"job_no": job_no, "revision": revision, "items_count": len(confirmed_items)} + ) + + return { + "success": True, + "message": f"{len(confirmed_items)}개 품목의 구매수량이 확정되었습니다", + "job_no": job_no, + "revision": revision, + "confirmed_items": confirmed_items + } + + except Exception as e: + db.rollback() + print(f"구매수량 확정 실패: {str(e)}") + raise HTTPException(status_code=500, detail=f"구매수량 확정 실패: {str(e)}") + + +@router.put("/{file_id}") +async def update_file_info( + file_id: int, + bom_name: Optional[str] = Body(None), + description: Optional[str] = Body(None), + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """파일 정보 수정""" + try: + # 파일 존재 확인 및 관련 정보 조회 + file_query = text(""" + SELECT id, bom_name, description, job_no, original_filename + FROM files + WHERE id = :file_id + """) + file_result = db.execute(file_query, {"file_id": file_id}).fetchone() + + if not file_result: + raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다") + + # 업데이트할 필드 준비 + update_fields = [] + params = {} + + if bom_name is not None: + update_fields.append("bom_name = :bom_name") + params["bom_name"] = bom_name + + if description is not None: + update_fields.append("description = :description") + params["description"] = description + + if not update_fields: + raise HTTPException(status_code=400, detail="수정할 정보가 없습니다") + + # BOM 이름 수정인 경우, 같은 job_no의 모든 리비전을 함께 업데이트 + if bom_name is not None and file_result.job_no: + # 같은 job_no의 모든 파일 업데이트 + update_query = text(f""" + UPDATE files + SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP + WHERE job_no = :job_no + """) + params["job_no"] = file_result.job_no + + else: + # 단일 파일만 업데이트 + update_query = text(f""" + UPDATE files + SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP + WHERE id = :file_id + """) + params["file_id"] = file_id + + db.execute(update_query, params) + db.commit() + + # 활동 로그 기록 - 간단하게 처리 + logger.info(f"파일 정보 수정 완료: 사용자={current_user['username']}, 파일ID={file_id}, BOM명={bom_name or 'N/A'}") + + return {"message": "파일 정보가 성공적으로 수정되었습니다"} + + except Exception as e: + logger.error(f"파일 정보 수정 실패: {str(e)}") + db.rollback() + raise HTTPException(status_code=500, detail=f"파일 정보 수정 실패: {str(e)}") + + +@router.get("/{file_id}/export-excel") +async def export_materials_to_excel( + file_id: int, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """자재 목록을 엑셀로 내보내기""" + try: + # 파일 정보 조회 + file_query = text(""" + SELECT f.id, f.original_filename, f.bom_name, f.job_no, f.revision, + p.project_name, p.official_project_code + FROM files f + LEFT JOIN projects p ON f.project_id = p.id + WHERE f.id = :file_id + """) + file_result = db.execute(file_query, {"file_id": file_id}).fetchone() + + if not file_result: + raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다") + + # 자재 목록 조회 + materials_query = text(""" + SELECT + m.line_number, + m.original_description, + m.quantity, + m.unit, + m.size_spec, + m.main_nom, + m.red_nom, + m.material_grade, + m.classified_category, + m.classification_confidence, + m.is_verified, + m.verified_by, + m.created_at + FROM materials m + WHERE m.file_id = :file_id + ORDER BY m.line_number ASC + """) + + materials_result = db.execute(materials_query, {"file_id": file_id}).fetchall() + + # 엑셀 데이터 준비 + excel_data = [] + for material in materials_result: + excel_data.append({ + "라인번호": material.line_number, + "품명": material.original_description, + "수량": material.quantity, + "단위": material.unit, + "사이즈": material.size_spec, + "주요NOM": material.main_nom, + "축소NOM": material.red_nom, + "재질등급": material.material_grade, + "분류": material.classified_category, + "신뢰도": material.classification_confidence, + "검증여부": "검증완료" if material.is_verified else "미검증", + "검증자": material.verified_by or "", + "등록일": material.created_at.strftime("%Y-%m-%d %H:%M:%S") if material.created_at else "" + }) + + # 활동 로그 기록 + activity_logger = ActivityLogger(db) + activity_logger.log_activity( + username=current_user["username"], + activity_type="엑셀 내보내기", + activity_description=f"자재 목록 엑셀 내보내기: {file_result.original_filename}", + target_type="file", + target_id=file_id, + metadata={ + "file_name": file_result.original_filename, + "bom_name": file_result.bom_name, + "job_no": file_result.job_no, + "revision": file_result.revision, + "materials_count": len(excel_data) + } + ) + + return { + "message": "엑셀 내보내기 준비 완료", + "file_info": { + "filename": file_result.original_filename, + "bom_name": file_result.bom_name, + "job_no": file_result.job_no, + "revision": file_result.revision, + "project_name": file_result.project_name + }, + "materials": excel_data, + "total_count": len(excel_data) + } + + except Exception as e: + logger.error(f"엑셀 내보내기 실패: {str(e)}") + raise HTTPException(status_code=500, detail=f"엑셀 내보내기 실패: {str(e)}") + + +@router.get("/{file_id}/materials/view-log") +async def log_materials_view( + file_id: int, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """자재 목록 조회 로그 기록""" + try: + # 파일 정보 조회 + file_query = text("SELECT original_filename, bom_name FROM files WHERE id = :file_id") + file_result = db.execute(file_query, {"file_id": file_id}).fetchone() + + if file_result: + # 활동 로그 기록 + activity_logger = ActivityLogger(db) + activity_logger.log_activity( + username=current_user["username"], + activity_type="자재 목록 조회", + activity_description=f"자재 목록 조회: {file_result.original_filename}", + target_type="file", + target_id=file_id, + metadata={ + "file_name": file_result.original_filename, + "bom_name": file_result.bom_name + } + ) + + return {"message": "조회 로그 기록 완료"} + + except Exception as e: + logger.error(f"조회 로그 기록 실패: {str(e)}") + return {"message": "조회 로그 기록 실패"} + + +@router.post("/{file_id}/process-missing-drawings") +async def process_missing_drawings( + file_id: int, + action: str = "delete", + drawings: List[str] = [], + db: Session = Depends(get_db) +): + """ + 누락된 도면 처리 + - action='delete': 누락된 도면의 이전 리비전 자재 삭제 처리 + - action='keep': 누락된 도면의 이전 리비전 자재 유지 + """ + try: + # 현재 파일 정보 확인 + from pydantic import BaseModel + + class MissingDrawingsRequest(BaseModel): + action: str + drawings: List[str] + + # parent_file_id 조회는 files 테이블에 없을 수 있으므로 revision으로 판단 + file_query = text(""" + SELECT f.id, f.job_no, f.revision, f.original_filename + FROM files f + WHERE f.id = :file_id + """) + file_result = db.execute(file_query, {"file_id": file_id}).fetchone() + + if not file_result: + raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다") + + # 이전 리비전 파일 찾기 + prev_revision_query = text(""" + SELECT id FROM files + WHERE job_no = :job_no + AND original_filename = :filename + AND revision < :current_revision + ORDER BY revision DESC + LIMIT 1 + """) + prev_result = db.execute(prev_revision_query, { + "job_no": file_result.job_no, + "filename": file_result.original_filename, + "current_revision": file_result.revision + }).fetchone() + + if not prev_result: + raise HTTPException(status_code=400, detail="이전 리비전을 찾을 수 없습니다") + + parent_file_id = prev_result.id + + if action == "delete": + # 누락된 도면의 자재를 deleted_not_purchased 상태로 변경 + for drawing in drawings: + update_query = text(""" + UPDATE materials + SET revision_status = 'deleted_not_purchased' + WHERE file_id = :parent_file_id + AND (drawing_name = :drawing OR line_no = :drawing) + """) + db.execute(update_query, { + "parent_file_id": parent_file_id, + "drawing": drawing + }) + + db.commit() + + return { + "success": True, + "message": f"{len(drawings)}개 도면의 자재가 삭제 처리되었습니다", + "action": "deleted", + "drawings_count": len(drawings) + } + else: + # keep - 이미 처리됨 (inventory 또는 active 상태 유지) + return { + "success": True, + "message": "누락된 도면의 자재가 유지됩니다", + "action": "kept", + "drawings_count": len(drawings) + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"도면 처리 실패: {str(e)}") + +@router.post("/save-excel") +async def save_excel_file( + request: ExcelSaveRequest, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 엑셀 파일을 서버에 저장하고 메타데이터를 기록 + """ + try: + # 엑셀 저장 디렉토리 생성 + excel_dir = Path("uploads/excel_exports") + excel_dir.mkdir(parents=True, exist_ok=True) + + # 파일 경로 생성 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_filename = f"{request.category}_{timestamp}_{request.filename}" + file_path = excel_dir / safe_filename + + # 엑셀 파일 생성 (openpyxl 사용) + import openpyxl + from openpyxl.styles import Font, PatternFill, Alignment + + wb = openpyxl.Workbook() + ws = wb.active + ws.title = request.category + + # 헤더 설정 + headers = ['TAGNO', '품목명', '수량', '통화구분', '단가', '크기', '압력등급', '스케줄', + '재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3', + '관리항목4', '납기일(YYYY-MM-DD)'] + + # 헤더 스타일 + header_font = Font(bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_alignment = Alignment(horizontal="center", vertical="center") + + # 헤더 작성 + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + + # 데이터 작성 + for row_idx, material in enumerate(request.materials, 2): + # 기본 데이터 + data = [ + '', # TAGNO + request.category, # 품목명 + material.get('quantity', 0), # 수량 + 'KRW', # 통화구분 + 1, # 단가 + material.get('size_spec', '-'), # 크기 + '-', # 압력등급 + material.get('schedule', '-'), # 스케줄 + material.get('full_material_grade', material.get('material_grade', '-')), # 재질 + '-', # 상세내역 + material.get('user_requirement', ''), # 사용자요구 + '', '', '', '', # 관리항목들 + datetime.now().strftime('%Y-%m-%d') # 납기일 + ] + + # 데이터 입력 + for col, value in enumerate(data, 1): + ws.cell(row=row_idx, column=col, value=value) + + # 엑셀 파일 저장 + wb.save(file_path) + + # 데이터베이스에 메타데이터 저장 (테이블이 없으면 무시) + try: + save_query = text(""" + INSERT INTO excel_exports ( + file_id, category, filename, file_path, + material_count, created_by, created_at + ) VALUES ( + :file_id, :category, :filename, :file_path, + :material_count, :user_id, :created_at + ) + """) + + db.execute(save_query, { + "file_id": request.file_id, + "category": request.category, + "filename": safe_filename, + "file_path": str(file_path), + "material_count": len(request.materials), + "user_id": current_user.get('id'), + "created_at": datetime.now() + }) + + db.commit() + except Exception as db_error: + logger.warning(f"엑셀 메타데이터 저장 실패 (파일은 저장됨): {str(db_error)}") + # 메타데이터 저장 실패해도 파일은 저장되었으므로 계속 진행 + + logger.info(f"엑셀 파일 저장 완료: {safe_filename}") + + return { + "success": True, + "message": "엑셀 파일이 성공적으로 저장되었습니다.", + "filename": safe_filename, + "file_path": str(file_path), + "material_count": len(request.materials) + } + + except Exception as e: + logger.error(f"엑셀 파일 저장 실패: {str(e)}") + raise HTTPException(status_code=500, detail=f"엑셀 파일 저장 실패: {str(e)}") + +@router.get("/excel-exports") +async def get_excel_exports( + file_id: Optional[int] = None, + category: Optional[str] = None, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 저장된 엑셀 파일 목록 조회 + """ + try: + query = text(""" + SELECT + id, file_id, category, filename, file_path, + material_count, created_by, created_at + FROM excel_exports + WHERE 1=1 + """) + + params = {} + + if file_id: + query = text(str(query) + " AND file_id = :file_id") + params["file_id"] = file_id + + if category: + query = text(str(query) + " AND category = :category") + params["category"] = category + + query = text(str(query) + " ORDER BY created_at DESC") + + result = db.execute(query, params).fetchall() + + exports = [] + for row in result: + exports.append({ + "id": row.id, + "file_id": row.file_id, + "category": row.category, + "filename": row.filename, + "file_path": row.file_path, + "material_count": row.material_count, + "created_by": row.created_by, + "created_at": row.created_at.isoformat() if row.created_at else None + }) + + return { + "success": True, + "exports": exports + } + + except Exception as e: + logger.error(f"엑셀 내보내기 목록 조회 실패: {str(e)}") + return { + "success": False, + "exports": [], + "message": "엑셀 내보내기 목록을 조회할 수 없습니다." + } + + +def perform_simple_revision_comparison(db: Session, job_no: str, parent_file_id: int, new_materials: List[Dict]) -> Dict: + """ + 개선된 리비전 비교 로직 + + 요구사항: + 1. 구매확정된 자재는 완전히 제외 (수량 변경 여부와 무관) + 2. 삭제된 항목은 별도 리스트로 반환 + 3. 추가된 항목만 분류 대상 + + Args: + db: 데이터베이스 세션 + job_no: 프로젝트 번호 + parent_file_id: 이전 파일 ID + new_materials: 신규 자재 목록 + + Returns: + 비교 결과 { + has_purchased_materials: bool, + purchased_count: int, + unpurchased_count: int, + new_count: int, + removed_count: int, + materials_to_classify: List[Dict], # 신규 자재만 + removed_materials: List[Dict], # 삭제된 자재 + total_previous: int, + total_new: int + } + """ + try: + # 1. 이전 파일의 모든 자재 조회 (구매확정 정보 포함) + previous_materials_query = text(""" + SELECT original_description, classified_category, size_spec, material_grade, + main_nom, red_nom, drawing_name, line_no, + COALESCE(total_quantity, quantity, 0) as quantity, unit, + purchase_confirmed, purchase_confirmed_at, purchase_confirmed_by + FROM materials + WHERE file_id = :parent_file_id + ORDER BY id + """) + + previous_result = db.execute(previous_materials_query, {"parent_file_id": parent_file_id}) + previous_materials = previous_result.fetchall() + + if not previous_materials: + print("📝 이전 자료가 없음 - 전체 자재 분류") + return { + "has_purchased_materials": False, + "message": "이전 자료가 없습니다.", + "materials_to_classify": new_materials, + "new_count": len(new_materials), + "removed_materials": [], + "removed_count": 0 + } + + # 2. 이전 자재를 키별로 그룹화 + previous_dict = {} + purchased_dict = {} # 구매확정된 자재 별도 관리 + unpurchased_dict = {} # 미구매 자재 별도 관리 + + for material in previous_materials: + # 자재 식별 키 생성 (description + size) + key = f"{material.original_description.strip().upper()}|{material.size_spec or ''}" + + material_data = { + "original_description": material.original_description, + "classified_category": material.classified_category, + "size_spec": material.size_spec, + "material_grade": material.material_grade, + "main_nom": material.main_nom, + "red_nom": material.red_nom, + "drawing_name": material.drawing_name, + "line_no": material.line_no, + "quantity": float(material.quantity or 0), + "unit": material.unit, + "purchase_confirmed": material.purchase_confirmed, + "purchase_confirmed_at": material.purchase_confirmed_at, + "purchase_confirmed_by": material.purchase_confirmed_by + } + + if key in previous_dict: + # 동일 자재가 있으면 수량 합산 + previous_dict[key]["quantity"] += float(material.quantity or 0) + else: + previous_dict[key] = material_data + + # 구매확정 여부에 따라 분류 + if material.purchase_confirmed: + purchased_dict[key] = previous_dict[key] + else: + unpurchased_dict[key] = previous_dict[key] + + # 3. 신규 자재를 키별로 그룹화 + new_dict = {} + for material in new_materials: + key = f"{material.get('original_description', '').strip().upper()}|{material.get('size_spec', '') or ''}" + + # total_quantity 우선 사용 + quantity = float(material.get("total_quantity") or material.get("quantity", 0)) + + if key in new_dict: + new_dict[key]["quantity"] += quantity + else: + new_dict[key] = material.copy() + new_dict[key]["quantity"] = quantity + + # 4. 비교 수행 + materials_to_classify = [] # 분류가 필요한 신규 자재만 + removed_materials = [] # 삭제된 자재 (이전에는 있었지만 신규에는 없는) + new_count = 0 + excluded_purchased_count = 0 + + print(f"\n{'='*60}") + print(f"🔍 리비전 비교 시작") + print(f"{'='*60}") + print(f"📊 이전 자재: {len(previous_dict)}개 (구매확정: {len(purchased_dict)}개, 미구매: {len(unpurchased_dict)}개)") + print(f"📊 신규 자재: {len(new_dict)}개") + + # 4-1. 신규 자재 확인 (구매확정된 자재는 제외) + for key, new_material in new_dict.items(): + if key in purchased_dict: + # ✅ 구매확정된 자재는 완전히 제외 + excluded_purchased_count += 1 + print(f"🚫 구매확정 자재 제외: {new_material.get('original_description', '')[:50]}...") + elif key in unpurchased_dict: + # 미구매 자재인데 새 리비전에도 있음 + previous_material = unpurchased_dict[key] + + # 🔧 PIPE 특별 처리: 6,000mm(1본) 단위로 비교 + if previous_material.get("classified_category") == "PIPE": + import math + + # 이전 수량으로 필요한 본수 계산 + prev_qty = previous_material.get("quantity", 0) + new_qty = new_material.get("quantity", 0) + + prev_pipes_needed = math.ceil(prev_qty / 6000) + new_pipes_needed = math.ceil(new_qty / 6000) + + if prev_pipes_needed != new_pipes_needed: + # 필요한 본수가 변경됨 - 분류 필요 + print(f"🔧 PIPE 본수 변경: {new_material.get('original_description', '')[:40]}... " + f"{prev_qty}mm({prev_pipes_needed}본) → {new_qty}mm({new_pipes_needed}본)") + materials_to_classify.append(new_material) + new_count += 1 + else: + # 필요한 본수 동일 - 기존 분류 유지 + print(f"♻️ PIPE 본수 유지: {new_material.get('original_description', '')[:40]}... " + f"{prev_qty}mm → {new_qty}mm ({new_pipes_needed}본)") + else: + # 기타 자재: 기존 분류 유지 + print(f"♻️ 기존 미구매 자재 유지: {new_material.get('original_description', '')[:50]}...") + else: + # ✅ 완전히 새로운 자재 - 분류 필요 + print(f"➕ 신규 자재: {new_material.get('original_description', '')[:50]}...") + materials_to_classify.append(new_material) + new_count += 1 + + # 4-2. 삭제된 자재 확인 (미구매 자재 중에서만) + for key, previous_material in unpurchased_dict.items(): + if key not in new_dict: + # ✅ 이전에는 있었지만 새 리비전에는 없음 + removed_materials.append(previous_material) + print(f"➖ 삭제된 자재: {previous_material.get('original_description', '')[:50]}...") + + print(f"\n{'='*60}") + print(f"📊 비교 결과") + print(f"{'='*60}") + print(f"✅ 신규 자재: {new_count}개 (분류 필요)") + print(f"➖ 삭제 자재: {len(removed_materials)}개") + print(f"🚫 구매확정 제외: {excluded_purchased_count}개") + print(f"{'='*60}\n") + + return { + "has_purchased_materials": len(purchased_dict) > 0, + "purchased_count": len(purchased_dict), + "unpurchased_count": len(unpurchased_dict), + "new_count": new_count, + "removed_count": len(removed_materials), + "excluded_purchased_count": excluded_purchased_count, + "materials_to_classify": materials_to_classify, # 신규 자재만 + "removed_materials": removed_materials, # 삭제된 자재 + "purchased_materials_map": purchased_dict, # 🔥 구매확정된 자재 매핑 정보 + "total_previous": len(previous_dict), + "total_new": len(new_dict) + } + + except Exception as e: + print(f"❌ 리비전 비교 실패: {str(e)}") + import traceback + traceback.print_exc() + return { + "has_purchased_materials": False, + "message": f"비교 실패: {str(e)}", + "materials_to_classify": new_materials, + "removed_materials": [], + "new_count": len(new_materials), + "removed_count": 0 + } \ No newline at end of file diff --git a/tkeg/api/app/routers/jobs.py b/tkeg/api/app/routers/jobs.py new file mode 100644 index 0000000..e232903 --- /dev/null +++ b/tkeg/api/app/routers/jobs.py @@ -0,0 +1,48 @@ +"""Job(프로젝트) 라우터 — tkuser API 프록시""" +from fastapi import APIRouter, Depends, HTTPException, Request +from ..auth.middleware import get_current_user +from ..utils.tkuser_client import get_token_from_request, get_projects, get_project + +router = APIRouter() + + +@router.get("/") +async def get_jobs(request: Request, user: dict = Depends(get_current_user)): + """프로젝트 목록 조회 (tkuser 프록시)""" + token = get_token_from_request(request) + projects = await get_projects(token, active_only=True) + return { + "success": True, + "total_count": len(projects), + "jobs": [ + { + "job_no": p.get("job_no"), + "job_name": p.get("project_name"), + "client_name": p.get("client_name"), + "status": "진행중" if p.get("is_active") else "완료", + "created_at": p.get("created_at"), + "project_name": p.get("project_name"), + } + for p in projects + ], + } + + +@router.get("/{job_no}") +async def get_job(job_no: str, request: Request, user: dict = Depends(get_current_user)): + """프로젝트 상세 조회 — tkeg는 job_no로 참조하므로 목록에서 필터""" + token = get_token_from_request(request) + projects = await get_projects(token, active_only=False) + matched = next((p for p in projects if p.get("job_no") == job_no), None) + if not matched: + raise HTTPException(status_code=404, detail="Job을 찾을 수 없습니다") + return { + "success": True, + "job": { + "job_no": matched.get("job_no"), + "job_name": matched.get("project_name"), + "client_name": matched.get("client_name"), + "status": "진행중" if matched.get("is_active") else "완료", + "created_at": matched.get("created_at"), + }, + } diff --git a/tkeg/api/app/routers/material_comparison.py b/tkeg/api/app/routers/material_comparison.py new file mode 100644 index 0000000..008808a --- /dev/null +++ b/tkeg/api/app/routers/material_comparison.py @@ -0,0 +1,657 @@ +""" +자재 비교 및 발주 추적 API +- 리비전간 자재 비교 +- 추가 발주 필요량 계산 +- 발주 상태 관리 +""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import text +import json +from typing import List, Optional, Dict +from datetime import datetime + +from ..database import get_db + +router = APIRouter(prefix="/materials", tags=["material-comparison"]) + +@router.post("/compare-revisions") +async def compare_material_revisions( + job_no: str, + current_revision: str, + previous_revision: Optional[str] = None, + save_result: bool = True, + db: Session = Depends(get_db) +): + """ + 리비전간 자재 비교 및 추가 발주 필요량 계산 + - 해시 기반 고성능 비교 + - 누적 재고 고려한 실제 구매 필요량 계산 + """ + try: + # 1. 파일 정보 조회 + current_file = await get_file_by_revision(db, job_no, current_revision) + if not current_file: + raise HTTPException(status_code=404, detail=f"{job_no} {current_revision} 파일을 찾을 수 없습니다") + + # 2. 이전 리비전 자동 탐지 + if not previous_revision: + previous_revision = await get_previous_revision(db, job_no, current_revision) + + previous_file = None + if previous_revision: + previous_file = await get_file_by_revision(db, job_no, previous_revision) + + # 3. 자재 비교 실행 + comparison_result = await perform_material_comparison( + db, current_file, previous_file, job_no + ) + + # 4. 결과 저장 (선택사항) - 임시로 비활성화 + comparison_id = None + # TODO: 저장 기능 활성화 + # if save_result and previous_file and previous_revision: + # comparison_id = await save_comparison_result( + # db, job_no, current_revision, previous_revision, + # current_file["id"], previous_file["id"], comparison_result + # ) + + return { + "success": True, + "job_no": job_no, + "current_revision": current_revision, + "previous_revision": previous_revision, + "comparison_id": comparison_id, + "summary": comparison_result["summary"], + "new_items": comparison_result["new_items"], + "modified_items": comparison_result["modified_items"], + "removed_items": comparison_result["removed_items"] + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"자재 비교 실패: {str(e)}") + +@router.get("/comparison-history") +async def get_comparison_history( + job_no: str = Query(..., description="Job 번호"), + limit: int = Query(10, ge=1, le=50), + db: Session = Depends(get_db) +): + """ + 자재 비교 이력 조회 + """ + try: + query = text(""" + SELECT + id, current_revision, previous_revision, + new_items_count, modified_items_count, removed_items_count, + upload_date, created_by + FROM material_revisions_comparison + WHERE job_no = :job_no + ORDER BY upload_date DESC + LIMIT :limit + """) + + result = db.execute(query, {"job_no": job_no, "limit": limit}) + comparisons = result.fetchall() + + return { + "success": True, + "job_no": job_no, + "comparisons": [ + { + "id": comp[0], + "current_revision": comp[1], + "previous_revision": comp[2], + "new_items_count": comp[3], + "modified_items_count": comp[4], + "removed_items_count": comp[5], + "upload_date": comp[6], + "created_by": comp[7] + } + for comp in comparisons + ] + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"비교 이력 조회 실패: {str(e)}") + +@router.get("/inventory-status") +async def get_material_inventory_status( + job_no: str = Query(..., description="Job 번호"), + material_hash: Optional[str] = Query(None, description="특정 자재 해시"), + db: Session = Depends(get_db) +): + """ + 자재별 누적 재고 현황 조회 + """ + try: + # 임시로 빈 결과 반환 (추후 개선) + return { + "success": True, + "job_no": job_no, + "inventory": [] + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"재고 현황 조회 실패: {str(e)}") + +@router.post("/confirm-purchase") +async def confirm_material_purchase( + job_no: str, + revision: str, + confirmations: List[Dict], + confirmed_by: str = "system", + db: Session = Depends(get_db) +): + """ + 자재 발주 확정 처리 + confirmations = [ + { + "material_hash": "abc123", + "confirmed_quantity": 100, + "supplier_name": "ABC공급업체", + "unit_price": 1000 + } + ] + """ + try: + # 입력 데이터 검증 + if not job_no or not revision: + raise HTTPException(status_code=400, detail="Job 번호와 리비전은 필수입니다") + + if not confirmations: + raise HTTPException(status_code=400, detail="확정할 자재가 없습니다") + + # 각 확정 항목 검증 + for i, confirmation in enumerate(confirmations): + if not confirmation.get("material_hash"): + raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 material_hash가 없습니다") + + confirmed_qty = confirmation.get("confirmed_quantity") + if confirmed_qty is None or confirmed_qty < 0: + raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 확정 수량이 유효하지 않습니다") + + unit_price = confirmation.get("unit_price", 0) + if unit_price < 0: + raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 단가가 유효하지 않습니다") + + confirmed_items = [] + + for confirmation in confirmations: + # 발주 추적 테이블에 저장/업데이트 + upsert_query = text(""" + INSERT INTO material_purchase_tracking ( + job_no, material_hash, revision, description, size_spec, unit, + bom_quantity, calculated_quantity, confirmed_quantity, + purchase_status, supplier_name, unit_price, total_price, + confirmed_by, confirmed_at + ) + SELECT + :job_no, m.material_hash, :revision, m.original_description, + m.size_spec, m.unit, m.quantity, :calculated_qty, :confirmed_qty, + 'CONFIRMED', :supplier_name, :unit_price, :total_price, + :confirmed_by, CURRENT_TIMESTAMP + FROM materials m + WHERE m.material_hash = :material_hash + AND m.file_id = ( + SELECT id FROM files + WHERE job_no = :job_no AND revision = :revision + ORDER BY upload_date DESC LIMIT 1 + ) + LIMIT 1 + ON CONFLICT (job_no, material_hash, revision) + DO UPDATE SET + confirmed_quantity = :confirmed_qty, + purchase_status = 'CONFIRMED', + supplier_name = :supplier_name, + unit_price = :unit_price, + total_price = :total_price, + confirmed_by = :confirmed_by, + confirmed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + RETURNING id, description, confirmed_quantity + """) + + calculated_qty = confirmation.get("calculated_quantity", confirmation["confirmed_quantity"]) + total_price = confirmation["confirmed_quantity"] * confirmation.get("unit_price", 0) + + result = db.execute(upsert_query, { + "job_no": job_no, + "revision": revision, + "material_hash": confirmation["material_hash"], + "calculated_qty": calculated_qty, + "confirmed_qty": confirmation["confirmed_quantity"], + "supplier_name": confirmation.get("supplier_name", ""), + "unit_price": confirmation.get("unit_price", 0), + "total_price": total_price, + "confirmed_by": confirmed_by + }) + + confirmed_item = result.fetchone() + if confirmed_item: + confirmed_items.append({ + "id": confirmed_item[0], + "material_hash": confirmed_item[1], + "confirmed_quantity": confirmed_item[2], + "supplier_name": confirmed_item[3], + "unit_price": confirmed_item[4], + "total_price": confirmed_item[5] + }) + + db.commit() + + return { + "success": True, + "message": f"{len(confirmed_items)}개 자재 발주가 확정되었습니다", + "confirmed_items": confirmed_items, + "job_no": job_no, + "revision": revision + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"발주 확정 실패: {str(e)}") + +@router.get("/purchase-status") +async def get_purchase_status( + job_no: str = Query(..., description="Job 번호"), + revision: Optional[str] = Query(None, description="리비전 (전체 조회시 생략)"), + status: Optional[str] = Query(None, description="발주 상태 필터"), + db: Session = Depends(get_db) +): + """ + 발주 상태 조회 + """ + try: + where_conditions = ["job_no = :job_no"] + params = {"job_no": job_no} + + if revision: + where_conditions.append("revision = :revision") + params["revision"] = revision + + if status: + where_conditions.append("purchase_status = :status") + params["status"] = status + + query = text(f""" + SELECT + material_hash, revision, description, size_spec, unit, + bom_quantity, calculated_quantity, confirmed_quantity, + purchase_status, supplier_name, unit_price, total_price, + order_date, delivery_date, confirmed_by, confirmed_at + FROM material_purchase_tracking + WHERE {' AND '.join(where_conditions)} + ORDER BY revision DESC, description + """) + + result = db.execute(query, params) + purchases = result.fetchall() + + # 상태별 요약 + status_summary = {} + total_amount = 0 + + for purchase in purchases: + status_key = purchase.purchase_status + if status_key not in status_summary: + status_summary[status_key] = {"count": 0, "total_amount": 0} + + status_summary[status_key]["count"] += 1 + status_summary[status_key]["total_amount"] += purchase.total_price or 0 + total_amount += purchase.total_price or 0 + + return { + "success": True, + "job_no": job_no, + "revision": revision, + "purchases": [purchase._asdict() if hasattr(purchase, '_asdict') else dict(zip(purchase.keys(), purchase)) for purchase in purchases], + "summary": { + "total_items": len(purchases), + "total_amount": total_amount, + "status_breakdown": status_summary + } + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"발주 상태 조회 실패: {str(e)}") + +# ========== 헬퍼 함수들 ========== + +async def get_file_by_revision(db: Session, job_no: str, revision: str) -> Optional[Dict]: + """리비전으로 파일 정보 조회""" + query = text(""" + SELECT id, original_filename, revision, upload_date + FROM files + WHERE job_no = :job_no AND revision = :revision AND is_active = TRUE + ORDER BY upload_date DESC + LIMIT 1 + """) + + result = db.execute(query, {"job_no": job_no, "revision": revision}) + file_row = result.fetchone() + + if file_row: + return { + "id": file_row[0], + "original_filename": file_row[1], + "revision": file_row[2], + "upload_date": file_row[3] + } + return None + +async def get_previous_revision(db: Session, job_no: str, current_revision: str) -> Optional[str]: + """이전 리비전 자동 탐지 - 숫자 기반 비교""" + + # 현재 리비전의 숫자 추출 + try: + current_rev_num = int(current_revision.replace("Rev.", "")) + except (ValueError, AttributeError): + current_rev_num = 0 + + query = text(""" + SELECT revision + FROM files + WHERE job_no = :job_no AND is_active = TRUE + ORDER BY revision DESC + """) + + result = db.execute(query, {"job_no": job_no}) + revisions = result.fetchall() + + # 현재 리비전보다 낮은 리비전 중 가장 높은 것 찾기 + previous_revision = None + highest_prev_num = -1 + + for row in revisions: + rev = row[0] + try: + rev_num = int(rev.replace("Rev.", "")) + if rev_num < current_rev_num and rev_num > highest_prev_num: + highest_prev_num = rev_num + previous_revision = rev + except (ValueError, AttributeError): + continue + + return previous_revision + +async def perform_material_comparison( + db: Session, + current_file: Dict, + previous_file: Optional[Dict], + job_no: str +) -> Dict: + """ + 핵심 자재 비교 로직 - 간단한 버전 + """ + + # 1. 현재 리비전 자재 목록 (해시별로 그룹화) + current_materials = await get_materials_by_hash(db, current_file["id"]) + + # 2. 이전 리비전 자재 목록 + previous_materials = {} + if previous_file: + previous_materials = await get_materials_by_hash(db, previous_file["id"]) + + # 3. 비교 실행 + new_items = [] + modified_items = [] + removed_items = [] + + # 신규/변경 항목 찾기 + for material_hash, current_item in current_materials.items(): + current_qty = current_item["quantity"] + + if material_hash not in previous_materials: + # 완전히 새로운 항목 + new_item = { + "material_hash": material_hash, + "description": current_item["description"], + "size_spec": current_item["size_spec"], + "material_grade": current_item["material_grade"], + "quantity": current_qty, + "category": current_item["category"], + "unit": current_item["unit"] + } + # 파이프인 경우 pipe_details 정보 포함 + if current_item.get("pipe_details"): + new_item["pipe_details"] = current_item["pipe_details"] + new_items.append(new_item) + + else: + # 기존 항목 - 수량 변경 체크 + previous_qty = previous_materials[material_hash]["quantity"] + qty_change = current_qty - previous_qty + + if qty_change != 0: + modified_item = { + "material_hash": material_hash, + "description": current_item["description"], + "size_spec": current_item["size_spec"], + "material_grade": current_item["material_grade"], + "previous_quantity": previous_qty, + "current_quantity": current_qty, + "quantity_change": qty_change, + "category": current_item["category"], + "unit": current_item["unit"] + } + # 파이프인 경우 이전/현재 pipe_details 모두 포함 + if current_item.get("pipe_details"): + modified_item["pipe_details"] = current_item["pipe_details"] + + # 이전 리비전 pipe_details도 포함 + previous_item = previous_materials[material_hash] + if previous_item.get("pipe_details"): + modified_item["previous_pipe_details"] = previous_item["pipe_details"] + + # 실제 길이 변화 계산 (현재 총길이 - 이전 총길이) + if current_item.get("pipe_details"): + current_total = current_item["pipe_details"]["total_length_mm"] + previous_total = previous_item["pipe_details"]["total_length_mm"] + length_change = current_total - previous_total + modified_item["length_change"] = length_change + print(f"🔢 실제 길이 변화: {current_item['description'][:50]} - 이전:{previous_total:.0f}mm → 현재:{current_total:.0f}mm (변화:{length_change:+.0f}mm)") + + modified_items.append(modified_item) + + # 삭제된 항목 찾기 + for material_hash, previous_item in previous_materials.items(): + if material_hash not in current_materials: + removed_item = { + "material_hash": material_hash, + "description": previous_item["description"], + "size_spec": previous_item["size_spec"], + "material_grade": previous_item["material_grade"], + "quantity": previous_item["quantity"], + "category": previous_item["category"], + "unit": previous_item["unit"] + } + # 파이프인 경우 pipe_details 정보 포함 + if previous_item.get("pipe_details"): + removed_item["pipe_details"] = previous_item["pipe_details"] + removed_items.append(removed_item) + + return { + "summary": { + "total_current_items": len(current_materials), + "total_previous_items": len(previous_materials), + "new_items_count": len(new_items), + "modified_items_count": len(modified_items), + "removed_items_count": len(removed_items) + }, + "new_items": new_items, + "modified_items": modified_items, + "removed_items": removed_items + } + +async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]: + """파일의 자재를 해시별로 그룹화하여 조회""" + import hashlib + + # 로그 제거 + + query = text(""" + SELECT + m.id, + m.original_description, + m.size_spec, + m.material_grade, + m.quantity, + m.classified_category, + m.unit, + pd.length_mm, + m.line_number + FROM materials m + LEFT JOIN pipe_details pd ON m.id = pd.material_id + WHERE m.file_id = :file_id + ORDER BY m.line_number + """) + + result = db.execute(query, {"file_id": file_id}) + materials = result.fetchall() + + # 로그 제거 + + # 🔄 같은 파이프들을 Python에서 올바르게 그룹핑 + materials_dict = {} + for mat in materials: + # 자재 해시 생성 (description + size_spec + material_grade) + hash_source = f"{mat[1] or ''}|{mat[2] or ''}|{mat[3] or ''}" + material_hash = hashlib.md5(hash_source.encode()).hexdigest() + + # 개별 자재 로그 제거 (너무 많음) + + if material_hash in materials_dict: + # 🔄 기존 항목에 수량 합계 + existing = materials_dict[material_hash] + # 파이프가 아닌 경우만 quantity 합산 (파이프는 개별 길이가 다르므로 합산하지 않음) + if mat[5] != 'PIPE': + existing["quantity"] += float(mat[4]) if mat[4] else 0.0 + existing["line_number"] += f", {mat[8]}" if mat[8] else "" + + # 파이프인 경우 길이 정보 합산 + if mat[5] == 'PIPE' and mat[7] is not None: + if "pipe_details" in existing: + # 총길이 합산: 기존 총길이 + 현재 파이프의 실제 길이 (DB에 저장된 개별 길이) + current_total = existing["pipe_details"]["total_length_mm"] + current_count = existing["pipe_details"]["pipe_count"] + + # ✅ DB에서 가져온 length_mm는 이미 개별 파이프의 실제 길이이므로 수량을 곱하지 않음 + individual_length = float(mat[7]) # 개별 파이프의 실제 길이 + existing["pipe_details"]["total_length_mm"] = current_total + individual_length + existing["pipe_details"]["pipe_count"] = current_count + 1 # 파이프 개수는 1개씩 증가 + + # 평균 단위 길이 재계산 + total_length = existing["pipe_details"]["total_length_mm"] + total_count = existing["pipe_details"]["pipe_count"] + existing["pipe_details"]["length_mm"] = total_length / total_count + + # 파이프 합산 로그 제거 (너무 많음) + else: + # 첫 파이프 정보 설정 + individual_length = float(mat[7]) # 개별 파이프의 실제 길이 + existing["pipe_details"] = { + "length_mm": individual_length, + "total_length_mm": individual_length, # 첫 번째 파이프이므로 개별 길이와 동일 + "pipe_count": 1 # 첫 번째 파이프이므로 1개 + } + else: + # 🆕 새 항목 생성 + material_data = { + "material_hash": material_hash, + "description": mat[1], # original_description + "size_spec": mat[2], + "material_grade": mat[3], + "quantity": float(mat[4]) if mat[4] else 0.0, + "category": mat[5], # classified_category + "unit": mat[6] or 'EA', + "line_number": str(mat[8]) if mat[8] else '' + } + + # 파이프인 경우 pipe_details 정보 추가 + if mat[5] == 'PIPE' and mat[7] is not None: + individual_length = float(mat[7]) # 개별 파이프의 실제 길이 + material_data["pipe_details"] = { + "length_mm": individual_length, # 개별 파이프 길이 + "total_length_mm": individual_length, # 첫 번째 파이프이므로 개별 길이와 동일 + "pipe_count": 1 # 첫 번째 파이프이므로 1개 + } + # 파이프는 quantity를 1로 설정 (pipe_count와 동일) + material_data["quantity"] = 1 + + materials_dict[material_hash] = material_data + + # 파이프 데이터 요약만 출력 + pipe_count = sum(1 for data in materials_dict.values() if data.get('category') == 'PIPE') + pipe_with_details = sum(1 for data in materials_dict.values() + if data.get('category') == 'PIPE' and 'pipe_details' in data) + print(f"✅ 자재 처리 완료: 총 {len(materials_dict)}개, 파이프 {pipe_count}개 (길이정보: {pipe_with_details}개)") + + return materials_dict + +async def get_current_inventory(db: Session, job_no: str) -> Dict[str, float]: + """현재까지의 누적 재고량 조회 - 임시로 빈 딕셔너리 반환""" + # TODO: 실제 재고 시스템 구현 후 활성화 + return {} + +async def save_comparison_result( + db: Session, + job_no: str, + current_revision: str, + previous_revision: str, + current_file_id: int, + previous_file_id: int, + comparison_result: Dict +) -> int: + """비교 결과를 데이터베이스에 저장""" + + # 메인 비교 레코드 저장 + insert_query = text(""" + INSERT INTO material_revisions_comparison ( + job_no, current_revision, previous_revision, + current_file_id, previous_file_id, + total_current_items, total_previous_items, + new_items_count, modified_items_count, removed_items_count, + comparison_details, created_by + ) VALUES ( + :job_no, :current_revision, :previous_revision, + :current_file_id, :previous_file_id, + :total_current_items, :total_previous_items, + :new_items_count, :modified_items_count, :removed_items_count, + :comparison_details, 'system' + ) + ON CONFLICT (job_no, current_revision, previous_revision) + DO UPDATE SET + total_current_items = :total_current_items, + total_previous_items = :total_previous_items, + new_items_count = :new_items_count, + modified_items_count = :modified_items_count, + removed_items_count = :removed_items_count, + comparison_details = :comparison_details, + upload_date = CURRENT_TIMESTAMP + RETURNING id + """) + + import json + summary = comparison_result["summary"] + + result = db.execute(insert_query, { + "job_no": job_no, + "current_revision": current_revision, + "previous_revision": previous_revision, + "current_file_id": current_file_id, + "previous_file_id": previous_file_id, + "total_current_items": summary["total_current_items"], + "total_previous_items": summary["total_previous_items"], + "new_items_count": summary["new_items_count"], + "modified_items_count": summary["modified_items_count"], + "removed_items_count": summary["removed_items_count"], + "comparison_details": json.dumps(comparison_result, ensure_ascii=False) + }) + + comparison_id = result.fetchone()[0] + db.commit() + + return comparison_id \ No newline at end of file diff --git a/tkeg/api/app/routers/materials.py b/tkeg/api/app/routers/materials.py new file mode 100644 index 0000000..daa69e4 --- /dev/null +++ b/tkeg/api/app/routers/materials.py @@ -0,0 +1,161 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from sqlalchemy import text +from pydantic import BaseModel +from ..database import get_db +from ..auth.middleware import get_current_user + +router = APIRouter(prefix="/materials", tags=["materials"]) + +class BrandUpdate(BaseModel): + brand: str + +class UserRequirementUpdate(BaseModel): + user_requirement: str + +@router.patch("/{material_id}/brand") +async def update_material_brand( + material_id: int, + brand_data: BrandUpdate, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """자재의 브랜드 정보를 업데이트합니다.""" + try: + # 자재 존재 여부 확인 + result = db.execute( + text("SELECT id FROM materials WHERE id = :material_id"), + {"material_id": material_id} + ) + material = result.fetchone() + + if not material: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="자재를 찾을 수 없습니다." + ) + + # 브랜드 업데이트 + db.execute( + text(""" + UPDATE materials + SET brand = :brand, + updated_by = :updated_by + WHERE id = :material_id + """), + { + "brand": brand_data.brand.strip(), + "updated_by": current_user.get("username", "unknown"), + "material_id": material_id + } + ) + + db.commit() + + return { + "success": True, + "message": "브랜드가 성공적으로 업데이트되었습니다.", + "material_id": material_id, + "brand": brand_data.brand.strip() + } + + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"브랜드 업데이트 실패: {str(e)}" + ) + +@router.patch("/{material_id}/user-requirement") +async def update_material_user_requirement( + material_id: int, + requirement_data: UserRequirementUpdate, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """자재의 사용자 요구사항을 업데이트합니다.""" + try: + # 자재 존재 여부 확인 + result = db.execute( + text("SELECT id FROM materials WHERE id = :material_id"), + {"material_id": material_id} + ) + material = result.fetchone() + + if not material: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="자재를 찾을 수 없습니다." + ) + + # 사용자 요구사항 업데이트 + db.execute( + text(""" + UPDATE materials + SET user_requirement = :user_requirement, + updated_by = :updated_by + WHERE id = :material_id + """), + { + "user_requirement": requirement_data.user_requirement.strip(), + "updated_by": current_user.get("username", "unknown"), + "material_id": material_id + } + ) + + db.commit() + + return { + "success": True, + "message": "사용자 요구사항이 성공적으로 업데이트되었습니다.", + "material_id": material_id, + "user_requirement": requirement_data.user_requirement.strip() + } + + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"사용자 요구사항 업데이트 실패: {str(e)}" + ) + +@router.get("/{material_id}") +async def get_material( + material_id: int, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """자재 정보를 조회합니다.""" + try: + result = db.execute( + text(""" + SELECT id, original_description, classified_category, + brand, user_requirement, created_at, updated_by + FROM materials + WHERE id = :material_id + """), + {"material_id": material_id} + ) + material = result.fetchone() + + if not material: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="자재를 찾을 수 없습니다." + ) + + return { + "id": material.id, + "original_description": material.original_description, + "classified_category": material.classified_category, + "brand": material.brand, + "user_requirement": material.user_requirement, + "created_at": material.created_at, + "updated_by": material.updated_by + } + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"자재 조회 실패: {str(e)}" + ) diff --git a/tkeg/api/app/routers/purchase.py b/tkeg/api/app/routers/purchase.py new file mode 100644 index 0000000..e271e60 --- /dev/null +++ b/tkeg/api/app/routers/purchase.py @@ -0,0 +1,585 @@ +""" +구매 관리 API +- 구매 품목 생성/조회 +- 구매 수량 계산 +- 리비전 비교 +""" + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy.orm import Session +from sqlalchemy import text +from typing import List, Optional +from pydantic import BaseModel +import json +from datetime import datetime + +from ..database import get_db +from ..services.purchase_calculator import ( + generate_purchase_items_from_materials, + save_purchase_items_to_db, + calculate_pipe_purchase_quantity, + calculate_standard_purchase_quantity +) + +router = APIRouter(prefix="/purchase", tags=["purchase"]) + +# Pydantic 모델 (최적화된 구조) +class PurchaseItemMinimal(BaseModel): + """구매 확정용 최소 필수 데이터""" + item_code: str + category: str + specification: str + size: str = "" + material: str = "" + bom_quantity: float + calculated_qty: float + unit: str = "EA" + safety_factor: float = 1.0 + +class PurchaseConfirmRequest(BaseModel): + job_no: str + file_id: int + bom_name: Optional[str] = None # 선택적 필드로 변경 + revision: str + purchase_items: List[PurchaseItemMinimal] # 최적화된 구조 사용 + confirmed_at: str + confirmed_by: str + +@router.get("/items/calculate") +async def calculate_purchase_items( + job_no: str = Query(..., description="Job 번호"), + revision: str = Query("Rev.0", description="리비전"), + file_id: Optional[int] = Query(None, description="파일 ID (선택사항)"), + db: Session = Depends(get_db) +): + """ + 구매 품목 계산 (실시간) + - 자재 데이터로부터 구매 품목 생성 + - 수량 계산 (파이프 절단손실 포함) + """ + try: + # 1. 파일 ID 조회 (job_no, revision으로) + if not file_id: + file_query = text(""" + SELECT id FROM files + WHERE job_no = :job_no AND revision = :revision AND is_active = TRUE + ORDER BY updated_at DESC + LIMIT 1 + """) + file_result = db.execute(file_query, {"job_no": job_no, "revision": revision}).fetchone() + if not file_result: + raise HTTPException(status_code=404, detail=f"Job {job_no} {revision}에 해당하는 파일을 찾을 수 없습니다") + file_id = file_result[0] + + # 2. 구매 품목 생성 + purchase_items = generate_purchase_items_from_materials(db, file_id, job_no, revision) + + return { + "success": True, + "job_no": job_no, + "revision": revision, + "file_id": file_id, + "items": purchase_items, + "total_items": len(purchase_items) + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"구매 품목 계산 실패: {str(e)}") + +@router.post("/confirm") +async def confirm_purchase_quantities( + request: PurchaseConfirmRequest, + db: Session = Depends(get_db) +): + """ + 구매 수량 확정 + - 계산된 구매 수량을 확정 상태로 저장 + - 자재별 확정 수량 및 상태 업데이트 + - 리비전 비교를 위한 기준 데이터 생성 + """ + try: + # 1. 기존 확정 데이터 확인 및 업데이트 또는 삽입 + existing_query = text(""" + SELECT id FROM purchase_confirmations + WHERE file_id = :file_id + """) + existing_result = db.execute(existing_query, {"file_id": request.file_id}).fetchone() + + if existing_result: + # 기존 데이터 업데이트 + confirmation_id = existing_result[0] + update_query = text(""" + UPDATE purchase_confirmations + SET job_no = :job_no, + bom_name = :bom_name, + revision = :revision, + confirmed_at = :confirmed_at, + confirmed_by = :confirmed_by, + is_active = TRUE, + updated_at = CURRENT_TIMESTAMP + WHERE id = :confirmation_id + """) + db.execute(update_query, { + "confirmation_id": confirmation_id, + "job_no": request.job_no, + "bom_name": request.bom_name or f"{request.job_no}_{request.revision}", # 기본값 제공 + "revision": request.revision, + "confirmed_at": request.confirmed_at, + "confirmed_by": request.confirmed_by + }) + + # 기존 확정 품목들 삭제 + delete_items_query = text(""" + DELETE FROM confirmed_purchase_items + WHERE confirmation_id = :confirmation_id + """) + db.execute(delete_items_query, {"confirmation_id": confirmation_id}) + else: + # 새로운 확정 데이터 삽입 + confirm_query = text(""" + INSERT INTO purchase_confirmations ( + job_no, file_id, bom_name, revision, + confirmed_at, confirmed_by, is_active, created_at + ) VALUES ( + :job_no, :file_id, :bom_name, :revision, + :confirmed_at, :confirmed_by, TRUE, CURRENT_TIMESTAMP + ) RETURNING id + """) + + confirm_result = db.execute(confirm_query, { + "job_no": request.job_no, + "file_id": request.file_id, + "bom_name": request.bom_name or f"{request.job_no}_{request.revision}", # 기본값 제공 + "revision": request.revision, + "confirmed_at": request.confirmed_at, + "confirmed_by": request.confirmed_by + }) + + confirmation_id = confirm_result.fetchone()[0] + + # 3. 확정된 구매 품목들 저장 + saved_items = 0 + for item in request.purchase_items: + item_query = text(""" + INSERT INTO confirmed_purchase_items ( + confirmation_id, item_code, category, specification, + size, material, bom_quantity, calculated_qty, + unit, safety_factor, created_at + ) VALUES ( + :confirmation_id, :item_code, :category, :specification, + :size, :material, :bom_quantity, :calculated_qty, + :unit, :safety_factor, CURRENT_TIMESTAMP + ) + """) + + db.execute(item_query, { + "confirmation_id": confirmation_id, + "item_code": item.item_code or f"{item.category}-{saved_items+1}", + "category": item.category, + "specification": item.specification, + "size": item.size or "", + "material": item.material or "", + "bom_quantity": item.bom_quantity, + "calculated_qty": item.calculated_qty, + "unit": item.unit, + "safety_factor": item.safety_factor + }) + saved_items += 1 + + # 4. 파일 상태를 확정으로 업데이트 + file_update_query = text(""" + UPDATE files + SET purchase_confirmed = TRUE, + confirmed_at = :confirmed_at, + confirmed_by = :confirmed_by, + updated_at = CURRENT_TIMESTAMP + WHERE id = :file_id + """) + + db.execute(file_update_query, { + "file_id": request.file_id, + "confirmed_at": request.confirmed_at, + "confirmed_by": request.confirmed_by + }) + + db.commit() + + return { + "success": True, + "message": "구매 수량이 성공적으로 확정되었습니다", + "confirmation_id": confirmation_id, + "confirmed_items": saved_items, + "job_no": request.job_no, + "revision": request.revision, + "confirmed_at": request.confirmed_at, + "confirmed_by": request.confirmed_by + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"구매 수량 확정 실패: {str(e)}") + +@router.post("/items/save") +async def save_purchase_items( + job_no: str, + revision: str, + file_id: int, + db: Session = Depends(get_db) +): + """ + 구매 품목을 데이터베이스에 저장 + """ + try: + # 1. 구매 품목 생성 + purchase_items = generate_purchase_items_from_materials(db, file_id, job_no, revision) + + # 2. 데이터베이스에 저장 + saved_ids = save_purchase_items_to_db(db, purchase_items) + + return { + "success": True, + "message": f"{len(saved_ids)}개 구매 품목이 저장되었습니다", + "saved_items": len(saved_ids), + "item_ids": saved_ids + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"구매 품목 저장 실패: {str(e)}") + +@router.get("/items") +async def get_purchase_items( + job_no: str = Query(..., description="Job 번호"), + revision: str = Query("Rev.0", description="리비전"), + category: Optional[str] = Query(None, description="카테고리 필터"), + db: Session = Depends(get_db) +): + """ + 저장된 구매 품목 조회 + """ + try: + query = text(""" + SELECT pi.*, + COUNT(mpm.material_id) as material_count, + SUM(m.quantity) as total_material_quantity + FROM purchase_items pi + LEFT JOIN material_purchase_mapping mpm ON pi.id = mpm.purchase_item_id + LEFT JOIN materials m ON mpm.material_id = m.id + WHERE pi.job_no = :job_no AND pi.revision = :revision AND pi.is_active = TRUE + """) + + params = {"job_no": job_no, "revision": revision} + + if category: + query = text(str(query) + " AND pi.category = :category") + params["category"] = category + + query = text(str(query) + """ + GROUP BY pi.id + ORDER BY pi.category, pi.specification + """) + + result = db.execute(query, params) + items = result.fetchall() + + return { + "success": True, + "job_no": job_no, + "revision": revision, + "items": [dict(item) for item in items], + "total_items": len(items) + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"구매 품목 조회 실패: {str(e)}") + +@router.patch("/items/{item_id}") +async def update_purchase_item( + item_id: int, + safety_factor: Optional[float] = None, + calculated_qty: Optional[float] = None, + minimum_order_qty: Optional[float] = None, + db: Session = Depends(get_db) +): + """ + 구매 품목 수정 (수량 조정) + """ + try: + update_fields = [] + params = {"item_id": item_id} + + if safety_factor is not None: + update_fields.append("safety_factor = :safety_factor") + params["safety_factor"] = safety_factor + + if calculated_qty is not None: + update_fields.append("calculated_qty = :calculated_qty") + params["calculated_qty"] = calculated_qty + + if minimum_order_qty is not None: + update_fields.append("minimum_order_qty = :minimum_order_qty") + params["minimum_order_qty"] = minimum_order_qty + + if not update_fields: + raise HTTPException(status_code=400, detail="수정할 필드가 없습니다") + + update_fields.append("updated_at = CURRENT_TIMESTAMP") + + query = text(f""" + UPDATE purchase_items + SET {', '.join(update_fields)} + WHERE id = :item_id + RETURNING id, calculated_qty, safety_factor + """) + + result = db.execute(query, params) + updated_item = result.fetchone() + + if not updated_item: + raise HTTPException(status_code=404, detail="구매 품목을 찾을 수 없습니다") + + db.commit() + + return { + "success": True, + "message": "구매 품목이 수정되었습니다", + "item": dict(updated_item) + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"구매 품목 수정 실패: {str(e)}") + +@router.get("/revision-diff") +async def get_revision_diff( + job_no: str = Query(..., description="Job 번호"), + current_revision: str = Query(..., description="현재 리비전"), + previous_revision: str = Query(..., description="이전 리비전"), + db: Session = Depends(get_db) +): + """ + 리비전간 구매 수량 차이 계산 + """ + try: + # 1. 이전 리비전 구매 품목 조회 + prev_query = text(""" + SELECT item_code, category, specification, calculated_qty, bom_quantity + FROM purchase_items + WHERE job_no = :job_no AND revision = :prev_revision AND is_active = TRUE + """) + prev_items = db.execute(prev_query, { + "job_no": job_no, + "prev_revision": previous_revision + }).fetchall() + + # 2. 현재 리비전 구매 품목 조회 + curr_query = text(""" + SELECT item_code, category, specification, calculated_qty, bom_quantity + FROM purchase_items + WHERE job_no = :job_no AND revision = :curr_revision AND is_active = TRUE + """) + curr_items = db.execute(curr_query, { + "job_no": job_no, + "curr_revision": current_revision + }).fetchall() + + # 3. 차이 계산 + prev_dict = {item.item_code: dict(item) for item in prev_items} + curr_dict = {item.item_code: dict(item) for item in curr_items} + + changes = [] + added_items = 0 + modified_items = 0 + + # 현재 리비전에서 추가되거나 변경된 항목 + for item_code, curr_item in curr_dict.items(): + if item_code not in prev_dict: + # 새로 추가된 품목 + changes.append({ + "item_code": item_code, + "change_type": "ADDED", + "specification": curr_item["specification"], + "previous_qty": 0, + "current_qty": curr_item["calculated_qty"], + "qty_diff": curr_item["calculated_qty"], + "additional_needed": curr_item["calculated_qty"] + }) + added_items += 1 + else: + prev_item = prev_dict[item_code] + qty_diff = curr_item["calculated_qty"] - prev_item["calculated_qty"] + + if abs(qty_diff) > 0.001: # 수량 변경 + changes.append({ + "item_code": item_code, + "change_type": "MODIFIED", + "specification": curr_item["specification"], + "previous_qty": prev_item["calculated_qty"], + "current_qty": curr_item["calculated_qty"], + "qty_diff": qty_diff, + "additional_needed": max(qty_diff, 0) # 증가한 경우만 추가 구매 + }) + modified_items += 1 + + # 삭제된 품목 (현재 리비전에 없는 항목) + removed_items = 0 + for item_code, prev_item in prev_dict.items(): + if item_code not in curr_dict: + changes.append({ + "item_code": item_code, + "change_type": "REMOVED", + "specification": prev_item["specification"], + "previous_qty": prev_item["calculated_qty"], + "current_qty": 0, + "qty_diff": -prev_item["calculated_qty"], + "additional_needed": 0 + }) + removed_items += 1 + + # 요약 정보 + total_additional_needed = sum( + change["additional_needed"] for change in changes + if change["additional_needed"] > 0 + ) + + has_changes = len(changes) > 0 + + summary = f"추가: {added_items}개, 변경: {modified_items}개, 삭제: {removed_items}개" + if total_additional_needed > 0: + summary += f" (추가 구매 필요: {total_additional_needed:.1f})" + + return { + "success": True, + "job_no": job_no, + "previous_revision": previous_revision, + "current_revision": current_revision, + "comparison": { + "has_changes": has_changes, + "summary": summary, + "added_items": added_items, + "modified_items": modified_items, + "removed_items": removed_items, + "total_additional_needed": total_additional_needed, + "changes": changes + } + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"리비전 비교 실패: {str(e)}") + +@router.post("/orders/create") +async def create_purchase_order( + job_no: str, + revision: str, + items: List[dict], + supplier_name: Optional[str] = None, + required_date: Optional[str] = None, + db: Session = Depends(get_db) +): + """ + 구매 주문 생성 + """ + try: + from datetime import datetime, date + + # 1. 주문 번호 생성 + order_no = f"PO-{job_no}-{revision}-{datetime.now().strftime('%Y%m%d')}" + + # 2. 구매 주문 생성 + order_query = text(""" + INSERT INTO purchase_orders ( + order_no, job_no, revision, status, order_date, required_date, + supplier_name, created_by + ) VALUES ( + :order_no, :job_no, :revision, 'DRAFT', CURRENT_DATE, :required_date, + :supplier_name, 'system' + ) RETURNING id + """) + + order_result = db.execute(order_query, { + "order_no": order_no, + "job_no": job_no, + "revision": revision, + "required_date": required_date, + "supplier_name": supplier_name + }) + + order_id = order_result.fetchone()[0] + + # 3. 주문 상세 항목 생성 + total_amount = 0 + for item in items: + item_query = text(""" + INSERT INTO purchase_order_items ( + purchase_order_id, purchase_item_id, ordered_quantity, required_date + ) VALUES ( + :order_id, :item_id, :quantity, :required_date + ) + """) + + db.execute(item_query, { + "order_id": order_id, + "item_id": item["purchase_item_id"], + "quantity": item["ordered_quantity"], + "required_date": required_date + }) + + db.commit() + + return { + "success": True, + "message": "구매 주문이 생성되었습니다", + "order_no": order_no, + "order_id": order_id, + "items_count": len(items) + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"구매 주문 생성 실패: {str(e)}") + +@router.get("/orders") +async def get_purchase_orders( + job_no: Optional[str] = Query(None, description="Job 번호"), + status: Optional[str] = Query(None, description="주문 상태"), + db: Session = Depends(get_db) +): + """ + 구매 주문 목록 조회 + """ + try: + query = text(""" + SELECT po.*, + COUNT(poi.id) as items_count, + SUM(poi.ordered_quantity) as total_quantity + FROM purchase_orders po + LEFT JOIN purchase_order_items poi ON po.id = poi.purchase_order_id + WHERE 1=1 + """) + + params = {} + + if job_no: + query = text(str(query) + " AND po.job_no = :job_no") + params["job_no"] = job_no + + if status: + query = text(str(query) + " AND po.status = :status") + params["status"] = status + + query = text(str(query) + """ + GROUP BY po.id + ORDER BY po.created_at DESC + """) + + result = db.execute(query, params) + orders = result.fetchall() + + return { + "success": True, + "orders": [dict(order) for order in orders], + "total_orders": len(orders) + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"구매 주문 조회 실패: {str(e)}") \ No newline at end of file diff --git a/tkeg/api/app/routers/purchase_request.py b/tkeg/api/app/routers/purchase_request.py new file mode 100644 index 0000000..648aa93 --- /dev/null +++ b/tkeg/api/app/routers/purchase_request.py @@ -0,0 +1,834 @@ +""" +구매신청 관리 API +""" +from fastapi import APIRouter, Depends, HTTPException, status, Body, File, UploadFile, Form +from fastapi.responses import FileResponse +from pydantic import BaseModel +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import Optional, List, Dict +from datetime import datetime +from pathlib import Path +import os +import json + +from ..database import get_db +from ..auth.middleware import get_current_user +from ..utils.logger import get_logger + +logger = get_logger(__name__) + +router = APIRouter(prefix="/purchase-request", tags=["Purchase Request"]) + +# 엑셀 파일 저장 경로 +EXCEL_DIR = "uploads/excel_exports" +os.makedirs(EXCEL_DIR, exist_ok=True) + +class PurchaseRequestCreate(BaseModel): + file_id: int + job_no: Optional[str] = None + category: Optional[str] = None + material_ids: List[int] = [] + materials_data: List[Dict] = [] + grouped_materials: Optional[List[Dict]] = [] # 그룹화된 자재 정보 + +@router.post("/create") +async def create_purchase_request( + request_data: PurchaseRequestCreate, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 구매신청 생성 (엑셀 내보내기 = 구매신청) + """ + try: + # 🔍 디버깅: 요청 데이터 로깅 + logger.info(f"🔍 구매신청 생성 요청 - file_id: {request_data.file_id}, job_no: {request_data.job_no}") + logger.info(f"🔍 material_ids 개수: {len(request_data.material_ids)}") + logger.info(f"🔍 materials_data 개수: {len(request_data.materials_data)}") + if request_data.material_ids: + logger.info(f"🔍 material_ids 샘플: {request_data.material_ids[:5]}") + + print(f"🔍 [DEBUG] 구매신청 API 호출됨 - material_ids: {len(request_data.material_ids)}개") + # 구매신청 번호 생성 + today = datetime.now().strftime('%Y%m%d') + count_query = text(""" + SELECT COUNT(*) as count + FROM purchase_requests + WHERE request_no LIKE :pattern + """) + count = db.execute(count_query, {"pattern": f"PR-{today}%"}).fetchone().count + request_no = f"PR-{today}-{str(count + 1).zfill(3)}" + + # 자재 데이터를 JSON과 엑셀 파일로 저장 + json_filename = f"{request_no}.json" + excel_filename = f"{request_no}.xlsx" + json_path = os.path.join(EXCEL_DIR, json_filename) + excel_path = os.path.join(EXCEL_DIR, excel_filename) + + # JSON 저장 + save_materials_data( + request_data.materials_data, + json_path, + request_no, + request_data.job_no, + request_data.grouped_materials # 그룹화 정보 추가 + ) + + # 엑셀 파일 생성 및 저장 + create_excel_file( + request_data.grouped_materials or request_data.materials_data, + excel_path, + request_no, + request_data.job_no + ) + + # 구매신청 레코드 생성 + insert_request = text(""" + INSERT INTO purchase_requests ( + request_no, file_id, job_no, category, + material_count, excel_file_path, requested_by + ) VALUES ( + :request_no, :file_id, :job_no, :category, + :material_count, :excel_file_path, :requested_by + ) RETURNING request_id + """) + + result = db.execute(insert_request, { + "request_no": request_no, + "file_id": request_data.file_id, + "job_no": request_data.job_no, + "category": request_data.category, + "material_count": len(request_data.material_ids), + "excel_file_path": excel_filename, # 엑셀 파일명 저장 (JSON 대신) + "requested_by": current_user.get("user_id") + }) + request_id = result.fetchone().request_id + + # 구매신청 자재 상세 저장 + logger.info(f"Processing {len(request_data.material_ids)} material IDs for purchase request {request_no}") + logger.info(f"First 10 Material IDs: {request_data.material_ids[:10]}") # 처음 10개만 로그 + logger.info(f"Category: {request_data.category}, Job: {request_data.job_no}") + + inserted_count = 0 + for i, material_id in enumerate(request_data.material_ids): + material_data = request_data.materials_data[i] if i < len(request_data.materials_data) else {} + + # 이미 구매신청된 자재인지 확인 + check_existing = text(""" + SELECT 1 FROM purchase_request_items + WHERE material_id = :material_id + """) + existing = db.execute(check_existing, {"material_id": material_id}).fetchone() + + if not existing: + insert_item = text(""" + INSERT INTO purchase_request_items ( + request_id, material_id, description, category, subcategory, + material_grade, size_spec, quantity, unit, drawing_name, + notes, user_requirement + ) VALUES ( + :request_id, :material_id, :description, :category, :subcategory, + :material_grade, :size_spec, :quantity, :unit, :drawing_name, + :notes, :user_requirement + ) + """) + # quantity를 정수로 변환 (소수점 제거) + quantity_str = str(material_data.get("quantity", 0)) + try: + quantity = int(float(quantity_str)) + except (ValueError, TypeError): + quantity = 0 + + db.execute(insert_item, { + "request_id": request_id, + "material_id": material_id, + "description": material_data.get("description", material_data.get("original_description", "")), + "category": material_data.get("category", material_data.get("classified_category", "")), + "subcategory": material_data.get("subcategory", material_data.get("classified_subcategory", "")), + "material_grade": material_data.get("material_grade", ""), + "size_spec": material_data.get("size_spec", ""), + "quantity": quantity, + "unit": material_data.get("unit", "EA"), + "drawing_name": material_data.get("drawing_name", ""), + "notes": material_data.get("notes", ""), + "user_requirement": material_data.get("user_requirement", "") + }) + inserted_count += 1 + else: + logger.warning(f"Material {material_id} already in another purchase request, skipping") + + # 🔥 중요: materials 테이블의 purchase_confirmed 업데이트 + if request_data.material_ids: + print(f"🔥 [PURCHASE] purchase_confirmed 업데이트 시작: {len(request_data.material_ids)}개 자재") + print(f"🔥 [PURCHASE] material_ids: {request_data.material_ids[:5]}...") # 처음 5개만 로그 + + update_materials_query = text(""" + UPDATE materials + SET purchase_confirmed = true, + purchase_confirmed_at = NOW(), + purchase_confirmed_by = :confirmed_by + WHERE id = ANY(:material_ids) + """) + + result = db.execute(update_materials_query, { + "material_ids": request_data.material_ids, + "confirmed_by": current_user.get("username", "system") + }) + + print(f"🔥 [PURCHASE] UPDATE 결과: {result.rowcount}개 행 업데이트됨") + logger.info(f"✅ {len(request_data.material_ids)}개 자재의 purchase_confirmed를 true로 업데이트") + else: + print(f"⚠️ [PURCHASE] material_ids가 비어있음!") + + db.commit() + + logger.info(f"Purchase request created: {request_no} with {inserted_count} materials (out of {len(request_data.material_ids)} requested)") + + # 실제 저장된 자재 확인 + verify_query = text(""" + SELECT COUNT(*) as count FROM purchase_request_items WHERE request_id = :request_id + """) + verified_count = db.execute(verify_query, {"request_id": request_id}).fetchone().count + logger.info(f"✅ DB 검증: purchase_request_items에 {verified_count}개 저장됨") + + # purchase_requests 테이블의 total_items 필드 업데이트 + update_total_items = text(""" + UPDATE purchase_requests + SET total_items = :total_items + WHERE request_id = :request_id + """) + db.execute(update_total_items, { + "request_id": request_id, + "total_items": verified_count + }) + db.commit() + + logger.info(f"✅ total_items 업데이트 완료: {verified_count}개") + + return { + "success": True, + "request_no": request_no, + "request_id": request_id, + "material_count": len(request_data.material_ids), + "inserted_count": inserted_count, + "verified_count": verified_count, + "message": f"구매신청 {request_no}이 생성되었습니다" + } + + except Exception as e: + db.rollback() + logger.error(f"Failed to create purchase request: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"구매신청 생성 실패: {str(e)}" + ) + + +@router.get("/list") +async def get_purchase_requests( + file_id: Optional[int] = None, + job_no: Optional[str] = None, + status: Optional[str] = None, + # current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 구매신청 목록 조회 + """ + try: + query = text(""" + SELECT + pr.request_id, + pr.request_no, + pr.file_id, + pr.job_no, + pr.total_items, + pr.request_date, + pr.status, + pr.requested_by_username as requested_by, + f.original_filename, + j.job_name, + COUNT(pri.item_id) as item_count + FROM purchase_requests pr + LEFT JOIN files f ON pr.file_id = f.id + LEFT JOIN jobs j ON pr.job_no = j.job_no + LEFT JOIN purchase_request_items pri ON pr.request_id = pri.request_id + WHERE 1=1 + AND (:file_id IS NULL OR pr.file_id = :file_id) + AND (:job_no IS NULL OR pr.job_no = :job_no) + AND (:status IS NULL OR pr.status = :status) + GROUP BY + pr.request_id, pr.request_no, pr.file_id, pr.job_no, + pr.total_items, pr.request_date, pr.status, + pr.requested_by_username, f.original_filename, j.job_name + ORDER BY pr.request_date DESC + """) + + results = db.execute(query, { + "file_id": file_id, + "job_no": job_no, + "status": status + }).fetchall() + + requests = [] + for row in results: + requests.append({ + "request_id": row.request_id, + "request_no": row.request_no, + "file_id": row.file_id, + "job_no": row.job_no, + "job_name": row.job_name, + "category": "ALL", # 기본값 + "material_count": row.item_count or 0, # 실제 자재 개수 사용 + "item_count": row.item_count, + "excel_file_path": None, # 현재 테이블에 없음 + "requested_at": row.request_date.isoformat() if row.request_date else None, + "status": row.status, + "requested_by": row.requested_by, + "source_file": row.original_filename + }) + + return { + "success": True, + "requests": requests, + "count": len(requests) + } + + except Exception as e: + logger.error(f"Failed to get purchase requests: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"구매신청 목록 조회 실패: {str(e)}" + ) + + +@router.get("/{request_id}/materials") +async def get_request_materials( + request_id: int, + # current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 구매신청에 포함된 자재 목록 조회 (그룹화 정보 포함) + """ + try: + # 구매신청 정보 조회하여 JSON 파일 경로 가져오기 + info_query = text(""" + SELECT excel_file_path + FROM purchase_requests + WHERE request_id = :request_id + """) + info_result = db.execute(info_query, {"request_id": request_id}).fetchone() + + grouped_materials = [] + if info_result and info_result.excel_file_path: + json_path = os.path.join(EXCEL_DIR, info_result.excel_file_path) + if os.path.exists(json_path): + try: + with open(json_path, 'r', encoding='utf-8', errors='ignore') as f: + data = json.load(f) + grouped_materials = data.get("grouped_materials", []) + except Exception as e: + print(f"⚠️ JSON 파일 읽기 오류 (무시): {e}") + grouped_materials = [] + + # 개별 자재 정보 조회 (기존 코드) + query = text(""" + SELECT + pri.item_id, + pri.material_id, + pri.quantity as requested_quantity, + pri.unit as requested_unit, + pri.user_requirement, + pri.is_ordered, + pri.is_received, + m.original_description, + m.classified_category, + m.size_spec, + m.main_nom, + m.red_nom, + m.schedule, + m.material_grade, + m.full_material_grade, + m.quantity as original_quantity, + m.unit as original_unit, + m.classification_details, + pd.outer_diameter, pd.schedule as pipe_schedule, pd.material_spec, pd.manufacturing_method, + pd.end_preparation, pd.length_mm, + fd.fitting_type, fd.fitting_subtype, fd.connection_method as fitting_connection, + fd.pressure_rating as fitting_pressure, fd.schedule as fitting_schedule, + fld.flange_type, fld.facing_type, + fld.pressure_rating as flange_pressure, + gd.gasket_type, gd.gasket_subtype, gd.material_type as gasket_material, + gd.filler_material, gd.pressure_rating as gasket_pressure, gd.thickness as gasket_thickness, + bd.bolt_type, bd.material_standard as bolt_material, bd.length as bolt_length + FROM purchase_request_items pri + JOIN materials m ON pri.material_id = m.id + LEFT JOIN pipe_details pd ON m.id = pd.material_id + LEFT JOIN fitting_details fd ON m.id = fd.material_id + LEFT JOIN flange_details fld ON m.id = fld.material_id + LEFT JOIN gasket_details gd ON m.id = gd.material_id + LEFT JOIN bolt_details bd ON m.id = bd.material_id + WHERE pri.request_id = :request_id + ORDER BY m.classified_category, m.original_description + """) + + # 🎯 데이터베이스 쿼리 실행 + results = db.execute(query, {"request_id": request_id}).fetchall() + + materials = [] + + # 🎯 안전한 문자열 변환 함수 + def safe_str(value): + if value is None: + return '' + try: + if isinstance(value, bytes): + return value.decode('utf-8', errors='ignore') + return str(value) + except Exception: + return str(value) if value else '' + + for row in results: + try: + # quantity를 정수로 변환 (소수점 제거) + qty = row.requested_quantity or row.original_quantity + try: + qty_int = int(float(qty)) if qty else 0 + except (ValueError, TypeError): + qty_int = 0 + + # 안전한 문자열 변환 + original_description = safe_str(row.original_description) + size_spec = safe_str(row.size_spec) + material_grade = safe_str(row.material_grade) + full_material_grade = safe_str(row.full_material_grade) + user_requirement = safe_str(row.user_requirement) + + except Exception as e: + # 오류 발생 시 기본값 사용 + qty_int = 0 + original_description = '' + size_spec = '' + material_grade = '' + full_material_grade = '' + user_requirement = '' + + # BOM 페이지와 동일한 형식으로 데이터 구성 + material_dict = { + "item_id": row.item_id, + "material_id": row.material_id, + "id": row.material_id, + "original_description": original_description, + "classified_category": safe_str(row.classified_category), + "size_spec": size_spec, + "size_inch": safe_str(row.main_nom), + "main_nom": safe_str(row.main_nom), + "red_nom": safe_str(row.red_nom), + "schedule": safe_str(row.schedule), + "material_grade": material_grade, + "full_material_grade": full_material_grade, + "quantity": qty_int, + "unit": safe_str(row.requested_unit or row.original_unit), + "user_requirement": user_requirement, + "is_ordered": row.is_ordered, + "is_received": row.is_received, + "classification_details": safe_str(row.classification_details) + } + + # 카테고리별 상세 정보 추가 (안전한 문자열 처리) + if row.classified_category == 'PIPE' and row.manufacturing_method: + material_dict["pipe_details"] = { + "manufacturing_method": safe_str(row.manufacturing_method), + "schedule": safe_str(row.pipe_schedule), + "material_spec": safe_str(row.material_spec), + "end_preparation": safe_str(row.end_preparation), + "length_mm": row.length_mm + } + elif row.classified_category == 'FITTING' and row.fitting_type: + material_dict["fitting_details"] = { + "fitting_type": safe_str(row.fitting_type), + "fitting_subtype": safe_str(row.fitting_subtype), + "connection_method": safe_str(row.fitting_connection), + "pressure_rating": safe_str(row.fitting_pressure), + "schedule": safe_str(row.fitting_schedule) + } + elif row.classified_category == 'FLANGE' and row.flange_type: + material_dict["flange_details"] = { + "flange_type": safe_str(row.flange_type), + "facing_type": safe_str(row.facing_type), + "pressure_rating": safe_str(row.flange_pressure) + } + elif row.classified_category == 'GASKET' and row.gasket_type: + material_dict["gasket_details"] = { + "gasket_type": safe_str(row.gasket_type), + "gasket_subtype": safe_str(row.gasket_subtype), + "material_type": safe_str(row.gasket_material), + "filler_material": safe_str(row.filler_material), + "pressure_rating": safe_str(row.gasket_pressure), + "thickness": safe_str(row.gasket_thickness) + } + elif row.classified_category == 'BOLT' and row.bolt_type: + material_dict["bolt_details"] = { + "bolt_type": safe_str(row.bolt_type), + "material_standard": safe_str(row.bolt_material), + "length": safe_str(row.bolt_length) + } + + materials.append(material_dict) + + return { + "success": True, + "materials": materials, + "grouped_materials": grouped_materials, # 그룹화 정보 추가 + "count": len(grouped_materials) if grouped_materials else len(materials) + } + + except Exception as e: + logger.error(f"Failed to get request materials: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"구매신청 자재 조회 실패: {str(e)}" + ) + + +@router.get("/requested-materials") +async def get_requested_material_ids( + file_id: Optional[int] = None, + job_no: Optional[str] = None, + db: Session = Depends(get_db) +): + """ + 구매신청된 자재 ID 목록 조회 (BOM 페이지에서 비활성화용) + """ + try: + query = text(""" + SELECT DISTINCT pri.material_id + FROM purchase_request_items pri + JOIN purchase_requests pr ON pri.request_id = pr.request_id + WHERE 1=1 + AND (:file_id IS NULL OR pr.file_id = :file_id) + AND (:job_no IS NULL OR pr.job_no = :job_no) + """) + + results = db.execute(query, { + "file_id": file_id, + "job_no": job_no + }).fetchall() + + material_ids = [row.material_id for row in results] + + return { + "success": True, + "requested_material_ids": material_ids, + "count": len(material_ids) + } + + except Exception as e: + logger.error(f"Failed to get requested material IDs: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"구매신청 자재 ID 조회 실패: {str(e)}" + ) + + +@router.patch("/{request_id}/title") +async def update_request_title( + request_id: int, + title: str = Body(..., embed=True), + # current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 구매신청 제목(request_no) 업데이트 + """ + try: + # 구매신청 존재 확인 + check_query = text(""" + SELECT request_no FROM purchase_requests + WHERE request_id = :request_id + """) + existing = db.execute(check_query, {"request_id": request_id}).fetchone() + + if not existing: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="구매신청을 찾을 수 없습니다" + ) + + # 제목 업데이트 + update_query = text(""" + UPDATE purchase_requests + SET request_no = :title + WHERE request_id = :request_id + """) + + db.execute(update_query, { + "request_id": request_id, + "title": title + }) + db.commit() + + return { + "success": True, + "message": "구매신청 제목이 업데이트되었습니다", + "old_title": existing.request_no, + "new_title": title + } + + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"Failed to update request title: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"구매신청 제목 업데이트 실패: {str(e)}" + ) + + +@router.get("/{request_id}/download-excel") +async def download_request_excel( + request_id: int, + # current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 구매신청 엑셀 파일 직접 다운로드 (BOM 페이지에서 생성한 파일 그대로) + """ + from fastapi.responses import FileResponse + + try: + # 구매신청 정보 조회 + query = text(""" + SELECT request_no, excel_file_path, job_no + FROM purchase_requests + WHERE request_id = :request_id + """) + result = db.execute(query, {"request_id": request_id}).fetchone() + + if not result: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="구매신청을 찾을 수 없습니다" + ) + + excel_file_path = os.path.join(EXCEL_DIR, result.excel_file_path) + + if not os.path.exists(excel_file_path): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="엑셀 파일을 찾을 수 없습니다" + ) + + # 엑셀 파일 직접 다운로드 + return FileResponse( + path=excel_file_path, + filename=f"{result.job_no}_{result.request_no}.xlsx", + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to download request excel: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"엑셀 다운로드 실패: {str(e)}" + ) + + +def save_materials_data(materials_data: List[Dict], file_path: str, request_no: str, job_no: str, grouped_materials: List[Dict] = None): + """ + 자재 데이터를 JSON으로 저장 (프론트엔드에서 동일한 엑셀 포맷으로 생성하기 위해) + """ + # 수량을 정수로 변환하여 저장 + cleaned_materials = [] + for material in materials_data: + cleaned_material = material.copy() + if 'quantity' in cleaned_material: + try: + cleaned_material['quantity'] = int(float(cleaned_material['quantity'])) + except (ValueError, TypeError): + cleaned_material['quantity'] = 0 + cleaned_materials.append(cleaned_material) + + # 그룹화된 자재도 수량 정수 변환 + cleaned_grouped = [] + if grouped_materials: + for group in grouped_materials: + cleaned_group = group.copy() + if 'quantity' in cleaned_group: + try: + cleaned_group['quantity'] = int(float(cleaned_group['quantity'])) + except (ValueError, TypeError): + cleaned_group['quantity'] = 0 + cleaned_grouped.append(cleaned_group) + + data_to_save = { + "request_no": request_no, + "job_no": job_no, + "created_at": datetime.now().isoformat(), + "materials": cleaned_materials, + "grouped_materials": cleaned_grouped or [] + } + + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data_to_save, f, ensure_ascii=False, indent=2) + + +def create_excel_file(materials_data: List[Dict], file_path: str, request_no: str, job_no: str): + """ + 자재 데이터로 엑셀 파일 생성 (BOM 페이지와 동일한 형식) + """ + import openpyxl + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + + # 새 워크북 생성 + wb = openpyxl.Workbook() + wb.remove(wb.active) # 기본 시트 제거 + + # 카테고리별 그룹화 + category_groups = {} + for material in materials_data: + category = material.get('category', 'UNKNOWN') + if category not in category_groups: + category_groups[category] = [] + category_groups[category].append(material) + + # 각 카테고리별 시트 생성 + for category, items in category_groups.items(): + if not items: + continue + + ws = wb.create_sheet(title=category) + + # 헤더 정의 (P열에 납기일, 관리항목 통일) + headers = ['TAGNO', '품목명', '수량', '통화구분', '단가', '크기', '압력등급', '스케줄', + '재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3', + '관리항목4', '납기일(YYYY-MM-DD)', '관리항목5', '관리항목6', '관리항목7', + '관리항목8', '관리항목9', '관리항목10'] + + # 헤더 작성 + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.font = Font(bold=True, color="000000", size=12, name="맑은 고딕") + cell.fill = PatternFill(start_color="B3D9FF", end_color="B3D9FF", fill_type="solid") + cell.alignment = Alignment(horizontal="center", vertical="center") + cell.border = Border( + top=Side(style="thin", color="666666"), + bottom=Side(style="thin", color="666666"), + left=Side(style="thin", color="666666"), + right=Side(style="thin", color="666666") + ) + + # 데이터 작성 + for row_idx, material in enumerate(items, 2): + data = [ + '', # TAGNO + category, # 품목명 + material.get('quantity', 0), # 수량 + 'KRW', # 통화구분 + 1, # 단가 + material.get('size', '-'), # 크기 + '-', # 압력등급 (추후 개선) + material.get('schedule', '-'), # 스케줄 + material.get('material_grade', '-'), # 재질 + '-', # 상세내역 (추후 개선) + material.get('user_requirement', ''), # 사용자요구 + '', '', '', '', '', # 관리항목들 + datetime.now().strftime('%Y-%m-%d') # 납기일 + ] + + for col, value in enumerate(data, 1): + ws.cell(row=row_idx, column=col, value=value) + + # 컬럼 너비 자동 조정 + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max(max_length + 2, 10), 50) + ws.column_dimensions[column_letter].width = adjusted_width + + # 파일 저장 + wb.save(file_path) + + +@router.post("/upload-excel") +async def upload_request_excel( + excel_file: UploadFile = File(...), + request_id: int = Form(...), + category: str = Form(...), + # current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 구매신청에 대한 엑셀 파일 업로드 (BOM에서 생성한 원본 파일) + """ + try: + # 구매신청 정보 조회 + query = text(""" + SELECT request_no, job_no + FROM purchase_requests + WHERE request_id = :request_id + """) + result = db.execute(query, {"request_id": request_id}).fetchone() + + if not result: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="구매신청을 찾을 수 없습니다" + ) + + # 엑셀 저장 디렉토리 생성 + excel_dir = Path("uploads/excel_exports") + excel_dir.mkdir(parents=True, exist_ok=True) + + # 파일명 생성 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_filename = f"{result.job_no}_{result.request_no}_{timestamp}.xlsx" + file_path = excel_dir / safe_filename + + # 파일 저장 + content = await excel_file.read() + with open(file_path, "wb") as f: + f.write(content) + + # 구매신청 테이블에 엑셀 파일 경로 업데이트 + update_query = text(""" + UPDATE purchase_requests + SET excel_file_path = :excel_file_path + WHERE request_id = :request_id + """) + + db.execute(update_query, { + "excel_file_path": safe_filename, + "request_id": request_id + }) + + db.commit() + + logger.info(f"엑셀 파일 업로드 완료: {safe_filename}") + + return { + "success": True, + "message": "엑셀 파일이 성공적으로 업로드되었습니다", + "file_path": safe_filename + } + + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"Failed to upload excel file: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"엑셀 파일 업로드 실패: {str(e)}" + ) diff --git a/tkeg/api/app/routers/purchase_tracking.py b/tkeg/api/app/routers/purchase_tracking.py new file mode 100644 index 0000000..5d3dc4e --- /dev/null +++ b/tkeg/api/app/routers/purchase_tracking.py @@ -0,0 +1,454 @@ +""" +구매 추적 및 관리 API +엑셀 내보내기 이력 및 구매 상태 관리 +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import Optional, List, Dict, Any +from datetime import datetime, date +import json + +from ..database import get_db +from ..auth.middleware import get_current_user +from ..utils.logger import get_logger + +logger = get_logger(__name__) + +router = APIRouter(prefix="/purchase", tags=["Purchase Tracking"]) + + +@router.post("/export-history") +async def create_export_history( + file_id: int, + job_no: Optional[str] = None, + export_type: str = "full", + category: Optional[str] = None, + material_ids: List[int] = [], + filters_applied: Optional[Dict] = None, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 엑셀 내보내기 이력 생성 및 자재 추적 + """ + try: + # 내보내기 이력 생성 + insert_history = text(""" + INSERT INTO excel_export_history ( + file_id, job_no, exported_by, export_type, + category, material_count, filters_applied + ) VALUES ( + :file_id, :job_no, :exported_by, :export_type, + :category, :material_count, :filters_applied + ) RETURNING export_id + """) + + result = db.execute(insert_history, { + "file_id": file_id, + "job_no": job_no, + "exported_by": current_user.get("user_id"), + "export_type": export_type, + "category": category, + "material_count": len(material_ids), + "filters_applied": json.dumps(filters_applied) if filters_applied else None + }) + + export_id = result.fetchone().export_id + + # 내보낸 자재들 기록 + if material_ids: + for material_id in material_ids: + insert_material = text(""" + INSERT INTO exported_materials ( + export_id, material_id, purchase_status + ) VALUES ( + :export_id, :material_id, 'pending' + ) + """) + db.execute(insert_material, { + "export_id": export_id, + "material_id": material_id + }) + + db.commit() + + logger.info(f"Export history created: {export_id} with {len(material_ids)} materials") + + return { + "success": True, + "export_id": export_id, + "message": f"{len(material_ids)}개 자재의 내보내기 이력이 저장되었습니다" + } + + except Exception as e: + db.rollback() + logger.error(f"Failed to create export history: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"내보내기 이력 생성 실패: {str(e)}" + ) + + +@router.get("/export-history") +async def get_export_history( + file_id: Optional[int] = None, + job_no: Optional[str] = None, + limit: int = 50, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 엑셀 내보내기 이력 조회 + """ + try: + query = text(""" + SELECT + eeh.export_id, + eeh.file_id, + eeh.job_no, + eeh.export_date, + eeh.export_type, + eeh.category, + eeh.material_count, + u.name as exported_by_name, + f.original_filename, + COUNT(DISTINCT em.material_id) as actual_material_count, + COUNT(DISTINCT CASE WHEN em.purchase_status = 'pending' THEN em.material_id END) as pending_count, + COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) as requested_count, + COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) as ordered_count, + COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) as received_count + FROM excel_export_history eeh + LEFT JOIN users u ON eeh.exported_by = u.user_id + LEFT JOIN files f ON eeh.file_id = f.id + LEFT JOIN exported_materials em ON eeh.export_id = em.export_id + WHERE 1=1 + AND (:file_id IS NULL OR eeh.file_id = :file_id) + AND (:job_no IS NULL OR eeh.job_no = :job_no) + GROUP BY + eeh.export_id, eeh.file_id, eeh.job_no, eeh.export_date, + eeh.export_type, eeh.category, eeh.material_count, + u.name, f.original_filename + ORDER BY eeh.export_date DESC + LIMIT :limit + """) + + results = db.execute(query, { + "file_id": file_id, + "job_no": job_no, + "limit": limit + }).fetchall() + + history = [] + for row in results: + history.append({ + "export_id": row.export_id, + "file_id": row.file_id, + "job_no": row.job_no, + "export_date": row.export_date.isoformat() if row.export_date else None, + "export_type": row.export_type, + "category": row.category, + "material_count": row.material_count, + "exported_by": row.exported_by_name, + "file_name": row.original_filename, + "status_summary": { + "total": row.actual_material_count, + "pending": row.pending_count, + "requested": row.requested_count, + "ordered": row.ordered_count, + "received": row.received_count + } + }) + + return { + "success": True, + "history": history, + "count": len(history) + } + + except Exception as e: + logger.error(f"Failed to get export history: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"내보내기 이력 조회 실패: {str(e)}" + ) + + +@router.get("/materials/status") +async def get_materials_by_status( + status: Optional[str] = None, + export_id: Optional[int] = None, + file_id: Optional[int] = None, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 구매 상태별 자재 목록 조회 + """ + try: + query = text(""" + SELECT + em.id as exported_material_id, + em.material_id, + m.original_description, + m.classified_category, + m.quantity, + m.unit, + em.purchase_status, + em.purchase_request_no, + em.purchase_order_no, + em.vendor_name, + em.expected_date, + em.quantity_ordered, + em.quantity_received, + em.unit_price, + em.total_price, + em.notes, + em.updated_at, + eeh.export_date, + f.original_filename as file_name, + j.job_no, + j.job_name + FROM exported_materials em + JOIN materials m ON em.material_id = m.id + JOIN excel_export_history eeh ON em.export_id = eeh.export_id + LEFT JOIN files f ON eeh.file_id = f.id + LEFT JOIN jobs j ON eeh.job_no = j.job_no + WHERE 1=1 + AND (:status IS NULL OR em.purchase_status = :status) + AND (:export_id IS NULL OR em.export_id = :export_id) + AND (:file_id IS NULL OR eeh.file_id = :file_id) + ORDER BY em.updated_at DESC + """) + + results = db.execute(query, { + "status": status, + "export_id": export_id, + "file_id": file_id + }).fetchall() + + materials = [] + for row in results: + materials.append({ + "exported_material_id": row.exported_material_id, + "material_id": row.material_id, + "description": row.original_description, + "category": row.classified_category, + "quantity": row.quantity, + "unit": row.unit, + "purchase_status": row.purchase_status, + "purchase_request_no": row.purchase_request_no, + "purchase_order_no": row.purchase_order_no, + "vendor_name": row.vendor_name, + "expected_date": row.expected_date.isoformat() if row.expected_date else None, + "quantity_ordered": row.quantity_ordered, + "quantity_received": row.quantity_received, + "unit_price": float(row.unit_price) if row.unit_price else None, + "total_price": float(row.total_price) if row.total_price else None, + "notes": row.notes, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + "export_date": row.export_date.isoformat() if row.export_date else None, + "file_name": row.file_name, + "job_no": row.job_no, + "job_name": row.job_name + }) + + return { + "success": True, + "materials": materials, + "count": len(materials) + } + + except Exception as e: + logger.error(f"Failed to get materials by status: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"구매 상태별 자재 조회 실패: {str(e)}" + ) + + +@router.patch("/materials/{exported_material_id}/status") +async def update_purchase_status( + exported_material_id: int, + new_status: str, + purchase_request_no: Optional[str] = None, + purchase_order_no: Optional[str] = None, + vendor_name: Optional[str] = None, + expected_date: Optional[date] = None, + quantity_ordered: Optional[int] = None, + quantity_received: Optional[int] = None, + unit_price: Optional[float] = None, + notes: Optional[str] = None, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 자재 구매 상태 업데이트 + """ + try: + # 현재 상태 조회 + get_current = text(""" + SELECT purchase_status, material_id + FROM exported_materials + WHERE id = :id + """) + current = db.execute(get_current, {"id": exported_material_id}).fetchone() + + if not current: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="해당 자재를 찾을 수 없습니다" + ) + + # 상태 업데이트 + update_query = text(""" + UPDATE exported_materials + SET + purchase_status = :new_status, + purchase_request_no = COALESCE(:pr_no, purchase_request_no), + purchase_order_no = COALESCE(:po_no, purchase_order_no), + vendor_name = COALESCE(:vendor, vendor_name), + expected_date = COALESCE(:expected_date, expected_date), + quantity_ordered = COALESCE(:qty_ordered, quantity_ordered), + quantity_received = COALESCE(:qty_received, quantity_received), + unit_price = COALESCE(:unit_price, unit_price), + total_price = CASE + WHEN :unit_price IS NOT NULL AND :qty_ordered IS NOT NULL + THEN :unit_price * :qty_ordered + WHEN :unit_price IS NOT NULL AND quantity_ordered IS NOT NULL + THEN :unit_price * quantity_ordered + ELSE total_price + END, + notes = COALESCE(:notes, notes), + updated_by = :updated_by, + requested_date = CASE WHEN :new_status = 'requested' THEN CURRENT_TIMESTAMP ELSE requested_date END, + ordered_date = CASE WHEN :new_status = 'ordered' THEN CURRENT_TIMESTAMP ELSE ordered_date END, + received_date = CASE WHEN :new_status = 'received' THEN CURRENT_TIMESTAMP ELSE received_date END + WHERE id = :id + """) + + db.execute(update_query, { + "id": exported_material_id, + "new_status": new_status, + "pr_no": purchase_request_no, + "po_no": purchase_order_no, + "vendor": vendor_name, + "expected_date": expected_date, + "qty_ordered": quantity_ordered, + "qty_received": quantity_received, + "unit_price": unit_price, + "notes": notes, + "updated_by": current_user.get("user_id") + }) + + # 이력 기록 + insert_history = text(""" + INSERT INTO purchase_status_history ( + exported_material_id, material_id, + previous_status, new_status, + changed_by, reason + ) VALUES ( + :em_id, :material_id, + :prev_status, :new_status, + :changed_by, :reason + ) + """) + + db.execute(insert_history, { + "em_id": exported_material_id, + "material_id": current.material_id, + "prev_status": current.purchase_status, + "new_status": new_status, + "changed_by": current_user.get("user_id"), + "reason": notes + }) + + db.commit() + + logger.info(f"Purchase status updated: {exported_material_id} from {current.purchase_status} to {new_status}") + + return { + "success": True, + "message": f"구매 상태가 {new_status}로 변경되었습니다" + } + + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"Failed to update purchase status: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"구매 상태 업데이트 실패: {str(e)}" + ) + + +@router.get("/status-summary") +async def get_status_summary( + file_id: Optional[int] = None, + job_no: Optional[str] = None, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 구매 상태 요약 통계 + """ + try: + query = text(""" + SELECT + em.purchase_status, + COUNT(DISTINCT em.material_id) as material_count, + SUM(em.quantity_exported) as total_quantity, + SUM(em.total_price) as total_amount, + COUNT(DISTINCT em.export_id) as export_count + FROM exported_materials em + JOIN excel_export_history eeh ON em.export_id = eeh.export_id + WHERE 1=1 + AND (:file_id IS NULL OR eeh.file_id = :file_id) + AND (:job_no IS NULL OR eeh.job_no = :job_no) + GROUP BY em.purchase_status + """) + + results = db.execute(query, { + "file_id": file_id, + "job_no": job_no + }).fetchall() + + summary = {} + total_materials = 0 + total_amount = 0 + + for row in results: + summary[row.purchase_status] = { + "material_count": row.material_count, + "total_quantity": row.total_quantity, + "total_amount": float(row.total_amount) if row.total_amount else 0, + "export_count": row.export_count + } + total_materials += row.material_count + if row.total_amount: + total_amount += float(row.total_amount) + + # 기본 상태들 추가 (없는 경우 0으로) + for status in ['pending', 'requested', 'ordered', 'received', 'cancelled']: + if status not in summary: + summary[status] = { + "material_count": 0, + "total_quantity": 0, + "total_amount": 0, + "export_count": 0 + } + + return { + "success": True, + "summary": summary, + "total_materials": total_materials, + "total_amount": total_amount + } + + except Exception as e: + logger.error(f"Failed to get status summary: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"구매 상태 요약 조회 실패: {str(e)}" + ) diff --git a/tkeg/api/app/routers/revision_management.py b/tkeg/api/app/routers/revision_management.py new file mode 100644 index 0000000..e6c6b97 --- /dev/null +++ b/tkeg/api/app/routers/revision_management.py @@ -0,0 +1,327 @@ +""" +간단한 리비전 관리 API +- 리비전 세션 생성 및 관리 +- 자재 비교 및 변경사항 처리 +""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import text +from typing import List, Optional, Dict, Any +from datetime import datetime +from pydantic import BaseModel + +from ..database import get_db +from ..auth.middleware import get_current_user +from ..services.revision_session_service import RevisionSessionService +from ..services.revision_comparison_service import RevisionComparisonService + +router = APIRouter(prefix="/revision-management", tags=["revision-management"]) + +class RevisionSessionCreate(BaseModel): + job_no: str + current_file_id: int + previous_file_id: int + +@router.post("/sessions") +async def create_revision_session( + session_data: RevisionSessionCreate, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """리비전 세션 생성""" + try: + session_service = RevisionSessionService(db) + + # 실제 DB에 세션 생성 + result = session_service.create_revision_session( + job_no=session_data.job_no, + current_file_id=session_data.current_file_id, + previous_file_id=session_data.previous_file_id, + username=current_user.get("username") + ) + + return { + "success": True, + "data": result, + "message": "리비전 세션이 생성되었습니다." + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"리비전 세션 생성 실패: {str(e)}") + +@router.get("/sessions/{session_id}") +async def get_session_status( + session_id: int, + db: Session = Depends(get_db) +): + """세션 상태 조회""" + try: + session_service = RevisionSessionService(db) + + # 실제 DB에서 세션 상태 조회 + result = session_service.get_session_status(session_id) + + return { + "success": True, + "data": result + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"세션 상태 조회 실패: {str(e)}") + +@router.get("/sessions/{session_id}/summary") +async def get_revision_summary( + session_id: int, + db: Session = Depends(get_db) +): + """리비전 요약 조회""" + try: + comparison_service = RevisionComparisonService(db) + + # 세션의 모든 변경사항 조회 + changes = comparison_service.get_session_changes(session_id) + + # 요약 통계 계산 + summary = { + "session_id": session_id, + "total_changes": len(changes), + "new_materials": len([c for c in changes if c['change_type'] == 'added']), + "changed_materials": len([c for c in changes if c['change_type'] == 'quantity_changed']), + "removed_materials": len([c for c in changes if c['change_type'] == 'removed']), + "categories": {} + } + + # 카테고리별 통계 + for change in changes: + category = change['category'] + if category not in summary["categories"]: + summary["categories"][category] = { + "total_changes": 0, + "added": 0, + "changed": 0, + "removed": 0 + } + + summary["categories"][category]["total_changes"] += 1 + if change['change_type'] == 'added': + summary["categories"][category]["added"] += 1 + elif change['change_type'] == 'quantity_changed': + summary["categories"][category]["changed"] += 1 + elif change['change_type'] == 'removed': + summary["categories"][category]["removed"] += 1 + + return { + "success": True, + "data": summary + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"리비전 요약 조회 실패: {str(e)}") + +@router.post("/sessions/{session_id}/compare/{category}") +async def compare_category( + session_id: int, + category: str, + db: Session = Depends(get_db) +): + """카테고리별 자재 비교""" + try: + # 세션 정보 조회 + session_service = RevisionSessionService(db) + session_status = session_service.get_session_status(session_id) + session_info = session_status["session_info"] + + # 자재 비교 수행 + comparison_service = RevisionComparisonService(db) + result = comparison_service.compare_materials_by_category( + current_file_id=session_info["current_file_id"], + previous_file_id=session_info["previous_file_id"], + category=category, + session_id=session_id + ) + + return { + "success": True, + "data": result + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"카테고리 비교 실패: {str(e)}") + +@router.get("/history/{job_no}") +async def get_revision_history( + job_no: str, + db: Session = Depends(get_db) +): + """리비전 히스토리 조회""" + try: + session_service = RevisionSessionService(db) + + # 실제 DB에서 리비전 히스토리 조회 + history = session_service.get_job_revision_history(job_no) + + return { + "success": True, + "data": { + "job_no": job_no, + "history": history + } + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"리비전 히스토리 조회 실패: {str(e)}") + +# 세션 변경사항 조회 +@router.get("/sessions/{session_id}/changes") +async def get_session_changes( + session_id: int, + category: Optional[str] = Query(None), + db: Session = Depends(get_db) +): + """세션의 변경사항 조회""" + try: + comparison_service = RevisionComparisonService(db) + + # 세션의 변경사항 조회 + changes = comparison_service.get_session_changes(session_id, category) + + return { + "success": True, + "data": { + "changes": changes, + "total_count": len(changes) + } + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"세션 변경사항 조회 실패: {str(e)}") + +# 리비전 액션 처리 +@router.post("/changes/{change_id}/process") +async def process_revision_action( + change_id: int, + action_data: Dict[str, Any], + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """리비전 액션 처리""" + try: + comparison_service = RevisionComparisonService(db) + + # 액션 처리 + result = comparison_service.process_revision_action( + change_id=change_id, + action=action_data.get("action"), + username=current_user.get("username"), + notes=action_data.get("notes") + ) + + return { + "success": True, + "data": result + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"리비전 액션 처리 실패: {str(e)}") + +# 세션 완료 +@router.post("/sessions/{session_id}/complete") +async def complete_revision_session( + session_id: int, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """리비전 세션 완료""" + try: + session_service = RevisionSessionService(db) + + # 세션 완료 처리 + result = session_service.complete_session( + session_id=session_id, + username=current_user.get("username") + ) + + return { + "success": True, + "data": result + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"리비전 세션 완료 실패: {str(e)}") + +# 세션 취소 +@router.post("/sessions/{session_id}/cancel") +async def cancel_revision_session( + session_id: int, + reason: Optional[str] = Query(None), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """리비전 세션 취소""" + try: + session_service = RevisionSessionService(db) + + # 세션 취소 처리 + result = session_service.cancel_session( + session_id=session_id, + username=current_user.get("username"), + reason=reason + ) + + return { + "success": True, + "data": {"cancelled": result} + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"리비전 세션 취소 실패: {str(e)}") + +@router.get("/categories") +async def get_supported_categories(): + """지원 카테고리 목록 조회""" + try: + categories = [ + {"key": "PIPE", "name": "배관", "description": "파이프 및 배관 자재"}, + {"key": "FITTING", "name": "피팅", "description": "배관 연결 부품"}, + {"key": "FLANGE", "name": "플랜지", "description": "플랜지 및 연결 부품"}, + {"key": "VALVE", "name": "밸브", "description": "각종 밸브류"}, + {"key": "GASKET", "name": "가스켓", "description": "씰링 부품"}, + {"key": "BOLT", "name": "볼트", "description": "체결 부품"}, + {"key": "SUPPORT", "name": "서포트", "description": "지지대 및 구조물"}, + {"key": "SPECIAL", "name": "특수자재", "description": "특수 목적 자재"}, + {"key": "UNCLASSIFIED", "name": "미분류", "description": "분류되지 않은 자재"} + ] + + return { + "success": True, + "data": { + "categories": categories + } + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"지원 카테고리 조회 실패: {str(e)}") + +@router.get("/actions") +async def get_supported_actions(): + """지원 액션 목록 조회""" + try: + actions = [ + {"key": "new_material", "name": "신규 자재", "description": "새로 추가된 자재"}, + {"key": "additional_purchase", "name": "추가 구매", "description": "구매된 자재의 수량 증가"}, + {"key": "inventory_transfer", "name": "재고 이관", "description": "구매된 자재의 수량 감소 또는 제거"}, + {"key": "purchase_cancel", "name": "구매 취소", "description": "미구매 자재의 제거"}, + {"key": "quantity_update", "name": "수량 업데이트", "description": "미구매 자재의 수량 변경"}, + {"key": "maintain", "name": "유지", "description": "변경사항 없음"} + ] + + return { + "success": True, + "data": { + "actions": actions + } + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"지원 액션 조회 실패: {str(e)}") \ No newline at end of file diff --git a/tkeg/api/app/routers/tubing.py b/tkeg/api/app/routers/tubing.py new file mode 100644 index 0000000..f3c6170 --- /dev/null +++ b/tkeg/api/app/routers/tubing.py @@ -0,0 +1,538 @@ +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)}") \ No newline at end of file diff --git a/tkeg/api/app/schemas.py b/tkeg/api/app/schemas.py new file mode 100644 index 0000000..7f1ce4d --- /dev/null +++ b/tkeg/api/app/schemas.py @@ -0,0 +1,47 @@ +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional, List + +# Project Schemas (project_name 추가) +class ProjectBase(BaseModel): + official_project_code: str = Field(..., description="공식 프로젝트 코드") + project_name: str = Field(..., description="프로젝트명") # 추가 + design_project_code: Optional[str] = Field(None, description="설계 프로젝트 코드") + is_code_matched: bool = Field(False, description="코드 매칭 여부") + status: str = Field("active", description="프로젝트 상태") + +class ProjectCreate(ProjectBase): + pass + +class ProjectResponse(ProjectBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + +# File Schemas (기존 유지) +class FileResponse(BaseModel): + id: int + filename: str + original_filename: str + file_path: str + project_id: int + project_code: Optional[str] = None + revision: str = "Rev.0" + description: Optional[str] = None + upload_date: datetime + parsed_count: int = 0 + is_active: bool = True + + class Config: + from_attributes = True + +class FileUploadResponse(BaseModel): + success: bool + message: str + file_id: int + filename: str + parsed_materials_count: int + file_path: str diff --git a/tkeg/api/app/schemas/__init__.py b/tkeg/api/app/schemas/__init__.py new file mode 100644 index 0000000..4f797c1 --- /dev/null +++ b/tkeg/api/app/schemas/__init__.py @@ -0,0 +1,69 @@ +""" +스키마 모듈 +API 요청/응답 모델 정의 +""" +from .response_models import ( + BaseResponse, + ErrorResponse, + SuccessResponse, + FileInfo, + FileListResponse, + FileDeleteResponse, + MaterialInfo, + MaterialListResponse, + JobInfo, + JobListResponse, + ClassificationResult, + ClassificationResponse, + MaterialStatistics, + ProjectStatistics, + StatisticsResponse, + CacheInfo, + SystemHealthResponse, + APIResponse, + # 열거형 + FileStatus, + MaterialCategory, + JobStatus +) + +__all__ = [ + # 기본 응답 모델 + "BaseResponse", + "ErrorResponse", + "SuccessResponse", + + # 파일 관련 + "FileInfo", + "FileListResponse", + "FileDeleteResponse", + + # 자재 관련 + "MaterialInfo", + "MaterialListResponse", + + # 작업 관련 + "JobInfo", + "JobListResponse", + + # 분류 관련 + "ClassificationResult", + "ClassificationResponse", + + # 통계 관련 + "MaterialStatistics", + "ProjectStatistics", + "StatisticsResponse", + + # 시스템 관련 + "CacheInfo", + "SystemHealthResponse", + + # 유니온 타입 + "APIResponse", + + # 열거형 + "FileStatus", + "MaterialCategory", + "JobStatus" +] diff --git a/tkeg/api/app/schemas/response_models.py b/tkeg/api/app/schemas/response_models.py new file mode 100644 index 0000000..600885b --- /dev/null +++ b/tkeg/api/app/schemas/response_models.py @@ -0,0 +1,354 @@ +""" +API 응답 모델 정의 +타입 안정성 및 API 문서화를 위한 Pydantic 모델들 +""" +from pydantic import BaseModel, Field, ConfigDict +from typing import List, Optional, Dict, Any, Union +from datetime import datetime +from enum import Enum + + +# ================================ +# 기본 응답 모델 +# ================================ + +class BaseResponse(BaseModel): + """기본 응답 모델""" + success: bool = Field(description="요청 성공 여부") + message: Optional[str] = Field(None, description="응답 메시지") + timestamp: datetime = Field(default_factory=datetime.now, description="응답 시간") + + +class ErrorResponse(BaseResponse): + """에러 응답 모델""" + success: bool = Field(False, description="요청 성공 여부") + error: Dict[str, Any] = Field(description="에러 정보") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "success": False, + "message": "요청 처리 중 오류가 발생했습니다", + "error": { + "code": "VALIDATION_ERROR", + "details": "입력 데이터가 올바르지 않습니다" + }, + "timestamp": "2025-01-01T12:00:00" + } + } + ) + + +class SuccessResponse(BaseResponse): + """성공 응답 모델""" + success: bool = Field(True, description="요청 성공 여부") + data: Optional[Any] = Field(None, description="응답 데이터") + + +# ================================ +# 열거형 정의 +# ================================ + +class FileStatus(str, Enum): + """파일 상태""" + ACTIVE = "active" + INACTIVE = "inactive" + PROCESSING = "processing" + ERROR = "error" + + +class MaterialCategory(str, Enum): + """자재 카테고리""" + PIPE = "PIPE" + FITTING = "FITTING" + VALVE = "VALVE" + FLANGE = "FLANGE" + BOLT = "BOLT" + GASKET = "GASKET" + INSTRUMENT = "INSTRUMENT" + EXCLUDE = "EXCLUDE" + + +class JobStatus(str, Enum): + """작업 상태""" + ACTIVE = "active" + COMPLETED = "completed" + ON_HOLD = "on_hold" + CANCELLED = "cancelled" + + +# ================================ +# 파일 관련 모델 +# ================================ + +class FileInfo(BaseModel): + """파일 정보 모델""" + id: int = Field(description="파일 ID") + filename: str = Field(description="파일명") + original_filename: str = Field(description="원본 파일명") + job_no: Optional[str] = Field(None, description="작업 번호") + bom_name: Optional[str] = Field(None, description="BOM 이름") + revision: str = Field(default="Rev.0", description="리비전") + parsed_count: int = Field(default=0, description="파싱된 자재 수") + bom_type: str = Field(default="unknown", description="BOM 타입") + status: FileStatus = Field(description="파일 상태") + file_size: Optional[int] = Field(None, description="파일 크기 (bytes)") + upload_date: datetime = Field(description="업로드 일시") + description: Optional[str] = Field(None, description="파일 설명") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "id": 1, + "filename": "BOM_Rev1.xlsx", + "original_filename": "BOM_Rev1.xlsx", + "job_no": "TK-2025-001", + "bom_name": "메인 BOM", + "revision": "Rev.1", + "parsed_count": 150, + "bom_type": "excel", + "status": "active", + "file_size": 2048576, + "upload_date": "2025-01-01T12:00:00", + "description": "파일: BOM_Rev1.xlsx" + } + } + ) + + +class FileListResponse(BaseResponse): + """파일 목록 응답 모델""" + success: bool = Field(True, description="요청 성공 여부") + data: List[FileInfo] = Field(description="파일 목록") + total_count: int = Field(description="전체 파일 수") + cache_hit: bool = Field(default=False, description="캐시 히트 여부") + + +class FileDeleteResponse(BaseResponse): + """파일 삭제 응답 모델""" + success: bool = Field(True, description="삭제 성공 여부") + message: str = Field(description="삭제 결과 메시지") + deleted_file_id: int = Field(description="삭제된 파일 ID") + + +# ================================ +# 자재 관련 모델 +# ================================ + +class MaterialInfo(BaseModel): + """자재 정보 모델""" + id: int = Field(description="자재 ID") + file_id: int = Field(description="파일 ID") + line_number: Optional[int] = Field(None, description="엑셀 행 번호") + original_description: str = Field(description="원본 품명") + classified_category: Optional[MaterialCategory] = Field(None, description="분류된 카테고리") + classified_subcategory: Optional[str] = Field(None, description="세부 분류") + material_grade: Optional[str] = Field(None, description="재질 등급") + schedule: Optional[str] = Field(None, description="스케줄") + size_spec: Optional[str] = Field(None, description="사이즈 규격") + quantity: float = Field(description="수량") + unit: str = Field(description="단위") + classification_confidence: Optional[float] = Field(None, description="분류 신뢰도") + is_verified: bool = Field(default=False, description="검증 여부") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "id": 1, + "file_id": 1, + "line_number": 5, + "original_description": "PIPE, SEAMLESS, A333-6, 6\", SCH40", + "classified_category": "PIPE", + "classified_subcategory": "SEAMLESS", + "material_grade": "A333-6", + "schedule": "SCH40", + "size_spec": "6\"", + "quantity": 12.5, + "unit": "EA", + "classification_confidence": 0.95, + "is_verified": False + } + } + ) + + +class MaterialListResponse(BaseResponse): + """자재 목록 응답 모델""" + success: bool = Field(True, description="요청 성공 여부") + data: List[MaterialInfo] = Field(description="자재 목록") + total_count: int = Field(description="전체 자재 수") + file_info: Optional[FileInfo] = Field(None, description="파일 정보") + cache_hit: bool = Field(default=False, description="캐시 히트 여부") + + +# ================================ +# 작업 관련 모델 +# ================================ + +class JobInfo(BaseModel): + """작업 정보 모델""" + job_no: str = Field(description="작업 번호") + job_name: str = Field(description="작업명") + client_name: Optional[str] = Field(None, description="고객사명") + end_user: Optional[str] = Field(None, description="최종 사용자") + epc_company: Optional[str] = Field(None, description="EPC 회사") + status: JobStatus = Field(description="작업 상태") + created_at: datetime = Field(description="생성 일시") + file_count: int = Field(default=0, description="파일 수") + material_count: int = Field(default=0, description="자재 수") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "job_no": "TK-2025-001", + "job_name": "석유화학 플랜트 배관 프로젝트", + "client_name": "한국석유화학", + "end_user": "울산공장", + "epc_company": "현대엔지니어링", + "status": "active", + "created_at": "2025-01-01T09:00:00", + "file_count": 3, + "material_count": 450 + } + } + ) + + +class JobListResponse(BaseResponse): + """작업 목록 응답 모델""" + success: bool = Field(True, description="요청 성공 여부") + data: List[JobInfo] = Field(description="작업 목록") + total_count: int = Field(description="전체 작업 수") + cache_hit: bool = Field(default=False, description="캐시 히트 여부") + + +# ================================ +# 분류 관련 모델 +# ================================ + +class ClassificationResult(BaseModel): + """분류 결과 모델""" + category: MaterialCategory = Field(description="분류된 카테고리") + subcategory: Optional[str] = Field(None, description="세부 분류") + confidence: float = Field(description="분류 신뢰도 (0.0-1.0)") + material_grade: Optional[str] = Field(None, description="재질 등급") + size_spec: Optional[str] = Field(None, description="사이즈 규격") + schedule: Optional[str] = Field(None, description="스케줄") + details: Optional[Dict[str, Any]] = Field(None, description="분류 상세 정보") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "category": "PIPE", + "subcategory": "SEAMLESS", + "confidence": 0.95, + "material_grade": "A333-6", + "size_spec": "6\"", + "schedule": "SCH40", + "details": { + "matched_keywords": ["PIPE", "SEAMLESS", "A333-6"], + "size_detected": True, + "material_detected": True + } + } + } + ) + + +class ClassificationResponse(BaseResponse): + """분류 응답 모델""" + success: bool = Field(True, description="분류 성공 여부") + data: ClassificationResult = Field(description="분류 결과") + processing_time: float = Field(description="처리 시간 (초)") + cache_hit: bool = Field(default=False, description="캐시 히트 여부") + + +# ================================ +# 통계 관련 모델 +# ================================ + +class MaterialStatistics(BaseModel): + """자재 통계 모델""" + category: MaterialCategory = Field(description="자재 카테고리") + count: int = Field(description="개수") + percentage: float = Field(description="비율 (%)") + total_quantity: float = Field(description="총 수량") + unique_items: int = Field(description="고유 항목 수") + + +class ProjectStatistics(BaseModel): + """프로젝트 통계 모델""" + job_no: str = Field(description="작업 번호") + total_materials: int = Field(description="총 자재 수") + total_files: int = Field(description="총 파일 수") + category_breakdown: List[MaterialStatistics] = Field(description="카테고리별 분석") + classification_accuracy: float = Field(description="분류 정확도") + verified_percentage: float = Field(description="검증 완료율") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "job_no": "TK-2025-001", + "total_materials": 450, + "total_files": 3, + "category_breakdown": [ + { + "category": "PIPE", + "count": 180, + "percentage": 40.0, + "total_quantity": 1250.5, + "unique_items": 45 + } + ], + "classification_accuracy": 0.92, + "verified_percentage": 0.75 + } + } + ) + + +class StatisticsResponse(BaseResponse): + """통계 응답 모델""" + success: bool = Field(True, description="요청 성공 여부") + data: ProjectStatistics = Field(description="통계 데이터") + cache_hit: bool = Field(default=False, description="캐시 히트 여부") + + +# ================================ +# 시스템 관련 모델 +# ================================ + +class CacheInfo(BaseModel): + """캐시 정보 모델""" + status: str = Field(description="캐시 상태") + used_memory: str = Field(description="사용 메모리") + connected_clients: int = Field(description="연결된 클라이언트 수") + hit_rate: float = Field(description="캐시 히트율 (%)") + total_commands: int = Field(description="총 명령 수") + + +class SystemHealthResponse(BaseResponse): + """시스템 상태 응답 모델""" + success: bool = Field(True, description="요청 성공 여부") + data: Dict[str, Any] = Field(description="시스템 상태 정보") + cache_info: Optional[CacheInfo] = Field(None, description="캐시 정보") + database_status: str = Field(description="데이터베이스 상태") + api_version: str = Field(description="API 버전") + + +# ================================ +# 유니온 타입 (여러 응답 타입) +# ================================ + +# API 응답으로 사용할 수 있는 모든 타입 +APIResponse = Union[ + SuccessResponse, + ErrorResponse, + FileListResponse, + FileDeleteResponse, + MaterialListResponse, + JobListResponse, + ClassificationResponse, + StatisticsResponse, + SystemHealthResponse +] diff --git a/tkeg/api/app/services/__init__.py b/tkeg/api/app/services/__init__.py new file mode 100644 index 0000000..57d4295 --- /dev/null +++ b/tkeg/api/app/services/__init__.py @@ -0,0 +1,13 @@ +""" +서비스 모듈 +""" + +from .material_classifier import classify_material, get_manufacturing_method_from_material +from .materials_schema import MATERIAL_STANDARDS, SPECIAL_MATERIALS + +__all__ = [ + 'classify_material', + 'get_manufacturing_method_from_material', + 'MATERIAL_STANDARDS', + 'SPECIAL_MATERIALS' +] diff --git a/tkeg/api/app/services/activity_logger.py b/tkeg/api/app/services/activity_logger.py new file mode 100644 index 0000000..7951841 --- /dev/null +++ b/tkeg/api/app/services/activity_logger.py @@ -0,0 +1,362 @@ +""" +사용자 활동 로그 서비스 +모든 업무 활동을 추적하고 기록하는 서비스 +""" + +from sqlalchemy.orm import Session +from sqlalchemy import text +from typing import Optional, Dict, Any +from fastapi import Request +import json +from datetime import datetime + +from ..utils.logger import get_logger + +logger = get_logger(__name__) + + +class ActivityLogger: + """사용자 활동 로그 관리 클래스""" + + def __init__(self, db: Session): + self.db = db + + def log_activity( + self, + username: str, + activity_type: str, + activity_description: str, + target_id: Optional[int] = None, + target_type: Optional[str] = None, + user_id: Optional[int] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> int: + """ + 사용자 활동 로그 기록 + + Args: + username: 사용자명 (필수) + activity_type: 활동 유형 (FILE_UPLOAD, PROJECT_CREATE 등) + activity_description: 활동 설명 + target_id: 대상 ID (파일, 프로젝트 등) + target_type: 대상 유형 (FILE, PROJECT 등) + user_id: 사용자 ID + ip_address: IP 주소 + user_agent: 브라우저 정보 + metadata: 추가 메타데이터 + + Returns: + int: 생성된 로그 ID + """ + try: + insert_query = text(""" + INSERT INTO user_activity_logs ( + user_id, username, activity_type, activity_description, + target_id, target_type, ip_address, user_agent, metadata + ) VALUES ( + :user_id, :username, :activity_type, :activity_description, + :target_id, :target_type, :ip_address, :user_agent, :metadata + ) RETURNING id + """) + + result = self.db.execute(insert_query, { + 'user_id': user_id, + 'username': username, + 'activity_type': activity_type, + 'activity_description': activity_description, + 'target_id': target_id, + 'target_type': target_type, + 'ip_address': ip_address, + 'user_agent': user_agent, + 'metadata': json.dumps(metadata) if metadata else None + }) + + log_id = result.fetchone()[0] + self.db.commit() + + logger.info(f"Activity logged: {username} - {activity_type} - {activity_description}") + return log_id + + except Exception as e: + logger.error(f"Failed to log activity: {str(e)}") + self.db.rollback() + raise + + def log_file_upload( + self, + username: str, + file_id: int, + filename: str, + file_size: int, + job_no: str, + revision: str, + user_id: Optional[int] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None + ) -> int: + """파일 업로드 활동 로그""" + metadata = { + 'filename': filename, + 'file_size': file_size, + 'job_no': job_no, + 'revision': revision, + 'upload_time': datetime.now().isoformat() + } + + return self.log_activity( + username=username, + activity_type='FILE_UPLOAD', + activity_description=f'BOM 파일 업로드: {filename} (Job: {job_no}, Rev: {revision})', + target_id=file_id, + target_type='FILE', + user_id=user_id, + ip_address=ip_address, + user_agent=user_agent, + metadata=metadata + ) + + def log_project_create( + self, + username: str, + project_id: int, + project_name: str, + job_no: str, + user_id: Optional[int] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None + ) -> int: + """프로젝트 생성 활동 로그""" + metadata = { + 'project_name': project_name, + 'job_no': job_no, + 'create_time': datetime.now().isoformat() + } + + return self.log_activity( + username=username, + activity_type='PROJECT_CREATE', + activity_description=f'프로젝트 생성: {project_name} ({job_no})', + target_id=project_id, + target_type='PROJECT', + user_id=user_id, + ip_address=ip_address, + user_agent=user_agent, + metadata=metadata + ) + + def log_material_classify( + self, + username: str, + file_id: int, + classified_count: int, + job_no: str, + revision: str, + user_id: Optional[int] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None + ) -> int: + """자재 분류 활동 로그""" + metadata = { + 'classified_count': classified_count, + 'job_no': job_no, + 'revision': revision, + 'classify_time': datetime.now().isoformat() + } + + return self.log_activity( + username=username, + activity_type='MATERIAL_CLASSIFY', + activity_description=f'자재 분류 완료: {classified_count}개 자재 (Job: {job_no}, Rev: {revision})', + target_id=file_id, + target_type='FILE', + user_id=user_id, + ip_address=ip_address, + user_agent=user_agent, + metadata=metadata + ) + + def log_purchase_confirm( + self, + username: str, + job_no: str, + revision: str, + confirmed_count: int, + total_amount: Optional[float] = None, + user_id: Optional[int] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None + ) -> int: + """구매 확정 활동 로그""" + metadata = { + 'job_no': job_no, + 'revision': revision, + 'confirmed_count': confirmed_count, + 'total_amount': total_amount, + 'confirm_time': datetime.now().isoformat() + } + + return self.log_activity( + username=username, + activity_type='PURCHASE_CONFIRM', + activity_description=f'구매 확정: {confirmed_count}개 품목 (Job: {job_no}, Rev: {revision})', + target_id=None, # 구매는 특정 ID가 없음 + target_type='PURCHASE', + user_id=user_id, + ip_address=ip_address, + user_agent=user_agent, + metadata=metadata + ) + + def get_user_activities( + self, + username: str, + activity_type: Optional[str] = None, + limit: int = 50, + offset: int = 0 + ) -> list: + """사용자 활동 이력 조회""" + try: + where_clause = "WHERE username = :username" + params = {'username': username} + + if activity_type: + where_clause += " AND activity_type = :activity_type" + params['activity_type'] = activity_type + + query = text(f""" + SELECT + id, activity_type, activity_description, + target_id, target_type, metadata, created_at + FROM user_activity_logs + {where_clause} + ORDER BY created_at DESC + LIMIT :limit OFFSET :offset + """) + + params.update({'limit': limit, 'offset': offset}) + result = self.db.execute(query, params) + + activities = [] + for row in result.fetchall(): + activity = { + 'id': row[0], + 'activity_type': row[1], + 'activity_description': row[2], + 'target_id': row[3], + 'target_type': row[4], + 'metadata': json.loads(row[5]) if row[5] else {}, + 'created_at': row[6].isoformat() if row[6] else None + } + activities.append(activity) + + return activities + + except Exception as e: + logger.error(f"Failed to get user activities: {str(e)}") + return [] + + def get_recent_activities( + self, + days: int = 7, + limit: int = 100 + ) -> list: + """최근 활동 조회 (전체 사용자)""" + try: + query = text(""" + SELECT + username, activity_type, activity_description, + target_id, target_type, created_at + FROM user_activity_logs + WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '%s days' + ORDER BY created_at DESC + LIMIT :limit + """ % days) + + result = self.db.execute(query, {'limit': limit}) + + activities = [] + for row in result.fetchall(): + activity = { + 'username': row[0], + 'activity_type': row[1], + 'activity_description': row[2], + 'target_id': row[3], + 'target_type': row[4], + 'created_at': row[5].isoformat() if row[5] else None + } + activities.append(activity) + + return activities + + except Exception as e: + logger.error(f"Failed to get recent activities: {str(e)}") + return [] + + +def get_client_info(request: Request) -> tuple: + """ + 요청에서 클라이언트 정보 추출 + + Args: + request: FastAPI Request 객체 + + Returns: + tuple: (ip_address, user_agent) + """ + # IP 주소 추출 (프록시 고려) + ip_address = ( + request.headers.get('x-forwarded-for', '').split(',')[0].strip() or + request.headers.get('x-real-ip', '') or + request.client.host if request.client else 'unknown' + ) + + # User-Agent 추출 + user_agent = request.headers.get('user-agent', 'unknown') + + return ip_address, user_agent + + +def log_activity_from_request( + db: Session, + request: Request, + username: str, + activity_type: str, + activity_description: str, + target_id: Optional[int] = None, + target_type: Optional[str] = None, + user_id: Optional[int] = None, + metadata: Optional[Dict[str, Any]] = None +) -> int: + """ + 요청 정보를 포함한 활동 로그 기록 (편의 함수) + + Args: + db: 데이터베이스 세션 + request: FastAPI Request 객체 + username: 사용자명 + activity_type: 활동 유형 + activity_description: 활동 설명 + target_id: 대상 ID + target_type: 대상 유형 + user_id: 사용자 ID + metadata: 추가 메타데이터 + + Returns: + int: 생성된 로그 ID + """ + ip_address, user_agent = get_client_info(request) + + activity_logger = ActivityLogger(db) + return activity_logger.log_activity( + username=username, + activity_type=activity_type, + activity_description=activity_description, + target_id=target_id, + target_type=target_type, + user_id=user_id, + ip_address=ip_address, + user_agent=user_agent, + metadata=metadata + ) diff --git a/tkeg/api/app/services/bolt_classifier.py b/tkeg/api/app/services/bolt_classifier.py new file mode 100644 index 0000000..3ec18f2 --- /dev/null +++ b/tkeg/api/app/services/bolt_classifier.py @@ -0,0 +1,1251 @@ +""" +BOLT 분류 시스템 +볼트, 너트, 와셔, 스터드 등 체결용 부품 분류 +""" + +import re +from typing import Dict, List, Optional +from .material_classifier import classify_material + +def classify_bolt_material(description: str) -> Dict: + """볼트용 재질 분류 (ASTM A193, A194 등)""" + + desc_upper = description.upper() + + # A320/A194M 동시 처리 (예: "ASTM A320/A194M GR B8/8") - 저온용 볼트 조합 + if "A320" in desc_upper and "A194" in desc_upper: + # B8/8 등급 추출 + bolt_grade = "UNKNOWN" + nut_grade = "UNKNOWN" + + if "B8" in desc_upper: + bolt_grade = "B8" + nut_grade = "8" # A320/A194M의 경우 보통 B8/8 조합 + elif "L7" in desc_upper: + bolt_grade = "L7" + elif "B8M" in desc_upper: + bolt_grade = "B8M" + + combined_grade = f"{bolt_grade}/{nut_grade}" if bolt_grade != "UNKNOWN" and nut_grade != "UNKNOWN" else f"{bolt_grade}" if bolt_grade != "UNKNOWN" else "A320/A194M" + + return { + "standard": "ASTM A320/A194M", + "grade": combined_grade, + "material_type": "LOW_TEMP_STAINLESS", # 저온용 스테인리스 + "manufacturing": "FORGED", + "confidence": 0.95, + "evidence": ["ASTM_A320_A194M_COMBINED"] + } + + # A193/A194 동시 처리 (예: "ASTM A193/A194 GR B7/2H") + if "A193" in desc_upper and "A194" in desc_upper: + # B7/2H 등급 추출 + bolt_grade = "UNKNOWN" + nut_grade = "UNKNOWN" + + if "B7" in desc_upper: + bolt_grade = "B7" + if "2H" in desc_upper: + nut_grade = "2H" + elif " 8" in desc_upper or "GR 8" in desc_upper: + nut_grade = "8" + + combined_grade = f"{bolt_grade}/{nut_grade}" if bolt_grade != "UNKNOWN" and nut_grade != "UNKNOWN" else "A193/A194" + + return { + "standard": "ASTM A193/A194", + "grade": combined_grade, + "material_type": "ALLOY_STEEL", # B7/2H 조합은 보통 합금강 + "manufacturing": "FORGED", + "confidence": 0.95, + "evidence": ["ASTM_A193_A194_COMBINED"] + } + + # ASTM A193 (볼트용 강재) + if any(pattern in desc_upper for pattern in ["A193", "ASTM A193"]): + # B7, B8 등 등급 추출 (GR B7/2H 형태도 지원) + grade = "UNKNOWN" + if "GR B7" in desc_upper or " B7" in desc_upper: + grade = "B7" + elif "GR B8" in desc_upper or " B8" in desc_upper: + grade = "B8" + elif "GR B16" in desc_upper or " B16" in desc_upper: + grade = "B16" + + return { + "standard": "ASTM A193", + "grade": grade if grade != "UNKNOWN" else "ASTM A193", + "material_type": "ALLOY_STEEL" if "B7" in grade else "STAINLESS_STEEL", + "manufacturing": "FORGED", + "confidence": 0.95, + "evidence": ["ASTM_A193_BOLT_MATERIAL"] + } + + # ASTM A194 (너트용 강재) + if any(pattern in desc_upper for pattern in ["A194", "ASTM A194"]): + grade = "UNKNOWN" + if "GR 2H" in desc_upper or " 2H" in desc_upper or "/2H" in desc_upper: + grade = "2H" + elif "GR 8" in desc_upper or " 8" in desc_upper: + grade = "8" + + return { + "standard": "ASTM A194", + "grade": grade if grade != "UNKNOWN" else "ASTM A194", + "material_type": "ALLOY_STEEL" if "2H" in grade else "STAINLESS_STEEL", + "manufacturing": "FORGED", + "confidence": 0.95, + "evidence": ["ASTM_A194_NUT_MATERIAL"] + } + + # ASTM A320 (저온용 볼트) + if any(pattern in desc_upper for pattern in ["A320", "ASTM A320"]): + grade = "UNKNOWN" + if "L7" in desc_upper: + grade = "L7" + elif "L43" in desc_upper: + grade = "L43" + elif "B8M" in desc_upper: + grade = "B8M" + + return { + "standard": "ASTM A320", + "grade": grade if grade != "UNKNOWN" else "ASTM A320", + "material_type": "LOW_TEMP_STEEL", + "manufacturing": "FORGED", + "confidence": 0.95, + "evidence": ["ASTM_A320_LOW_TEMP_BOLT"] + } + + # ASTM A325 (구조용 볼트) + if any(pattern in desc_upper for pattern in ["A325", "ASTM A325"]): + return { + "standard": "ASTM A325", + "grade": "ASTM A325", + "material_type": "STRUCTURAL_STEEL", + "manufacturing": "HEAT_TREATED", + "confidence": 0.95, + "evidence": ["ASTM_A325_STRUCTURAL_BOLT"] + } + + # ASTM A490 (고강도 구조용 볼트) + if any(pattern in desc_upper for pattern in ["A490", "ASTM A490"]): + return { + "standard": "ASTM A490", + "grade": "ASTM A490", + "material_type": "HIGH_STRENGTH_STEEL", + "manufacturing": "HEAT_TREATED", + "confidence": 0.95, + "evidence": ["ASTM_A490_HIGH_STRENGTH_BOLT"] + } + + # DIN 934 (DIN 너트) + if any(pattern in desc_upper for pattern in ["DIN 934", "DIN934"]): + return { + "standard": "DIN 934", + "grade": "DIN 934", + "material_type": "CARBON_STEEL", + "manufacturing": "FORGED", + "confidence": 0.90, + "evidence": ["DIN_934_NUT"] + } + + # ISO 4762 (소켓 헤드 캡 스크류) + if any(pattern in desc_upper for pattern in ["ISO 4762", "ISO4762", "DIN 912", "DIN912"]): + return { + "standard": "ISO 4762", + "grade": "ISO 4762", + "material_type": "ALLOY_STEEL", + "manufacturing": "HEAT_TREATED", + "confidence": 0.90, + "evidence": ["ISO_4762_SOCKET_SCREW"] + } + + # 일반적인 볼트 재질 패턴 추가 확인 + if "B7" in desc_upper and "2H" in desc_upper: + return { + "standard": "ASTM A193/A194", + "grade": "B7/2H", + "material_type": "ALLOY_STEEL", + "manufacturing": "FORGED", + "confidence": 0.85, + "evidence": ["B7_2H_PATTERN"] + } + + # 단독 B7 패턴 + if "B7" in desc_upper: + return { + "standard": "ASTM A193", + "grade": "B7", + "material_type": "ALLOY_STEEL", + "manufacturing": "FORGED", + "confidence": 0.80, + "evidence": ["B7_PATTERN"] + } + + # 단독 2H 패턴 + if "2H" in desc_upper: + return { + "standard": "ASTM A194", + "grade": "2H", + "material_type": "ALLOY_STEEL", + "manufacturing": "FORGED", + "confidence": 0.80, + "evidence": ["2H_PATTERN"] + } + + # 기본 재질 분류기 호출 (materials_schema 문제가 있어도 우회) + try: + return classify_material(description) + except: + # materials_schema에 문제가 있으면 기본값 반환 + return { + "standard": "UNKNOWN", + "grade": "UNKNOWN", + "material_type": "UNKNOWN", + "manufacturing": "UNKNOWN", + "confidence": 0.0, + "evidence": ["MATERIAL_SCHEMA_ERROR"] + } + +# ========== 볼트 타입별 분류 ========== +BOLT_TYPES = { + "HEX_BOLT": { + "dat_file_patterns": ["BOLT_HEX", "HEX_BOLT", "HEXB_"], + "description_keywords": ["HEX BOLT", "HEXAGON BOLT", "육각볼트", "HEX HEAD"], + "characteristics": "육각 머리 볼트", + "applications": "일반 체결용", + "head_type": "HEXAGON" + }, + + "SOCKET_HEAD_CAP": { + "dat_file_patterns": ["SHCS_", "SOCKET_", "CAP_BOLT"], + "description_keywords": ["SOCKET HEAD CAP", "SHCS", "소켓헤드", "알렌볼트"], + "characteristics": "소켓 헤드 캡 스크류", + "applications": "정밀 체결용", + "head_type": "SOCKET" + }, + + "STUD_BOLT": { + "dat_file_patterns": ["STUD_", "STUD_BOLT", "_TK", "BLT_"], + "description_keywords": ["STUD BOLT", "STUD", "스터드볼트", "전나사", "BLT"], + "characteristics": "양끝 나사 스터드", + "applications": "플랜지 체결용", + "head_type": "NONE" + }, + + "FLANGE_BOLT": { + "dat_file_patterns": ["FLG_BOLT", "FLANGE_BOLT"], + "description_keywords": ["FLANGE BOLT", "플랜지볼트"], + "characteristics": "플랜지 전용 볼트", + "applications": "플랜지 체결 전용", + "head_type": "HEXAGON" + }, + + "PSV_BOLT": { + "dat_file_patterns": ["PSV_BOLT", "PSV_BLT"], + "description_keywords": ["PSV", "PRESSURE SAFETY VALVE BOLT"], + "characteristics": "압력안전밸브용 특수 볼트", + "applications": "PSV 체결 전용", + "head_type": "HEXAGON", + "special_application": "PSV" + }, + + "LT_BOLT": { + "dat_file_patterns": ["LT_BOLT", "LT_BLT"], + "description_keywords": ["LT BOLT", "LT BLT", "LOW TEMP", "저온용"], + "characteristics": "저온용 특수 볼트", + "applications": "저온 환경 체결용", + "head_type": "HEXAGON", + "special_application": "LT" + }, + + "CK_BOLT": { + "dat_file_patterns": ["CK_BOLT", "CK_BLT", "CHECK_BOLT"], + "description_keywords": ["CK", "CHECK VALVE BOLT"], + "characteristics": "체크밸브용 특수 볼트", + "applications": "체크밸브 체결 전용", + "head_type": "HEXAGON", + "special_application": "CK" + }, + + "ORI_BOLT": { + "dat_file_patterns": ["ORI_BOLT", "ORI_BLT", "ORIFICE_BOLT"], + "description_keywords": ["ORI", "ORIFICE", "오리피스"], + "characteristics": "오리피스용 특수 볼트", + "applications": "오리피스 체결 전용", + "head_type": "HEXAGON", + "special_application": "ORI" + }, + + "MACHINE_SCREW": { + "dat_file_patterns": ["MACH_SCR", "M_SCR"], + "description_keywords": ["MACHINE SCREW", "머신스크류", "기계나사"], + "characteristics": "기계용 나사", + "applications": "기계 부품 체결", + "head_type": "VARIOUS" + }, + + "SET_SCREW": { + "dat_file_patterns": ["SET_SCR", "GRUB_"], + "description_keywords": ["SET SCREW", "GRUB SCREW", "세트스크류", "고정나사"], + "characteristics": "고정용 나사", + "applications": "축 고정, 위치 고정", + "head_type": "SOCKET_OR_NONE" + }, + + "U_BOLT": { + "dat_file_patterns": ["U_BOLT", "UBOLT_"], + "description_keywords": ["U-BOLT", "U BOLT", "유볼트"], + "characteristics": "U자형 볼트", + "applications": "파이프 고정용", + "head_type": "NONE" + }, + + "EYE_BOLT": { + "dat_file_patterns": ["EYE_BOLT", "EYEB_"], + "description_keywords": ["EYE BOLT", "아이볼트", "고리볼트"], + "characteristics": "고리 형태 볼트", + "applications": "인양, 고정용", + "head_type": "EYE" + } +} + +# ========== 너트 타입별 분류 ========== +NUT_TYPES = { + "HEX_NUT": { + "dat_file_patterns": ["NUT_HEX", "HEX_NUT"], + "description_keywords": ["HEX NUT", "HEXAGON NUT", "육각너트"], + "characteristics": "육각 너트", + "applications": "일반 체결용" + }, + + "HEAVY_HEX_NUT": { + "dat_file_patterns": ["HEAVY_NUT", "HVY_NUT"], + "description_keywords": ["HEAVY HEX NUT", "HEAVY NUT", "헤비너트"], + "characteristics": "두꺼운 육각 너트", + "applications": "고강도 체결용" + }, + + "LOCK_NUT": { + "dat_file_patterns": ["LOCK_NUT", "LOCKN_"], + "description_keywords": ["LOCK NUT", "잠금너트", "록너트"], + "characteristics": "잠금 기능 너트", + "applications": "진동 방지용" + }, + + "WING_NUT": { + "dat_file_patterns": ["WING_NUT", "WINGN_"], + "description_keywords": ["WING NUT", "윙너트", "나비너트"], + "characteristics": "날개형 너트", + "applications": "수동 체결용" + }, + + "COUPLING_NUT": { + "dat_file_patterns": ["COUPL_NUT", "CONN_NUT"], + "description_keywords": ["COUPLING NUT", "커플링너트", "연결너트"], + "characteristics": "연결용 너트", + "applications": "스터드 연결용" + } +} + +# ========== 와셔 타입별 분류 ========== +WASHER_TYPES = { + "FLAT_WASHER": { + "dat_file_patterns": ["WASH_FLAT", "FLAT_WASH"], + "description_keywords": ["FLAT WASHER", "평와셔", "플랫와셔"], + "characteristics": "평판형 와셔", + "applications": "하중 분산용" + }, + + "SPRING_WASHER": { + "dat_file_patterns": ["SPRING_WASH", "SPR_WASH"], + "description_keywords": ["SPRING WASHER", "스프링와셔", "탄성와셔"], + "characteristics": "탄성 와셔", + "applications": "진동 방지용" + }, + + "LOCK_WASHER": { + "dat_file_patterns": ["LOCK_WASH", "LOCKW_"], + "description_keywords": ["LOCK WASHER", "록와셔", "잠금와셔"], + "characteristics": "잠금 와셔", + "applications": "풀림 방지용" + }, + + "BELLEVILLE_WASHER": { + "dat_file_patterns": ["BELL_WASH", "BELLEV_"], + "description_keywords": ["BELLEVILLE WASHER", "벨레빌와셔", "접시와셔"], + "characteristics": "접시형 스프링 와셔", + "applications": "고하중 탄성용" + } +} + +# ========== 나사 규격별 분류 ========== +THREAD_STANDARDS = { + "METRIC": { + "patterns": [r"M(\d+)(?:X(\d+(?:\.\d+)?))?", r"(\d+)MM"], + "description": "미터 나사", + "pitch_patterns": [r"X(\d+(?:\.\d+)?)"], + "common_sizes": ["M6", "M8", "M10", "M12", "M16", "M20", "M24", "M30", "M36"] + }, + + "INCH": { + "patterns": [ + r"(\d+(?:/\d+)?)\s*[\"\']\s*UNC", # 1/2" UNC + r"(\d+(?:/\d+)?)\s*[\"\']\s*UNF", # 1/2" UNF + r"(\d+(?:/\d+)?)\s*[\"\']-(\d+)", # 1/2"-13 + r"(\d+\.\d+)", # 0.625 (소수점 인치) + r"(\d+(?:/\d+)?)\s*INCH", # 1/2 INCH + r"(\d+(?:/\d+)?)\s*IN" # 1/2 IN + ], + "description": "인치 나사", + "thread_types": ["UNC", "UNF"], + "common_sizes": ["1/4\"", "5/16\"", "3/8\"", "1/2\"", "5/8\"", "0.625\"", "3/4\"", "7/8\"", "1\""] + }, + + "BSW": { + "patterns": [r"(\d+(?:/\d+)?)\s*[\"\']\s*BSW", r"(\d+(?:/\d+)?)\s*[\"\']\s*BSF"], + "description": "영국 표준 나사", + "thread_types": ["BSW", "BSF"], + "common_sizes": ["1/4\"", "5/16\"", "3/8\"", "1/2\"", "5/8\"", "3/4\""] + } +} + +# ========== 길이 및 등급 분류 ========== +BOLT_GRADES = { + "METRIC": { + "8.8": {"tensile_strength": "800 MPa", "yield_strength": "640 MPa"}, + "10.9": {"tensile_strength": "1000 MPa", "yield_strength": "900 MPa"}, + "12.9": {"tensile_strength": "1200 MPa", "yield_strength": "1080 MPa"} + }, + + "INCH": { + "A307": {"grade": "A", "tensile_strength": "60 ksi"}, + "A325": {"type": "1", "tensile_strength": "120 ksi"}, + "A490": {"type": "1", "tensile_strength": "150 ksi"} + } +} + +def convert_decimal_to_fraction(decimal_str: str) -> str: + """소수점 인치를 분수로 변환 (현장 표준)""" + + try: + decimal = float(decimal_str) + + # 일반적인 인치 분수 변환표 + inch_fractions = { + 0.125: "1/8", + 0.1875: "3/16", + 0.25: "1/4", + 0.3125: "5/16", + 0.375: "3/8", + 0.4375: "7/16", + 0.5: "1/2", + 0.5625: "9/16", + 0.625: "5/8", + 0.6875: "11/16", + 0.75: "3/4", + 0.8125: "13/16", + 0.875: "7/8", + 0.9375: "15/16", + 1.0: "1", + 1.125: "1-1/8", + 1.25: "1-1/4", + 1.375: "1-3/8", + 1.5: "1-1/2", + 1.625: "1-5/8", + 1.75: "1-3/4", + 1.875: "1-7/8", + 2.0: "2" + } + + # 정확한 매칭 (소수점 오차 고려) + for dec_val, fraction in inch_fractions.items(): + if abs(decimal - dec_val) < 0.001: # 1mm 오차 허용 + return fraction + + # 정확한 매칭이 없으면 가장 가까운 값 찾기 + closest_decimal = min(inch_fractions.keys(), key=lambda x: abs(x - decimal)) + if abs(closest_decimal - decimal) < 0.0625: # 1/16" 이내 오차만 허용 + return inch_fractions[closest_decimal] + + # 변환할 수 없으면 원래 값 반환 + return str(decimal) + + except ValueError: + return decimal_str + +def classify_surface_treatment(description: str) -> Dict: + """볼트 표면처리 분류 (아연도금, 스테인리스 등)""" + + desc_upper = description.upper() + treatments = [] + + # 전기아연도금 + if any(keyword in desc_upper for keyword in ["ELEC.GALV", "ELEC GALV", "ELECTRO GALV", "전기아연도금"]): + treatments.append({ + "type": "ELECTRO_GALVANIZING", + "description": "전기아연도금", + "code": "ELEC.GALV", + "corrosion_resistance": "보통" + }) + + # 용융아연도금 + if any(keyword in desc_upper for keyword in ["HOT DIP GALV", "HDG", "용융아연도금"]): + treatments.append({ + "type": "HOT_DIP_GALVANIZING", + "description": "용융아연도금", + "code": "HDG", + "corrosion_resistance": "높음" + }) + + # 스테인리스 (표면처리 불필요) + if any(keyword in desc_upper for keyword in ["STAINLESS", "STS", "스테인리스"]): + treatments.append({ + "type": "STAINLESS_STEEL", + "description": "스테인리스강", + "code": "STS", + "corrosion_resistance": "매우높음" + }) + + # 니켈도금 + if any(keyword in desc_upper for keyword in ["NICKEL", "NI PLATING", "니켈도금"]): + treatments.append({ + "type": "NICKEL_PLATING", + "description": "니켈도금", + "code": "NI", + "corrosion_resistance": "높음" + }) + + # 크롬도금 + if any(keyword in desc_upper for keyword in ["CHROME", "CR PLATING", "크롬도금"]): + treatments.append({ + "type": "CHROME_PLATING", + "description": "크롬도금", + "code": "CR", + "corrosion_resistance": "매우높음" + }) + + return { + "treatments": treatments, + "has_treatment": len(treatments) > 0, + "treatment_count": len(treatments), + "primary_treatment": treatments[0] if treatments else None + } + +def classify_special_application_bolts(description: str) -> Dict: + """ + 특수 용도 볼트 분류 및 카운팅 (PSV, LT, CK) + + 주의: 이 함수는 이미 BOLT로 분류된 아이템에서만 호출되어야 함 + PSV, LT, CK는 해당 장비용 볼트를 의미하며, 장비 자체가 아님 + """ + + desc_upper = description.upper() + special_applications = [] + special_details = {} + + # PSV 볼트 확인 (압력안전밸브용 볼트) + psv_patterns = [ + r'\bPSV\b', # 단어 경계로 PSV만 + r'PRESSURE\s+SAFETY\s+VALVE', + r'압력안전밸브', + r'PSV\s+BOLT', + r'PSV\s+BLT' + ] + + import re + if any(re.search(pattern, desc_upper) for pattern in psv_patterns): + special_applications.append("PSV") + special_details["PSV"] = { + "type": "압력안전밸브용 볼트", + "application": "PSV 체결 전용", + "critical": True # 안전 장비용으로 중요 + } + + # LT 볼트 확인 (저온용 볼트) + lt_patterns = [ + r'\bLT\s', # LT 다음에 공백이 있는 경우만 (LT BOLT, LT BLT) + r'^LT\b', # 문장 시작의 LT만 + r'LOW\s+TEMP', + r'저온용', + r'CRYOGENIC', + r'LT\s+BOLT', + r'LT\s+BLT' + ] + + if any(re.search(pattern, desc_upper) for pattern in lt_patterns): + special_applications.append("LT") + special_details["LT"] = { + "type": "저온용 볼트", + "application": "저온 환경 체결용", + "critical": True # 저온 환경용으로 중요 + } + + # CK 볼트 확인 (체크밸브용 볼트) + ck_patterns = [ + r'\bCK\b', # 단어 경계로 CK만 + r'CHECK\s+VALVE', + r'체크밸브', + r'CK\s+BOLT', + r'CK\s+BLT' + ] + + if any(re.search(pattern, desc_upper) for pattern in ck_patterns): + special_applications.append("CK") + special_details["CK"] = { + "type": "체크밸브용 볼트", + "application": "체크밸브 체결 전용", + "critical": False # 일반적 + } + + # ORI 볼트 확인 (오리피스용 볼트) + ori_patterns = [ + r'\bORI\b', # 단어 경계로 ORI만 + r'ORIFICE', + r'오리피스', + r'ORI\s+BOLT', + r'ORI\s+BLT' + ] + + if any(re.search(pattern, desc_upper) for pattern in ori_patterns): + special_applications.append("ORI") + special_details["ORI"] = { + "type": "오리피스용 볼트", + "application": "오리피스 체결 전용", + "critical": True # 유량 측정용으로 중요 + } + + return { + "detected_applications": special_applications, + "special_details": special_details, + "is_special_bolt": len(special_applications) > 0, + "special_count": len(special_applications), + "classification_note": "특수 장비용 볼트 (장비 자체 아님)" + } + +def classify_bolt(dat_file: str, description: str, main_nom: str, length: Optional[float] = None) -> Dict: + """ + 완전한 BOLT 분류 + + Args: + dat_file: DAT_FILE 필드 + description: DESCRIPTION 필드 + main_nom: MAIN_NOM 필드 (나사 사이즈) + + Returns: + 완전한 볼트 분류 결과 + """ + + + + # 1. 재질 분류 (볼트 전용 버전) + material_result = classify_bolt_material(description) + + # 2. 체결재 타입 분류 (볼트/너트/와셔) + fastener_category = classify_fastener_category(dat_file, description) + + # 3. 구체적 타입 분류 + specific_type_result = classify_specific_fastener_type( + dat_file, description, fastener_category + ) + + # 4. 나사 규격 분류 + thread_result = classify_thread_specification(main_nom, description) + + # 5. 길이 및 치수 추출 + dimensions_result = extract_bolt_dimensions(main_nom, description) + + # 6. 등급 및 강도 분류 + grade_result = classify_bolt_grade(description, thread_result) + + # 7. 특수 용도 볼트 분류 (PSV, LT, CK) + special_result = classify_special_application_bolts(description) + + # 8. 표면처리 분류 (ELEC.GALV 등) + surface_result = classify_surface_treatment(description) + + # 9. 최종 결과 조합 + return { + "category": "BOLT", + + # 재질 정보 (공통 모듈) + "material": { + "standard": material_result.get('standard', 'UNKNOWN'), + "grade": material_result.get('grade', 'UNKNOWN'), + "material_type": material_result.get('material_type', 'UNKNOWN'), + "confidence": material_result.get('confidence', 0.0) + }, + + # 체결재 분류 정보 + "fastener_category": { + "category": fastener_category.get('category', 'UNKNOWN'), + "confidence": fastener_category.get('confidence', 0.0) + }, + + "fastener_type": { + "type": specific_type_result.get('type', 'UNKNOWN'), + "characteristics": specific_type_result.get('characteristics', ''), + "confidence": specific_type_result.get('confidence', 0.0), + "evidence": specific_type_result.get('evidence', []), + "applications": specific_type_result.get('applications', ''), + "head_type": specific_type_result.get('head_type', '') + }, + + "thread_specification": { + "standard": thread_result.get('standard', 'UNKNOWN'), + "size": thread_result.get('size', ''), + "size_fraction": thread_result.get('size_fraction', ''), + "pitch": thread_result.get('pitch', ''), + "thread_type": thread_result.get('thread_type', ''), + "confidence": thread_result.get('confidence', 0.0) + }, + + "dimensions": { + "nominal_size": dimensions_result.get('nominal_size', main_nom), + "nominal_size_fraction": dimensions_result.get('nominal_size_fraction', main_nom), + "length": dimensions_result.get('length', ''), + "diameter": dimensions_result.get('diameter', ''), + "dimension_description": dimensions_result.get('dimension_description', ''), + "bolts_per_flange": dimensions_result.get('bolts_per_flange', 1) + }, + + "grade_strength": { + "grade": grade_result.get('grade', 'UNKNOWN'), + "tensile_strength": grade_result.get('tensile_strength', ''), + "yield_strength": grade_result.get('yield_strength', ''), + "confidence": grade_result.get('confidence', 0.0) + }, + + # 특수 용도 볼트 정보 + "special_applications": { + "is_special_bolt": special_result.get('is_special_bolt', False), + "detected_applications": special_result.get('detected_applications', []), + "special_details": special_result.get('special_details', {}), + "special_count": special_result.get('special_count', 0), + "classification_note": special_result.get('classification_note', '') + }, + + # 표면처리 정보 + "surface_treatment": { + "has_treatment": surface_result.get('has_treatment', False), + "treatments": surface_result.get('treatments', []), + "treatment_count": surface_result.get('treatment_count', 0), + "primary_treatment": surface_result.get('primary_treatment', None) + }, + + # 전체 신뢰도 + "overall_confidence": calculate_bolt_confidence({ + "material": material_result.get('confidence', 0), + "fastener_type": specific_type_result.get('confidence', 0), + "thread": thread_result.get('confidence', 0), + "grade": grade_result.get('confidence', 0) + }) + } + +def classify_fastener_category(dat_file: str, description: str) -> Dict: + """체결재 카테고리 분류 (볼트/너트/와셔)""" + + dat_upper = dat_file.upper() + desc_upper = description.upper() + combined_text = f"{dat_upper} {desc_upper}" + + # 볼트 키워드 + bolt_keywords = ["BOLT", "SCREW", "STUD", "BLT", "볼트", "나사", "스크류", "A193", "A194", "A320", "A325", "A490"] + if any(keyword in combined_text for keyword in bolt_keywords): + return { + "category": "BOLT", + "confidence": 0.9, + "evidence": ["BOLT_KEYWORDS"] + } + + # 너트 키워드 + nut_keywords = ["NUT", "너트"] + if any(keyword in combined_text for keyword in nut_keywords): + return { + "category": "NUT", + "confidence": 0.9, + "evidence": ["NUT_KEYWORDS"] + } + + # 와셔 키워드 + washer_keywords = ["WASHER", "WASH", "와셔"] + if any(keyword in combined_text for keyword in washer_keywords): + return { + "category": "WASHER", + "confidence": 0.9, + "evidence": ["WASHER_KEYWORDS"] + } + + + # 볼트가 아닌 것 같은 키워드들 체크 + non_bolt_keywords = [ + "PIPE", "TUBE", "파이프", "배관", # 파이프 + "ELBOW", "TEE", "REDUCER", "CAP", "엘보", "티", "리듀서", # 피팅 + "VALVE", "GATE", "BALL", "GLOBE", "CHECK", "밸브", # 밸브 + "FLANGE", "FLG", "플랜지", # 플랜지 + "GASKET", "GASK", "가스켓", # 가스켓 + "GAUGE", "METER", "TRANSMITTER", "INDICATOR", "SENSOR", "계기", "게이지", # 계기 + "THERMOWELL", "ORIFICE", "MANOMETER" # 특수 계기 + ] + + if any(keyword in combined_text for keyword in non_bolt_keywords): + return { + "category": "UNKNOWN", + "confidence": 0.1, + "evidence": ["NON_BOLT_KEYWORDS_DETECTED"] + } + + # 기본값: BOLT (하지만 낮은 신뢰도) + return { + "category": "BOLT", + "confidence": 0.1, # 0.6에서 0.1로 낮춤 + "evidence": ["DEFAULT_BOLT_LOW_CONFIDENCE"] + } + +def classify_specific_fastener_type(dat_file: str, description: str, + fastener_category: Dict) -> Dict: + """구체적 체결재 타입 분류""" + + category = fastener_category.get('category', 'BOLT') + dat_upper = dat_file.upper() + desc_upper = description.upper() + + if category == "BOLT": + type_dict = BOLT_TYPES + elif category == "NUT": + type_dict = NUT_TYPES + elif category == "WASHER": + type_dict = WASHER_TYPES + else: + type_dict = BOLT_TYPES # 기본값 + + # DAT_FILE 패턴 확인 + for fastener_type, type_data in type_dict.items(): + for pattern in type_data.get("dat_file_patterns", []): + if pattern in dat_upper: + return { + "type": fastener_type, + "characteristics": type_data["characteristics"], + "confidence": 0.95, + "evidence": [f"DAT_FILE_PATTERN: {pattern}"], + "applications": type_data["applications"], + "head_type": type_data.get("head_type", "") + } + + # DESCRIPTION 키워드 확인 + for fastener_type, type_data in type_dict.items(): + for keyword in type_data.get("description_keywords", []): + if keyword in desc_upper: + return { + "type": fastener_type, + "characteristics": type_data["characteristics"], + "confidence": 0.85, + "evidence": [f"DESCRIPTION_KEYWORD: {keyword}"], + "applications": type_data["applications"], + "head_type": type_data.get("head_type", "") + } + + # 기본값 + default_type = f"{category}_GENERAL" + return { + "type": default_type, + "characteristics": f"일반 {category.lower()}", + "confidence": 0.6, + "evidence": ["DEFAULT_TYPE"], + "applications": "일반용", + "head_type": "" + } + +def classify_thread_specification(main_nom: str, description: str) -> Dict: + """나사 규격 분류""" + + combined_text = f"{main_nom} {description}".upper() + + # 각 표준별 패턴 확인 + for standard, standard_data in THREAD_STANDARDS.items(): + for pattern in standard_data["patterns"]: + match = re.search(pattern, combined_text) + if match: + size = match.group(1) + + # 피치 정보 추출 (미터 나사) + pitch = "" + if standard == "METRIC" and len(match.groups()) > 1 and match.group(2): + pitch = match.group(2) + elif standard == "INCH" and len(match.groups()) > 1 and match.group(2): + pitch = match.group(2) # TPI (Threads Per Inch) + + # 나사 타입 확인 + thread_type = "" + if standard in ["INCH", "BSW"]: + for t_type in standard_data.get("thread_types", []): + if t_type in combined_text: + thread_type = t_type + break + + # 인치 사이즈를 분수로 변환 + size_fraction = size + if standard == "INCH": + try: + if '.' in size and size.replace('.', '').isdigit(): + size_fraction = convert_decimal_to_fraction(size).replace('"', '') + except: + size_fraction = size + + return { + "standard": standard, + "size": size, # 원래 값 + "size_fraction": size_fraction, # 분수 변환값 + "pitch": pitch, + "thread_type": thread_type, + "confidence": 0.9, + "matched_pattern": pattern, + "description": standard_data["description"] + } + + return { + "standard": "UNKNOWN", + "size": main_nom, + "pitch": "", + "thread_type": "", + "confidence": 0.0, + "description": "" + } + +def extract_bolt_dimensions(main_nom: str, description: str) -> Dict: + """볼트 치수 정보 추출""" + + desc_upper = description.upper() + actual_bolt_size = main_nom + + # 실제 BOM 형태: "ORI, 0.75, 145.0000 LG, 300LB, ASTM A193/A194 GR B7/2H, ELEC.GALV" + # 첫 번째 숫자가 실제 볼트 사이즈 (접두사 건너뛰기) + import re + # 설명에서 첫 번째 숫자 추출 (볼트 사이즈) + first_number_match = re.search(r'(\d+(?:\.\d+)?)', description) + if first_number_match: + actual_bolt_size = first_number_match.group(1) + + # 플랜지 볼트의 경우 실제 볼트 직경을 description에서 추출 + if "FLANGE BOLT" in desc_upper or "FLG_BOLT" in desc_upper: + # 플랜지 볼트에서 실제 볼트 사이즈 패턴 찾기 + # 예: "FLANGE BOLT 6" 150LB M16" → M16 + # 예: "FLANGE BOLT 1-1/2" 5/8" x 100mm" → 5/8 + + bolt_size_patterns = [ + r'M(\d+)', # M16, M20 등 메트릭 + r'(\d+-\d+/\d+)\s*["\']?\s*X', # 1-1/2" X 등 (복합 분수) + r'(\d+/\d+)\s*["\']?\s*X', # 5/8" X, 3/4" X 등 (단순 분수) + r'(\d+(?:\.\d+)?)\s*["\']?\s*X', # 0.625" X 등 (소수) + r'(\d+-\d+/\d+)\s*["\']?\s*DIA', # 1-1/2" DIA 등 (복합 분수) + r'(\d+/\d+)\s*["\']?\s*DIA', # 5/8" DIA 등 (단순 분수) + r'(\d+(?:\.\d+)?)\s*["\']?\s*DIA', # 0.625" DIA 등 (소수) + r'(\d+-\d+/\d+)\s*["\']?\s*(?:LONG|LG|LENGTH)', # 복합 분수 + 길이 + r'(\d+/\d+)\s*["\']?\s*(?:LONG|LG|LENGTH)', # 단순 분수 + 길이 + r'(\d+(?:\.\d+)?)\s*["\']?\s*(?:LONG|LG|LENGTH)', # 소수 + 길이 + r'(\d+(?:\.\d+)?)\s*MM\s*DIA', # 16MM DIA 등 + ] + + for pattern in bolt_size_patterns: + match = re.search(pattern, desc_upper) + if match: + extracted_size = match.group(1) + # M16 같은 메트릭은 M 제거 + if pattern.startswith(r'M'): + actual_bolt_size = extracted_size + else: + actual_bolt_size = extracted_size + break + + # 볼트 사이즈를 분수로 변환 (인치인 경우) + nominal_size_fraction = actual_bolt_size + try: + # 소수점 인치를 분수로 변환 + if '.' in actual_bolt_size and actual_bolt_size.replace('.', '').isdigit(): + nominal_size_fraction = convert_decimal_to_fraction(actual_bolt_size) + except: + nominal_size_fraction = actual_bolt_size + + # 플랜지당 볼트 세트 수 추출 (예: (8), (4)) + bolts_per_flange = 1 # 기본값 + flange_bolt_pattern = re.search(r'\((\d+)\)', description) + if flange_bolt_pattern: + bolts_per_flange = int(flange_bolt_pattern.group(1)) + + dimensions = { + "nominal_size": actual_bolt_size, # 실제 볼트 사이즈 + "nominal_size_fraction": nominal_size_fraction, # 분수 변환값 + "length": "", + "diameter": "", + "dimension_description": nominal_size_fraction, # 분수로 표시 + "bolts_per_flange": bolts_per_flange # 플랜지당 볼트 세트 수 + } + + # 길이 정보 추출 (개선된 패턴) + length_patterns = [ + r'(\d+(?:\.\d+)?)\s*LG', # 70.0000 LG, 145.0000 LG 형태 (최우선) + r'(\d+(?:\.\d+)?)\s*MM\s*LG', # 70MM LG 형태 + r'L\s*(\d+(?:\.\d+)?)\s*MM', + r'LENGTH\s*(\d+(?:\.\d+)?)\s*MM', + r'(\d+(?:\.\d+)?)\s*MM\s*LONG', + r'X\s*(\d+(?:\.\d+)?)\s*MM', # M8 X 20MM 형태 + r',\s*(\d+(?:\.\d+)?)\s*LG', # ", 145.0000 LG" 형태 (PSV, LT 볼트용) + r',\s*(\d+(?:\.\d+)?)\s+(?:CK|PSV|LT)', # ", 140 CK" 형태 (PSV 볼트용) + r'PSV\s+(\d+(?:\.\d+)?)', # PSV 140 형태 (PSV 볼트 전용) + r'(\d+(?:\.\d+)?)\s+PSV', # 140 PSV 형태 (PSV 볼트 전용) + r'(\d+(?:\.\d+)?)\s*MM(?:\s|,|$)', # 75MM 형태 (단독) + r'(\d+(?:\.\d+)?)\s*mm(?:\s|,|$)' # 75mm 형태 (단독) + ] + + for pattern in length_patterns: + match = re.search(pattern, desc_upper) + if match: + length_value = match.group(1) + # 소수점 제거 (145.0000 → 145) + if '.' in length_value and length_value.endswith('.0000'): + length_value = length_value.split('.')[0] + elif '.' in length_value and all(c == '0' for c in length_value.split('.')[1]): + length_value = length_value.split('.')[0] + + dimensions["length"] = f"{length_value}mm" + break + + # 지름 정보 (이미 main_nom에 있지만 확인) + diameter_patterns = [ + r'D\s*(\d+(?:\.\d+)?)\s*MM', + r'DIA\s*(\d+(?:\.\d+)?)\s*MM' + ] + + for pattern in diameter_patterns: + match = re.search(pattern, desc_upper) + if match: + dimensions["diameter"] = f"{match.group(1)}mm" + break + + # 치수 설명 조합 (분수 사용) + desc_parts = [nominal_size_fraction] + if dimensions["length"]: + desc_parts.append(f"L{dimensions['length']}") + + dimensions["dimension_description"] = " ".join(desc_parts) + + return dimensions + +def classify_bolt_grade(description: str, thread_result: Dict) -> Dict: + """볼트 등급 및 강도 분류""" + + desc_upper = description.upper() + thread_standard = thread_result.get('standard', 'UNKNOWN') + + if thread_standard == "METRIC": + # 미터 나사 등급 (8.8, 10.9, 12.9) + grade_patterns = [r'(\d+\.\d+)', r'CLASS\s*(\d+\.\d+)', r'등급\s*(\d+\.\d+)'] + + for pattern in grade_patterns: + match = re.search(pattern, desc_upper) + if match: + grade = match.group(1) + grade_info = BOLT_GRADES["METRIC"].get(grade, {}) + + return { + "grade": f"Grade {grade}", + "tensile_strength": grade_info.get("tensile_strength", ""), + "yield_strength": grade_info.get("yield_strength", ""), + "confidence": 0.9, + "standard": "METRIC" + } + + elif thread_standard == "INCH": + # 인치 나사 등급 (A307, A325, A490) + astm_patterns = [r'ASTM\s*(A\d+)', r'(A\d+)'] + + for pattern in astm_patterns: + match = re.search(pattern, desc_upper) + if match: + grade = match.group(1) + grade_info = BOLT_GRADES["INCH"].get(grade, {}) + + return { + "grade": f"ASTM {grade}", + "tensile_strength": grade_info.get("tensile_strength", ""), + "yield_strength": grade_info.get("yield_strength", ""), + "confidence": 0.9, + "standard": "INCH" + } + + return { + "grade": "UNKNOWN", + "tensile_strength": "", + "yield_strength": "", + "confidence": 0.0, + "standard": "" + } + +def calculate_bolt_confidence(confidence_scores: Dict) -> float: + """볼트 분류 전체 신뢰도 계산 (개선된 버전)""" + + # 기본 점수들 + material_conf = confidence_scores.get("material", 0) + fastener_type_conf = confidence_scores.get("fastener_type", 0) + thread_conf = confidence_scores.get("thread", 0) + grade_conf = confidence_scores.get("grade", 0) + + # 체결재 카테고리 신뢰도 (BOLT인지 확실한가?) + fastener_category_conf = confidence_scores.get("fastener_category", 0) + + # 볼트 확신도 보너스 + bolt_certainty_bonus = 0.0 + + # 1. 체결재 카테고리가 BOLT이고 신뢰도가 높으면 보너스 + if fastener_category_conf >= 0.8: + bolt_certainty_bonus += 0.2 + + # 2. 피팅 타입이 명확하게 인식되면 보너스 + if fastener_type_conf >= 0.8: + bolt_certainty_bonus += 0.1 + + # 3. ASTM 볼트 재질이 인식되면 큰 보너스 + if material_conf >= 0.8: + bolt_certainty_bonus += 0.2 + elif material_conf >= 0.5: + bolt_certainty_bonus += 0.1 + + # 기본 가중 평균 (재질 비중을 낮추고 체결재 타입 비중 증가) + weights = { + "fastener_category": 0.3, # 새로 추가 + "material": 0.1, # 낮춤 (0.2 -> 0.1) + "fastener_type": 0.4, # 유지 + "thread": 0.15, # 낮춤 (0.3 -> 0.15) + "grade": 0.05 # 낮춤 (0.1 -> 0.05) + } + + weighted_sum = sum( + confidence_scores.get(key, 0) * weight + for key, weight in weights.items() + ) + + # 최종 신뢰도 = 기본 가중평균 + 보너스 + final_confidence = weighted_sum + bolt_certainty_bonus + + # 최대값 1.0으로 제한 + return round(min(final_confidence, 1.0), 2) + +# ========== 특수 기능들 ========== + +def extract_bolt_additional_requirements(description: str) -> str: + """볼트 설명에서 추가요구사항 추출 (표면처리, 특수 요구사항 등)""" + + desc_upper = description.upper() + additional_reqs = [] + + # 표면처리 패턴들 + surface_treatments = { + 'ELEC.GALV': '전기아연도금', + 'ELEC GALV': '전기아연도금', + 'GALVANIZED': '아연도금', + 'GALV': '아연도금', + 'HOT DIP GALV': '용융아연도금', + 'HDG': '용융아연도금', + 'ZINC PLATED': '아연도금', + 'ZINC': '아연도금', + 'STAINLESS': '스테인리스', + 'SS': '스테인리스', + 'PASSIVATED': '부동태화', + 'ANODIZED': '아노다이징', + 'BLACK OXIDE': '흑색산화', + 'PHOSPHATE': '인산처리', + 'DACROMET': '다크로메트', + 'GEOMET': '지오메트' + } + + # 특수 요구사항 패턴들 + special_requirements = { + 'HEAVY HEX': '중육각', + 'FULL THREAD': '전나사', + 'PARTIAL THREAD': '부분나사', + 'FINE THREAD': '세나사', + 'COARSE THREAD': '조나사', + 'LEFT HAND': '좌나사', + 'RIGHT HAND': '우나사', + 'SOCKET HEAD': '소켓헤드', + 'BUTTON HEAD': '버튼헤드', + 'FLAT HEAD': '평머리', + 'PAN HEAD': '팬헤드', + 'TRUSS HEAD': '트러스헤드', + 'WASHER FACE': '와셔면', + 'SERRATED': '톱니형', + 'LOCK': '잠금', + 'SPRING': '스프링', + 'WAVE': '웨이브' + } + + # 표면처리 확인 + for pattern, korean in surface_treatments.items(): + if pattern in desc_upper: + additional_reqs.append(korean) + + # 특수 요구사항 확인 + for pattern, korean in special_requirements.items(): + if pattern in desc_upper: + additional_reqs.append(korean) + + # 중복 제거 및 정렬 + additional_reqs = list(set(additional_reqs)) + + return ', '.join(additional_reqs) if additional_reqs else '' + +def get_bolt_purchase_info(bolt_result: Dict) -> Dict: + """볼트 구매 정보 생성""" + + fastener_category = bolt_result["fastener_category"]["category"] + fastener_type = bolt_result["fastener_type"]["type"] + thread_standard = bolt_result["thread_specification"]["standard"] + grade = bolt_result["grade_strength"]["grade"] + + # 공급업체 타입 결정 + if grade in ["Grade 10.9", "Grade 12.9", "ASTM A325", "ASTM A490"]: + supplier_type = "고강도 볼트 전문업체" + elif thread_standard == "METRIC": + supplier_type = "미터 볼트 업체" + elif fastener_type in ["SOCKET_HEAD_CAP", "SET_SCREW"]: + supplier_type = "정밀 볼트 업체" + else: + supplier_type = "일반 볼트 업체" + + # 납기 추정 + if grade in ["Grade 12.9", "ASTM A490"]: + lead_time = "4-6주 (고강도 특수품)" + elif fastener_type in ["STUD_BOLT", "U_BOLT"]: + lead_time = "2-4주 (제작품)" + else: + lead_time = "1-2주 (재고품)" + + # 구매 단위 + if fastener_category == "WASHER": + purchase_unit = "EA (개별)" + elif fastener_type == "STUD_BOLT": + purchase_unit = "SET (세트)" + else: + purchase_unit = "EA 또는 BOX" + + return { + "supplier_type": supplier_type, + "lead_time_estimate": lead_time, + "purchase_category": f"{fastener_type} {thread_standard}", + "purchase_unit": purchase_unit, + "grade_note": f"{grade} {bolt_result['grade_strength']['tensile_strength']}", + "thread_note": f"{thread_standard} {bolt_result['thread_specification']['size']}", + "applications": bolt_result["fastener_type"]["applications"] + } + +def is_high_strength_bolt(bolt_result: Dict) -> bool: + """고강도 볼트 여부 판단""" + grade = bolt_result.get("grade_strength", {}).get("grade", "") + high_strength_grades = ["Grade 10.9", "Grade 12.9", "ASTM A325", "ASTM A490"] + return any(g in grade for g in high_strength_grades) + +def is_stainless_bolt(bolt_result: Dict) -> bool: + """스테인리스 볼트 여부 판단""" + material_type = bolt_result.get("material", {}).get("material_type", "") + return material_type == "STAINLESS_STEEL" diff --git a/tkeg/api/app/services/classifier_constants.py b/tkeg/api/app/services/classifier_constants.py new file mode 100644 index 0000000..5e50b6d --- /dev/null +++ b/tkeg/api/app/services/classifier_constants.py @@ -0,0 +1,157 @@ +""" +자재 분류 시스템용 상수 및 키워드 정의 +중복 로직 제거 및 유지보수성 향상을 위해 중앙 집중화됨 +""" + +from typing import Dict, List + +# ============================================================================== +# 1. 압력 등급 (Pressure Ratings) +# ============================================================================== + +# 단순 키워드 목록 (Integrated Classifier용) +LEVEL3_PRESSURE_KEYWORDS = [ + "150LB", "300LB", "600LB", "900LB", "1500LB", + "2500LB", "3000LB", "6000LB", "9000LB" +] + +# 상세 스펙 및 메타데이터 (Fitting Classifier용) +PRESSURE_RATINGS_SPECS = { + "150LB": {"max_pressure": "285 PSI", "common_use": "저압 일반용"}, + "300LB": {"max_pressure": "740 PSI", "common_use": "중압용"}, + "600LB": {"max_pressure": "1480 PSI", "common_use": "고압용"}, + "900LB": {"max_pressure": "2220 PSI", "common_use": "고압용"}, + "1500LB": {"max_pressure": "3705 PSI", "common_use": "고압용"}, + "2500LB": {"max_pressure": "6170 PSI", "common_use": "초고압용"}, + "3000LB": {"max_pressure": "7400 PSI", "common_use": "소구경 고압용"}, + "6000LB": {"max_pressure": "14800 PSI", "common_use": "소구경 초고압용"}, + "9000LB": {"max_pressure": "22200 PSI", "common_use": "소구경 극고압용"} +} + +# 정규식 패턴 (Fitting Classifier용) +PRESSURE_PATTERNS = [ + r"(\d+)LB", + r"CLASS\s*(\d+)", + r"CL\s*(\d+)", + r"(\d+)#", + r"(\d+)\s*LB" +] + +# ============================================================================== +# 2. OLET 키워드 (OLET Keywords) +# ============================================================================== +# Fitting Classifier와 Integrated Classifier에서 공통 사용 +OLET_KEYWORDS = [ + # Full Names + "SOCK-O-LET", "WELD-O-LET", "ELL-O-LET", "THREAD-O-LET", "ELB-O-LET", + "NIP-O-LET", "COUP-O-LET", + # Variations + "SOCKOLET", "WELDOLET", "ELLOLET", "THREADOLET", "ELBOLET", "NIPOLET", "COUPOLET", + "OLET", "올렛", "O-LET", "SOCKLET", "SOCKET-O-LET", "WELD O-LET", "ELL O-LET", + "THREADED-O-LET", "ELBOW-O-LET", "NIPPLE-O-LET", "COUPLING-O-LET", + # Abbreviations (Caution: specific context needed sometimes) + "SOL", "WOL", "EOL", "TOL", "NOL", "COL" +] + +# ============================================================================== +# 3. 연결 방식 (Connection Methods) +# ============================================================================== +LEVEL3_CONNECTION_KEYWORDS = { + "SW": ["SW", "SOCKET WELD", "소켓웰드", "SOCKET-WELD", "_SW_"], + "THD": ["THD", "THREADED", "NPT", "나사", "THRD", "TR", "_TR", "_THD"], + "BW": ["BW", "BUTT WELD", "맞대기용접", "BUTT-WELD", "_BW"], + "FL": ["FL", "FLANGED", "플랜지", "FLG", "_FL_"] +} + +# ============================================================================== +# 4. 재질 키워드 (Material Keywords) +# ============================================================================== +LEVEL4_MATERIAL_KEYWORDS = { + "PIPE": ["A106", "A333", "A312", "A53"], + "FITTING": ["A234", "A403", "A420"], + "FLANGE": ["A182", "A350"], + "VALVE": ["A216", "A217", "A351", "A352"], + "BOLT": ["A193", "A194", "A320", "A325", "A490"] +} + +GENERIC_MATERIALS = { + "A105": ["VALVE", "FLANGE", "FITTING"], + "316": ["VALVE", "FLANGE", "FITTING", "PIPE", "BOLT"], + "304": ["VALVE", "FLANGE", "FITTING", "PIPE", "BOLT"] +} + +# ============================================================================== +# 5. 메인 분류 키워드 (Level 1 Type Keywords) +# ============================================================================== +LEVEL1_TYPE_KEYWORDS = { + "BOLT": [ + "FLANGE BOLT", "U-BOLT", "U BOLT", "BOLT", "STUD", "NUT", "SCREW", + "WASHER", "볼트", "너트", "스터드", "나사", "와셔", "유볼트" + ], + "VALVE": [ + "VALVE", "GATE", "BALL", "GLOBE", "CHECK", "BUTTERFLY", "NEEDLE", + "RELIEF", "SIGHT GLASS", "STRAINER", "밸브", "게이트", "볼", "글로브", + "체크", "버터플라이", "니들", "릴리프", "사이트글라스", "스트레이너" + ], + "FLANGE": [ + "FLG", "FLANGE", "플랜지", "프랜지", "ORIFICE", "SPECTACLE", "PADDLE", + "SPACER", "BLIND", "REDUCING FLANGE", "RED FLANGE" + ], + "PIPE": [ + "PIPE", "TUBE", "파이프", "배관", "SMLS", "SEAMLESS" + ], + "FITTING": [ + # Standard Fittings + "ELBOW", "ELL", "TEE", "REDUCER", "CAP", "COUPLING", "NIPPLE", "SWAGE", "PLUG", + "엘보", "티", "리듀서", "캡", "니플", "커플링", "플러그", "CONC", "ECC", + # Instrument Fittings + "SWAGELOK", "DK-LOK", "HY-LOK", "SUPERLOK", "TUBE FITTING", "COMPRESSION", + "UNION", "FERRULE", "NUT & FERRULE", "MALE CONNECTOR", "FEMALE CONNECTOR", + "TUBE ADAPTER", "PORT CONNECTOR", "CONNECTOR" + ] + OLET_KEYWORDS, # OLET Keywords 병합 + "GASKET": [ + "GASKET", "GASK", "가스켓", "SWG", "SPIRAL" + ], + "INSTRUMENT": [ + "GAUGE", "TRANSMITTER", "SENSOR", "THERMOMETER", "계기", "게이지", "트랜스미터", "센서" + ], + "SUPPORT": [ + "URETHANE BLOCK", "URETHANE", "BLOCK SHOE", "CLAMP", "SUPPORT", "HANGER", + "SPRING", "우레탄", "블록", "클램프", "서포트", "행거", "스프링", "PIPE CLAMP" + ], + "PLATE": [ + "PLATE", "PL", "CHECKER PLATE", "판재", "철판" + ], + "STRUCTURAL": [ + "H-BEAM", "BEAM", "ANGLE", "CHANNEL", "H-SECTION", "I-BEAM", "형강", "앵글", "채널" + ] +} + +# ============================================================================== +# 6. 서브타입 키워드 (Level 2 Subtype Keywords) +# ============================================================================== +LEVEL2_SUBTYPE_KEYWORDS = { + "VALVE": { + "GATE": ["GATE VALVE", "GATE", "게이트 밸브"], + "BALL": ["BALL VALVE", "BALL", "볼 밸브"], + "GLOBE": ["GLOBE VALVE", "GLOBE", "글로브 밸브"], + "CHECK": ["CHECK VALVE", "CHECK", "체크 밸브", "역지 밸브"] + }, + "FLANGE": { + "WELD_NECK": ["WELD NECK", "WN", "웰드넥"], + "SLIP_ON": ["SLIP ON", "SO", "슬립온"], + "BLIND": ["BLIND", "BL", "막음", "차단"], + "SOCKET_WELD": ["SOCKET WELD", "소켓웰드"] + }, + "BOLT": { + "HEX_BOLT": ["HEX BOLT", "HEXAGON", "육각 볼트"], + "STUD_BOLT": ["STUD BOLT", "STUD", "스터드 볼트"], + "U_BOLT": ["U-BOLT", "U BOLT", "유볼트"] + }, + "SUPPORT": { + "URETHANE_BLOCK": ["URETHANE BLOCK", "BLOCK SHOE", "우레탄 블록"], + "CLAMP": ["CLAMP", "클램프"], + "HANGER": ["HANGER", "SUPPORT", "행거", "서포트"], + "SPRING": ["SPRING", "스프링"] + } +} diff --git a/tkeg/api/app/services/excel_parser.py b/tkeg/api/app/services/excel_parser.py new file mode 100644 index 0000000..c75165f --- /dev/null +++ b/tkeg/api/app/services/excel_parser.py @@ -0,0 +1,300 @@ + +import pandas as pd +import re +from typing import List, Dict, Optional +import uuid +from datetime import datetime +from pathlib import Path + +# 허용된 확장자 +ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"} + +class BOMParser: + """BOM 파일 파싱을 담당하는 클래스""" + + @staticmethod + def validate_extension(filename: str) -> bool: + return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS + + @staticmethod + def generate_unique_filename(original_filename: str) -> str: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + unique_id = str(uuid.uuid4())[:8] + stem = Path(original_filename).stem + suffix = Path(original_filename).suffix + return f"{stem}_{timestamp}_{unique_id}{suffix}" + + @staticmethod + def detect_format(df: pd.DataFrame) -> str: + """ + 엑셀 헤더를 분석하여 양식을 감지합니다. + + Returns: + 'INVENTOR': 인벤터 추출 양식 (NO., NAME, Q'ty, LENGTH & THICKNESS...) + 'STANDARD': 기존 표준/퍼지 매핑 엑셀 (DWG_NAME, LINE_NUM, MAIN_NOM...) + """ + columns = [str(c).strip().upper() for c in df.columns] + + # 인벤터 양식 특징 (오타 포함) + INVENTOR_KEYWORDS = ['LENGTH & THICKNESS', 'DESCIPTION', "Q'TY"] + + for keyword in INVENTOR_KEYWORDS: + if any(keyword in col for col in columns): + return 'INVENTOR' + + # 표준 양식 특징 + STANDARD_KEYWORDS = ['MAIN_NOM', 'DWG_NAME', 'LINE_NUM'] + for keyword in STANDARD_KEYWORDS: + if any(keyword in col for col in columns): + return 'STANDARD' + + return 'STANDARD' # 기본값 + + @classmethod + def parse_file(cls, file_path: str) -> List[Dict]: + """파일 경로를 받아 적절한 파서를 통해 데이터를 추출합니다.""" + file_extension = Path(file_path).suffix.lower() + + try: + if file_extension == ".csv": + df = pd.read_csv(file_path, encoding='utf-8') + elif file_extension in [".xlsx", ".xls"]: + # xlrd 엔진 명시 (xls 지원) + if file_extension == ".xls": + df = pd.read_excel(file_path, sheet_name=0, engine='xlrd') + else: + df = pd.read_excel(file_path, sheet_name=0, engine='openpyxl') + else: + raise ValueError("지원하지 않는 파일 형식") + + # 데이터프레임 전처리 (빈 행 제거 등) + df = df.dropna(how='all') + + # 양식 감지 + format_type = cls.detect_format(df) + print(f"📋 감지된 BOM 양식: {format_type}") + + if format_type == 'INVENTOR': + return cls._parse_inventor_bom(df) + else: + return cls._parse_standard_bom(df) + + except Exception as e: + raise ValueError(f"파일 파싱 실패: {str(e)}") + + @staticmethod + def _parse_standard_bom(df: pd.DataFrame) -> List[Dict]: + """기존의 퍼지 매핑 방식 파서 (표준 양식)""" + # 컬럼명 전처리 + df.columns = df.columns.str.strip().str.upper() + + # 대소문자 구분 없는 매핑을 위해 컬럼명 대문자화 사용 + column_mapping = { + 'description': ['DESCRIPTION', 'ITEM', 'MATERIAL', '품명', '자재명', 'SPECIFICATION'], + 'quantity': ['QTY', 'QUANTITY', 'EA', '수량', 'AMOUNT'], + 'main_size': ['MAIN_NOM', 'NOMINAL_DIAMETER', 'ND', '주배관', 'SIZE'], + 'red_size': ['RED_NOM', 'REDUCED_DIAMETER', '축소배관'], + 'length': ['LENGTH', 'LEN', '길이'], + 'weight': ['WEIGHT', 'WT', '중량'], + 'dwg_name': ['DWG_NAME', 'DRAWING', '도면명', '도면번호'], + 'line_num': ['LINE_NUM', 'LINE_NUMBER', '라인번호'] + } + + mapped_columns = {} + for standard_col, possible_names in column_mapping.items(): + for possible_name in possible_names: + # 대문자로 비교 + possible_upper = possible_name.upper() + if possible_upper in df.columns: + mapped_columns[standard_col] = possible_upper + break + + print(f"📋 [Standard] 컬럼 매핑 결과: {mapped_columns}") + + materials = [] + for index, row in df.iterrows(): + description = str(row.get(mapped_columns.get('description', ''), '')) + + # 제외 항목 처리 + description_upper = description.upper() + if ('WELD GAP' in description_upper or 'WELDING GAP' in description_upper or + '웰드갭' in description_upper or '용접갭' in description_upper): + continue + + # 수량 처리 + quantity_raw = row.get(mapped_columns.get('quantity', ''), 0) + try: + quantity = float(quantity_raw) if pd.notna(quantity_raw) else 0 + except: + quantity = 0 + + # 재질 등급 추출 (ASTM) + material_grade = "" + if "ASTM" in description_upper: + astm_match = re.search(r'ASTM\s+(A\d{3,4}[A-Z]*(?:\s+(?:GR\s+)?[A-Z0-9]+)?)', description_upper) + if astm_match: + material_grade = astm_match.group(0).strip() + + # 사이즈 처리 + main_size = str(row.get(mapped_columns.get('main_size', ''), '')) + red_size = str(row.get(mapped_columns.get('red_size', ''), '')) + + main_nom = main_size if main_size != 'nan' and main_size != '' else None + red_nom = red_size if red_size != 'nan' and red_size != '' else None + + if main_size != 'nan' and red_size != 'nan' and red_size != '': + size_spec = f"{main_size} x {red_size}" + elif main_size != 'nan' and main_size != '': + size_spec = main_size + else: + size_spec = "" + + # 길이 처리 + length_raw = row.get(mapped_columns.get('length', ''), '') + length_value = None + if pd.notna(length_raw) and str(length_raw).strip() != '': + try: + length_value = float(str(length_raw).strip()) + except: + length_value = None + + # 도면/라인 번호 + dwg_name = row.get(mapped_columns.get('dwg_name', ''), '') + dwg_name = str(dwg_name).strip() if pd.notna(dwg_name) and str(dwg_name).strip() not in ['', 'nan', 'None'] else None + + line_num = row.get(mapped_columns.get('line_num', ''), '') + line_num = str(line_num).strip() if pd.notna(line_num) and str(line_num).strip() not in ['', 'nan', 'None'] else None + + if description and description not in ['nan', 'None', '']: + materials.append({ + 'original_description': description, + 'quantity': quantity, + 'unit': "EA", + 'size_spec': size_spec, + 'main_nom': main_nom, + 'red_nom': red_nom, + 'material_grade': material_grade, + 'length': length_value, + 'dwg_name': dwg_name, + 'line_num': line_num, + 'line_number': index + 1, + 'row_number': index + 1 + }) + + return materials + + @staticmethod + def _parse_inventor_bom(df: pd.DataFrame) -> List[Dict]: + """ + [신규] 인벤터 추출 양식 파서 + 헤더: NO., NAME, Q'ty, LENGTH & THICKNESS, WEIGHT, DESCIPTION, REMARK + 특징: Size 컬럼 부재, NAME에 주요 정보 포함 + """ + print("⚠️ [Inventor] 인벤터 양식 파서를 사용합니다.") + + # 컬럼명 전처리 (좌우 공백 제거 및 대문자화) + df.columns = df.columns.str.strip().str.upper() + + # 인벤터 전용 매핑 + col_name = 'NAME' + col_qty = "Q'TY" + col_desc = 'DESCIPTION' # 오타 그대로 반영 + col_remark = 'REMARK' + col_length = 'LENGTH & THICKNESS' # 길이 정보가 여기 있을 수 있음 + + materials = [] + for index, row in df.iterrows(): + # 1. 품명 (NAME 컬럼 우선 사용) + name_val = str(row.get(col_name, '')).strip() + desc_val = str(row.get(col_desc, '')).strip() + + # NAME과 DESCIPTION 병합 (필요시) + # 보통 인벤터에서 NAME이 'ADAPTER OD 1/4" x NPT(F) 1/2"' 같이 상세 스펙 + # DESCIPTION은 'SS-400-7-8' 같은 자재 코드일 수 있음 + # 일단 NAME을 메인 description으로 사용하고, DESCIPTION을 괄호에 추가 + if desc_val and desc_val not in ['nan', 'None', '']: + full_description = f"{name_val} ({desc_val})" + else: + full_description = name_val + + if not full_description or full_description in ['nan', 'None', '']: + continue + + # 2. 수량 + qty_raw = row.get(col_qty, 0) + try: + quantity = float(qty_raw) if pd.notna(qty_raw) else 0 + except: + quantity = 0 + + # 3. 사이즈 추출 (NAME 컬럼 분석) + # 패턴: 1/2", 1/4", 100A, 50A, 10x20 등 + size_spec = "" + main_nom = None + red_nom = None + + # 인치/MM 사이즈 추출 시도 + # 예: "ADAPTER OD 1/4" x NPT(F) 1/2"" -> 1/4" x 1/2" + # 예: "ELBOW 90D 100A" -> 100A + + # 인치 패턴 (1/2", 3/4" 등) + inch_sizes = re.findall(r'(\d+(?:/\d+)?)"', name_val) + # A단위 패턴 (100A, 50A 등) + a_sizes = re.findall(r'(\d+)A', name_val) + + if inch_sizes: + if len(inch_sizes) >= 2: + main_nom = f'{inch_sizes[0]}"' + red_nom = f'{inch_sizes[1]}"' + size_spec = f'{main_nom} x {red_nom}' + else: + main_nom = f'{inch_sizes[0]}"' + size_spec = main_nom + elif a_sizes: + if len(a_sizes) >= 2: + main_nom = f'{a_sizes[0]}A' + red_nom = f'{a_sizes[1]}A' + size_spec = f'{main_nom} x {red_nom}' + else: + main_nom = f'{a_sizes[0]}A' + size_spec = main_nom + + # 4. 재질 정보 + material_grade = "" + # NAME이나 DESCIPTION에서 재질 키워드 찾기 (SS, SUS, A105 등) + combined_text = (full_description + " " + desc_val).upper() + if "SUS" in combined_text or "SS" in combined_text: + if "304" in combined_text: material_grade = "SUS304" + elif "316" in combined_text: material_grade = "SUS316" + else: material_grade = "SUS" + elif "A105" in combined_text: + material_grade = "A105" + + # 5. 길이 정보 + length_value = None + length_raw = row.get(col_length, '') + # 값이 있고 숫자로 변환 가능하면 사용 + if pd.notna(length_raw) and str(length_raw).strip(): + try: + # '100 mm' 등의 형식 처리 필요할 수 있음 + length_str = str(length_raw).lower().replace('mm', '').strip() + length_value = float(length_str) + except: + pass + + materials.append({ + 'original_description': full_description, + 'quantity': quantity, + 'unit': "EA", + 'size_spec': size_spec, + 'main_nom': main_nom, + 'red_nom': red_nom, + 'material_grade': material_grade, + 'length': length_value, + 'dwg_name': None, # 인벤터 파일엔 도면명 컬럼이 없음 + 'line_num': None, + 'line_number': index + 1, + 'row_number': index + 1 + }) + + return materials diff --git a/tkeg/api/app/services/exclude_classifier.py b/tkeg/api/app/services/exclude_classifier.py new file mode 100644 index 0000000..737e194 --- /dev/null +++ b/tkeg/api/app/services/exclude_classifier.py @@ -0,0 +1,80 @@ +""" +EXCLUDE 분류 시스템 +실제 자재가 아닌 계산용/제외 항목들 분류 +""" + +import re +from typing import Dict, List, Optional + +# ========== 제외 대상 타입 ========== +EXCLUDE_TYPES = { + "CUTTING_LOSS": { + "description_keywords": ["CUTTING LOSS", "CUT LOSS", "절단로스", "컷팅로스"], + "characteristics": "절단 시 손실 고려용 계산 항목", + "reason": "실제 자재 아님 - 절단 로스 계산용" + }, + "SPARE_ALLOWANCE": { + "description_keywords": ["SPARE", "ALLOWANCE", "여유분", "스페어"], + "characteristics": "예비품/여유분 계산 항목", + "reason": "실제 자재 아님 - 여유분 계산용" + }, + "THICKNESS_NOTE": { + "description_keywords": ["THK", "THICK", "두께", "THICKNESS"], + "characteristics": "두께 표기용 항목", + "reason": "실제 자재 아님 - 두께 정보" + }, + "CALCULATION_ITEM": { + "description_keywords": ["CALC", "CALCULATION", "계산", "산정"], + "characteristics": "기타 계산용 항목", + "reason": "실제 자재 아님 - 계산 목적" + } +} + +def classify_exclude(dat_file: str, description: str, main_nom: str = "") -> Dict: + """ + 제외 대상 분류 + + Args: + dat_file: DAT_FILE 필드 + description: DESCRIPTION 필드 + main_nom: MAIN_NOM 필드 + + Returns: + 제외 분류 결과 + """ + + desc_upper = description.upper() + + # 제외 대상 키워드 확인 + for exclude_type, type_data in EXCLUDE_TYPES.items(): + for keyword in type_data["description_keywords"]: + if keyword in desc_upper: + return { + "category": "EXCLUDE", + "exclude_type": exclude_type, + "characteristics": type_data["characteristics"], + "reason": type_data["reason"], + "overall_confidence": 0.95, + "evidence": [f"EXCLUDE_KEYWORD: {keyword}"], + "recommendation": "BOM에서 제외 권장" + } + + # 제외 대상 아님 + return { + "category": "UNKNOWN", + "overall_confidence": 0.0, + "reason": "제외 대상 키워드 없음" + } + +def is_exclude_item(description: str) -> bool: + """간단한 제외 대상 체크""" + desc_upper = description.upper() + + exclude_keywords = [ + "WELD GAP", "WELDING GAP", "GAP", + "CUTTING LOSS", "CUT LOSS", + "SPARE", "ALLOWANCE", + "THK", "THICK" + ] + + return any(keyword in desc_upper for keyword in exclude_keywords) \ No newline at end of file diff --git a/tkeg/api/app/services/file_service.py b/tkeg/api/app/services/file_service.py new file mode 100644 index 0000000..ec2b32f --- /dev/null +++ b/tkeg/api/app/services/file_service.py @@ -0,0 +1,333 @@ +""" +파일 관리 비즈니스 로직 +API 레이어에서 분리된 핵심 비즈니스 로직 +""" +from typing import List, Dict, Optional, Tuple +from sqlalchemy.orm import Session +from sqlalchemy import text +from fastapi import HTTPException + +from ..utils.logger import get_logger +from ..utils.cache_manager import tkmp_cache +from ..utils.transaction_manager import TransactionManager, async_transactional +from ..schemas.response_models import FileInfo +from ..config import get_settings + +logger = get_logger(__name__) +settings = get_settings() + + +class FileService: + """파일 관리 서비스""" + + def __init__(self, db: Session): + self.db = db + self.transaction_manager = TransactionManager(db) + + async def get_files( + self, + job_no: Optional[str] = None, + show_history: bool = False, + use_cache: bool = True + ) -> Tuple[List[Dict], bool]: + """ + 파일 목록 조회 + + Args: + job_no: 작업 번호 + show_history: 이력 표시 여부 + use_cache: 캐시 사용 여부 + + Returns: + Tuple[List[Dict], bool]: (파일 목록, 캐시 히트 여부) + """ + try: + logger.info(f"파일 목록 조회 - job_no: {job_no}, show_history: {show_history}") + + # 캐시 확인 + if use_cache: + cached_files = tkmp_cache.get_file_list(job_no, show_history) + if cached_files: + logger.info(f"캐시에서 파일 목록 반환 - {len(cached_files)}개 파일") + return cached_files, True + + # 데이터베이스에서 조회 + query, params = self._build_file_query(job_no, show_history) + result = self.db.execute(text(query), params) + files = result.fetchall() + + # 결과 변환 + file_list = self._convert_files_to_dict(files) + + # 캐시에 저장 + if use_cache: + tkmp_cache.set_file_list(file_list, job_no, show_history) + logger.debug("파일 목록 캐시 저장 완료") + + logger.info(f"파일 목록 조회 완료 - {len(file_list)}개 파일 반환") + return file_list, False + + except Exception as e: + logger.error(f"파일 목록 조회 실패: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}") + + def _build_file_query(self, job_no: Optional[str], show_history: bool) -> Tuple[str, Dict]: + """파일 조회 쿼리 생성""" + if show_history: + # 전체 이력 표시 + query = "SELECT * FROM files" + params = {} + + if job_no: + query += " WHERE job_no = :job_no" + params["job_no"] = job_no + + query += " ORDER BY original_filename, revision DESC" + else: + # 최신 리비전만 표시 + if job_no: + query = """ + SELECT f1.* FROM files f1 + INNER JOIN ( + SELECT original_filename, MAX(revision) as max_revision + FROM files + WHERE job_no = :job_no + GROUP BY original_filename + ) f2 ON f1.original_filename = f2.original_filename + AND f1.revision = f2.max_revision + WHERE f1.job_no = :job_no + ORDER BY f1.upload_date DESC + """ + params = {"job_no": job_no} + else: + query = "SELECT * FROM files ORDER BY upload_date DESC" + params = {} + + return query, params + + def _convert_files_to_dict(self, files) -> List[Dict]: + """파일 결과를 딕셔너리로 변환""" + return [ + { + "id": f.id, + "filename": f.original_filename, + "original_filename": f.original_filename, + "name": f.original_filename, + "job_no": f.job_no, + "bom_name": f.bom_name or f.original_filename, + "revision": f.revision or "Rev.0", + "parsed_count": f.parsed_count or 0, + "bom_type": f.file_type or "unknown", + "status": "active" if f.is_active else "inactive", + "file_size": f.file_size, + "created_at": f.upload_date, + "upload_date": f.upload_date, + "description": f"파일: {f.original_filename}" + } + for f in files + ] + + async def delete_file(self, file_id: int) -> Dict: + """ + 파일 삭제 (트랜잭션 관리 적용) + + Args: + file_id: 파일 ID + + Returns: + Dict: 삭제 결과 + """ + try: + logger.info(f"파일 삭제 요청 - file_id: {file_id}") + + # 트랜잭션 내에서 삭제 작업 수행 + with self.transaction_manager.transaction(): + # 파일 정보 조회 + file_info = self._get_file_info(file_id) + if not file_info: + raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다") + + # 관련 데이터 삭제 (세이브포인트 사용) + with self.transaction_manager.savepoint("delete_related_data"): + self._delete_related_data(file_id) + + # 파일 삭제 + with self.transaction_manager.savepoint("delete_file_record"): + self._delete_file_record(file_id) + + # 트랜잭션이 성공적으로 완료되면 캐시 무효화 + self._invalidate_file_cache(file_id, file_info) + + logger.info(f"파일 삭제 완료 - file_id: {file_id}, filename: {file_info.original_filename}") + + return { + "success": True, + "message": "파일과 관련 데이터가 삭제되었습니다", + "deleted_file_id": file_id + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"파일 삭제 실패 - file_id: {file_id}, error: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {str(e)}") + + def _get_file_info(self, file_id: int): + """파일 정보 조회""" + file_query = text("SELECT * FROM files WHERE id = :file_id") + file_result = self.db.execute(file_query, {"file_id": file_id}) + return file_result.fetchone() + + def _delete_related_data(self, file_id: int): + """관련 데이터 삭제""" + # 상세 테이블 목록 + detail_tables = [ + 'pipe_details', 'fitting_details', 'valve_details', + 'flange_details', 'bolt_details', 'gasket_details', + 'instrument_details' + ] + + # 해당 파일의 materials ID 조회 + material_ids_query = text("SELECT id FROM materials WHERE file_id = :file_id") + material_ids_result = self.db.execute(material_ids_query, {"file_id": file_id}) + material_ids = [row[0] for row in material_ids_result] + + if material_ids: + logger.info(f"관련 자재 데이터 삭제 - {len(material_ids)}개 자재") + # 각 상세 테이블에서 관련 데이터 삭제 + for table in detail_tables: + delete_detail_query = text(f"DELETE FROM {table} WHERE material_id = ANY(:material_ids)") + self.db.execute(delete_detail_query, {"material_ids": material_ids}) + + # materials 테이블 데이터 삭제 + materials_query = text("DELETE FROM materials WHERE file_id = :file_id") + self.db.execute(materials_query, {"file_id": file_id}) + + def _delete_file_record(self, file_id: int): + """파일 레코드 삭제""" + delete_query = text("DELETE FROM files WHERE id = :file_id") + self.db.execute(delete_query, {"file_id": file_id}) + + def _invalidate_file_cache(self, file_id: int, file_info): + """파일 관련 캐시 무효화""" + tkmp_cache.invalidate_file_cache(file_id) + if hasattr(file_info, 'job_no') and file_info.job_no: + tkmp_cache.invalidate_job_cache(file_info.job_no) + + async def get_file_statistics(self, job_no: Optional[str] = None) -> Dict: + """ + 파일 통계 조회 + + Args: + job_no: 작업 번호 + + Returns: + Dict: 파일 통계 + """ + try: + # 캐시 확인 + if job_no: + cached_stats = tkmp_cache.get_statistics(job_no, "file_stats") + if cached_stats: + return cached_stats + + # 통계 쿼리 실행 + stats_query = self._build_statistics_query(job_no) + result = self.db.execute(text(stats_query["query"]), stats_query["params"]) + stats_data = result.fetchall() + + # 통계 데이터 변환 + statistics = self._convert_statistics_data(stats_data) + + # 캐시에 저장 + if job_no: + tkmp_cache.set_statistics(statistics, job_no, "file_stats") + + return statistics + + except Exception as e: + logger.error(f"파일 통계 조회 실패: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"파일 통계 조회 실패: {str(e)}") + + def _build_statistics_query(self, job_no: Optional[str]) -> Dict: + """통계 쿼리 생성""" + base_query = """ + SELECT + COUNT(*) as total_files, + COUNT(DISTINCT job_no) as total_jobs, + SUM(CASE WHEN is_active = true THEN 1 ELSE 0 END) as active_files, + SUM(file_size) as total_size, + AVG(file_size) as avg_size, + MAX(upload_date) as latest_upload, + MIN(upload_date) as earliest_upload + FROM files + """ + + params = {} + if job_no: + base_query += " WHERE job_no = :job_no" + params["job_no"] = job_no + + return {"query": base_query, "params": params} + + def _convert_statistics_data(self, stats_data) -> Dict: + """통계 데이터 변환""" + if not stats_data: + return { + "total_files": 0, + "total_jobs": 0, + "active_files": 0, + "total_size": 0, + "avg_size": 0, + "latest_upload": None, + "earliest_upload": None + } + + stats = stats_data[0] + return { + "total_files": stats.total_files or 0, + "total_jobs": stats.total_jobs or 0, + "active_files": stats.active_files or 0, + "total_size": stats.total_size or 0, + "total_size_mb": round((stats.total_size or 0) / (1024 * 1024), 2), + "avg_size": stats.avg_size or 0, + "avg_size_mb": round((stats.avg_size or 0) / (1024 * 1024), 2), + "latest_upload": stats.latest_upload, + "earliest_upload": stats.earliest_upload + } + + async def validate_file_access(self, file_id: int, user_id: Optional[str] = None) -> bool: + """ + 파일 접근 권한 검증 + + Args: + file_id: 파일 ID + user_id: 사용자 ID + + Returns: + bool: 접근 권한 여부 + """ + try: + # 파일 존재 여부 확인 + file_info = self._get_file_info(file_id) + if not file_info: + return False + + # 파일이 활성 상태인지 확인 + if not file_info.is_active: + logger.warning(f"비활성 파일 접근 시도 - file_id: {file_id}") + return False + + # 추가 권한 검증 로직 (필요시 구현) + # 예: 사용자별 프로젝트 접근 권한 등 + + return True + + except Exception as e: + logger.error(f"파일 접근 권한 검증 실패: {str(e)}", exc_info=True) + return False + + +def get_file_service(db: Session) -> FileService: + """파일 서비스 팩토리 함수""" + return FileService(db) diff --git a/tkeg/api/app/services/fitting_classifier.py b/tkeg/api/app/services/fitting_classifier.py new file mode 100644 index 0000000..651be57 --- /dev/null +++ b/tkeg/api/app/services/fitting_classifier.py @@ -0,0 +1,886 @@ +""" +FITTING 분류 시스템 V2 +재질 분류 + 피팅 특화 분류 + 스풀 시스템 통합 +""" + +import re +from typing import Dict, List, Optional +from .material_classifier import classify_material, get_manufacturing_method_from_material +from .classifier_constants import PRESSURE_PATTERNS, PRESSURE_RATINGS_SPECS, OLET_KEYWORDS + +# ========== FITTING 타입별 분류 (실제 BOM 기반) ========== +FITTING_TYPES = { + "ELBOW": { + "dat_file_patterns": ["90L_", "45L_", "ELL_", "ELBOW_"], + "description_keywords": ["ELBOW", "ELL", "엘보", "90 ELBOW", "45 ELBOW", "LR ELBOW", "SR ELBOW", "90 ELL", "45 ELL"], + "subtypes": { + "90DEG_LONG_RADIUS": ["90 LR", "90° LR", "90DEG LR", "90도 장반경", "90 LONG RADIUS", "LR 90"], + "90DEG_SHORT_RADIUS": ["90 SR", "90° SR", "90DEG SR", "90도 단반경", "90 SHORT RADIUS", "SR 90"], + "45DEG_LONG_RADIUS": ["45 LR", "45° LR", "45DEG LR", "45도 장반경", "45 LONG RADIUS", "LR 45"], + "45DEG_SHORT_RADIUS": ["45 SR", "45° SR", "45DEG SR", "45도 단반경", "45 SHORT RADIUS", "SR 45"], + "90DEG": ["90", "90°", "90DEG", "90도"], + "45DEG": ["45", "45°", "45DEG", "45도"], + "LONG_RADIUS": ["LR", "LONG RADIUS", "장반경"], + "SHORT_RADIUS": ["SR", "SHORT RADIUS", "단반경"] + }, + "default_subtype": "90DEG", + "common_connections": ["BUTT_WELD", "SOCKET_WELD"], + "size_range": "1/2\" ~ 48\"" + }, + + "TEE": { + "dat_file_patterns": ["TEE_", "T_"], + "description_keywords": ["TEE", "티"], + "subtypes": { + "EQUAL": ["EQUAL TEE", "등경티", "EQUAL"], + "REDUCING": ["REDUCING TEE", "RED TEE", "축소티", "REDUCING", "RD"] + }, + "size_analysis": True, # RED_NOM으로 REDUCING 여부 판단 + "common_connections": ["BUTT_WELD", "SOCKET_WELD"], + "size_range": "1/2\" ~ 48\"" + }, + + "REDUCER": { + "dat_file_patterns": ["CNC_", "ECC_", "RED_", "REDUCER_"], + "description_keywords": ["REDUCER", "RED", "리듀서"], + "subtypes": { + "CONCENTRIC": ["CONCENTRIC", "CONC", "CNC", "동심", "CON"], + "ECCENTRIC": ["ECCENTRIC", "ECC", "편심"] + }, + "requires_two_sizes": True, + "common_connections": ["BUTT_WELD"], + "size_range": "1/2\" ~ 48\"" + }, + + "CAP": { + "dat_file_patterns": ["CAP_"], + "description_keywords": ["CAP", "캡", "막음"], + "subtypes": { + "BUTT_WELD": ["BW", "BUTT WELD"], + "SOCKET_WELD": ["SW", "SOCKET WELD"], + "THREADED": ["THD", "THREADED", "나사", "NPT"] + }, + "common_connections": ["BUTT_WELD", "SOCKET_WELD", "THREADED"], + "size_range": "1/4\" ~ 24\"" + }, + + "PLUG": { + "dat_file_patterns": ["PLUG_", "HEX_PLUG"], + "description_keywords": ["PLUG", "플러그", "HEX.PLUG", "HEX PLUG", "HEXAGON PLUG"], + "subtypes": { + "HEX": ["HEX", "HEXAGON", "육각"], + "SQUARE": ["SQUARE", "사각"], + "THREADED": ["THD", "THREADED", "나사", "NPT"] + }, + "common_connections": ["THREADED", "NPT"], + "size_range": "1/8\" ~ 4\"" + }, + + "NIPPLE": { + "dat_file_patterns": ["NIP_", "NIPPLE_"], + "description_keywords": ["NIPPLE", "니플"], + "subtypes": { + "THREADED": ["THREADED", "THD", "NPT", "나사"], + "SOCKET_WELD": ["SOCKET WELD", "SW", "소켓웰드"], + "CLOSE": ["CLOSE NIPPLE", "CLOSE"], + "SHORT": ["SHORT NIPPLE", "SHORT"], + "LONG": ["LONG NIPPLE", "LONG"] + }, + "common_connections": ["THREADED", "SOCKET_WELD"], + "size_range": "1/8\" ~ 4\"" + }, + + "SWAGE": { + "dat_file_patterns": ["SWG_"], + "description_keywords": ["SWAGE", "스웨지"], + "subtypes": { + "CONCENTRIC": ["CONCENTRIC", "CONC", "CN", "CON", "동심"], + "ECCENTRIC": ["ECCENTRIC", "ECC", "EC", "편심"] + }, + "requires_two_sizes": True, + "common_connections": ["BUTT_WELD", "SOCKET_WELD"], + "size_range": "1/2\" ~ 12\"" + }, + + "OLET": { + "dat_file_patterns": ["SOL_", "WOL_", "TOL_", "EOL_", "NOL_", "COL_", "OLET_", "SOCK-O-LET", "WELD-O-LET", "ELL-O-LET", "THREAD-O-LET", "ELB-O-LET", "NIP-O-LET", "COUP-O-LET"], + "description_keywords": OLET_KEYWORDS, + "subtypes": { + "SOCKOLET": ["SOCK-O-LET", "SOCKOLET", "SOL", "SOCK O-LET", "SOCKET-O-LET", "SOCKLET"], + "WELDOLET": ["WELD-O-LET", "WELDOLET", "WOL", "WELD O-LET", "WELDING-O-LET"], + "ELLOLET": ["ELL-O-LET", "ELLOLET", "EOL", "ELL O-LET", "ELBOW-O-LET"], + "THREADOLET": ["THREAD-O-LET", "THREADOLET", "TOL", "THREADED-O-LET"], + "ELBOLET": ["ELB-O-LET", "ELBOLET", "EOL", "ELBOW-O-LET"], + "NIPOLET": ["NIP-O-LET", "NIPOLET", "NOL", "NIPPLE-O-LET"], + "COUPOLET": ["COUP-O-LET", "COUPOLET", "COL", "COUPLING-O-LET"] + }, + "requires_two_sizes": True, # 주배관 x 분기관 + "common_connections": ["SOCKET_WELD", "THREADED", "BUTT_WELD"], + "size_range": "1/8\" ~ 4\"" + }, + + "COUPLING": { + "dat_file_patterns": ["CPL_", "COUPLING_"], + "description_keywords": ["COUPLING", "커플링"], + "subtypes": { + "FULL": ["FULL COUPLING", "FULL"], + "HALF": ["HALF COUPLING", "HALF"], + "REDUCING": ["REDUCING COUPLING", "RED"] + }, + "common_connections": ["SOCKET_WELD", "THREADED"], + "size_range": "1/8\" ~ 4\"" + } +} + +# ========== 연결 방식별 분류 ========== +CONNECTION_METHODS = { + "BUTT_WELD": { + "codes": ["BW", "BUTT WELD", "맞대기용접", "BUTT-WELD"], + "dat_patterns": ["_BW"], + "size_range": "1/2\" ~ 48\"", + "pressure_range": "150LB ~ 2500LB", + "typical_manufacturing": "WELDED_FABRICATED", + "confidence": 0.95 + }, + "SOCKET_WELD": { + "codes": ["SW", "SOCKET WELD", "소켓웰드", "SOCKET-WELD"], + "dat_patterns": ["_SW_"], + "size_range": "1/8\" ~ 4\"", + "pressure_range": "150LB ~ 9000LB", + "typical_manufacturing": "FORGED", + "confidence": 0.95 + }, + "THREADED": { + "codes": ["THD", "THRD", "NPT", "THREADED", "나사", "TR"], + "dat_patterns": ["_TR", "_THD"], + "size_range": "1/8\" ~ 4\"", + "pressure_range": "150LB ~ 6000LB", + "typical_manufacturing": "FORGED", + "confidence": 0.95 + }, + "FLANGED": { + "codes": ["FL", "FLG", "FLANGED", "플랜지"], + "dat_patterns": ["_FL_"], + "size_range": "1/2\" ~ 48\"", + "pressure_range": "150LB ~ 2500LB", + "typical_manufacturing": "FORGED_OR_CAST", + "confidence": 0.9 + } +} + +# ========== 압력 등급별 분류 ========== +PRESSURE_RATINGS = { + "patterns": PRESSURE_PATTERNS, + "standard_ratings": PRESSURE_RATINGS_SPECS +} + +def classify_fitting(dat_file: str, description: str, main_nom: str, + red_nom: str = None, length: float = None) -> Dict: + """ + 완전한 FITTING 분류 + + Args: + dat_file: DAT_FILE 필드 + description: DESCRIPTION 필드 + main_nom: MAIN_NOM 필드 (주 사이즈) + red_nom: RED_NOM 필드 (축소 사이즈, 선택사항) + + Returns: + 완전한 피팅 분류 결과 + """ + + desc_upper = description.upper() + dat_upper = dat_file.upper() + + # 1. 피팅 키워드 확인 (재질만 있어도 통합 분류기가 이미 피팅으로 분류했으므로 진행) + # OLET 키워드를 우선 확인하여 정확한 분류 수행 + olet_keywords = OLET_KEYWORDS + has_olet_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in olet_keywords) + + fitting_keywords = ['ELBOW', 'ELL', 'TEE', 'REDUCER', 'RED', 'CAP', 'NIPPLE', 'SWAGE', 'COUPLING', 'PLUG', '엘보', '티', '리듀서', '캡', '니플', '스웨지', '올렛', '커플링', '플러그'] + olet_keywords + has_fitting_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in fitting_keywords) + + # 피팅 재질 확인 (A234, A403, A420) + fitting_materials = ['A234', 'A403', 'A420'] + has_fitting_material = any(material in desc_upper for material in fitting_materials) + + # 피팅 키워드도 없고 피팅 재질도 없으면 UNKNOWN + if not has_fitting_keyword and not has_fitting_material: + return { + "category": "UNKNOWN", + "overall_confidence": 0.0, + "reason": "피팅 키워드 및 재질 없음" + } + + # 2. 재질 분류 (공통 모듈 사용) + material_result = classify_material(description) + + # 2. 피팅 타입 분류 + fitting_type_result = classify_fitting_type(dat_file, description, main_nom, red_nom) + + # 3. 연결 방식 분류 + connection_result = classify_connection_method(dat_file, description) + + # 4. 압력 등급 분류 + pressure_result = classify_pressure_rating(dat_file, description) + + # 4.5. 스케줄 분류 (니플 등에 중요) - 분리 스케줄 지원 + schedule_result = classify_fitting_schedule_with_reducing(description, main_nom, red_nom) + + # 5. 제작 방법 추정 + manufacturing_result = determine_fitting_manufacturing( + material_result, connection_result, pressure_result, main_nom + ) + + # 6. 최종 결과 조합 + # --- 계장용(Instrument/Swagelok) 피팅 감지 로직 추가 --- + instrument_keywords = ["SWAGELOK", "DK-LOK", "TUBE FITTING", "UNION", "FERRULE", "MALE CONNECTOR", "FEMALE CONNECTOR"] + is_instrument = any(kw in desc_upper for kw in instrument_keywords) + + if is_instrument: + fitting_type_result["category"] = "INSTRUMENT_FITTING" + if "SWAGELOK" in desc_upper: fitting_type_result["brand"] = "SWAGELOK" + + # Tube OD 추출 (예: 1/4", 6MM, 12MM) + tube_match = re.search(r'(\d+(?:/\d+)?)\s*(?:\"|INCH|MM)\s*(?:OD|TUBE)', desc_upper) + if tube_match: + fitting_type_result["tube_od"] = tube_match.group(0) + + return { + "category": "FITTING", + "fitting_type": fitting_type_result, + "connection_method": connection_result, + "pressure_rating": pressure_result, + "schedule": schedule_result, + "manufacturing": manufacturing_result, + "overall_confidence": calculate_fitting_confidence({ + "material": material_result.get("confidence", 0), + "fitting_type": fitting_type_result.get("confidence", 0), + "connection": connection_result.get("confidence", 0), + "pressure": pressure_result.get("confidence", 0) + }) + } + + +def analyze_size_pattern_for_fitting_type(description: str, main_nom: str, red_nom: str = None) -> Dict: + """ + 실제 BOM 패턴 기반 TEE vs REDUCER 구분 + + 실제 패턴: + - TEE RED, SMLS, SCH 40 x SCH 80 → TEE (키워드 우선) + - RED CONC, SMLS, SCH 80 x SCH 80 → REDUCER (키워드 우선) + - 모두 A x B 형태 (메인 x 감소) + """ + + desc_upper = description.upper() + + # 1. 키워드 기반 분류 (최우선) - 실제 BOM 패턴 + if "TEE RED" in desc_upper or "TEE REDUCING" in desc_upper: + return { + "type": "TEE", + "subtype": "REDUCING", + "confidence": 0.95, + "evidence": ["KEYWORD_TEE_RED"], + "subtype_confidence": 0.95, + "requires_two_sizes": False + } + + if "RED CONC" in desc_upper or "REDUCER CONC" in desc_upper: + return { + "type": "REDUCER", + "subtype": "CONCENTRIC", + "confidence": 0.95, + "evidence": ["KEYWORD_RED_CONC"], + "subtype_confidence": 0.95, + "requires_two_sizes": True + } + + if "RED ECC" in desc_upper or "REDUCER ECC" in desc_upper: + return { + "type": "REDUCER", + "subtype": "ECCENTRIC", + "confidence": 0.95, + "evidence": ["KEYWORD_RED_ECC"], + "subtype_confidence": 0.95, + "requires_two_sizes": True + } + + # 2. 사이즈 패턴 분석 (보조) - 기존 로직 유지 + # x 또는 × 기호로 연결된 사이즈들 찾기 + connected_sizes = re.findall(r'(\d+(?:\s+\d+/\d+)?(?:\.\d+)?)"?\s*[xX×]\s*(\d+(?:\s+\d+/\d+)?(?:\.\d+)?)"?(?:\s*[xX×]\s*(\d+(?:\s+\d+/\d+)?(?:\.\d+)?)"?)?', description) + + if connected_sizes: + # 연결된 사이즈들을 리스트로 변환 + sizes = [] + for size_group in connected_sizes: + for size in size_group: + if size.strip(): + sizes.append(size.strip()) + + # 중복 제거하되 순서 유지 + unique_sizes = [] + for size in sizes: + if size not in unique_sizes: + unique_sizes.append(size) + + sizes = unique_sizes + + if len(sizes) == 3: + # A x B x B 패턴 → TEE REDUCING + if sizes[1] == sizes[2]: + return { + "type": "TEE", + "subtype": "REDUCING", + "confidence": 0.85, + "evidence": [f"SIZE_PATTERN_TEE_REDUCING: {' x '.join(sizes)}"], + "subtype_confidence": 0.85, + "requires_two_sizes": False + } + # A x B x C 패턴 → TEE REDUCING (모두 다른 사이즈) + else: + return { + "type": "TEE", + "subtype": "REDUCING", + "confidence": 0.80, + "evidence": [f"SIZE_PATTERN_TEE_REDUCING_UNEQUAL: {' x '.join(sizes)}"], + "subtype_confidence": 0.80, + "requires_two_sizes": False + } + elif len(sizes) == 2: + # A x B 패턴 → 키워드가 없으면 REDUCER로 기본 분류 + if "CONC" in desc_upper or "CONCENTRIC" in desc_upper: + return { + "type": "REDUCER", + "subtype": "CONCENTRIC", + "confidence": 0.80, + "evidence": [f"SIZE_PATTERN_REDUCER_CONC: {' x '.join(sizes)}"], + "subtype_confidence": 0.80, + "requires_two_sizes": True + } + elif "ECC" in desc_upper or "ECCENTRIC" in desc_upper: + return { + "type": "REDUCER", + "subtype": "ECCENTRIC", + "confidence": 0.80, + "evidence": [f"SIZE_PATTERN_REDUCER_ECC: {' x '.join(sizes)}"], + "subtype_confidence": 0.80, + "requires_two_sizes": True + } + else: + # 키워드 없는 A x B 패턴은 낮은 신뢰도로 REDUCER + return { + "type": "REDUCER", + "subtype": "CONCENTRIC", # 기본값 + "confidence": 0.60, + "evidence": [f"SIZE_PATTERN_REDUCER_DEFAULT: {' x '.join(sizes)}"], + "subtype_confidence": 0.60, + "requires_two_sizes": True + } + + return {"confidence": 0.0} + +def classify_fitting_type(dat_file: str, description: str, + main_nom: str, red_nom: str = None) -> Dict: + """피팅 타입 분류""" + + dat_upper = dat_file.upper() + desc_upper = description.upper() + + # 0. OLET 우선 확인 (ELL과의 혼동 방지) + olet_specific_keywords = OLET_KEYWORDS + for keyword in olet_specific_keywords: + if keyword in desc_upper or keyword in dat_upper: + subtype_result = classify_fitting_subtype( + "OLET", desc_upper, main_nom, red_nom, FITTING_TYPES["OLET"] + ) + return { + "type": "OLET", + "subtype": subtype_result["subtype"], + "confidence": 0.95, + "evidence": [f"OLET_PRIORITY_KEYWORD: {keyword}"], + "subtype_confidence": subtype_result["confidence"], + "requires_two_sizes": FITTING_TYPES["OLET"].get("requires_two_sizes", False) + } + + # 1. 사이즈 패턴 분석으로 TEE vs REDUCER 구분 + size_pattern_result = analyze_size_pattern_for_fitting_type(desc_upper, main_nom, red_nom) + if size_pattern_result.get("confidence", 0) > 0.85: + return size_pattern_result + + # 2. DAT_FILE 패턴으로 1차 분류 (가장 신뢰도 높음) + for fitting_type, type_data in FITTING_TYPES.items(): + for pattern in type_data["dat_file_patterns"]: + if pattern in dat_upper: + subtype_result = classify_fitting_subtype( + fitting_type, desc_upper, main_nom, red_nom, type_data + ) + + return { + "type": fitting_type, + "subtype": subtype_result["subtype"], + "confidence": 0.95, + "evidence": [f"DAT_FILE_PATTERN: {pattern}"], + "subtype_confidence": subtype_result["confidence"], + "requires_two_sizes": type_data.get("requires_two_sizes", False) + } + + # 3. DESCRIPTION 키워드로 2차 분류 + for fitting_type, type_data in FITTING_TYPES.items(): + for keyword in type_data["description_keywords"]: + if keyword in desc_upper: + subtype_result = classify_fitting_subtype( + fitting_type, desc_upper, main_nom, red_nom, type_data + ) + + return { + "type": fitting_type, + "subtype": subtype_result["subtype"], + "confidence": 0.85, + "evidence": [f"DESCRIPTION_KEYWORD: {keyword}"], + "subtype_confidence": subtype_result["confidence"], + "requires_two_sizes": type_data.get("requires_two_sizes", False) + } + + # 4. 분류 실패 + return { + "type": "UNKNOWN", + "subtype": "UNKNOWN", + "confidence": 0.0, + "evidence": ["NO_FITTING_TYPE_IDENTIFIED"], + "requires_two_sizes": False + } + +def classify_fitting_subtype(fitting_type: str, description: str, + main_nom: str, red_nom: str, type_data: Dict) -> Dict: + """피팅 서브타입 분류""" + + desc_upper = description.upper() + subtypes = type_data.get("subtypes", {}) + + # 1. 키워드 기반 서브타입 분류 (우선) - 대소문자 구분 없이 + for subtype, keywords in subtypes.items(): + for keyword in keywords: + if keyword.upper() in desc_upper: + return { + "subtype": subtype, + "confidence": 0.9, + "evidence": [f"SUBTYPE_KEYWORD: {keyword}"] + } + + # 1.5. ELBOW 특별 처리 - 조합 키워드 우선 확인 + if fitting_type == "ELBOW": + # 90도 + 반경 조합 + if ("90" in desc_upper or "90°" in desc_upper or "90DEG" in desc_upper): + if ("LR" in desc_upper or "LONG RADIUS" in desc_upper or "장반경" in desc_upper): + return { + "subtype": "90DEG_LONG_RADIUS", + "confidence": 0.95, + "evidence": ["90DEG + LONG_RADIUS"] + } + elif ("SR" in desc_upper or "SHORT RADIUS" in desc_upper or "단반경" in desc_upper): + return { + "subtype": "90DEG_SHORT_RADIUS", + "confidence": 0.95, + "evidence": ["90DEG + SHORT_RADIUS"] + } + else: + return { + "subtype": "90DEG", + "confidence": 0.85, + "evidence": ["90DEG_DETECTED"] + } + + # 45도 + 반경 조합 + elif ("45" in desc_upper or "45°" in desc_upper or "45DEG" in desc_upper): + if ("LR" in desc_upper or "LONG RADIUS" in desc_upper or "장반경" in desc_upper): + return { + "subtype": "45DEG_LONG_RADIUS", + "confidence": 0.95, + "evidence": ["45DEG + LONG_RADIUS"] + } + elif ("SR" in desc_upper or "SHORT RADIUS" in desc_upper or "단반경" in desc_upper): + return { + "subtype": "45DEG_SHORT_RADIUS", + "confidence": 0.95, + "evidence": ["45DEG + SHORT_RADIUS"] + } + else: + return { + "subtype": "45DEG", + "confidence": 0.85, + "evidence": ["45DEG_DETECTED"] + } + + # 반경만 있는 경우 (기본 90도 가정) + elif ("LR" in desc_upper or "LONG RADIUS" in desc_upper or "장반경" in desc_upper): + return { + "subtype": "90DEG_LONG_RADIUS", + "confidence": 0.8, + "evidence": ["LONG_RADIUS_DEFAULT_90DEG"] + } + elif ("SR" in desc_upper or "SHORT RADIUS" in desc_upper or "단반경" in desc_upper): + return { + "subtype": "90DEG_SHORT_RADIUS", + "confidence": 0.8, + "evidence": ["SHORT_RADIUS_DEFAULT_90DEG"] + } + + # 2. 사이즈 분석이 필요한 경우 (TEE, REDUCER 등) + if type_data.get("size_analysis"): + if red_nom and str(red_nom).strip() and red_nom != main_nom: + return { + "subtype": "REDUCING", + "confidence": 0.85, + "evidence": [f"SIZE_ANALYSIS_REDUCING: {main_nom} x {red_nom}"] + } + else: + return { + "subtype": "EQUAL", + "confidence": 0.8, + "evidence": [f"SIZE_ANALYSIS_EQUAL: {main_nom}"] + } + + # 3. 두 사이즈가 필요한 경우 확인 + if type_data.get("requires_two_sizes"): + if red_nom and str(red_nom).strip(): + confidence = 0.8 + evidence = [f"TWO_SIZES_PROVIDED: {main_nom} x {red_nom}"] + else: + confidence = 0.6 + evidence = [f"TWO_SIZES_EXPECTED_BUT_MISSING"] + else: + confidence = 0.7 + evidence = ["SINGLE_SIZE_FITTING"] + + # 4. 기본값 + default_subtype = type_data.get("default_subtype", "GENERAL") + return { + "subtype": default_subtype, + "confidence": confidence, + "evidence": evidence + } + +def classify_connection_method(dat_file: str, description: str) -> Dict: + """연결 방식 분류""" + + dat_upper = dat_file.upper() + desc_upper = description.upper() + combined_text = f"{dat_upper} {desc_upper}" + + # 1. DAT_FILE 패턴 우선 확인 (가장 신뢰도 높음) + for method, method_data in CONNECTION_METHODS.items(): + for pattern in method_data["dat_patterns"]: + if pattern in dat_upper: + return { + "method": method, + "confidence": 0.95, + "matched_code": pattern, + "source": "DAT_FILE_PATTERN", + "size_range": method_data["size_range"], + "pressure_range": method_data["pressure_range"], + "typical_manufacturing": method_data["typical_manufacturing"] + } + + # 2. 키워드 확인 + for method, method_data in CONNECTION_METHODS.items(): + for code in method_data["codes"]: + if code in combined_text: + return { + "method": method, + "confidence": method_data["confidence"], + "matched_code": code, + "source": "KEYWORD_MATCH", + "size_range": method_data["size_range"], + "pressure_range": method_data["pressure_range"], + "typical_manufacturing": method_data["typical_manufacturing"] + } + + return { + "method": "UNKNOWN", + "confidence": 0.0, + "matched_code": "", + "source": "NO_CONNECTION_METHOD_FOUND" + } + +def classify_pressure_rating(dat_file: str, description: str) -> Dict: + """압력 등급 분류""" + + combined_text = f"{dat_file} {description}".upper() + + # 패턴 매칭으로 압력 등급 추출 + for pattern in PRESSURE_RATINGS["patterns"]: + match = re.search(pattern, combined_text) + if match: + rating_num = match.group(1) + rating = f"{rating_num}LB" + + # 표준 등급 정보 확인 + rating_info = PRESSURE_RATINGS["standard_ratings"].get(rating, {}) + + if rating_info: + confidence = 0.95 + else: + confidence = 0.8 + rating_info = {"max_pressure": "확인 필요", "common_use": "비표준 등급"} + + return { + "rating": rating, + "confidence": confidence, + "matched_pattern": pattern, + "matched_value": rating_num, + "max_pressure": rating_info.get("max_pressure", ""), + "common_use": rating_info.get("common_use", "") + } + + return { + "rating": "UNKNOWN", + "confidence": 0.0, + "matched_pattern": "", + "max_pressure": "", + "common_use": "" + } + +def determine_fitting_manufacturing(material_result: Dict, connection_result: Dict, + pressure_result: Dict, main_nom: str) -> Dict: + """피팅 제작 방법 결정""" + + evidence = [] + + # 1. 재질 기반 제작방법 (가장 확실) + material_manufacturing = get_manufacturing_method_from_material(material_result) + if material_manufacturing in ["FORGED", "CAST"]: + evidence.append(f"MATERIAL_STANDARD: {material_result.get('standard')}") + + characteristics = { + "FORGED": "고강도, 고압용, 소구경", + "CAST": "복잡형상, 중저압용" + }.get(material_manufacturing, "") + + return { + "method": material_manufacturing, + "confidence": 0.9, + "evidence": evidence, + "characteristics": characteristics + } + + # 2. 연결방식 + 압력등급 조합 추정 + connection_method = connection_result.get("method", "") + pressure_rating = pressure_result.get("rating", "") + + # 고압 + 소켓웰드/나사 = 단조 + high_pressure = ["3000LB", "6000LB", "9000LB"] + forged_connections = ["SOCKET_WELD", "THREADED"] + + if (any(pressure in pressure_rating for pressure in high_pressure) and + connection_method in forged_connections): + evidence.append(f"HIGH_PRESSURE: {pressure_rating}") + evidence.append(f"FORGED_CONNECTION: {connection_method}") + return { + "method": "FORGED", + "confidence": 0.85, + "evidence": evidence, + "characteristics": "고압용 단조품" + } + + # 3. 연결방식별 일반적 제작방법 + connection_manufacturing = connection_result.get("typical_manufacturing", "") + if connection_manufacturing: + evidence.append(f"CONNECTION_TYPICAL: {connection_method}") + + characteristics_map = { + "FORGED": "단조품, 고강도", + "WELDED_FABRICATED": "용접제작품, 대구경", + "FORGED_OR_CAST": "단조 또는 주조" + } + + return { + "method": connection_manufacturing, + "confidence": 0.7, + "evidence": evidence, + "characteristics": characteristics_map.get(connection_manufacturing, "") + } + + # 4. 기본 추정 + return { + "method": "UNKNOWN", + "confidence": 0.0, + "evidence": ["INSUFFICIENT_MANUFACTURING_INFO"], + "characteristics": "" + } + +def format_fitting_size(main_nom: str, red_nom: str = None) -> str: + """피팅 사이즈 표기 포맷팅""" + main_nom_str = str(main_nom) if main_nom is not None else "" + red_nom_str = str(red_nom) if red_nom is not None else "" + if red_nom_str.strip() and red_nom_str != main_nom_str: + return f"{main_nom_str} x {red_nom_str}" + else: + return main_nom_str + +def calculate_fitting_confidence(confidence_scores: Dict) -> float: + """피팅 분류 전체 신뢰도 계산""" + + scores = [score for score in confidence_scores.values() if score > 0] + + if not scores: + return 0.0 + + # 가중 평균 (피팅 타입이 가장 중요) + weights = { + "material": 0.25, + "fitting_type": 0.4, + "connection": 0.25, + "pressure": 0.1 + } + + weighted_sum = sum( + confidence_scores.get(key, 0) * weight + for key, weight in weights.items() + ) + + return round(weighted_sum, 2) + +# ========== 특수 분류 함수들 ========== + +def is_high_pressure_fitting(pressure_rating: str) -> bool: + """고압 피팅 여부 판단""" + high_pressure_ratings = ["3000LB", "6000LB", "9000LB"] + return pressure_rating in high_pressure_ratings + +def is_small_bore_fitting(main_nom: str) -> bool: + """소구경 피팅 여부 판단""" + try: + # 간단한 사이즈 파싱 (인치 기준) + size_num = float(re.findall(r'(\d+(?:\.\d+)?)', main_nom)[0]) + return size_num <= 2.0 + except: + return False + +def get_fitting_purchase_info(fitting_result: Dict) -> Dict: + """피팅 구매 정보 생성""" + + fitting_type = fitting_result["fitting_type"]["type"] + connection = fitting_result["connection_method"]["method"] + pressure = fitting_result["pressure_rating"]["rating"] + manufacturing = fitting_result["manufacturing"]["method"] + + # 공급업체 타입 결정 + if manufacturing == "FORGED": + supplier_type = "단조 피팅 전문업체" + elif manufacturing == "CAST": + supplier_type = "주조 피팅 전문업체" + else: + supplier_type = "일반 피팅 업체" + + # 납기 추정 + if is_high_pressure_fitting(pressure): + lead_time = "6-10주 (고압용)" + elif manufacturing == "FORGED": + lead_time = "4-8주 (단조품)" + else: + lead_time = "2-6주 (일반품)" + + return { + "supplier_type": supplier_type, + "lead_time_estimate": lead_time, + "purchase_category": f"{fitting_type} {connection} {pressure}", + "manufacturing_note": fitting_result["manufacturing"]["characteristics"] + } + +def classify_fitting_schedule(description: str) -> Dict: + """피팅 스케줄 분류 (특히 니플용)""" + + desc_upper = description.upper() + + # 스케줄 패턴 매칭 + schedule_patterns = [ + r'SCH\s*(\d+)', + r'SCHEDULE\s*(\d+)', + r'스케줄\s*(\d+)' + ] + + for pattern in schedule_patterns: + match = re.search(pattern, desc_upper) + if match: + schedule_number = match.group(1) + schedule = f"SCH {schedule_number}" + + # 일반적인 스케줄 정보 + common_schedules = { + "10": {"wall": "얇음", "pressure": "저압"}, + "20": {"wall": "얇음", "pressure": "저압"}, + "40": {"wall": "표준", "pressure": "중압"}, + "80": {"wall": "두꺼움", "pressure": "고압"}, + "120": {"wall": "매우 두꺼움", "pressure": "고압"}, + "160": {"wall": "매우 두꺼움", "pressure": "초고압"} + } + + schedule_info = common_schedules.get(schedule_number, {"wall": "비표준", "pressure": "확인 필요"}) + + return { + "schedule": schedule, + "schedule_number": schedule_number, + "wall_thickness": schedule_info["wall"], + "pressure_class": schedule_info["pressure"], + "confidence": 0.95, + "matched_pattern": pattern + } + + return { + "schedule": "UNKNOWN", + "schedule_number": "", + "wall_thickness": "", + "pressure_class": "", + "confidence": 0.0, + "matched_pattern": "" + } + +def classify_fitting_schedule_with_reducing(description: str, main_nom: str, red_nom: str = None) -> Dict: + """ + 실제 BOM 패턴 기반 분리 스케줄 처리 + + 실제 패턴: + - "TEE RED, SMLS, SCH 40 x SCH 80" → main: SCH 40, red: SCH 80 + - "RED CONC, SMLS, SCH 40S x SCH 40S" → main: SCH 40S, red: SCH 40S + - "RED CONC, SMLS, SCH 80 x SCH 80" → main: SCH 80, red: SCH 80 + """ + + desc_upper = description.upper() + + # 1. 분리 스케줄 패턴 확인 (SCH XX x SCH YY) - 개선된 패턴 + separated_schedule_patterns = [ + r'SCH\s*(\d+S?)\s*[xX×]\s*SCH\s*(\d+S?)', # SCH 40 x SCH 80 + r'SCH\s*(\d+S?)\s*X\s*(\d+S?)', # SCH 40S X 40S (SCH 생략) + ] + + for pattern in separated_schedule_patterns: + separated_match = re.search(pattern, desc_upper) + if separated_match: + main_schedule = f"SCH {separated_match.group(1)}" + red_schedule = f"SCH {separated_match.group(2)}" + + return { + "schedule": main_schedule, # 기본 스케줄 (호환성) + "main_schedule": main_schedule, + "red_schedule": red_schedule, + "has_different_schedules": main_schedule != red_schedule, + "confidence": 0.95, + "matched_pattern": separated_match.group(0), + "schedule_type": "SEPARATED" + } + + # 2. 단일 스케줄 패턴 (기존 로직 사용) + basic_result = classify_fitting_schedule(description) + + # 단일 스케줄을 main/red 모두에 적용 + schedule = basic_result.get("schedule", "UNKNOWN") + + return { + "schedule": schedule, # 기본 스케줄 (호환성) + "main_schedule": schedule, + "red_schedule": schedule if red_nom else None, + "has_different_schedules": False, + "confidence": basic_result.get("confidence", 0.0), + "matched_pattern": basic_result.get("matched_pattern", ""), + "schedule_type": "UNIFIED" + } diff --git a/tkeg/api/app/services/flange_classifier.py b/tkeg/api/app/services/flange_classifier.py new file mode 100644 index 0000000..b0ceef6 --- /dev/null +++ b/tkeg/api/app/services/flange_classifier.py @@ -0,0 +1,595 @@ +""" +FLANGE 분류 시스템 +일반 플랜지 + SPECIAL 플랜지 분류 +""" + +import re +from typing import Dict, List, Optional +from .material_classifier import classify_material, get_manufacturing_method_from_material + +# ========== SPECIAL FLANGE 타입 ========== +SPECIAL_FLANGE_TYPES = { + "ORIFICE": { + "dat_file_patterns": ["FLG_ORI_", "ORI_", "ORIFICE_"], + "description_keywords": ["ORIFICE", "오리피스", "유량측정", "구멍"], + "characteristics": "유량 측정용 구멍", + "special_features": ["BORE_SIZE", "FLOW_MEASUREMENT"] + }, + "SPECTACLE_BLIND": { + "dat_file_patterns": ["FLG_SPB_", "SPB_", "SPEC_"], + "description_keywords": ["SPECTACLE BLIND", "SPECTACLE", "안경형", "SPB"], + "characteristics": "안경형 차단판 (운전/정지 전환)", + "special_features": ["SWITCHING", "ISOLATION"] + }, + "PADDLE_BLIND": { + "dat_file_patterns": ["FLG_PAD_", "PAD_", "PADDLE_"], + "description_keywords": ["PADDLE BLIND", "PADDLE", "패들형"], + "characteristics": "패들형 차단판", + "special_features": ["PERMANENT_ISOLATION"] + }, + "SPACER": { + "dat_file_patterns": ["FLG_SPC_", "SPC_", "SPACER_"], + "description_keywords": ["SPACER", "스페이서", "거리조정"], + "characteristics": "거리 조정용", + "special_features": ["SPACING", "THICKNESS"] + }, + "REDUCING": { + "dat_file_patterns": ["FLG_RED_", "RED_FLG"], + "description_keywords": ["REDUCING", "축소", "RED"], + "characteristics": "사이즈 축소용", + "special_features": ["SIZE_CHANGE", "REDUCING"], + "requires_two_sizes": True + }, + "EXPANDER": { + "dat_file_patterns": ["FLG_EXP_", "EXP_"], + "description_keywords": ["EXPANDER", "EXPANDING", "확대"], + "characteristics": "사이즈 확대용", + "special_features": ["SIZE_CHANGE", "EXPANDING"], + "requires_two_sizes": True + }, + "SWIVEL": { + "dat_file_patterns": ["FLG_SWV_", "SWV_"], + "description_keywords": ["SWIVEL", "회전", "ROTATING"], + "characteristics": "회전/각도 조정용", + "special_features": ["ROTATION", "ANGLE_ADJUSTMENT"] + }, + "INSULATION_SET": { + "dat_file_patterns": ["FLG_INS_", "INS_SET"], + "description_keywords": ["INSULATION", "절연", "ISOLATING", "INS SET"], + "characteristics": "절연 플랜지 세트", + "special_features": ["ELECTRICAL_ISOLATION"] + }, + "DRIP_RING": { + "dat_file_patterns": ["FLG_DRP_", "DRP_"], + "description_keywords": ["DRIP RING", "드립링", "DRIP"], + "characteristics": "드립 링", + "special_features": ["DRIP_PREVENTION"] + }, + "NOZZLE": { + "dat_file_patterns": ["FLG_NOZ_", "NOZ_"], + "description_keywords": ["NOZZLE", "노즐", "OUTLET"], + "characteristics": "노즐 플랜지 (특수 형상)", + "special_features": ["SPECIAL_SHAPE", "OUTLET"] + } +} + +# ========== 일반 FLANGE 타입 ========== +STANDARD_FLANGE_TYPES = { + "WELD_NECK": { + "dat_file_patterns": ["FLG_WN_", "WN_", "WELD_NECK"], + "description_keywords": ["WELD NECK", "WN", "웰드넥"], + "characteristics": "목 부분 용접형", + "size_range": "1/2\" ~ 48\"", + "pressure_range": "150LB ~ 2500LB" + }, + "SLIP_ON": { + "dat_file_patterns": ["FLG_SO_", "SO_", "SLIP_ON"], + "description_keywords": ["SLIP ON", "SO", "슬립온"], + "characteristics": "끼워서 용접형", + "size_range": "1/2\" ~ 24\"", + "pressure_range": "150LB ~ 600LB" + }, + "SOCKET_WELD": { + "dat_file_patterns": ["FLG_SW_", "SW_"], + "description_keywords": ["SOCKET WELD", "SW", "소켓웰드"], + "characteristics": "소켓 용접형", + "size_range": "1/8\" ~ 4\"", + "pressure_range": "150LB ~ 9000LB" + }, + "THREADED": { + "dat_file_patterns": ["FLG_THD_", "THD_", "FLG_TR_"], + "description_keywords": ["THREADED", "THD", "나사", "NPT"], + "characteristics": "나사 연결형", + "size_range": "1/8\" ~ 4\"", + "pressure_range": "150LB ~ 6000LB" + }, + "BLIND": { + "dat_file_patterns": ["FLG_BL_", "BL_", "BLIND_"], + "description_keywords": ["BLIND", "BL", "막음", "차단"], + "characteristics": "막음용", + "size_range": "1/2\" ~ 48\"", + "pressure_range": "150LB ~ 2500LB" + }, + "LAP_JOINT": { + "dat_file_patterns": ["FLG_LJ_", "LJ_", "LAP_"], + "description_keywords": ["LAP JOINT", "LJ", "랩조인트"], + "characteristics": "스터브엔드 조합형", + "size_range": "1/2\" ~ 24\"", + "pressure_range": "150LB ~ 600LB" + } +} + +# ========== 면 가공별 분류 ========== +FACE_FINISHES = { + "RAISED_FACE": { + "codes": ["RF", "RAISED FACE", "볼록면"], + "characteristics": "볼록한 면 (가장 일반적)", + "pressure_range": "150LB ~ 600LB", + "confidence": 0.95 + }, + "FLAT_FACE": { + "codes": ["FF", "FLAT FACE", "평면"], + "characteristics": "평평한 면", + "pressure_range": "150LB ~ 300LB", + "confidence": 0.95 + }, + "RING_TYPE_JOINT": { + "codes": ["RTJ", "RING TYPE JOINT", "링타입"], + "characteristics": "홈이 파진 고압용", + "pressure_range": "600LB ~ 2500LB", + "confidence": 0.95 + } +} + +# ========== 압력 등급별 분류 ========== +FLANGE_PRESSURE_RATINGS = { + "patterns": [ + r"(\d+)LB", + r"CLASS\s*(\d+)", + r"CL\s*(\d+)", + r"(\d+)#", + r"(\d+)\s*LB" + ], + "standard_ratings": { + "150LB": {"max_pressure": "285 PSI", "common_use": "저압 일반용", "typical_face": "RF"}, + "300LB": {"max_pressure": "740 PSI", "common_use": "중압용", "typical_face": "RF"}, + "600LB": {"max_pressure": "1480 PSI", "common_use": "고압용", "typical_face": "RTJ"}, + "900LB": {"max_pressure": "2220 PSI", "common_use": "고압용", "typical_face": "RTJ"}, + "1500LB": {"max_pressure": "3705 PSI", "common_use": "고압용", "typical_face": "RTJ"}, + "2500LB": {"max_pressure": "6170 PSI", "common_use": "초고압용", "typical_face": "RTJ"}, + "3000LB": {"max_pressure": "7400 PSI", "common_use": "소구경 고압용", "typical_face": "RTJ"}, + "6000LB": {"max_pressure": "14800 PSI", "common_use": "소구경 초고압용", "typical_face": "RTJ"}, + "9000LB": {"max_pressure": "22200 PSI", "common_use": "소구경 극고압용", "typical_face": "RTJ"} + } +} + +def classify_flange(dat_file: str, description: str, main_nom: str, + red_nom: str = None, length: float = None) -> Dict: + """ + 완전한 FLANGE 분류 + + Args: + dat_file: DAT_FILE 필드 + description: DESCRIPTION 필드 + main_nom: MAIN_NOM 필드 (주 사이즈) + red_nom: RED_NOM 필드 (축소 사이즈, REDUCING 플랜지용) + + Returns: + 완전한 플랜지 분류 결과 + """ + + desc_upper = description.upper() + dat_upper = dat_file.upper() + + # 1. 플랜지 키워드 확인 (재질만 있어도 통합 분류기가 이미 플랜지로 분류했으므로 진행) + # 사이트 글라스와 스트레이너는 밸브로 분류되어야 함 + if 'SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or '사이트글라스' in desc_upper or '스트레이너' in desc_upper: + return { + "category": "VALVE", + "overall_confidence": 1.0, + "reason": "SIGHT GLASS 또는 STRAINER는 밸브로 분류" + } + + flange_keywords = ['FLG', 'FLANGE', '플랜지', 'ORIFICE', 'SPECTACLE', 'PADDLE', 'SPACER'] + has_flange_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in flange_keywords) + + # 플랜지 재질 확인 (A182, A350, A105 - 범용이지만 플랜지에 많이 사용) + flange_materials = ['A182', 'A350', 'A105'] + has_flange_material = any(material in desc_upper for material in flange_materials) + + # 플랜지 키워드도 없고 플랜지 재질도 없으면 UNKNOWN + if not has_flange_keyword and not has_flange_material: + return { + "category": "UNKNOWN", + "overall_confidence": 0.0, + "reason": "플랜지 키워드 및 재질 없음" + } + + # 2. 재질 분류 (공통 모듈 사용) + material_result = classify_material(description) + + # 2. SPECIAL vs STANDARD 분류 + flange_category_result = classify_flange_category(dat_file, description) + + # 3. 플랜지 타입 분류 + flange_type_result = classify_flange_type( + dat_file, description, main_nom, red_nom, flange_category_result + ) + + # 4. 면 가공 분류 + face_finish_result = classify_face_finish(dat_file, description) + + # 5. 압력 등급 분류 + pressure_result = classify_flange_pressure_rating(dat_file, description) + + # 6. 제작 방법 추정 + manufacturing_result = determine_flange_manufacturing( + material_result, flange_type_result, pressure_result, main_nom + ) + + # 7. 최종 결과 조합 + return { + "category": "FLANGE", + + # 재질 정보 (공통 모듈) + "material": { + "standard": material_result.get('standard', 'UNKNOWN'), + "grade": material_result.get('grade', 'UNKNOWN'), + "material_type": material_result.get('material_type', 'UNKNOWN'), + "confidence": material_result.get('confidence', 0.0) + }, + + # 플랜지 분류 정보 + "flange_category": { + "category": flange_category_result.get('category', 'UNKNOWN'), + "is_special": flange_category_result.get('is_special', False), + "confidence": flange_category_result.get('confidence', 0.0) + }, + + "flange_type": { + "type": flange_type_result.get('type', 'UNKNOWN'), + "characteristics": flange_type_result.get('characteristics', ''), + "confidence": flange_type_result.get('confidence', 0.0), + "evidence": flange_type_result.get('evidence', []), + "special_features": flange_type_result.get('special_features', []) + }, + + "face_finish": { + "finish": face_finish_result.get('finish', 'UNKNOWN'), + "characteristics": face_finish_result.get('characteristics', ''), + "confidence": face_finish_result.get('confidence', 0.0) + }, + + "pressure_rating": { + "rating": pressure_result.get('rating', 'UNKNOWN'), + "confidence": pressure_result.get('confidence', 0.0), + "max_pressure": pressure_result.get('max_pressure', ''), + "common_use": pressure_result.get('common_use', ''), + "typical_face": pressure_result.get('typical_face', '') + }, + + "manufacturing": { + "method": manufacturing_result.get('method', 'UNKNOWN'), + "confidence": manufacturing_result.get('confidence', 0.0), + "evidence": manufacturing_result.get('evidence', []), + "characteristics": manufacturing_result.get('characteristics', '') + }, + + "size_info": { + "main_size": main_nom, + "reduced_size": red_nom, + "size_description": format_flange_size(main_nom, red_nom), + "requires_two_sizes": flange_type_result.get('requires_two_sizes', False) + }, + + # 전체 신뢰도 + "overall_confidence": calculate_flange_confidence({ + "material": material_result.get('confidence', 0), + "flange_type": flange_type_result.get('confidence', 0), + "face_finish": face_finish_result.get('confidence', 0), + "pressure": pressure_result.get('confidence', 0) + }) + } + +def classify_flange_category(dat_file: str, description: str) -> Dict: + """SPECIAL vs STANDARD 플랜지 분류""" + + dat_upper = dat_file.upper() + desc_upper = description.upper() + combined_text = f"{dat_upper} {desc_upper}" + + # SPECIAL 플랜지 확인 (우선) + for special_type, type_data in SPECIAL_FLANGE_TYPES.items(): + # DAT_FILE 패턴 확인 + for pattern in type_data["dat_file_patterns"]: + if pattern in dat_upper: + return { + "category": "SPECIAL", + "special_type": special_type, + "is_special": True, + "confidence": 0.95, + "evidence": [f"SPECIAL_DAT_PATTERN: {pattern}"] + } + + # DESCRIPTION 키워드 확인 + for keyword in type_data["description_keywords"]: + if keyword in combined_text: + return { + "category": "SPECIAL", + "special_type": special_type, + "is_special": True, + "confidence": 0.9, + "evidence": [f"SPECIAL_KEYWORD: {keyword}"] + } + + # STANDARD 플랜지로 분류 + return { + "category": "STANDARD", + "is_special": False, + "confidence": 0.8, + "evidence": ["NO_SPECIAL_INDICATORS"] + } + +def classify_flange_type(dat_file: str, description: str, main_nom: str, + red_nom: str, category_result: Dict) -> Dict: + """플랜지 타입 분류 (SPECIAL 또는 STANDARD)""" + + dat_upper = dat_file.upper() + desc_upper = description.upper() + + if category_result.get('is_special'): + # SPECIAL 플랜지 타입 확인 + special_type = category_result.get('special_type') + if special_type and special_type in SPECIAL_FLANGE_TYPES: + type_data = SPECIAL_FLANGE_TYPES[special_type] + + return { + "type": special_type, + "characteristics": type_data["characteristics"], + "confidence": 0.95, + "evidence": [f"SPECIAL_TYPE: {special_type}"], + "special_features": type_data["special_features"], + "requires_two_sizes": type_data.get("requires_two_sizes", False) + } + + else: + # STANDARD 플랜지 타입 확인 + for flange_type, type_data in STANDARD_FLANGE_TYPES.items(): + # DAT_FILE 패턴 확인 + for pattern in type_data["dat_file_patterns"]: + if pattern in dat_upper: + return { + "type": flange_type, + "characteristics": type_data["characteristics"], + "confidence": 0.95, + "evidence": [f"STANDARD_DAT_PATTERN: {pattern}"], + "special_features": [], + "requires_two_sizes": False + } + + # DESCRIPTION 키워드 확인 + for keyword in type_data["description_keywords"]: + if keyword in desc_upper: + return { + "type": flange_type, + "characteristics": type_data["characteristics"], + "confidence": 0.85, + "evidence": [f"STANDARD_KEYWORD: {keyword}"], + "special_features": [], + "requires_two_sizes": False + } + + # 분류 실패 + return { + "type": "UNKNOWN", + "characteristics": "", + "confidence": 0.0, + "evidence": ["NO_FLANGE_TYPE_IDENTIFIED"], + "special_features": [], + "requires_two_sizes": False + } + +def classify_face_finish(dat_file: str, description: str) -> Dict: + """플랜지 면 가공 분류""" + + combined_text = f"{dat_file} {description}".upper() + + for finish_type, finish_data in FACE_FINISHES.items(): + for code in finish_data["codes"]: + if code in combined_text: + return { + "finish": finish_type, + "confidence": finish_data["confidence"], + "matched_code": code, + "characteristics": finish_data["characteristics"], + "pressure_range": finish_data["pressure_range"] + } + + # 기본값: RF (가장 일반적) + return { + "finish": "RAISED_FACE", + "confidence": 0.6, + "matched_code": "DEFAULT", + "characteristics": "기본값 (가장 일반적)", + "pressure_range": "150LB ~ 600LB" + } + +def classify_flange_pressure_rating(dat_file: str, description: str) -> Dict: + """플랜지 압력 등급 분류""" + + combined_text = f"{dat_file} {description}".upper() + + # 패턴 매칭으로 압력 등급 추출 + for pattern in FLANGE_PRESSURE_RATINGS["patterns"]: + match = re.search(pattern, combined_text) + if match: + rating_num = match.group(1) + rating = f"{rating_num}LB" + + # 표준 등급 정보 확인 + rating_info = FLANGE_PRESSURE_RATINGS["standard_ratings"].get(rating, {}) + + if rating_info: + confidence = 0.95 + else: + confidence = 0.8 + rating_info = {"max_pressure": "확인 필요", "common_use": "비표준 등급", "typical_face": "RF"} + + return { + "rating": rating, + "confidence": confidence, + "matched_pattern": pattern, + "matched_value": rating_num, + "max_pressure": rating_info.get("max_pressure", ""), + "common_use": rating_info.get("common_use", ""), + "typical_face": rating_info.get("typical_face", "RF") + } + + return { + "rating": "UNKNOWN", + "confidence": 0.0, + "matched_pattern": "", + "max_pressure": "", + "common_use": "", + "typical_face": "" + } + +def determine_flange_manufacturing(material_result: Dict, flange_type_result: Dict, + pressure_result: Dict, main_nom: str) -> Dict: + """플랜지 제작 방법 결정""" + + evidence = [] + + # 1. 재질 기반 제작방법 (가장 확실) + material_manufacturing = get_manufacturing_method_from_material(material_result) + if material_manufacturing in ["FORGED", "CAST"]: + evidence.append(f"MATERIAL_STANDARD: {material_result.get('standard')}") + + characteristics = { + "FORGED": "고강도, 고압용", + "CAST": "복잡형상, 중저압용" + }.get(material_manufacturing, "") + + return { + "method": material_manufacturing, + "confidence": 0.9, + "evidence": evidence, + "characteristics": characteristics + } + + # 2. 압력등급 + 사이즈 조합으로 추정 + pressure_rating = pressure_result.get('rating', '') + + # 고압 = 단조 + high_pressure = ["600LB", "900LB", "1500LB", "2500LB", "3000LB", "6000LB", "9000LB"] + if any(pressure in pressure_rating for pressure in high_pressure): + evidence.append(f"HIGH_PRESSURE: {pressure_rating}") + return { + "method": "FORGED", + "confidence": 0.85, + "evidence": evidence, + "characteristics": "고압용 단조품" + } + + # 저압 + 대구경 = 주조 가능 + low_pressure = ["150LB", "300LB"] + if any(pressure in pressure_rating for pressure in low_pressure): + try: + size_num = float(re.findall(r'(\d+(?:\.\d+)?)', main_nom)[0]) + if size_num >= 12.0: # 12인치 이상 + evidence.append(f"LARGE_SIZE_LOW_PRESSURE: {main_nom}, {pressure_rating}") + return { + "method": "CAST", + "confidence": 0.8, + "evidence": evidence, + "characteristics": "대구경 저압용 주조품" + } + except: + pass + + # 3. 기본 추정 + return { + "method": "FORGED", + "confidence": 0.7, + "evidence": ["DEFAULT_FORGED"], + "characteristics": "일반적으로 단조품" + } + +def format_flange_size(main_nom: str, red_nom: str = None) -> str: + """플랜지 사이즈 표기 포맷팅""" + main_nom_str = str(main_nom) if main_nom is not None else "" + red_nom_str = str(red_nom) if red_nom is not None else "" + if red_nom_str.strip() and red_nom_str != main_nom_str: + return f"{main_nom_str} x {red_nom_str}" + else: + return main_nom_str + +def calculate_flange_confidence(confidence_scores: Dict) -> float: + """플랜지 분류 전체 신뢰도 계산""" + + scores = [score for score in confidence_scores.values() if score > 0] + + if not scores: + return 0.0 + + # 가중 평균 + weights = { + "material": 0.25, + "flange_type": 0.4, + "face_finish": 0.2, + "pressure": 0.15 + } + + weighted_sum = sum( + confidence_scores.get(key, 0) * weight + for key, weight in weights.items() + ) + + return round(weighted_sum, 2) + +# ========== 특수 기능들 ========== + +def is_high_pressure_flange(pressure_rating: str) -> bool: + """고압 플랜지 여부 판단""" + high_pressure_ratings = ["600LB", "900LB", "1500LB", "2500LB", "3000LB", "6000LB", "9000LB"] + return pressure_rating in high_pressure_ratings + +def is_special_flange(flange_result: Dict) -> bool: + """특수 플랜지 여부 판단""" + return flange_result.get("flange_category", {}).get("is_special", False) + +def get_flange_purchase_info(flange_result: Dict) -> Dict: + """플랜지 구매 정보 생성""" + + flange_type = flange_result["flange_type"]["type"] + pressure = flange_result["pressure_rating"]["rating"] + manufacturing = flange_result["manufacturing"]["method"] + is_special = flange_result["flange_category"]["is_special"] + + # 공급업체 타입 결정 + if is_special: + supplier_type = "특수 플랜지 전문업체" + elif manufacturing == "FORGED": + supplier_type = "단조 플랜지 업체" + elif manufacturing == "CAST": + supplier_type = "주조 플랜지 업체" + else: + supplier_type = "일반 플랜지 업체" + + # 납기 추정 + if is_special: + lead_time = "8-12주 (특수품)" + elif is_high_pressure_flange(pressure): + lead_time = "6-10주 (고압용)" + elif manufacturing == "FORGED": + lead_time = "4-8주 (단조품)" + else: + lead_time = "2-6주 (일반품)" + + return { + "supplier_type": supplier_type, + "lead_time_estimate": lead_time, + "purchase_category": f"{flange_type} {pressure}", + "manufacturing_note": flange_result["manufacturing"]["characteristics"], + "special_requirements": flange_result["flange_type"]["special_features"] + } diff --git a/tkeg/api/app/services/gasket_classifier.py b/tkeg/api/app/services/gasket_classifier.py new file mode 100644 index 0000000..39b606f --- /dev/null +++ b/tkeg/api/app/services/gasket_classifier.py @@ -0,0 +1,616 @@ +""" +GASKET 분류 시스템 +플랜지용 가스켓 및 씰링 제품 분류 +""" + +import re +from typing import Dict, List, Optional +from .material_classifier import classify_material + +# ========== 가스켓 타입별 분류 ========== +GASKET_TYPES = { + "SPIRAL_WOUND": { + "dat_file_patterns": ["SWG_", "SPIRAL_"], + "description_keywords": ["SPIRAL WOUND", "SPIRAL", "스파이럴", "SWG"], + "characteristics": "금속 스트립과 필러의 나선형 조합", + "pressure_range": "150LB ~ 2500LB", + "temperature_range": "-200°C ~ 800°C", + "applications": "고온고압, 일반 산업용" + }, + + "RING_JOINT": { + "dat_file_patterns": ["RTJ_", "RJ_", "RING_"], + "description_keywords": ["RING JOINT", "RTJ", "RING TYPE JOINT", "링조인트"], + "characteristics": "금속 링 형태의 고압용 가스켓", + "pressure_range": "600LB ~ 2500LB", + "temperature_range": "-100°C ~ 650°C", + "applications": "고압 플랜지 전용" + }, + + "FULL_FACE": { + "dat_file_patterns": ["FF_", "FULL_"], + "description_keywords": ["FULL FACE", "FF", "풀페이스"], + "characteristics": "플랜지 전면 커버 가스켓", + "pressure_range": "150LB ~ 300LB", + "temperature_range": "-50°C ~ 400°C", + "applications": "평면 플랜지용" + }, + + "RAISED_FACE": { + "dat_file_patterns": ["RF_", "RAISED_"], + "description_keywords": ["RAISED FACE", "RF", "레이즈드"], + "characteristics": "볼록한 면 전용 가스켓", + "pressure_range": "150LB ~ 600LB", + "temperature_range": "-50°C ~ 450°C", + "applications": "일반 볼록면 플랜지용" + }, + + "O_RING": { + "dat_file_patterns": ["OR_", "ORING_"], + "description_keywords": ["O-RING", "O RING", "ORING", "오링"], + "characteristics": "원형 단면의 씰링 링", + "pressure_range": "저압 ~ 고압 (재질별)", + "temperature_range": "-60°C ~ 300°C (재질별)", + "applications": "홈 씰링, 회전축 씰링" + }, + + "SHEET_GASKET": { + "dat_file_patterns": ["SHEET_", "SHT_"], + "description_keywords": ["SHEET GASKET", "SHEET", "시트"], + "characteristics": "판 형태의 가스켓", + "pressure_range": "150LB ~ 600LB", + "temperature_range": "-50°C ~ 500°C", + "applications": "일반 플랜지, 맨홀" + }, + + "KAMMPROFILE": { + "dat_file_patterns": ["KAMM_", "KP_"], + "description_keywords": ["KAMMPROFILE", "KAMM", "캄프로파일"], + "characteristics": "파형 금속에 소프트 코팅", + "pressure_range": "150LB ~ 1500LB", + "temperature_range": "-200°C ~ 700°C", + "applications": "고온고압, 화학공정" + }, + + "CUSTOM_GASKET": { + "dat_file_patterns": ["CUSTOM_", "SPEC_"], + "description_keywords": ["CUSTOM", "SPECIAL", "특주", "맞춤"], + "characteristics": "특수 제작 가스켓", + "pressure_range": "요구사항별", + "temperature_range": "요구사항별", + "applications": "특수 형상, 특수 조건" + } +} + +# ========== 가스켓 재질별 분류 ========== +GASKET_MATERIALS = { + "GRAPHITE": { + "keywords": ["GRAPHITE", "그라파이트", "흑연"], + "characteristics": "고온 내성, 화학 안정성", + "temperature_range": "-200°C ~ 650°C", + "applications": "고온 스팀, 화학공정" + }, + + "PTFE": { + "keywords": ["PTFE", "TEFLON", "테프론"], + "characteristics": "화학 내성, 낮은 마찰", + "temperature_range": "-200°C ~ 260°C", + "applications": "화학공정, 식품용" + }, + + "VITON": { + "keywords": ["VITON", "FKM", "바이톤"], + "characteristics": "유류 내성, 고온 내성", + "temperature_range": "-20°C ~ 200°C", + "applications": "유류, 고온 가스" + }, + + "EPDM": { + "keywords": ["EPDM", "이피디엠"], + "characteristics": "일반 고무, 스팀 내성", + "temperature_range": "-50°C ~ 150°C", + "applications": "스팀, 일반용" + }, + + "NBR": { + "keywords": ["NBR", "NITRILE", "니트릴"], + "characteristics": "유류 내성", + "temperature_range": "-30°C ~ 100°C", + "applications": "유압, 윤활유" + }, + + "METAL": { + "keywords": ["METAL", "SS", "STAINLESS", "금속"], + "characteristics": "고온고압 내성", + "temperature_range": "-200°C ~ 800°C", + "applications": "극한 조건" + }, + + "COMPOSITE": { + "keywords": ["COMPOSITE", "복합재", "FIBER"], + "characteristics": "다층 구조", + "temperature_range": "재질별 상이", + "applications": "특수 조건" + } +} + +# ========== 압력 등급별 분류 ========== +GASKET_PRESSURE_RATINGS = { + "patterns": [ + r"(\d+)LB", + r"CLASS\s*(\d+)", + r"CL\s*(\d+)", + r"(\d+)#" + ], + "standard_ratings": { + "150LB": {"max_pressure": "285 PSI", "typical_gasket": "SHEET, SPIRAL_WOUND"}, + "300LB": {"max_pressure": "740 PSI", "typical_gasket": "SPIRAL_WOUND, SHEET"}, + "600LB": {"max_pressure": "1480 PSI", "typical_gasket": "SPIRAL_WOUND, RTJ"}, + "900LB": {"max_pressure": "2220 PSI", "typical_gasket": "SPIRAL_WOUND, RTJ"}, + "1500LB": {"max_pressure": "3705 PSI", "typical_gasket": "RTJ, SPIRAL_WOUND"}, + "2500LB": {"max_pressure": "6170 PSI", "typical_gasket": "RTJ"} + } +} + +# ========== 사이즈 표기법 ========== +GASKET_SIZE_PATTERNS = { + "flange_size": r"(\d+(?:\.\d+)?)\s*[\"\'']?\s*(?:INCH|IN|인치)?", + "inner_diameter": r"ID\s*(\d+(?:\.\d+)?)", + "outer_diameter": r"OD\s*(\d+(?:\.\d+)?)", + "thickness": r"THK?\s*(\d+(?:\.\d+)?)\s*MM" +} + +def classify_gasket(dat_file: str, description: str, main_nom: str, length: float = None) -> Dict: + """ + 완전한 GASKET 분류 + + Args: + dat_file: DAT_FILE 필드 + description: DESCRIPTION 필드 + main_nom: MAIN_NOM 필드 (플랜지 사이즈) + + Returns: + 완전한 가스켓 분류 결과 + """ + + # 1. 재질 분류 (공통 모듈 + 가스켓 전용) + material_result = classify_material(description) + gasket_material_result = classify_gasket_material(description) + + # 2. 가스켓 타입 분류 + gasket_type_result = classify_gasket_type(dat_file, description) + + # 3. 압력 등급 분류 + pressure_result = classify_gasket_pressure_rating(dat_file, description) + + # 4. 사이즈 정보 추출 + size_result = extract_gasket_size_info(main_nom, description) + + # 5. 온도 범위 추출 + temperature_result = extract_temperature_range(description, gasket_type_result, gasket_material_result) + + # 6. 최종 결과 조합 + return { + "category": "GASKET", + + # 재질 정보 (공통 + 가스켓 전용) + "material": { + "standard": material_result.get('standard', 'UNKNOWN'), + "grade": material_result.get('grade', 'UNKNOWN'), + "material_type": material_result.get('material_type', 'UNKNOWN'), + "confidence": material_result.get('confidence', 0.0) + }, + + "gasket_material": { + "material": gasket_material_result.get('material', 'UNKNOWN'), + "characteristics": gasket_material_result.get('characteristics', ''), + "temperature_range": gasket_material_result.get('temperature_range', ''), + "confidence": gasket_material_result.get('confidence', 0.0), + "swg_details": gasket_material_result.get('swg_details', {}) + }, + + # 가스켓 분류 정보 + "gasket_type": { + "type": gasket_type_result.get('type', 'UNKNOWN'), + "characteristics": gasket_type_result.get('characteristics', ''), + "confidence": gasket_type_result.get('confidence', 0.0), + "evidence": gasket_type_result.get('evidence', []), + "pressure_range": gasket_type_result.get('pressure_range', ''), + "applications": gasket_type_result.get('applications', '') + }, + + "pressure_rating": { + "rating": pressure_result.get('rating', 'UNKNOWN'), + "confidence": pressure_result.get('confidence', 0.0), + "max_pressure": pressure_result.get('max_pressure', ''), + "typical_gasket": pressure_result.get('typical_gasket', '') + }, + + "size_info": { + "flange_size": size_result.get('flange_size', main_nom), + "inner_diameter": size_result.get('inner_diameter', ''), + "outer_diameter": size_result.get('outer_diameter', ''), + "thickness": size_result.get('thickness', ''), + "size_description": size_result.get('size_description', main_nom) + }, + + "temperature_info": { + "range": temperature_result.get('range', ''), + "max_temp": temperature_result.get('max_temp', ''), + "min_temp": temperature_result.get('min_temp', ''), + "confidence": temperature_result.get('confidence', 0.0) + }, + + # 전체 신뢰도 + "overall_confidence": calculate_gasket_confidence({ + "gasket_type": gasket_type_result.get('confidence', 0), + "gasket_material": gasket_material_result.get('confidence', 0), + "pressure": pressure_result.get('confidence', 0), + "size": size_result.get('confidence', 0.8) # 기본 신뢰도 + }) + } + +def classify_gasket_type(dat_file: str, description: str) -> Dict: + """가스켓 타입 분류""" + + dat_upper = dat_file.upper() + desc_upper = description.upper() + + # 1. DAT_FILE 패턴으로 1차 분류 + for gasket_type, type_data in GASKET_TYPES.items(): + for pattern in type_data["dat_file_patterns"]: + if pattern in dat_upper: + return { + "type": gasket_type, + "characteristics": type_data["characteristics"], + "confidence": 0.95, + "evidence": [f"DAT_FILE_PATTERN: {pattern}"], + "pressure_range": type_data["pressure_range"], + "temperature_range": type_data["temperature_range"], + "applications": type_data["applications"] + } + + # 2. DESCRIPTION 키워드로 2차 분류 + for gasket_type, type_data in GASKET_TYPES.items(): + for keyword in type_data["description_keywords"]: + if keyword in desc_upper: + return { + "type": gasket_type, + "characteristics": type_data["characteristics"], + "confidence": 0.85, + "evidence": [f"DESCRIPTION_KEYWORD: {keyword}"], + "pressure_range": type_data["pressure_range"], + "temperature_range": type_data["temperature_range"], + "applications": type_data["applications"] + } + + # 3. 분류 실패 + return { + "type": "UNKNOWN", + "characteristics": "", + "confidence": 0.0, + "evidence": ["NO_GASKET_TYPE_IDENTIFIED"], + "pressure_range": "", + "temperature_range": "", + "applications": "" + } + +def parse_swg_details(description: str) -> Dict: + """SWG (Spiral Wound Gasket) 상세 정보 파싱""" + + desc_upper = description.upper() + result = { + "face_type": "UNKNOWN", + "outer_ring": "UNKNOWN", + "filler": "UNKNOWN", + "inner_ring": "UNKNOWN", + "thickness": None, + "detailed_construction": "", + "confidence": 0.0 + } + + # H/F/I/O 패턴 파싱 (Head/Face/Inner/Outer) + hfio_pattern = r'H/F/I/O|HFIO' + if re.search(hfio_pattern, desc_upper): + result["face_type"] = "H/F/I/O" + result["confidence"] += 0.3 + + # 재질 구성 파싱 (SS304/GRAPHITE/CS/CS) + # H/F/I/O 다음에 나오는 재질 구성을 찾음 + material_pattern = r'H/F/I/O\s+([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)' + material_match = re.search(material_pattern, desc_upper) + if material_match: + result["outer_ring"] = material_match.group(1) # SS304 + result["filler"] = material_match.group(2) # GRAPHITE + result["inner_ring"] = material_match.group(3) # CS + # 네 번째는 보통 outer ring 반복 + result["detailed_construction"] = f"{material_match.group(1)}/{material_match.group(2)}/{material_match.group(3)}/{material_match.group(4)}" + result["confidence"] += 0.4 + else: + # H/F/I/O 없이 재질만 있는 경우 + material_pattern_simple = r'([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)' + material_match = re.search(material_pattern_simple, desc_upper) + if material_match: + result["outer_ring"] = material_match.group(1) + result["filler"] = material_match.group(2) + result["inner_ring"] = material_match.group(3) + result["detailed_construction"] = f"{material_match.group(1)}/{material_match.group(2)}/{material_match.group(3)}/{material_match.group(4)}" + result["confidence"] += 0.3 + + # 두께 파싱 (4.5mm) + thickness_pattern = r'(\d+(?:\.\d+)?)\s*MM' + thickness_match = re.search(thickness_pattern, desc_upper) + if thickness_match: + result["thickness"] = float(thickness_match.group(1)) + result["confidence"] += 0.3 + + return result + +def classify_gasket_material(description: str) -> Dict: + """가스켓 전용 재질 분류 (SWG 상세 정보 포함)""" + + desc_upper = description.upper() + + # SWG 상세 정보 파싱 + swg_details = None + if "SWG" in desc_upper or "SPIRAL WOUND" in desc_upper: + swg_details = parse_swg_details(description) + + # 기본 가스켓 재질 확인 + for material_type, material_data in GASKET_MATERIALS.items(): + for keyword in material_data["keywords"]: + if keyword in desc_upper: + result = { + "material": material_type, + "characteristics": material_data["characteristics"], + "temperature_range": material_data["temperature_range"], + "confidence": 0.9, + "matched_keyword": keyword, + "applications": material_data["applications"] + } + + # SWG 상세 정보 추가 + if swg_details and swg_details["confidence"] > 0: + result["swg_details"] = swg_details + result["confidence"] = min(0.95, result["confidence"] + swg_details["confidence"] * 0.1) + + return result + + # 일반 재질 키워드 확인 + if any(keyword in desc_upper for keyword in ["RUBBER", "고무"]): + return { + "material": "RUBBER", + "characteristics": "일반 고무계", + "temperature_range": "-50°C ~ 100°C", + "confidence": 0.7, + "matched_keyword": "RUBBER", + "applications": "일반용" + } + + return { + "material": "UNKNOWN", + "characteristics": "", + "temperature_range": "", + "confidence": 0.0, + "matched_keyword": "", + "applications": "" + } + +def classify_gasket_pressure_rating(dat_file: str, description: str) -> Dict: + """가스켓 압력 등급 분류""" + + combined_text = f"{dat_file} {description}".upper() + + # 패턴 매칭으로 압력 등급 추출 + for pattern in GASKET_PRESSURE_RATINGS["patterns"]: + match = re.search(pattern, combined_text) + if match: + rating_num = match.group(1) + rating = f"{rating_num}LB" + + # 표준 등급 정보 확인 + rating_info = GASKET_PRESSURE_RATINGS["standard_ratings"].get(rating, {}) + + if rating_info: + confidence = 0.95 + else: + confidence = 0.8 + rating_info = { + "max_pressure": "확인 필요", + "typical_gasket": "확인 필요" + } + + return { + "rating": rating, + "confidence": confidence, + "matched_pattern": pattern, + "matched_value": rating_num, + "max_pressure": rating_info.get("max_pressure", ""), + "typical_gasket": rating_info.get("typical_gasket", "") + } + + return { + "rating": "UNKNOWN", + "confidence": 0.0, + "matched_pattern": "", + "max_pressure": "", + "typical_gasket": "" + } + +def extract_gasket_size_info(main_nom: str, description: str) -> Dict: + """가스켓 사이즈 정보 추출""" + + desc_upper = description.upper() + size_info = { + "flange_size": main_nom, + "inner_diameter": "", + "outer_diameter": "", + "thickness": "", + "size_description": main_nom, + "confidence": 0.8 + } + + # 내경(ID) 추출 + id_match = re.search(GASKET_SIZE_PATTERNS["inner_diameter"], desc_upper) + if id_match: + size_info["inner_diameter"] = f"{id_match.group(1)}mm" + + # 외경(OD) 추출 + od_match = re.search(GASKET_SIZE_PATTERNS["outer_diameter"], desc_upper) + if od_match: + size_info["outer_diameter"] = f"{od_match.group(1)}mm" + + # 두께(THK) 추출 + thk_match = re.search(GASKET_SIZE_PATTERNS["thickness"], desc_upper) + if thk_match: + size_info["thickness"] = f"{thk_match.group(1)}mm" + + # 사이즈 설명 조합 + size_parts = [main_nom] + if size_info["inner_diameter"] and size_info["outer_diameter"]: + size_parts.append(f"ID{size_info['inner_diameter']}") + size_parts.append(f"OD{size_info['outer_diameter']}") + if size_info["thickness"]: + size_parts.append(f"THK{size_info['thickness']}") + + size_info["size_description"] = " ".join(size_parts) + + return size_info + +def extract_temperature_range(description: str, gasket_type_result: Dict, + gasket_material_result: Dict) -> Dict: + """온도 범위 정보 추출""" + + desc_upper = description.upper() + + # DESCRIPTION에서 직접 온도 추출 + temp_patterns = [ + r'(\-?\d+(?:\.\d+)?)\s*°?C\s*~\s*(\-?\d+(?:\.\d+)?)\s*°?C', + r'(\-?\d+(?:\.\d+)?)\s*TO\s*(\-?\d+(?:\.\d+)?)\s*°?C', + r'MAX\s*(\-?\d+(?:\.\d+)?)\s*°?C', + r'MIN\s*(\-?\d+(?:\.\d+)?)\s*°?C' + ] + + for pattern in temp_patterns: + match = re.search(pattern, desc_upper) + if match: + if len(match.groups()) == 2: # 범위 + return { + "range": f"{match.group(1)}°C ~ {match.group(2)}°C", + "min_temp": f"{match.group(1)}°C", + "max_temp": f"{match.group(2)}°C", + "confidence": 0.95, + "source": "DESCRIPTION_RANGE" + } + else: # 단일 온도 + temp_value = match.group(1) + if "MAX" in pattern: + return { + "range": f"~ {temp_value}°C", + "max_temp": f"{temp_value}°C", + "confidence": 0.9, + "source": "DESCRIPTION_MAX" + } + + # 가스켓 재질 기반 온도 범위 + material_temp = gasket_material_result.get('temperature_range', '') + if material_temp: + return { + "range": material_temp, + "confidence": 0.8, + "source": "MATERIAL_BASED" + } + + # 가스켓 타입 기반 온도 범위 + type_temp = gasket_type_result.get('temperature_range', '') + if type_temp: + return { + "range": type_temp, + "confidence": 0.7, + "source": "TYPE_BASED" + } + + return { + "range": "", + "confidence": 0.0, + "source": "NO_TEMPERATURE_INFO" + } + +def calculate_gasket_confidence(confidence_scores: Dict) -> float: + """가스켓 분류 전체 신뢰도 계산""" + + scores = [score for score in confidence_scores.values() if score > 0] + + if not scores: + return 0.0 + + # 가중 평균 + weights = { + "gasket_type": 0.4, + "gasket_material": 0.3, + "pressure": 0.2, + "size": 0.1 + } + + weighted_sum = sum( + confidence_scores.get(key, 0) * weight + for key, weight in weights.items() + ) + + return round(weighted_sum, 2) + +# ========== 특수 기능들 ========== + +def get_gasket_purchase_info(gasket_result: Dict) -> Dict: + """가스켓 구매 정보 생성""" + + gasket_type = gasket_result["gasket_type"]["type"] + gasket_material = gasket_result["gasket_material"]["material"] + pressure = gasket_result["pressure_rating"]["rating"] + + # 공급업체 타입 결정 + if gasket_type == "CUSTOM_GASKET": + supplier_type = "특수 가스켓 제작업체" + elif gasket_material in ["GRAPHITE", "PTFE"]: + supplier_type = "고급 씰링 전문업체" + elif gasket_type in ["SPIRAL_WOUND", "RING_JOINT"]: + supplier_type = "산업용 가스켓 전문업체" + else: + supplier_type = "일반 가스켓 업체" + + # 납기 추정 + if gasket_type == "CUSTOM_GASKET": + lead_time = "4-8주 (특수 제작)" + elif gasket_type in ["SPIRAL_WOUND", "KAMMPROFILE"]: + lead_time = "2-4주 (제작품)" + else: + lead_time = "1-2주 (재고품)" + + # 구매 단위 + if gasket_type == "O_RING": + purchase_unit = "EA (개별)" + elif gasket_type == "SHEET_GASKET": + purchase_unit = "SHEET (시트)" + else: + purchase_unit = "SET (세트)" + + return { + "supplier_type": supplier_type, + "lead_time_estimate": lead_time, + "purchase_category": f"{gasket_type} {pressure}", + "purchase_unit": purchase_unit, + "material_note": gasket_result["gasket_material"]["characteristics"], + "temperature_note": gasket_result["temperature_info"]["range"], + "applications": gasket_result["gasket_type"]["applications"] + } + +def is_high_temperature_gasket(gasket_result: Dict) -> bool: + """고온용 가스켓 여부 판단""" + temp_range = gasket_result.get("temperature_info", {}).get("range", "") + return any(indicator in temp_range for indicator in ["500°C", "600°C", "700°C", "800°C"]) + +def is_high_pressure_gasket(gasket_result: Dict) -> bool: + """고압용 가스켓 여부 판단""" + pressure_rating = gasket_result.get("pressure_rating", {}).get("rating", "") + high_pressure_ratings = ["600LB", "900LB", "1500LB", "2500LB"] + return any(pressure in pressure_rating for pressure in high_pressure_ratings) diff --git a/tkeg/api/app/services/instrument_classifier.py b/tkeg/api/app/services/instrument_classifier.py new file mode 100644 index 0000000..0b8c413 --- /dev/null +++ b/tkeg/api/app/services/instrument_classifier.py @@ -0,0 +1,233 @@ +""" +INSTRUMENT 분류 시스템 (간단 버전) +완제품 구매용 기본 분류만 +""" + +import re +from typing import Dict, List, Optional +from .material_classifier import classify_material + +# ========== 기본 계기 타입 ========== +INSTRUMENT_TYPES = { + "PRESSURE_GAUGE": { + "dat_file_patterns": ["PG_", "PRESS_G"], + "description_keywords": ["PRESSURE GAUGE", "압력계", "PG"], + "characteristics": "압력 측정용 게이지" + }, + "TEMPERATURE_GAUGE": { + "dat_file_patterns": ["TG_", "TEMP_G"], + "description_keywords": ["TEMPERATURE GAUGE", "온도계", "TG", "THERMOMETER"], + "characteristics": "온도 측정용 게이지" + }, + "FLOW_METER": { + "dat_file_patterns": ["FM_", "FLOW_"], + "description_keywords": ["FLOW METER", "유량계", "FM"], + "characteristics": "유량 측정용" + }, + "LEVEL_GAUGE": { + "dat_file_patterns": ["LG_", "LEVEL_"], + "description_keywords": ["LEVEL GAUGE", "액위계", "LG", "SIGHT GLASS"], + "characteristics": "액위 측정용" + }, + "TRANSMITTER": { + "dat_file_patterns": ["PT_", "TT_", "FT_", "LT_"], + "description_keywords": ["TRANSMITTER", "트랜스미터", "4-20MA"], + "characteristics": "신호 전송용" + }, + "INDICATOR": { + "dat_file_patterns": ["PI_", "TI_", "FI_", "LI_"], + "description_keywords": ["INDICATOR", "지시계", "DISPLAY"], + "characteristics": "표시용" + }, + "SPECIAL_INSTRUMENT": { + "dat_file_patterns": ["INST_", "SPEC_"], + "description_keywords": ["THERMOWELL", "ORIFICE PLATE", "MANOMETER", "ROTAMETER"], + "characteristics": "특수 계기류" + } +} + +def classify_instrument(dat_file: str, description: str, main_nom: str, length: Optional[float] = None) -> Dict: + """ + 간단한 INSTRUMENT 분류 + + Args: + dat_file: DAT_FILE 필드 + description: DESCRIPTION 필드 + main_nom: MAIN_NOM 필드 (연결 사이즈) + + Returns: + 간단한 계기 분류 결과 + """ + + # 1. 먼저 계기인지 확인 (계기 키워드가 있어야 함) + desc_upper = description.upper() + dat_upper = dat_file.upper() + combined_text = f"{dat_upper} {desc_upper}" + + # 계기 관련 키워드 확인 + instrument_keywords = [ + "GAUGE", "METER", "TRANSMITTER", "INDICATOR", "SENSOR", + "THERMOMETER", "MANOMETER", "ROTAMETER", "THERMOWELL", + "ORIFICE PLATE", "DISPLAY", "4-20MA", "4-20 MA", + "압력계", "온도계", "유량계", "액위계", "게이지", "계기", + "트랜스미터", "지시계", "센서" + ] + + # 계기가 아닌 것들의 키워드 + non_instrument_keywords = [ + "BOLT", "SCREW", "STUD", "NUT", "WASHER", "볼트", "나사", "너트", "와셔", + "PIPE", "TUBE", "파이프", "배관", + "ELBOW", "TEE", "REDUCER", "CAP", "엘보", "티", "리듀서", + "VALVE", "GATE", "BALL", "GLOBE", "CHECK", "밸브", + "FLANGE", "FLG", "플랜지", + "GASKET", "GASK", "가스켓" + ] + + # 계기가 아닌 키워드가 있으면 거부 + if any(keyword in combined_text for keyword in non_instrument_keywords): + return { + "category": "UNKNOWN", + "overall_confidence": 0.1, + "reason": "NON_INSTRUMENT_KEYWORDS_DETECTED" + } + + # 계기 키워드가 없으면 거부 + has_instrument_keyword = any(keyword in combined_text for keyword in instrument_keywords) + if not has_instrument_keyword: + return { + "category": "UNKNOWN", + "overall_confidence": 0.1, + "reason": "NO_INSTRUMENT_KEYWORDS_FOUND" + } + + # 2. 재질 분류 (공통 모듈) + material_result = classify_material(description) + + # 3. 계기 타입 분류 + instrument_type_result = classify_instrument_type(dat_file, description) + + # 4. 측정 범위 추출 (있다면) + measurement_range = extract_measurement_range(description) + + # 5. 전체 신뢰도 계산 + base_confidence = 0.8 if has_instrument_keyword else 0.1 + instrument_confidence = instrument_type_result.get('confidence', 0.0) + overall_confidence = (base_confidence + instrument_confidence) / 2 + + # 6. 최종 결과 + return { + "category": "INSTRUMENT", + + # 재질 정보 + "material": { + "standard": material_result.get('standard', 'UNKNOWN'), + "grade": material_result.get('grade', 'UNKNOWN'), + "material_type": material_result.get('material_type', 'UNKNOWN'), + "confidence": material_result.get('confidence', 0.0) + }, + + # 계기 정보 + "instrument_type": { + "type": instrument_type_result.get('type', 'UNKNOWN'), + "characteristics": instrument_type_result.get('characteristics', ''), + "confidence": instrument_type_result.get('confidence', 0.0) + }, + + "measurement_info": { + "range": measurement_range.get('range', ''), + "unit": measurement_range.get('unit', ''), + "signal_type": measurement_range.get('signal_type', '') + }, + + "size_info": { + "connection_size": main_nom, + "size_description": main_nom + }, + + "purchase_info": { + "category": "완제품 구매", + "supplier_type": "계기 전문업체", + "lead_time": "2-4주", + "note": "사양서 확인 후 주문" + }, + + # 전체 신뢰도 + "overall_confidence": overall_confidence + } + +def classify_instrument_type(dat_file: str, description: str) -> Dict: + """계기 타입 분류""" + + dat_upper = dat_file.upper() + desc_upper = description.upper() + + # DAT_FILE 패턴 확인 + for inst_type, type_data in INSTRUMENT_TYPES.items(): + for pattern in type_data["dat_file_patterns"]: + if pattern in dat_upper: + return { + "type": inst_type, + "characteristics": type_data["characteristics"], + "confidence": 0.9, + "evidence": [f"DAT_PATTERN: {pattern}"] + } + + # DESCRIPTION 키워드 확인 + for inst_type, type_data in INSTRUMENT_TYPES.items(): + for keyword in type_data["description_keywords"]: + if keyword in desc_upper: + return { + "type": inst_type, + "characteristics": type_data["characteristics"], + "confidence": 0.8, + "evidence": [f"KEYWORD: {keyword}"] + } + + return { + "type": "UNKNOWN", + "characteristics": "분류되지 않은 계기", + "confidence": 0.0, + "evidence": ["NO_INSTRUMENT_TYPE_FOUND"] + } + +def extract_measurement_range(description: str) -> Dict: + """측정 범위 추출 (간단히)""" + + desc_upper = description.upper() + + # 압력 범위 + pressure_match = re.search(r'(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*(PSI|BAR|KPA)', desc_upper) + if pressure_match: + return { + "range": f"{pressure_match.group(1)}-{pressure_match.group(2)}", + "unit": pressure_match.group(3), + "signal_type": "PRESSURE" + } + + # 온도 범위 + temp_match = re.search(r'(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*(°?C|°?F)', desc_upper) + if temp_match: + return { + "range": f"{temp_match.group(1)}-{temp_match.group(2)}", + "unit": temp_match.group(3), + "signal_type": "TEMPERATURE" + } + + # 신호 타입 + if "4-20MA" in desc_upper or "4-20 MA" in desc_upper: + return { + "range": "4-20mA", + "unit": "mA", + "signal_type": "ANALOG" + } + + return { + "range": "", + "unit": "", + "signal_type": "" + } + +def calculate_simple_confidence(scores: List[float]) -> float: + """간단한 신뢰도 계산""" + valid_scores = [s for s in scores if s > 0] + return round(sum(valid_scores) / len(valid_scores), 2) if valid_scores else 0.0 diff --git a/tkeg/api/app/services/integrated_classifier.py b/tkeg/api/app/services/integrated_classifier.py new file mode 100644 index 0000000..70bd74b --- /dev/null +++ b/tkeg/api/app/services/integrated_classifier.py @@ -0,0 +1,301 @@ +""" +통합 자재 분류 시스템 +메모리에 정의된 키워드 우선순위 체계를 적용 +""" + +import re +from typing import Dict, List, Optional, Tuple +from .fitting_classifier import classify_fitting +from .classifier_constants import ( + LEVEL1_TYPE_KEYWORDS, + LEVEL2_SUBTYPE_KEYWORDS, + LEVEL3_CONNECTION_KEYWORDS, + LEVEL3_PRESSURE_KEYWORDS, + LEVEL4_MATERIAL_KEYWORDS, + GENERIC_MATERIALS +) + +def classify_material_integrated(description: str, main_nom: str = "", + red_nom: str = "", length: float = None) -> Dict: + """ + 통합 자재 분류 함수 + + Args: + description: 자재 설명 + main_nom: 주 사이즈 + red_nom: 축소 사이즈 (플랜지/피팅용) + length: 길이 (파이프용) + + Returns: + 분류 결과 딕셔너리 + """ + + desc_upper = description.upper() + + # 최우선: SPECIAL 키워드 확인 (도면 업로드가 필요한 특수 자재) + # SPECIAL이 포함된 경우 (단, SPECIFICATION은 제외) + if 'SPECIAL' in desc_upper and 'SPECIFICATION' not in desc_upper: + return { + "category": "SPECIAL", + "confidence": 1.0, + "evidence": ["SPECIAL_KEYWORD"], + "classification_level": "LEVEL0_SPECIAL", + "reason": "SPECIAL 키워드 발견" + } + + # 스페셜 관련 한글 키워드 + if '스페셜' in desc_upper or 'SPL' in desc_upper: + return { + "category": "SPECIAL", + "confidence": 1.0, + "evidence": ["SPECIAL_KEYWORD"], + "classification_level": "LEVEL0_SPECIAL", + "reason": "스페셜 키워드 발견" + } + + + # VALVE 카테고리 우선 확인 (SIGHT GLASS, STRAINER) + if ('SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or + '사이트글라스' in desc_upper or '스트레이너' in desc_upper): + return { + "category": "VALVE", + "confidence": 1.0, + "evidence": ["VALVE_SPECIAL_KEYWORD"], + "classification_level": "LEVEL0_VALVE", + "reason": "SIGHT GLASS 또는 STRAINER 키워드 발견" + } + + # SUPPORT 카테고리 우선 확인 (BOLT 카테고리보다 먼저) + # U-BOLT, CLAMP, URETHANE BLOCK 등 + if ('U-BOLT' in desc_upper or 'U BOLT' in desc_upper or '유볼트' in desc_upper or + 'URETHANE BLOCK' in desc_upper or 'BLOCK SHOE' in desc_upper or '우레탄' in desc_upper or + 'CLAMP' in desc_upper or '클램프' in desc_upper or 'PIPE CLAMP' in desc_upper): + return { + "category": "SUPPORT", + "confidence": 1.0, + "evidence": ["SUPPORT_SYSTEM_KEYWORD"], + "classification_level": "LEVEL0_SUPPORT", + "reason": "SUPPORT 시스템 키워드 발견" + } + + # [신규] Swagelok 스타일 파트 넘버 패턴 확인 + # 예: SS-400-1-4, SS-810-6, B-400-9, SS-1610-P + swagelok_pattern = r'\b(SS|S|B|A|M)-([0-9]{3,4}|[0-9]+M[0-9]*)-([0-9A-Z])' + if re.search(swagelok_pattern, desc_upper): + return { + "category": "TUBE_FITTING", + "confidence": 0.98, + "evidence": ["SWAGELOK_PART_NO"], + "classification_level": "LEVEL0_PARTNO", + "reason": "Swagelok 스타일 파트넘버 감지" + } + + # 쉼표로 구분된 각 부분을 별도로 체크 (예: "NIPPLE, SMLS, SCH 80") + desc_parts = [part.strip() for part in desc_upper.split(',')] + + # 1단계: Level 1 키워드로 타입 식별 + detected_types = [] + + # 특별 우선순위: REDUCING FLANGE 먼저 확인 (강화된 로직) + reducing_flange_patterns = [ + "REDUCING FLANGE", "RED FLANGE", "REDUCER FLANGE", + "REDUCING FLG", "RED FLG", "REDUCER FLG" + ] + + # FLANGE와 REDUCING/RED/REDUCER가 함께 있는 경우도 확인 + has_flange = any(flange_word in desc_upper for flange_word in ["FLANGE", "FLG"]) + has_reducing = any(red_word in desc_upper for red_word in ["REDUCING", "RED", "REDUCER"]) + + # 직접 패턴 매칭 또는 FLANGE + REDUCING 조합 + reducing_flange_detected = False + for pattern in reducing_flange_patterns: + if pattern in desc_upper: + detected_types.append(("FLANGE", "REDUCING FLANGE")) + reducing_flange_detected = True + break + + # FLANGE와 REDUCING이 모두 있으면 REDUCING FLANGE로 분류 + if not reducing_flange_detected and has_flange and has_reducing: + detected_types.append(("FLANGE", "REDUCING FLANGE")) + reducing_flange_detected = True + + # REDUCING FLANGE가 감지되지 않은 경우에만 일반 키워드 검사 + if not reducing_flange_detected: + for material_type, keywords in LEVEL1_TYPE_KEYWORDS.items(): + type_found = False + # 긴 키워드부터 확인 (FLANGE BOLT가 FLANGE보다 먼저 매칭되도록) + sorted_keywords = sorted(keywords, key=len, reverse=True) + for keyword in sorted_keywords: + # [강화된 로직] 짧은 키워드나 중의적 키워드에 대한 엄격한 검사 + is_strict_match = True + + # 1. "PL" 키워드 검사 (PLATE) + if keyword == "PL": + # 단독 단어이거나 숫자 뒤에 붙는 경우만 허용 (예: 10PL, 10 PL) + # COUPLING, NIPPLE, PLUG 등에 포함된 PL은 제외 + pl_pattern = r'(\b|\d)PL\b' + if not re.search(pl_pattern, desc_upper): + is_strict_match = False + + # 2. "ANGLE" 키워드 검사 (STRUCTURAL) + elif keyword == "ANGLE" or keyword == "앵글": + # VALVE와 함께 쓰이면 제외 (ANGLE VALVE) + if "VALVE" in desc_upper or "밸브" in desc_upper: + is_strict_match = False + + # 3. "UNION" 키워드 검사 (FITTING) + elif keyword == "UNION": + # 계장용인지 파이프용인지 구분은 fitting_classifier에서 하되, + # 여기서는 일단 FITTING으로 잡히도록 둠. + pass + + # 4. "BEAM" 키워드 검사 (STRUCTURAL) + elif keyword == "BEAM": + # "BEAM CLAMP" 같은 경우 SUPPORT로 가야 함 (SUPPORT가 우선순위 높으므로 괜찮음) + pass + + if not is_strict_match: + continue + + # 전체 문자열에서 찾기 + if keyword in desc_upper: + detected_types.append((material_type, keyword)) + type_found = True + break + # 각 부분에서도 정확히 매칭되는지 확인 + for part in desc_parts: + if keyword == part or keyword in part: + detected_types.append((material_type, keyword)) + type_found = True + break + if type_found: + break + + # 2단계: 복수 타입 감지 시 Level 2로 구체화 + if len(detected_types) > 1: + # Level 2 키워드로 우선순위 결정 + for material_type, subtype_dict in LEVEL2_SUBTYPE_KEYWORDS.items(): + for subtype, keywords in subtype_dict.items(): + for keyword in keywords: + if keyword in desc_upper: + return { + "category": material_type, + "confidence": 0.95, + "evidence": [f"L1_KEYWORD: {detected_types}", f"L2_KEYWORD: {keyword}"], + "classification_level": "LEVEL2" + } + + # Level 2 키워드가 없으면 우선순위로 결정 + # BOLT > SUPPORT > FITTING > VALVE > FLANGE > PIPE (볼트 우선, 더 구체적인 것 우선) + type_priority = ["BOLT", "SUPPORT", "FITTING", "VALVE", "FLANGE", "PIPE", "GASKET", "INSTRUMENT"] + for priority_type in type_priority: + for detected_type, keyword in detected_types: + if detected_type == priority_type: + return { + "category": priority_type, + "confidence": 0.85, + "evidence": [f"L1_MULTI_TYPE: {detected_types}", f"PRIORITY: {priority_type}"], + "classification_level": "LEVEL1_PRIORITY" + } + + # 3단계: 단일 타입 확정 또는 Level 3/4로 판단 + if len(detected_types) == 1: + material_type = detected_types[0][0] + + # FITTING으로 분류된 경우 상세 분류기 호출 + if material_type == "FITTING": + try: + detailed_result = classify_fitting("", description, main_nom, red_nom, length) + # 상세 분류 결과가 있으면 사용, 없으면 기본 FITTING 반환 + if detailed_result and detailed_result.get("category"): + return detailed_result + except Exception as e: + # 상세 분류 실패 시 기본 FITTING으로 처리 + pass + + return { + "category": material_type, + "confidence": 0.9, + "evidence": [f"L1_KEYWORD: {detected_types[0][1]}"], + "classification_level": "LEVEL1" + } + + # 4단계: Level 1 없으면 재질 기반 분류 + if not detected_types: + # 전용 재질 확인 + for material_type, materials in LEVEL4_MATERIAL_KEYWORDS.items(): + for material in materials: + if material in desc_upper: + # 볼트 재질(A193, A194)은 다른 키워드가 있는지 확인 + if material_type == "BOLT": + # 다른 타입 키워드가 있으면 볼트로 분류하지 않음 + other_type_found = False + for other_type, keywords in LEVEL1_TYPE_KEYWORDS.items(): + if other_type != "BOLT": + for keyword in keywords: + if keyword in desc_upper: + other_type_found = True + break + if other_type_found: + break + + if other_type_found: + continue # 볼트로 분류하지 않음 + + # FITTING으로 분류된 경우 상세 분류기 호출 + if material_type == "FITTING": + try: + detailed_result = classify_fitting("", description, main_nom, red_nom, length) + if detailed_result and detailed_result.get("category"): + return detailed_result + except Exception as e: + pass + + return { + "category": material_type, + "confidence": 0.35, # 재질만으로 분류 시 낮은 신뢰도 + "evidence": [f"L4_MATERIAL: {material}"], + "classification_level": "LEVEL4" + } + + # 범용 재질 확인 + for material, priority_types in GENERIC_MATERIALS.items(): + if material in desc_upper: + # 우선순위에 따라 타입 결정 + material_type = priority_types[0] # 첫 번째 우선순위 + + # FITTING으로 분류된 경우 상세 분류기 호출 + if material_type == "FITTING": + try: + detailed_result = classify_fitting("", description, main_nom, red_nom, length) + if detailed_result and detailed_result.get("category"): + return detailed_result + except Exception as e: + pass + + return { + "category": material_type, + "confidence": 0.3, + "evidence": [f"GENERIC_MATERIAL: {material}"], + "classification_level": "LEVEL4_GENERIC" + } + + # 분류 실패 + return { + "category": "UNCLASSIFIED", + "confidence": 0.0, + "evidence": ["NO_CLASSIFICATION_POSSIBLE"], + "classification_level": "NONE" + } + +def should_exclude_material(description: str) -> bool: + """ + 제외 대상 자재인지 확인 + """ + exclude_keywords = [ + "DUMMY", "RESERVED", "SPARE", "DELETED", "CANCELED", + "더미", "예비", "삭제", "취소", "예약" + ] + + desc_upper = description.upper() + return any(keyword in desc_upper for keyword in exclude_keywords) \ No newline at end of file diff --git a/tkeg/api/app/services/material_classifier.py b/tkeg/api/app/services/material_classifier.py new file mode 100644 index 0000000..3e36d80 --- /dev/null +++ b/tkeg/api/app/services/material_classifier.py @@ -0,0 +1,338 @@ +""" +재질 분류를 위한 공통 함수 +materials_schema.py의 데이터를 사용하여 재질을 분류 +""" + +import re +from typing import Dict, List, Optional, Tuple +from .materials_schema import ( + MATERIAL_STANDARDS, + SPECIAL_MATERIALS, + MANUFACTURING_MATERIAL_MAP, + GENERIC_MATERIAL_KEYWORDS +) + +def classify_material(description: str) -> Dict: + """ + 공통 재질 분류 함수 + + Args: + description: 자재 설명 (DESCRIPTION 필드) + + Returns: + 재질 분류 결과 딕셔너리 + """ + + desc_upper = str(description).upper().strip() if description is not None else "" + + # 1단계: 특수 재질 우선 확인 (가장 구체적) + special_result = check_special_materials(desc_upper) + if special_result['confidence'] > 0.9: + return special_result + + # 2단계: ASTM/ASME 규격 확인 + astm_result = check_astm_materials(desc_upper) + if astm_result['confidence'] > 0.8: + return astm_result + + # 3단계: KS 규격 확인 + ks_result = check_ks_materials(desc_upper) + if ks_result['confidence'] > 0.8: + return ks_result + + # 4단계: JIS 규격 확인 + jis_result = check_jis_materials(desc_upper) + if jis_result['confidence'] > 0.8: + return jis_result + + # 5단계: 일반 키워드 확인 + generic_result = check_generic_materials(desc_upper) + + return generic_result + +def check_special_materials(description: str) -> Dict: + """특수 재질 확인""" + + # SUPER ALLOYS 확인 + for alloy_family, alloy_data in SPECIAL_MATERIALS["SUPER_ALLOYS"].items(): + for pattern in alloy_data["patterns"]: + match = re.search(pattern, description) + if match: + grade = match.group(1) if match.groups() else "STANDARD" + grade_info = alloy_data["grades"].get(grade, {}) + + return { + "standard": f"{alloy_family}", + "grade": f"{alloy_family} {grade}", + "material_type": "SUPER_ALLOY", + "manufacturing": alloy_data.get("manufacturing", "SPECIAL"), + "composition": grade_info.get("composition", ""), + "applications": grade_info.get("applications", ""), + "confidence": 0.95, + "evidence": [f"SPECIAL_MATERIAL: {alloy_family} {grade}"] + } + + # TITANIUM 확인 + titanium_data = SPECIAL_MATERIALS["TITANIUM"] + for pattern in titanium_data["patterns"]: + match = re.search(pattern, description) + if match: + grade = match.group(1) if match.groups() else "2" + grade_info = titanium_data["grades"].get(grade, {}) + + return { + "standard": "TITANIUM", + "grade": f"Titanium Grade {grade}", + "material_type": "TITANIUM", + "manufacturing": "FORGED_OR_SEAMLESS", + "composition": grade_info.get("composition", f"Ti Grade {grade}"), + "confidence": 0.95, + "evidence": [f"TITANIUM: Grade {grade}"] + } + + return {"confidence": 0.0} + +def check_astm_materials(description: str) -> Dict: + """ASTM/ASME 규격 확인""" + + astm_data = MATERIAL_STANDARDS["ASTM_ASME"] + + # FORGED 등급 확인 + for standard, standard_data in astm_data["FORGED_GRADES"].items(): + result = check_astm_standard(description, standard, standard_data) + if result["confidence"] > 0.8: + return result + + # WELDED 등급 확인 + for standard, standard_data in astm_data["WELDED_GRADES"].items(): + result = check_astm_standard(description, standard, standard_data) + if result["confidence"] > 0.8: + return result + + # CAST 등급 확인 + for standard, standard_data in astm_data["CAST_GRADES"].items(): + result = check_astm_standard(description, standard, standard_data) + if result["confidence"] > 0.8: + return result + + # PIPE 등급 확인 + for standard, standard_data in astm_data["PIPE_GRADES"].items(): + result = check_astm_standard(description, standard, standard_data) + if result["confidence"] > 0.8: + return result + + return {"confidence": 0.0} + +def check_astm_standard(description: str, standard: str, standard_data: Dict) -> Dict: + """개별 ASTM 규격 확인""" + + # 직접 패턴이 있는 경우 (A105 등) + if "patterns" in standard_data: + for pattern in standard_data["patterns"]: + match = re.search(pattern, description) + if match: + grade_code = match.group(1) if match.groups() else "" + full_grade = f"ASTM {standard}" + (f" {grade_code}" if grade_code else "") + + return { + "standard": f"ASTM {standard}", + "grade": full_grade, + "material_type": determine_material_type(standard, grade_code), + "manufacturing": standard_data.get("manufacturing", "UNKNOWN"), + "confidence": 0.9, + "evidence": [f"ASTM_{standard}: {grade_code if grade_code else 'Direct Match'}"] + } + + # 하위 분류가 있는 경우 (A182, A234 등) + else: + for subtype, subtype_data in standard_data.items(): + for pattern in subtype_data["patterns"]: + match = re.search(pattern, description) + if match: + grade_code = match.group(1) if match.groups() else "" + grade_info = subtype_data["grades"].get(grade_code, {}) + + # A312의 경우 TP304 형태로 전체 grade 표시 + if standard == "A312" and grade_code and not grade_code.startswith("TP"): + full_grade = f"ASTM {standard} TP{grade_code}" + elif grade_code.startswith("TP"): + full_grade = f"ASTM {standard} {grade_code}" + # A403의 경우 WP304 형태로 전체 grade 표시 + elif standard == "A403" and grade_code and not grade_code.startswith("WP"): + full_grade = f"ASTM {standard} WP{grade_code}" + elif grade_code.startswith("WP"): + full_grade = f"ASTM {standard} {grade_code}" + # A420의 경우 WPL3 형태로 전체 grade 표시 + elif standard == "A420" and grade_code and not grade_code.startswith("WPL"): + full_grade = f"ASTM {standard} WPL{grade_code}" + elif grade_code.startswith("WPL"): + full_grade = f"ASTM {standard} {grade_code}" + else: + full_grade = f"ASTM {standard} {grade_code}" if grade_code else f"ASTM {standard}" + + return { + "standard": f"ASTM {standard}", + "grade": full_grade, + "material_type": determine_material_type(standard, grade_code), + "manufacturing": subtype_data.get("manufacturing", "UNKNOWN"), + "composition": grade_info.get("composition", ""), + "applications": grade_info.get("applications", ""), + "confidence": 0.9, + "evidence": [f"ASTM_{standard}: {grade_code}"] + } + + return {"confidence": 0.0} + +def check_ks_materials(description: str) -> Dict: + """KS 규격 확인""" + + ks_data = MATERIAL_STANDARDS["KS"] + + for category, standards in ks_data.items(): + for standard, standard_data in standards.items(): + for pattern in standard_data["patterns"]: + match = re.search(pattern, description) + if match: + return { + "standard": f"KS {standard}", + "grade": f"KS {standard}", + "material_type": determine_material_type_from_description(description), + "manufacturing": standard_data.get("manufacturing", "UNKNOWN"), + "description": standard_data["description"], + "confidence": 0.85, + "evidence": [f"KS_{standard}"] + } + + return {"confidence": 0.0} + +def check_jis_materials(description: str) -> Dict: + """JIS 규격 확인""" + + jis_data = MATERIAL_STANDARDS["JIS"] + + for category, standards in jis_data.items(): + for standard, standard_data in standards.items(): + for pattern in standard_data["patterns"]: + match = re.search(pattern, description) + if match: + return { + "standard": f"JIS {standard}", + "grade": f"JIS {standard}", + "material_type": determine_material_type_from_description(description), + "manufacturing": standard_data.get("manufacturing", "UNKNOWN"), + "description": standard_data["description"], + "confidence": 0.85, + "evidence": [f"JIS_{standard}"] + } + + return {"confidence": 0.0} + +def check_generic_materials(description: str) -> Dict: + """일반 재질 키워드 확인""" + + for material_type, keywords in GENERIC_MATERIAL_KEYWORDS.items(): + for keyword in keywords: + if keyword in description: + return { + "standard": "GENERIC", + "grade": keyword, + "material_type": material_type, + "manufacturing": "UNKNOWN", + "confidence": 0.6, + "evidence": [f"GENERIC: {keyword}"] + } + + return { + "standard": "UNKNOWN", + "grade": "UNKNOWN", + "material_type": "UNKNOWN", + "manufacturing": "UNKNOWN", + "confidence": 0.0, + "evidence": ["NO_MATERIAL_FOUND"] + } + +def determine_material_type(standard: str, grade: str) -> str: + """규격과 등급으로 재질 타입 결정""" + + # grade가 None이면 기본값 처리 + if not grade: + grade = "" + + # 스테인리스 등급 + stainless_patterns = ["304", "316", "321", "347", "F304", "F316", "WP304", "CF8"] + if any(pattern in grade for pattern in stainless_patterns): + return "STAINLESS_STEEL" + + # 합금강 등급 + alloy_patterns = ["F1", "F5", "F11", "F22", "F91", "WP1", "WP5", "WP11", "WP22", "WP91"] + if any(pattern in grade for pattern in alloy_patterns): + return "ALLOY_STEEL" + + # 주조품 + if standard in ["A216", "A351"]: + return "CAST_STEEL" + + # 기본값은 탄소강 + return "CARBON_STEEL" + +def determine_material_type_from_description(description: str) -> str: + """설명에서 재질 타입 추정""" + + desc_upper = description.upper() + + if any(keyword in desc_upper for keyword in ["SS", "STS", "STAINLESS", "304", "316"]): + return "STAINLESS_STEEL" + elif any(keyword in desc_upper for keyword in ["ALLOY", "합금", "CR", "MO"]): + return "ALLOY_STEEL" + elif any(keyword in desc_upper for keyword in ["CAST", "주조"]): + return "CAST_STEEL" + else: + return "CARBON_STEEL" + +def get_manufacturing_method_from_material(material_result: Dict) -> str: + """재질 정보로부터 제작방법 추정""" + + if material_result.get("confidence", 0) < 0.5: + return "UNKNOWN" + + material_standard = material_result.get('standard', '') + + # 직접 매핑 + if 'A182' in material_standard or 'A105' in material_standard: + return 'FORGED' + elif 'A234' in material_standard or 'A403' in material_standard or 'A420' in material_standard: + return 'WELDED_FABRICATED' + elif 'A216' in material_standard or 'A351' in material_standard: + return 'CAST' + elif 'A106' in material_standard or 'A312' in material_standard: + return 'SEAMLESS' + elif 'A53' in material_standard: + return 'WELDED_OR_SEAMLESS' + + # manufacturing 필드가 있으면 직접 사용 + manufacturing = material_result.get("manufacturing", "UNKNOWN") + if manufacturing != "UNKNOWN": + return manufacturing + + return "UNKNOWN" + +def get_material_confidence_factors(material_result: Dict) -> List[str]: + """재질 분류 신뢰도 영향 요소 반환""" + + factors = [] + confidence = material_result.get("confidence", 0) + + if confidence >= 0.9: + factors.append("HIGH_CONFIDENCE") + elif confidence >= 0.7: + factors.append("MEDIUM_CONFIDENCE") + else: + factors.append("LOW_CONFIDENCE") + + if material_result.get("standard") == "UNKNOWN": + factors.append("NO_STANDARD_FOUND") + + if material_result.get("manufacturing") == "UNKNOWN": + factors.append("MANUFACTURING_UNCLEAR") + + return factors diff --git a/tkeg/api/app/services/material_grade_extractor.py b/tkeg/api/app/services/material_grade_extractor.py new file mode 100644 index 0000000..275a12a --- /dev/null +++ b/tkeg/api/app/services/material_grade_extractor.py @@ -0,0 +1,263 @@ +""" +전체 재질명 추출기 +원본 설명에서 완전한 재질명을 추출하여 축약되지 않은 형태로 제공 +""" + +import re +from typing import Optional, Dict + +def extract_full_material_grade(description: str) -> str: + """ + 원본 설명에서 전체 재질명 추출 + + Args: + description: 원본 자재 설명 + + Returns: + 전체 재질명 (예: "ASTM A312 TP304", "ASTM A106 GR B") + """ + if not description: + return "" + + desc_upper = description.upper().strip() + + # 1. ASTM 규격 패턴들 (가장 구체적인 것부터) + astm_patterns = [ + # A320 L7, A325, A490 등 단독 규격 (ASTM 없이) + r'\bA320\s+L[0-9]+\b', # A320 L7 + r'\bA325\b', # A325 + r'\bA490\b', # A490 + # ASTM A193/A194 GR B7/2H (볼트용 조합 패턴) - 최우선 + r'ASTM\s+A193/A194\s+GR\s+[A-Z0-9/]+', + r'ASTM\s+A193/A194\s+[A-Z0-9/]+', + # ASTM A320/A194M GR B8/8 (저온용 볼트 조합 패턴) + r'ASTM\s+A320[M]?/A194[M]?\s+GR\s+[A-Z0-9/]+', + r'ASTM\s+A320[M]?/A194[M]?\s+[A-Z0-9/]+', + # 단독 A193/A194 패턴 (ASTM 없이) + r'\bA193/A194\s+GR\s+[A-Z0-9/]+\b', + r'\bA193/A194\s+[A-Z0-9/]+\b', + # 단독 A320/A194M 패턴 (ASTM 없이) + r'\bA320[M]?/A194[M]?\s+GR\s+[A-Z0-9/]+\b', + r'\bA320[M]?/A194[M]?\s+[A-Z0-9/]+\b', + # ASTM A312 TP304, ASTM A312 TP316L 등 + r'ASTM\s+A\d{3,4}[A-Z]*\s+TP\d+[A-Z]*', + # ASTM A182 F304, ASTM A182 F316L 등 + r'ASTM\s+A\d{3,4}[A-Z]*\s+F\d+[A-Z]*', + # ASTM A403 WP304, ASTM A234 WPB 등 + r'ASTM\s+A\d{3,4}[A-Z]*\s+WP[A-Z0-9]+', + # ASTM A351 CF8M, ASTM A216 WCB 등 + r'ASTM\s+A\d{3,4}[A-Z]*\s+[A-Z]{2,4}[0-9]*[A-Z]*', + # ASTM A106 GR B, ASTM A105 등 - GR 포함 + r'ASTM\s+A\d{3,4}[A-Z]*\s+GR\s+[A-Z0-9/]+', + r'ASTM\s+A\d{3,4}[A-Z]*\s+GRADE\s+[A-Z0-9/]+', + # ASTM A106 B (GR 없이 바로 등급) - 단일 문자 등급 + r'ASTM\s+A\d{3,4}[A-Z]*\s+[A-Z](?=\s|$)', + # ASTM A105, ASTM A234 등 (등급 없는 경우) + r'ASTM\s+A\d{3,4}[A-Z]*(?!\s+[A-Z0-9])', + # 2자리 ASTM 규격도 지원 (A10, A36 등) + r'ASTM\s+A\d{2}(?:\s+GR\s+[A-Z0-9/]+)?', + ] + + for pattern in astm_patterns: + match = re.search(pattern, desc_upper) + if match: + full_grade = match.group(0).strip() + # 추가 정보가 있는지 확인 (PBE, BBE 등은 제외) + end_pos = match.end() + remaining = desc_upper[end_pos:].strip() + + # 끝단 가공 정보는 제외 + end_prep_codes = ['PBE', 'BBE', 'POE', 'BOE', 'TOE'] + for code in end_prep_codes: + remaining = re.sub(rf'\b{code}\b', '', remaining).strip() + + # 남은 재질 관련 정보가 있으면 추가 + additional_info = [] + if remaining: + # 일반적인 재질 추가 정보 패턴 + additional_patterns = [ + r'\bH\b', # H (고온용) + r'\bL\b', # L (저탄소) + r'\bN\b', # N (질소 첨가) + r'\bS\b', # S (황 첨가) + r'\bMOD\b', # MOD (개량형) + ] + + for add_pattern in additional_patterns: + if re.search(add_pattern, remaining): + additional_info.append(re.search(add_pattern, remaining).group(0)) + + if additional_info: + full_grade += ' ' + ' '.join(additional_info) + + return full_grade + + # 2. ASME 규격 패턴들 + asme_patterns = [ + r'ASME\s+SA\d+[A-Z]*\s+TP\d+[A-Z]*(?:\s+[A-Z]+)*', + r'ASME\s+SA\d+[A-Z]*\s+GR\s+[A-Z0-9]+(?:\s+[A-Z]+)*', + r'ASME\s+SA\d+[A-Z]*\s+F\d+[A-Z]*(?:\s+[A-Z]+)*', + r'ASME\s+SA\d+[A-Z]*(?!\s+[A-Z0-9])', + ] + + for pattern in asme_patterns: + match = re.search(pattern, desc_upper) + if match: + return match.group(0).strip() + + # 3. KS 규격 패턴들 + ks_patterns = [ + r'KS\s+D\d+[A-Z]*\s+[A-Z0-9]+(?:\s+[A-Z]+)*', + r'KS\s+D\d+[A-Z]*(?!\s+[A-Z0-9])', + ] + + for pattern in ks_patterns: + match = re.search(pattern, desc_upper) + if match: + return match.group(0).strip() + + # 4. JIS 규격 패턴들 + jis_patterns = [ + r'JIS\s+[A-Z]\d+[A-Z]*\s+[A-Z0-9]+(?:\s+[A-Z]+)*', + r'JIS\s+[A-Z]\d+[A-Z]*(?!\s+[A-Z0-9])', + ] + + for pattern in jis_patterns: + match = re.search(pattern, desc_upper) + if match: + return match.group(0).strip() + + # 5. 특수 재질 패턴들 + special_patterns = [ + # Inconel, Hastelloy 등 + r'INCONEL\s+\d+[A-Z]*', + r'HASTELLOY\s+[A-Z]\d*[A-Z]*', + r'MONEL\s+\d+[A-Z]*', + # Titanium + r'TITANIUM\s+GRADE\s+\d+[A-Z]*', + r'TI\s+GR\s*\d+[A-Z]*', + # 듀플렉스 스테인리스 + r'DUPLEX\s+\d+[A-Z]*', + r'SUPER\s+DUPLEX\s+\d+[A-Z]*', + ] + + for pattern in special_patterns: + match = re.search(pattern, desc_upper) + if match: + return match.group(0).strip() + + # 6. 일반 스테인리스 패턴들 (숫자만) + stainless_patterns = [ + r'\b(304L?|316L?|321|347|310|317L?|904L?|254SMO)\b', + r'\bSS\s*(304L?|316L?|321|347|310|317L?|904L?|254SMO)\b', + r'\bSUS\s*(304L?|316L?|321|347|310|317L?|904L?|254SMO)\b', + ] + + for pattern in stainless_patterns: + match = re.search(pattern, desc_upper) + if match: + grade = match.group(1) if match.groups() else match.group(0) + if grade.startswith(('SS', 'SUS')): + return grade + else: + return f"SS{grade}" + + # 7. 탄소강 패턴들 + carbon_patterns = [ + r'\bSM\d+[A-Z]*\b', # SM400, SM490 등 + r'\bSS\d+[A-Z]*\b', # SS400, SS490 등 (스테인리스와 구분) + r'\bS\d+C\b', # S45C, S50C 등 + ] + + for pattern in carbon_patterns: + match = re.search(pattern, desc_upper) + if match: + return match.group(0).strip() + + # 8. 기존 material_grade가 있으면 그대로 반환 + # (분류기에서 이미 처리된 경우) + return "" + +def update_full_material_grades(db, batch_size: int = 1000) -> Dict: + """ + 기존 자재들의 full_material_grade 업데이트 + + Args: + db: 데이터베이스 세션 + batch_size: 배치 처리 크기 + + Returns: + 업데이트 결과 통계 + """ + from sqlalchemy import text + + try: + # 전체 자재 수 조회 + count_query = text("SELECT COUNT(*) FROM materials WHERE full_material_grade IS NULL OR full_material_grade = ''") + total_count = db.execute(count_query).scalar() + + print(f"📊 업데이트 대상 자재: {total_count}개") + + updated_count = 0 + processed_count = 0 + + # 배치 단위로 처리 + offset = 0 + while offset < total_count: + # 배치 조회 + select_query = text(""" + SELECT id, original_description, material_grade + FROM materials + WHERE full_material_grade IS NULL OR full_material_grade = '' + ORDER BY id + LIMIT :limit OFFSET :offset + """) + + results = db.execute(select_query, {"limit": batch_size, "offset": offset}).fetchall() + + if not results: + break + + # 배치 업데이트 + for material_id, original_description, current_grade in results: + full_grade = extract_full_material_grade(original_description) + + # 전체 재질명이 추출되지 않으면 기존 grade 사용 + if not full_grade and current_grade: + full_grade = current_grade + + if full_grade: + update_query = text(""" + UPDATE materials + SET full_material_grade = :full_grade + WHERE id = :material_id + """) + db.execute(update_query, { + "full_grade": full_grade, + "material_id": material_id + }) + updated_count += 1 + + processed_count += 1 + + # 배치 커밋 + db.commit() + offset += batch_size + + print(f"📈 진행률: {processed_count}/{total_count} ({processed_count/total_count*100:.1f}%)") + + return { + "total_processed": processed_count, + "updated_count": updated_count, + "success": True + } + + except Exception as e: + db.rollback() + print(f"❌ 업데이트 실패: {str(e)}") + return { + "total_processed": 0, + "updated_count": 0, + "success": False, + "error": str(e) + } diff --git a/tkeg/api/app/services/material_service.py b/tkeg/api/app/services/material_service.py new file mode 100644 index 0000000..d1ecccd --- /dev/null +++ b/tkeg/api/app/services/material_service.py @@ -0,0 +1,592 @@ + +from sqlalchemy.orm import Session +from sqlalchemy import text +from typing import List, Dict, Optional +import json +from datetime import datetime + +from app.services.integrated_classifier import classify_material_integrated, should_exclude_material +from app.services.bolt_classifier import classify_bolt +from app.services.flange_classifier import classify_flange +from app.services.fitting_classifier import classify_fitting +from app.services.gasket_classifier import classify_gasket +from app.services.instrument_classifier import classify_instrument +from app.services.valve_classifier import classify_valve +from app.services.support_classifier import classify_support +from app.services.plate_classifier import classify_plate +from app.services.structural_classifier import classify_structural +from app.services.pipe_classifier import classify_pipe_for_purchase, extract_end_preparation_info +from app.services.material_grade_extractor import extract_full_material_grade + +class MaterialService: + """자재 처리 및 저장을 담당하는 서비스""" + + @staticmethod + def process_and_save_materials( + db: Session, + file_id: int, + materials_data: List[Dict], + revision_comparison: Optional[Dict] = None, + parent_file_id: Optional[int] = None, + purchased_materials_map: Optional[Dict] = None + ) -> int: + """ + 자재 목록을 분류하고 DB에 저장합니다. + + Args: + db: DB 세션 + file_id: 파일 ID + materials_data: 파싱된 자재 데이터 목록 + revision_comparison: 리비전 비교 결과 + parent_file_id: 이전 리비전 파일 ID + purchased_materials_map: 구매 확정된 자재 매핑 정보 + + Returns: + 저장된 자재 수 + """ + materials_inserted = 0 + + # 변경/신규 자재 키 집합 (리비전 추적용) + changed_materials_keys = set() + new_materials_keys = set() + + # 리비전 업로드인 경우 변경사항 분석 + if parent_file_id is not None: + MaterialService._analyze_changes( + db, parent_file_id, materials_data, + changed_materials_keys, new_materials_keys + ) + + # 변경 없는 자재 (확정된 자재) 먼저 처리 + if revision_comparison and revision_comparison.get("has_previous_confirmation", False): + unchanged_materials = revision_comparison.get("unchanged_materials", []) + for material_data in unchanged_materials: + MaterialService._save_unchanged_material(db, file_id, material_data) + materials_inserted += 1 + + # 분류가 필요한 자재 처리 (신규 또는 변경된 자재) + # revision_comparison에서 필터링된 목록이 있으면 그것을 사용, 아니면 전체 + materials_to_classify = materials_data + if revision_comparison and revision_comparison.get("materials_to_classify"): + materials_to_classify = revision_comparison.get("materials_to_classify") + + print(f"🔧 자재 분류 및 저장 시작: {len(materials_to_classify)}개") + + for material_data in materials_to_classify: + MaterialService._classify_and_save_single_material( + db, file_id, material_data, + changed_materials_keys, new_materials_keys, + purchased_materials_map + ) + materials_inserted += 1 + + return materials_inserted + + @staticmethod + def _analyze_changes(db: Session, parent_file_id: int, materials_data: List[Dict], + changed_keys: set, new_keys: set): + """이전 리비전과 비교하여 변경/신규 자재를 식별합니다.""" + try: + prev_materials_query = text(""" + SELECT original_description, size_spec, material_grade, main_nom, + drawing_name, line_no, quantity + FROM materials + WHERE file_id = :parent_file_id + """) + prev_materials = db.execute(prev_materials_query, {"parent_file_id": parent_file_id}).fetchall() + + prev_dict = {} + for pm in prev_materials: + key = MaterialService._generate_material_key( + pm.drawing_name, pm.line_no, pm.original_description, + pm.size_spec, pm.material_grade + ) + prev_dict[key] = float(pm.quantity) if pm.quantity else 0 + + for mat in materials_data: + new_key = MaterialService._generate_material_key( + mat.get("dwg_name"), mat.get("line_num"), mat["original_description"], + mat.get("size_spec"), mat.get("material_grade") + ) + + if new_key in prev_dict: + if abs(prev_dict[new_key] - float(mat.get("quantity", 0))) > 0.001: + changed_keys.add(new_key) + else: + new_keys.add(new_key) + + except Exception as e: + print(f"❌ 변경사항 분석 실패: {e}") + + @staticmethod + def _generate_material_key(dwg, line, desc, size, grade): + """자재 고유 키 생성""" + parts = [] + if dwg: parts.append(str(dwg)) + elif line: parts.append(str(line)) + + parts.append(str(desc)) + parts.append(str(size or '')) + parts.append(str(grade or '')) + return "|".join(parts) + + @staticmethod + def _save_unchanged_material(db: Session, file_id: int, material_data: Dict): + """변경 없는(확정된) 자재 저장""" + previous_item = material_data.get("previous_item", {}) + + query = text(""" + INSERT INTO materials ( + file_id, original_description, classified_category, confidence, + quantity, unit, size_spec, material_grade, specification, + reused_from_confirmation, created_at + ) VALUES ( + :file_id, :desc, :category, 1.0, + :qty, :unit, :size, :grade, :spec, + TRUE, :created_at + ) + """) + + db.execute(query, { + "file_id": file_id, + "desc": material_data["original_description"], + "category": previous_item.get("category", "UNCLASSIFIED"), + "qty": material_data["quantity"], + "unit": material_data.get("unit", "EA"), + "size": material_data.get("size_spec", ""), + "grade": previous_item.get("material", ""), + "spec": previous_item.get("specification", ""), + "created_at": datetime.now() + }) + + @staticmethod + def _classify_and_save_single_material( + db: Session, file_id: int, material_data: Dict, + changed_keys: set, new_keys: set, purchased_map: Optional[Dict] + ): + """단일 자재 분류 및 저장 (상세 정보 포함)""" + description = material_data["original_description"] + main_nom = material_data.get("main_nom", "") + red_nom = material_data.get("red_nom", "") + length_val = material_data.get("length") + + # 1. 통합 분류 + integrated_result = classify_material_integrated(description, main_nom, red_nom, length_val) + classification_result = integrated_result + + # 2. 상세 분류 + if not should_exclude_material(description): + category = integrated_result.get('category') + if category == "PIPE": + classification_result = classify_pipe_for_purchase("", description, main_nom, length_val) + elif category == "FITTING": + classification_result = classify_fitting("", description, main_nom, red_nom) + elif category == "FLANGE": + classification_result = classify_flange("", description, main_nom, red_nom) + elif category == "VALVE": + classification_result = classify_valve("", description, main_nom) + elif category == "BOLT": + classification_result = classify_bolt("", description, main_nom) + elif category == "GASKET": + classification_result = classify_gasket("", description, main_nom) + elif category == "INSTRUMENT": + classification_result = classify_instrument("", description, main_nom) + elif category == "SUPPORT": + classification_result = classify_support("", description, main_nom) + elif category == "PLATE": + classification_result = classify_plate("", description, main_nom) + elif category == "STRUCTURAL": + classification_result = classify_structural("", description, main_nom) + + # 신뢰도 조정 + if integrated_result.get('confidence', 0) < 0.5: + classification_result['overall_confidence'] = min( + classification_result.get('overall_confidence', 1.0), + integrated_result.get('confidence', 0.0) + 0.2 + ) + else: + classification_result = {"category": "EXCLUDE", "overall_confidence": 0.95} + + # 3. 구매 확정 정보 상속 확인 + is_purchase_confirmed = False + purchase_confirmed_at = None + purchase_confirmed_by = None + + if purchased_map: + key = f"{description.strip().upper()}|{material_data.get('size_spec', '')}" + if key in purchased_map: + info = purchased_map[key] + is_purchase_confirmed = True + purchase_confirmed_at = info.get("purchase_confirmed_at") + purchase_confirmed_by = info.get("purchase_confirmed_by") + + # 4. 자재 기본 정보 저장 + full_grade = extract_full_material_grade(description) or material_data.get("material_grade", "") + + insert_query = text(""" + INSERT INTO materials ( + file_id, original_description, quantity, unit, size_spec, + main_nom, red_nom, material_grade, full_material_grade, line_number, row_number, + classified_category, classification_confidence, is_verified, + drawing_name, line_no, created_at, + purchase_confirmed, purchase_confirmed_at, purchase_confirmed_by, + revision_status + ) VALUES ( + :file_id, :desc, :qty, :unit, :size, + :main, :red, :grade, :full_grade, :line_num, :row_num, + :category, :confidence, :verified, + :dwg, :line, :created_at, + :confirmed, :confirmed_at, :confirmed_by, + :status + ) RETURNING id + """) + + # 리비전 상태 결정 + mat_key = MaterialService._generate_material_key( + material_data.get("dwg_name"), material_data.get("line_num"), description, + material_data.get("size_spec"), material_data.get("material_grade") + ) + rev_status = 'changed' if mat_key in changed_keys else ('active' if mat_key in new_keys else None) + + result = db.execute(insert_query, { + "file_id": file_id, + "desc": description, + "qty": material_data["quantity"], + "unit": material_data["unit"], + "size": material_data.get("size_spec", ""), + "main": main_nom, + "red": red_nom, + "grade": material_data.get("material_grade", ""), + "full_grade": full_grade, + "line_num": material_data.get("line_number"), + "row_num": material_data.get("row_number"), + "category": classification_result.get("category", "UNCLASSIFIED"), + "confidence": classification_result.get("overall_confidence", 0.0), + "verified": False, + "dwg": material_data.get("dwg_name"), + "line": material_data.get("line_num"), + "created_at": datetime.now(), + "confirmed": is_purchase_confirmed, + "confirmed_at": purchase_confirmed_at, + "confirmed_by": purchase_confirmed_by, + "status": rev_status + }) + + material_id = result.fetchone()[0] + + # 5. 상세 정보 저장 (별도 메서드로 분리) + MaterialService._save_material_details( + db, material_id, file_id, classification_result, material_data + ) + + @staticmethod + def _save_material_details(db: Session, material_id: int, file_id: int, + result: Dict, data: Dict): + """카테고리별 상세 정보 저장""" + category = result.get("category") + + if category == "PIPE": + MaterialService._save_pipe_details(db, material_id, file_id, result, data) + elif category == "FITTING": + MaterialService._save_fitting_details(db, material_id, file_id, result, data) + elif category == "FLANGE": + MaterialService._save_flange_details(db, material_id, file_id, result, data) + elif category == "BOLT": + MaterialService._save_bolt_details(db, material_id, file_id, result, data) + elif category == "VALVE": + MaterialService._save_valve_details(db, material_id, file_id, result, data) + elif category == "GASKET": + MaterialService._save_gasket_details(db, material_id, file_id, result, data) + elif category == "SUPPORT": + MaterialService._save_support_details(db, material_id, file_id, result, data) + elif category == "PLATE": + MaterialService._save_plate_details(db, material_id, file_id, result, data) + elif category == "STRUCTURAL": + MaterialService._save_structural_details(db, material_id, file_id, result, data) + + @staticmethod + def _save_plate_details(db: Session, mid: int, fid: int, res: Dict, data: Dict): + """판재 정보 업데이트 (현재는 materials 테이블에 요약 정보 저장)""" + details = res.get("details", {}) + spec = f"{details.get('thickness')}T x {details.get('dimensions')}" + db.execute(text(""" + UPDATE materials + SET size_spec = :size, material_grade = :mat + WHERE id = :id + """), {"size": spec, "mat": details.get("material"), "id": mid}) + + @staticmethod + def _save_structural_details(db: Session, mid: int, fid: int, res: Dict, data: Dict): + """형강 정보 업데이트 (현재는 materials 테이블에 요약 정보 저장)""" + details = res.get("details", {}) + spec = f"{details.get('type')} {details.get('dimension')}" + db.execute(text(""" + UPDATE materials + SET size_spec = :size + WHERE id = :id + """), {"size": spec, "id": mid}) + + # --- 각 카테고리별 상세 저장 메서드들 (기존 로직 이관) --- + + @staticmethod + def inherit_purchase_requests(db: Session, current_file_id: int, parent_file_id: int): + """이전 리비전의 구매신청 정보를 상속합니다.""" + try: + print(f"🔄 구매신청 정보 상속 처리 시작...") + + # 1. 이전 리비전에서 그룹별 구매신청 수량 집계 + prev_purchase_summary = text(""" + SELECT + m.original_description, + m.size_spec, + m.material_grade, + m.drawing_name, + COUNT(DISTINCT pri.material_id) as purchased_count, + SUM(pri.quantity) as total_purchased_qty, + MIN(pri.request_id) as request_id + FROM materials m + JOIN purchase_request_items pri ON m.id = pri.material_id + WHERE m.file_id = :parent_file_id + GROUP BY m.original_description, m.size_spec, m.material_grade, m.drawing_name + """) + + prev_purchases = db.execute(prev_purchase_summary, {"parent_file_id": parent_file_id}).fetchall() + + # 2. 새 리비전에서 같은 그룹의 자재에 수량만큼 구매신청 상속 + for prev_purchase in prev_purchases: + purchased_count = prev_purchase.purchased_count + + # 새 리비전에서 같은 그룹의 자재 조회 (순서대로) + new_group_materials = text(""" + SELECT id, quantity + FROM materials + WHERE file_id = :file_id + AND original_description = :description + AND COALESCE(size_spec, '') = :size_spec + AND COALESCE(material_grade, '') = :material_grade + AND COALESCE(drawing_name, '') = :drawing_name + ORDER BY id + LIMIT :limit + """) + + new_materials = db.execute(new_group_materials, { + "file_id": current_file_id, + "description": prev_purchase.original_description, + "size_spec": prev_purchase.size_spec or '', + "material_grade": prev_purchase.material_grade or '', + "drawing_name": prev_purchase.drawing_name or '', + "limit": purchased_count + }).fetchall() + + # 구매신청 수량만큼만 상속 + for new_mat in new_materials: + inherit_query = text(""" + INSERT INTO purchase_request_items ( + request_id, material_id, quantity, unit, user_requirement + ) VALUES ( + :request_id, :material_id, :quantity, 'EA', '' + ) + ON CONFLICT DO NOTHING + """) + db.execute(inherit_query, { + "request_id": prev_purchase.request_id, + "material_id": new_mat.id, + "quantity": new_mat.quantity + }) + + inherited_count = len(new_materials) + if inherited_count > 0: + print(f" ✅ {prev_purchase.original_description[:30]}... (도면: {prev_purchase.drawing_name or 'N/A'}) → {inherited_count}/{purchased_count}개 상속") + + # 커밋은 호출하는 쪽에서 일괄 처리하거나 여기서 처리 + # db.commit() + print(f"✅ 구매신청 정보 상속 완료") + + except Exception as e: + print(f"❌ 구매신청 정보 상속 실패: {str(e)}") + # 상속 실패는 전체 프로세스를 중단하지 않음 + + @staticmethod + def _save_pipe_details(db, mid, fid, res, data): + # PIPE 상세 저장 로직 + end_prep_info = extract_end_preparation_info(data["original_description"]) + + # 1. End Prep 정보 저장 + db.execute(text(""" + INSERT INTO pipe_end_preparations ( + material_id, file_id, end_preparation_type, end_preparation_code, + machining_required, cutting_note, original_description, confidence + ) VALUES ( + :mid, :fid, :type, :code, :req, :note, :desc, :conf + ) + """), { + "mid": mid, "fid": fid, + "type": end_prep_info["end_preparation_type"], + "code": end_prep_info["end_preparation_code"], + "req": end_prep_info["machining_required"], + "note": end_prep_info["cutting_note"], + "desc": end_prep_info["original_description"], + "conf": end_prep_info["confidence"] + }) + + # 2. Pipe Details 저장 + length_info = res.get("length_info", {}) + length_mm = length_info.get("length_mm") or data.get("length", 0.0) + + mat_info = res.get("material", {}) + sch_info = res.get("schedule", {}) + + # 재질 정보 업데이트 + if mat_info.get("grade"): + db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"), + {"g": mat_info.get("grade"), "id": mid}) + + db.execute(text(""" + INSERT INTO pipe_details ( + material_id, file_id, outer_diameter, schedule, + material_spec, manufacturing_method, length_mm + ) VALUES (:mid, :fid, :od, :sch, :spec, :method, :len) + """), { + "mid": mid, "fid": fid, + "od": data.get("main_nom") or data.get("size_spec"), + "sch": sch_info.get("schedule", "UNKNOWN") if isinstance(sch_info, dict) else str(sch_info), + "spec": mat_info.get("grade", "") if isinstance(mat_info, dict) else "", + "method": res.get("manufacturing", {}).get("method", "UNKNOWN") if isinstance(res.get("manufacturing"), dict) else "UNKNOWN", + "len": length_mm or 0.0 + }) + + @staticmethod + def _save_fitting_details(db, mid, fid, res, data): + fit_type = res.get("fitting_type", {}) + mat_info = res.get("material", {}) + + if mat_info.get("grade"): + db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"), + {"g": mat_info.get("grade"), "id": mid}) + + db.execute(text(""" + INSERT INTO fitting_details ( + material_id, file_id, fitting_type, fitting_subtype, + connection_method, pressure_rating, material_grade, + main_size, reduced_size + ) VALUES (:mid, :fid, :type, :subtype, :conn, :rating, :grade, :main, :red) + """), { + "mid": mid, "fid": fid, + "type": fit_type.get("type", "UNKNOWN") if isinstance(fit_type, dict) else str(fit_type), + "subtype": fit_type.get("subtype", "UNKNOWN") if isinstance(fit_type, dict) else "UNKNOWN", + "conn": res.get("connection_method", {}).get("method", "UNKNOWN") if isinstance(res.get("connection_method"), dict) else "UNKNOWN", + "rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN", + "grade": mat_info.get("grade", "") if isinstance(mat_info, dict) else "", + "main": data.get("main_nom") or data.get("size_spec"), + "red": data.get("red_nom", "") + }) + + @staticmethod + def _save_flange_details(db, mid, fid, res, data): + flg_type = res.get("flange_type", {}) + mat_info = res.get("material", {}) + + if mat_info.get("grade"): + db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"), + {"g": mat_info.get("grade"), "id": mid}) + + db.execute(text(""" + INSERT INTO flange_details ( + material_id, file_id, flange_type, pressure_rating, + facing_type, material_grade, size_inches + ) VALUES (:mid, :fid, :type, :rating, :face, :grade, :size) + """), { + "mid": mid, "fid": fid, + "type": flg_type.get("type", "UNKNOWN") if isinstance(flg_type, dict) else str(flg_type), + "rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN", + "face": res.get("face_finish", {}).get("finish", "UNKNOWN") if isinstance(res.get("face_finish"), dict) else "UNKNOWN", + "grade": mat_info.get("grade", "") if isinstance(mat_info, dict) else "", + "size": data.get("main_nom") or data.get("size_spec") + }) + + @staticmethod + def _save_bolt_details(db, mid, fid, res, data): + fast_type = res.get("fastener_type", {}) + mat_info = res.get("material", {}) + dim_info = res.get("dimensions", {}) + + if mat_info.get("grade"): + db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"), + {"g": mat_info.get("grade"), "id": mid}) + + # 볼트 타입 결정 (특수 용도 고려) + bolt_type = fast_type.get("type", "UNKNOWN") if isinstance(fast_type, dict) else str(fast_type) + special_apps = res.get("special_applications", {}).get("detected_applications", []) + if "LT" in special_apps: bolt_type = "LT_BOLT" + elif "PSV" in special_apps: bolt_type = "PSV_BOLT" + + # 코팅 타입 + desc_upper = data["original_description"].upper() + coating = "UNKNOWN" + if "GALV" in desc_upper: coating = "GALVANIZED" + elif "ZINC" in desc_upper: coating = "ZINC_PLATED" + + db.execute(text(""" + INSERT INTO bolt_details ( + material_id, file_id, bolt_type, thread_type, + diameter, length, material_grade, coating_type + ) VALUES (:mid, :fid, :type, :thread, :dia, :len, :grade, :coating) + """), { + "mid": mid, "fid": fid, + "type": bolt_type, + "thread": res.get("thread_specification", {}).get("standard", "UNKNOWN") if isinstance(res.get("thread_specification"), dict) else "UNKNOWN", + "dia": dim_info.get("nominal_size", data.get("main_nom", "")), + "len": dim_info.get("length", ""), + "grade": mat_info.get("grade", "") if isinstance(mat_info, dict) else "", + "coating": coating + }) + + @staticmethod + def _save_valve_details(db, mid, fid, res, data): + val_type = res.get("valve_type", {}) + mat_info = res.get("material", {}) + + if mat_info.get("grade"): + db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"), + {"g": mat_info.get("grade"), "id": mid}) + + db.execute(text(""" + INSERT INTO valve_details ( + material_id, file_id, valve_type, connection_method, + pressure_rating, body_material, size_inches + ) VALUES (:mid, :fid, :type, :conn, :rating, :body, :size) + """), { + "mid": mid, "fid": fid, + "type": val_type.get("type", "UNKNOWN") if isinstance(val_type, dict) else str(val_type), + "conn": res.get("connection_method", {}).get("method", "UNKNOWN") if isinstance(res.get("connection_method"), dict) else "UNKNOWN", + "rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN", + "body": mat_info.get("grade", "") if isinstance(mat_info, dict) else "", + "size": data.get("main_nom") or data.get("size_spec") + }) + + @staticmethod + def _save_gasket_details(db, mid, fid, res, data): + gask_type = res.get("gasket_type", {}) + + db.execute(text(""" + INSERT INTO gasket_details ( + material_id, file_id, gasket_type, pressure_rating, size_inches + ) VALUES (:mid, :fid, :type, :rating, :size) + """), { + "mid": mid, "fid": fid, + "type": gask_type.get("type", "UNKNOWN") if isinstance(gask_type, dict) else str(gask_type), + "rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN", + "size": data.get("main_nom") or data.get("size_spec") + }) + + @staticmethod + def _save_support_details(db, mid, fid, res, data): + db.execute(text(""" + INSERT INTO support_details ( + material_id, file_id, support_type, pipe_size + ) VALUES (:mid, :fid, :type, :size) + """), { + "mid": mid, "fid": fid, + "type": res.get("support_type", "UNKNOWN"), + "size": res.get("size_info", {}).get("pipe_size", "") + }) diff --git a/tkeg/api/app/services/materials_schema.py b/tkeg/api/app/services/materials_schema.py new file mode 100644 index 0000000..95fe00e --- /dev/null +++ b/tkeg/api/app/services/materials_schema.py @@ -0,0 +1,590 @@ +""" +재질 분류를 위한 공통 스키마 +모든 제품군(PIPE, FITTING, FLANGE 등)에서 공통 사용 +""" + +import re +from typing import Dict, List, Optional + +# ========== 미국 ASTM/ASME 규격 ========== +MATERIAL_STANDARDS = { + "ASTM_ASME": { + "FORGED_GRADES": { + "A182": { + "carbon_alloy": { + "patterns": [ + r"ASTM\s+A182\s+(?:GR\s*)?F(\d+)", + r"A182\s+(?:GR\s*)?F(\d+)", + r"ASME\s+SA182\s+(?:GR\s*)?F(\d+)" + ], + "grades": { + "F1": { + "composition": "0.5Mo", + "temp_max": "482°C", + "applications": "중온용" + }, + "F5": { + "composition": "5Cr-0.5Mo", + "temp_max": "649°C", + "applications": "고온용" + }, + "F11": { + "composition": "1.25Cr-0.5Mo", + "temp_max": "593°C", + "applications": "일반 고온용" + }, + "F22": { + "composition": "2.25Cr-1Mo", + "temp_max": "649°C", + "applications": "고온 고압용" + }, + "F91": { + "composition": "9Cr-1Mo-V", + "temp_max": "649°C", + "applications": "초고온용" + } + }, + "manufacturing": "FORGED" + }, + "stainless": { + "patterns": [ + r"ASTM\s+A182\s+F(\d{3}[LH]*)", + r"A182\s+F(\d{3}[LH]*)", + r"ASME\s+SA182\s+F(\d{3}[LH]*)" + ], + "grades": { + "F304": { + "composition": "18Cr-8Ni", + "applications": "일반용", + "corrosion_resistance": "보통" + }, + "F304L": { + "composition": "18Cr-8Ni-저탄소", + "applications": "용접용", + "corrosion_resistance": "보통" + }, + "F316": { + "composition": "18Cr-10Ni-2Mo", + "applications": "내식성", + "corrosion_resistance": "우수" + }, + "F316L": { + "composition": "18Cr-10Ni-2Mo-저탄소", + "applications": "용접+내식성", + "corrosion_resistance": "우수" + }, + "F321": { + "composition": "18Cr-8Ni-Ti", + "applications": "고온안정화", + "stabilizer": "Titanium" + }, + "F347": { + "composition": "18Cr-8Ni-Nb", + "applications": "고온안정화", + "stabilizer": "Niobium" + } + }, + "manufacturing": "FORGED" + } + }, + + "A105": { + "patterns": [ + r"ASTM\s+A105(?:\s+(?:GR\s*)?([ABC]))?", + r"A105(?:\s+(?:GR\s*)?([ABC]))?", + r"ASME\s+SA105" + ], + "description": "탄소강 단조품", + "composition": "탄소강", + "applications": "일반 압력용 단조품", + "manufacturing": "FORGED", + "pressure_rating": "150LB ~ 9000LB" + } + }, + + "WELDED_GRADES": { + "A234": { + "carbon": { + "patterns": [ + r"ASTM\s+A234\s+(?:GR\s*)?WP([ABC])", + r"A234\s+(?:GR\s*)?WP([ABC])", + r"ASME\s+SA234\s+(?:GR\s*)?WP([ABC])" + ], + "grades": { + "WPA": { + "yield_strength": "30 ksi", + "applications": "저압용", + "temp_range": "-29°C ~ 400°C" + }, + "WPB": { + "yield_strength": "35 ksi", + "applications": "일반용", + "temp_range": "-29°C ~ 400°C" + }, + "WPC": { + "yield_strength": "40 ksi", + "applications": "고압용", + "temp_range": "-29°C ~ 400°C" + } + }, + "manufacturing": "WELDED_FABRICATED" + }, + "alloy": { + "patterns": [ + r"ASTM\s+A234\s+(?:GR\s*)?WP(\d+)", + r"A234\s+(?:GR\s*)?WP(\d+)", + r"ASME\s+SA234\s+(?:GR\s*)?WP(\d+)" + ], + "grades": { + "WP1": { + "composition": "0.5Mo", + "temp_max": "482°C" + }, + "WP5": { + "composition": "5Cr-0.5Mo", + "temp_max": "649°C" + }, + "WP11": { + "composition": "1.25Cr-0.5Mo", + "temp_max": "593°C" + }, + "WP22": { + "composition": "2.25Cr-1Mo", + "temp_max": "649°C" + }, + "WP91": { + "composition": "9Cr-1Mo-V", + "temp_max": "649°C" + } + }, + "manufacturing": "WELDED_FABRICATED" + } + }, + "A403": { + "stainless": { + "patterns": [ + r"ASTM\s+A403\s+(?:GR\s*)?WP\s*(\d{3}[LH]*)", + r"A403\s+(?:GR\s*)?WP\s*(\d{3}[LH]*)", + r"ASME\s+SA403\s+(?:GR\s*)?WP\s*(\d{3}[LH]*)", + r"ASTM\s+A403\s+(WP\d{3}[LH]*)", + r"A403\s+(WP\d{3}[LH]*)", + r"(WP\d{3}[LH]*)\s+A403", + r"(WP\d{3}[LH]*)" + ], + "grades": { + "WP304": { + "base_grade": "304", + "manufacturing": "WELDED", + "applications": "일반 용접용" + }, + "WP304L": { + "base_grade": "304L", + "manufacturing": "WELDED", + "applications": "저탄소 용접용" + }, + "WP316": { + "base_grade": "316", + "manufacturing": "WELDED", + "applications": "내식성 용접용" + }, + "WP316L": { + "base_grade": "316L", + "manufacturing": "WELDED", + "applications": "저탄소 내식성 용접용" + } + }, + "manufacturing": "WELDED_FABRICATED" + } + }, + "A420": { + "low_temp_carbon": { + "patterns": [ + r"ASTM\s+A420\s+(?:GR\s*)?WPL\s*(\d+)", + r"A420\s+(?:GR\s*)?WPL\s*(\d+)", + r"ASME\s+SA420\s+(?:GR\s*)?WPL\s*(\d+)", + r"ASTM\s+A420\s+(WPL\d+)", + r"A420\s+(WPL\d+)", + r"(WPL\d+)\s+A420", + r"(WPL\d+)" + ], + "grades": { + "WPL1": { + "composition": "탄소강", + "temp_min": "-29°C", + "applications": "저온용 피팅" + }, + "WPL3": { + "composition": "3.5Ni", + "temp_min": "-46°C", + "applications": "저온용 피팅" + }, + "WPL6": { + "composition": "탄소강", + "temp_min": "-46°C", + "applications": "저온용 피팅" + } + }, + "manufacturing": "WELDED_FABRICATED" + } + } + }, + + "CAST_GRADES": { + "A216": { + "patterns": [ + r"ASTM\s+A216\s+(?:GR\s*)?([A-Z]{2,3})", + r"A216\s+(?:GR\s*)?([A-Z]{2,3})", + r"ASME\s+SA216\s+(?:GR\s*)?([A-Z]{2,3})" + ], + "grades": { + "WCA": { + "composition": "저탄소강", + "applications": "일반주조", + "temp_range": "-29°C ~ 425°C" + }, + "WCB": { + "composition": "탄소강", + "applications": "압력용기", + "temp_range": "-29°C ~ 425°C" + }, + "WCC": { + "composition": "중탄소강", + "applications": "고강도용", + "temp_range": "-29°C ~ 425°C" + } + }, + "manufacturing": "CAST" + }, + "A351": { + "patterns": [ + r"ASTM\s+A351\s+(?:GR\s*)?([A-Z0-9]+)", + r"A351\s+(?:GR\s*)?([A-Z0-9]+)", + r"ASME\s+SA351\s+(?:GR\s*)?([A-Z0-9]+)" + ], + "grades": { + "CF8": { + "base_grade": "304", + "manufacturing": "CAST", + "applications": "304 스테인리스 주조" + }, + "CF8M": { + "base_grade": "316", + "manufacturing": "CAST", + "applications": "316 스테인리스 주조" + }, + "CF3": { + "base_grade": "304L", + "manufacturing": "CAST", + "applications": "304L 스테인리스 주조" + }, + "CF3M": { + "base_grade": "316L", + "manufacturing": "CAST", + "applications": "316L 스테인리스 주조" + } + }, + "manufacturing": "CAST" + } + }, + + "PIPE_GRADES": { + "A106": { + "patterns": [ + r"ASTM\s+A106\s+(?:GR\s*)?([ABC])", + r"A106\s+(?:GR\s*)?([ABC])", + r"ASME\s+SA106\s+(?:GR\s*)?([ABC])" + ], + "grades": { + "A": { + "yield_strength": "30 ksi", + "applications": "저압용" + }, + "B": { + "yield_strength": "35 ksi", + "applications": "일반용" + }, + "C": { + "yield_strength": "40 ksi", + "applications": "고압용" + } + }, + "manufacturing": "SEAMLESS" + }, + "A53": { + "patterns": [ + r"ASTM\s+A53\s+(?:GR\s*)?([ABC])", + r"A53\s+(?:GR\s*)?([ABC])" + ], + "grades": { + "A": {"yield_strength": "30 ksi"}, + "B": {"yield_strength": "35 ksi"} + }, + "manufacturing": "WELDED_OR_SEAMLESS" + }, + "A312": { + "patterns": [ + r"ASTM\s+A312\s+TP\s*(\d{3}[LH]*)", + r"A312\s+TP\s*(\d{3}[LH]*)", + r"ASTM\s+A312\s+(TP\d{3}[LH]*)", + r"A312\s+(TP\d{3}[LH]*)", + r"(TP\d{3}[LH]*)\s+A312", + r"(TP\d{3}[LH]*)" + ], + "grades": { + "TP304": { + "base_grade": "304", + "manufacturing": "SEAMLESS" + }, + "TP304L": { + "base_grade": "304L", + "manufacturing": "SEAMLESS" + }, + "TP316": { + "base_grade": "316", + "manufacturing": "SEAMLESS" + }, + "TP316L": { + "base_grade": "316L", + "manufacturing": "SEAMLESS" + } + }, + "manufacturing": "SEAMLESS" + }, + "A333": { + "patterns": [ + r"ASTM\s+A333\s+(?:GR\s*)?(\d+)", + r"A333\s+(?:GR\s*)?(\d+)", + r"ASME\s+SA333\s+(?:GR\s*)?(\d+)" + ], + "grades": { + "1": { + "composition": "탄소강", + "temp_min": "-29°C", + "applications": "저온용 배관" + }, + "3": { + "composition": "3.5Ni", + "temp_min": "-46°C", + "applications": "저온용 배관" + }, + "6": { + "composition": "탄소강", + "temp_min": "-46°C", + "applications": "저온용 배관" + } + }, + "manufacturing": "SEAMLESS" + } + } + }, + + # ========== 한국 KS 규격 ========== + "KS": { + "PIPE_GRADES": { + "D3507": { + "patterns": [r"KS\s+D\s*3507\s+SPPS\s*(\d+)"], + "description": "배관용 탄소강관", + "manufacturing": "SEAMLESS" + }, + "D3583": { + "patterns": [r"KS\s+D\s*3583\s+STPG\s*(\d+)"], + "description": "압력배관용 탄소강관", + "manufacturing": "SEAMLESS" + }, + "D3576": { + "patterns": [r"KS\s+D\s*3576\s+STS\s*(\d{3}[LH]*)"], + "description": "배관용 스테인리스강관", + "manufacturing": "SEAMLESS" + } + }, + "FITTING_GRADES": { + "D3562": { + "patterns": [r"KS\s+D\s*3562"], + "description": "탄소강 단조 피팅", + "manufacturing": "FORGED" + }, + "D3563": { + "patterns": [r"KS\s+D\s*3563"], + "description": "스테인리스강 단조 피팅", + "manufacturing": "FORGED" + } + } + }, + + # ========== 일본 JIS 규격 ========== + "JIS": { + "PIPE_GRADES": { + "G3452": { + "patterns": [r"JIS\s+G\s*3452\s+SGP"], + "description": "배관용 탄소강관", + "manufacturing": "WELDED" + }, + "G3454": { + "patterns": [r"JIS\s+G\s*3454\s+STPG\s*(\d+)"], + "description": "압력배관용 탄소강관", + "manufacturing": "SEAMLESS" + }, + "G3459": { + "patterns": [r"JIS\s+G\s*3459\s+SUS\s*(\d{3}[LH]*)"], + "description": "배관용 스테인리스강관", + "manufacturing": "SEAMLESS" + } + }, + "FITTING_GRADES": { + "B2311": { + "patterns": [r"JIS\s+B\s*2311"], + "description": "강제 피팅", + "manufacturing": "FORGED" + }, + "B2312": { + "patterns": [r"JIS\s+B\s*2312"], + "description": "스테인리스강 피팅", + "manufacturing": "FORGED" + } + } + } +} + +# ========== 특수 재질 ========== +SPECIAL_MATERIALS = { + "SUPER_ALLOYS": { + "INCONEL": { + "patterns": [r"INCONEL\s*(\d+)"], + "grades": { + "600": { + "composition": "Ni-Cr", + "temp_max": "1177°C", + "applications": "고온 산화 환경" + }, + "625": { + "composition": "Ni-Cr-Mo", + "temp_max": "982°C", + "applications": "고온 부식 환경" + }, + "718": { + "composition": "Ni-Cr-Fe", + "temp_max": "704°C", + "applications": "고온 고강도" + } + }, + "manufacturing": "FORGED_OR_CAST" + }, + "HASTELLOY": { + "patterns": [r"HASTELLOY\s*([A-Z0-9]+)"], + "grades": { + "C276": { + "composition": "Ni-Mo-Cr", + "corrosion": "최고급", + "applications": "강산성 환경" + }, + "C22": { + "composition": "Ni-Cr-Mo-W", + "applications": "화학공정" + } + }, + "manufacturing": "FORGED_OR_CAST" + }, + "MONEL": { + "patterns": [r"MONEL\s*(\d+)"], + "grades": { + "400": { + "composition": "Ni-Cu", + "applications": "해양 환경" + } + } + } + }, + "TITANIUM": { + "patterns": [ + r"TITANIUM(?:\s+(?:GR|GRADE)\s*(\d+))?", + r"Ti(?:\s+(?:GR|GRADE)\s*(\d+))?", + r"ASTM\s+B\d+\s+(?:GR|GRADE)\s*(\d+)" + ], + "grades": { + "1": { + "purity": "상업용 순티타늄", + "strength": "낮음", + "applications": "화학공정" + }, + "2": { + "purity": "상업용 순티타늄 (일반)", + "strength": "보통", + "applications": "일반용" + }, + "5": { + "composition": "Ti-6Al-4V", + "strength": "고강도", + "applications": "항공우주" + } + }, + "manufacturing": "FORGED_OR_SEAMLESS" + }, + "COPPER_ALLOYS": { + "BRASS": { + "patterns": [r"BRASS|황동"], + "composition": "Cu-Zn", + "applications": ["계기용", "선박용"] + }, + "BRONZE": { + "patterns": [r"BRONZE|청동"], + "composition": "Cu-Sn", + "applications": ["해양용", "베어링"] + }, + "CUPRONICKEL": { + "patterns": [r"Cu-?Ni|CUPRONICKEL"], + "composition": "Cu-Ni", + "applications": ["해수 배관"] + } + } +} + +# ========== 제작방법별 재질 매핑 ========== +MANUFACTURING_MATERIAL_MAP = { + "FORGED": { + "primary_standards": ["ASTM A182", "ASTM A105"], + "size_range": "1/8\" ~ 4\"", + "pressure_range": "150LB ~ 9000LB", + "characteristics": "고강도, 고압용" + }, + "WELDED_FABRICATED": { + "primary_standards": ["ASTM A234", "ASTM A403"], + "size_range": "1/2\" ~ 48\"", + "pressure_range": "150LB ~ 2500LB", + "characteristics": "대구경, 중저압용" + }, + "CAST": { + "primary_standards": ["ASTM A216", "ASTM A351"], + "size_range": "1/2\" ~ 24\"", + "applications": ["복잡형상", "밸브본체"], + "characteristics": "복잡 형상 가능" + }, + "SEAMLESS": { + "primary_standards": ["ASTM A106", "ASTM A312", "ASTM A335"], + "manufacturing": "열간압연/냉간인발", + "characteristics": "이음새 없는 관" + }, + "WELDED": { + "primary_standards": ["ASTM A53", "ASTM A312"], + "manufacturing": "전기저항용접/아크용접", + "characteristics": "용접선 있는 관" + } +} + +# ========== 일반 재질 키워드 ========== +GENERIC_MATERIAL_KEYWORDS = { + "CARBON_STEEL": [ + "CS", "CARBON STEEL", "탄소강", "SS400", "SM490" + ], + "STAINLESS_STEEL": [ + "SS", "STS", "STAINLESS", "스테인리스", "304", "316", "321", "347" + ], + "ALLOY_STEEL": [ + "ALLOY", "합금강", "CHROME MOLY", "Cr-Mo" + ], + "CAST_IRON": [ + "CAST IRON", "주철", "FC", "FCD" + ], + "DUCTILE_IRON": [ + "DUCTILE IRON", "구상흑연주철", "덕타일" + ] +} diff --git a/tkeg/api/app/services/pipe_classifier.py b/tkeg/api/app/services/pipe_classifier.py new file mode 100644 index 0000000..3a6430b --- /dev/null +++ b/tkeg/api/app/services/pipe_classifier.py @@ -0,0 +1,573 @@ +""" +PIPE 분류 전용 모듈 +재질 분류 + 파이프 특화 분류 +""" + +import re +from typing import Dict, List, Optional +from .material_classifier import classify_material, get_manufacturing_method_from_material + +# ========== PIPE USER 요구사항 키워드 ========== +PIPE_USER_REQUIREMENTS = { + "IMPACT_TEST": { + "keywords": ["IMPACT", "충격시험", "CHARPY", "CVN", "IMPACT TEST", "충격", "NOTCH"], + "description": "충격시험 요구", + "confidence": 0.95 + }, + "ASME_CODE": { + "keywords": ["ASME", "ASME CODE", "CODE", "B31.1", "B31.3", "B31.4", "B31.8", "VIII"], + "description": "ASME 코드 준수", + "confidence": 0.95 + }, + "STRESS_RELIEF": { + "keywords": ["STRESS RELIEF", "SR", "응력제거", "열처리", "HEAT TREATMENT"], + "description": "응력제거 열처리", + "confidence": 0.90 + }, + "RADIOGRAPHIC_TEST": { + "keywords": ["RT", "RADIOGRAPHIC", "방사선시험", "X-RAY", "엑스레이"], + "description": "방사선 시험", + "confidence": 0.90 + }, + "ULTRASONIC_TEST": { + "keywords": ["UT", "ULTRASONIC", "초음파시험", "초음파"], + "description": "초음파 시험", + "confidence": 0.90 + }, + "MAGNETIC_PARTICLE": { + "keywords": ["MT", "MAGNETIC PARTICLE", "자분탐상", "자분"], + "description": "자분탐상 시험", + "confidence": 0.90 + }, + "LIQUID_PENETRANT": { + "keywords": ["PT", "LIQUID PENETRANT", "침투탐상", "침투"], + "description": "침투탐상 시험", + "confidence": 0.90 + }, + "HYDROSTATIC_TEST": { + "keywords": ["HYDROSTATIC", "수압시험", "PRESSURE TEST", "압력시험"], + "description": "수압 시험", + "confidence": 0.90 + }, + "LOW_TEMPERATURE": { + "keywords": ["LOW TEMP", "저온", "LTCS", "LOW TEMPERATURE", "CRYOGENIC"], + "description": "저온용", + "confidence": 0.85 + }, + "HIGH_TEMPERATURE": { + "keywords": ["HIGH TEMP", "고온", "HTCS", "HIGH TEMPERATURE"], + "description": "고온용", + "confidence": 0.85 + } +} + +# ========== PIPE 제조 방법별 분류 ========== +PIPE_MANUFACTURING = { + "SEAMLESS": { + "keywords": ["SEAMLESS", "SMLS", "심리스", "무계목"], + "confidence": 0.95, + "characteristics": "이음새 없는 고품질 파이프" + }, + "WELDED": { + "keywords": ["WELDED", "WLD", "ERW", "SAW", "용접", "전기저항용접"], + "confidence": 0.95, + "characteristics": "용접으로 제조된 파이프" + }, + "CAST": { + "keywords": ["CAST", "주조"], + "confidence": 0.85, + "characteristics": "주조로 제조된 파이프" + } +} + +# ========== PIPE 끝 가공별 분류 ========== +PIPE_END_PREP = { + "BOTH_ENDS_BEVELED": { + "codes": ["BBE", "BOE", "BOTH END", "BOTH BEVELED", "양쪽개선"], + "cutting_note": "양쪽 개선", + "machining_required": True, + "confidence": 0.95 + }, + "ONE_END_BEVELED": { + "codes": ["BE", "BEV", "PBE", "PIPE BEVELED END", "POE"], + "cutting_note": "한쪽 개선", + "machining_required": True, + "confidence": 0.95 + }, + "NO_BEVEL": { + "codes": ["PE", "PLAIN END", "PPE", "평단", "무개선"], + "cutting_note": "무 개선", + "machining_required": False, + "confidence": 0.95 + }, + "THREADED": { + "codes": ["TOE", "THE", "THREADED", "나사", "스레드"], + "cutting_note": "나사 가공", + "machining_required": True, + "confidence": 0.90 + } +} + +# ========== 구매용 파이프 분류 (끝단 가공 제외) ========== +def get_purchase_pipe_description(description: str) -> str: + """구매용 파이프 설명 - 끝단 가공 정보 제거""" + + # 모든 끝단 가공 코드들을 수집 + end_prep_codes = [] + for prep_data in PIPE_END_PREP.values(): + end_prep_codes.extend(prep_data["codes"]) + + # 설명에서 끝단 가공 코드 제거 + clean_description = description.upper() + + # 끝단 가공 코드들을 길이 순으로 정렬 (긴 것부터 처리) + end_prep_codes.sort(key=len, reverse=True) + + for code in end_prep_codes: + # 단어 경계를 고려하여 제거 (부분 매칭 방지) + pattern = r'\b' + re.escape(code) + r'\b' + clean_description = re.sub(pattern, '', clean_description, flags=re.IGNORECASE) + + # 끝단 가공 관련 패턴들 추가 제거 + # BOE-POE, POE-TOE 같은 조합 패턴들 + end_prep_patterns = [ + r'\b[A-Z]{2,3}E-[A-Z]{2,3}E\b', # BOE-POE, POE-TOE 등 + r'\b[A-Z]{2,3}E-[A-Z]{2,3}\b', # BOE-TO, POE-TO 등 + r'\b[A-Z]{2,3}-[A-Z]{2,3}E\b', # BO-POE, PO-TOE 등 + r'\b[A-Z]{2,3}-[A-Z]{2,3}\b', # BO-PO, PO-TO 등 + ] + + for pattern in end_prep_patterns: + clean_description = re.sub(pattern, '', clean_description, flags=re.IGNORECASE) + + # 남은 하이픈과 공백 정리 + clean_description = re.sub(r'\s*-\s*', ' ', clean_description) # 하이픈 제거 + clean_description = re.sub(r'\s+', ' ', clean_description).strip() # 연속 공백 정리 + + return clean_description + +def extract_end_preparation_info(description: str) -> Dict: + """파이프 설명에서 끝단 가공 정보 추출""" + + desc_upper = description.upper() + + # 끝단 가공 코드 찾기 + for prep_type, prep_data in PIPE_END_PREP.items(): + for code in prep_data["codes"]: + if code in desc_upper: + return { + "end_preparation_type": prep_type, + "end_preparation_code": code, + "machining_required": prep_data["machining_required"], + "cutting_note": prep_data["cutting_note"], + "confidence": prep_data["confidence"], + "matched_pattern": code, + "original_description": description, + "clean_description": get_purchase_pipe_description(description) + } + + # 기본값: PBE (양쪽 무개선) + return { + "end_preparation_type": "NO_BEVEL", # PBE로 매핑될 예정 + "end_preparation_code": "PBE", + "machining_required": False, + "cutting_note": "양쪽 무개선 (기본값)", + "confidence": 0.5, + "matched_pattern": "DEFAULT", + "original_description": description, + "clean_description": get_purchase_pipe_description(description) + } + +# ========== PIPE 스케줄별 분류 ========== +PIPE_SCHEDULE = { + "patterns": [ + r"SCH\s*(\d+)", + r"SCHEDULE\s*(\d+)", + r"스케줄\s*(\d+)" + ], + "common_schedules": ["10", "20", "40", "80", "120", "160"], + "wall_thickness_patterns": [ + r"(\d+(?:\.\d+)?)\s*mm\s*THK", + r"(\d+(?:\.\d+)?)\s*THK" + ] +} + +def extract_pipe_user_requirements(description: str) -> List[str]: + """ + 파이프 설명에서 User 요구사항 추출 + + Args: + description: 파이프 설명 + + Returns: + 발견된 요구사항 리스트 + """ + desc_upper = description.upper() + found_requirements = [] + + for req_type, req_data in PIPE_USER_REQUIREMENTS.items(): + for keyword in req_data["keywords"]: + if keyword in desc_upper: + found_requirements.append(req_data["description"]) + break # 같은 타입에서 중복 방지 + + return found_requirements + +def classify_pipe_for_purchase(dat_file: str, description: str, main_nom: str, + length: Optional[float] = None) -> Dict: + """구매용 파이프 분류 - 끝단 가공 정보 제외""" + + # 끝단 가공 정보 제거한 설명으로 분류 + clean_description = get_purchase_pipe_description(description) + + # 기본 파이프 분류 수행 + result = classify_pipe(dat_file, clean_description, main_nom, length) + + # 구매용임을 표시 + result["purchase_classification"] = True + result["original_description"] = description + result["clean_description"] = clean_description + + return result + +def classify_pipe(dat_file: str, description: str, main_nom: str, + length: Optional[float] = None) -> Dict: + """ + 완전한 PIPE 분류 + + Args: + dat_file: DAT_FILE 필드 + description: DESCRIPTION 필드 + main_nom: MAIN_NOM 필드 (사이즈) + length: LENGTH 필드 (절단 치수) + + Returns: + 완전한 파이프 분류 결과 + """ + + desc_upper = description.upper() + + # 1. 명칭 우선 확인 (다른 자재 타입 키워드가 있으면 파이프가 아님) + other_material_keywords = [ + 'FLG', 'FLANGE', '플랜지', # 플랜지 + 'ELBOW', 'ELL', 'TEE', 'REDUCER', 'CAP', 'COUPLING', '엘보', '티', '리듀서', # 피팅 + 'VALVE', 'BALL', 'GATE', 'GLOBE', 'CHECK', '밸브', # 밸브 + 'BOLT', 'STUD', 'NUT', 'SCREW', '볼트', '너트', # 볼트 + 'GASKET', 'GASK', '가스켓', # 가스켓 + 'GAUGE', 'SENSOR', 'TRANSMITTER', 'INSTRUMENT', '계기' # 계기 + ] + + for keyword in other_material_keywords: + if keyword in desc_upper: + return { + "category": "UNKNOWN", + "overall_confidence": 0.0, + "reason": f"다른 자재 키워드 발견: {keyword}" + } + + # 2. 파이프 키워드 확인 + pipe_keywords = ['PIPE', 'TUBE', '파이프', '배관', 'SMLS', 'SEAMLESS'] + has_pipe_keyword = any(keyword in desc_upper for keyword in pipe_keywords) + + # 파이프 재질 확인 (A106, A333, A312, A53) + pipe_materials = ['A106', 'A333', 'A312', 'A53'] + has_pipe_material = any(material in desc_upper for material in pipe_materials) + + # 파이프 키워드도 없고 파이프 재질도 없으면 UNKNOWN + if not has_pipe_keyword and not has_pipe_material: + return { + "category": "UNKNOWN", + "overall_confidence": 0.0, + "reason": "파이프 키워드 및 재질 없음" + } + + # 3. 재질 분류 (공통 모듈 사용) + material_result = classify_material(description) + + # 2. 제조 방법 분류 + manufacturing_result = classify_pipe_manufacturing(description, material_result) + + # 3. 끝 가공 분류 + end_prep_result = classify_pipe_end_preparation(description) + + # 4. 스케줄 분류 (재질 정보 전달) + schedule_result = classify_pipe_schedule(description, material_result) + + # 5. 길이(절단 치수) 처리 + length_info = extract_pipe_length_info(length, description) + + # 6. User 요구사항 추출 + user_requirements = extract_pipe_user_requirements(description) + + # 7. 최종 결과 조합 + return { + "category": "PIPE", + + # 재질 정보 (공통 모듈) + "material": { + "standard": material_result.get('standard', 'UNKNOWN'), + "grade": material_result.get('grade', 'UNKNOWN'), + "material_type": material_result.get('material_type', 'UNKNOWN'), + "confidence": material_result.get('confidence', 0.0) + }, + + # 파이프 특화 정보 + "manufacturing": { + "method": manufacturing_result.get('method', 'UNKNOWN'), + "confidence": manufacturing_result.get('confidence', 0.0), + "evidence": manufacturing_result.get('evidence', []) + }, + + "end_preparation": { + "type": end_prep_result.get('type', 'UNKNOWN'), + "cutting_note": end_prep_result.get('cutting_note', ''), + "machining_required": end_prep_result.get('machining_required', False), + "confidence": end_prep_result.get('confidence', 0.0) + }, + + "schedule": { + "schedule": schedule_result.get('schedule', 'UNKNOWN'), + "wall_thickness": schedule_result.get('wall_thickness', ''), + "confidence": schedule_result.get('confidence', 0.0) + }, + + "length_info": length_info, + + "size_info": { + "nominal_size": main_nom, + "length_mm": length_info.get('length_mm') + }, + + # User 요구사항 + "user_requirements": user_requirements, + + # 전체 신뢰도 + "overall_confidence": calculate_pipe_confidence({ + "material": material_result.get('confidence', 0), + "manufacturing": manufacturing_result.get('confidence', 0), + "end_prep": end_prep_result.get('confidence', 0), + "schedule": schedule_result.get('confidence', 0) + }) + } + +def classify_pipe_manufacturing(description: str, material_result: Dict) -> Dict: + """파이프 제조 방법 분류""" + + desc_upper = description.upper() + + # 1. DESCRIPTION 키워드 우선 확인 + for method, method_data in PIPE_MANUFACTURING.items(): + for keyword in method_data["keywords"]: + if keyword in desc_upper: + return { + "method": method, + "confidence": method_data["confidence"], + "evidence": [f"KEYWORD: {keyword}"], + "characteristics": method_data["characteristics"] + } + + # 2. 재질 규격으로 추정 + material_manufacturing = get_manufacturing_method_from_material(material_result) + if material_manufacturing in ["SEAMLESS", "WELDED"]: + return { + "method": material_manufacturing, + "confidence": 0.8, + "evidence": [f"MATERIAL_STANDARD: {material_result.get('standard')}"], + "characteristics": PIPE_MANUFACTURING.get(material_manufacturing, {}).get("characteristics", "") + } + + # 3. 기본값 + return { + "method": "UNKNOWN", + "confidence": 0.0, + "evidence": ["NO_MANUFACTURING_INFO"] + } + +def classify_pipe_end_preparation(description: str) -> Dict: + """파이프 끝 가공 분류""" + + desc_upper = description.upper() + + # 우선순위: 양쪽 > 한쪽 > 무개선 + for prep_type, prep_data in PIPE_END_PREP.items(): + for code in prep_data["codes"]: + if code in desc_upper: + return { + "type": prep_type, + "cutting_note": prep_data["cutting_note"], + "machining_required": prep_data["machining_required"], + "confidence": prep_data["confidence"], + "matched_code": code + } + + # 기본값: 무개선 + return { + "type": "NO_BEVEL", + "cutting_note": "무 개선 (기본값)", + "machining_required": False, + "confidence": 0.5, + "matched_code": "DEFAULT" + } + +def classify_pipe_schedule(description: str, material_result: Dict = None) -> Dict: + """파이프 스케줄 분류 - 재질별 표현 개선""" + + desc_upper = description.upper() + + # 재질 정보 확인 + material_type = "CARBON" # 기본값 + if material_result: + material_grade = material_result.get('grade', '').upper() + material_standard = material_result.get('standard', '').upper() + + # 스테인리스 스틸 판단 + if any(sus_indicator in material_grade or sus_indicator in material_standard + for sus_indicator in ['SUS', 'SS', 'A312', 'A358', 'A376', '304', '316', '321', '347']): + material_type = "STAINLESS" + + # 1. 스케줄 패턴 확인 + for pattern in PIPE_SCHEDULE["patterns"]: + match = re.search(pattern, desc_upper) + if match: + schedule_num = match.group(1) + + # 재질별 스케줄 표현 + if material_type == "STAINLESS": + # 스테인리스 스틸: SCH 40S, SCH 80S + if schedule_num in ["10", "20", "40", "80", "120", "160"]: + schedule_display = f"SCH {schedule_num}S" + else: + schedule_display = f"SCH {schedule_num}" + else: + # 카본 스틸: SCH 40, SCH 80 + schedule_display = f"SCH {schedule_num}" + + return { + "schedule": schedule_display, + "schedule_number": schedule_num, + "material_type": material_type, + "confidence": 0.95, + "matched_pattern": pattern + } + + # 2. 두께 패턴 확인 + for pattern in PIPE_SCHEDULE["wall_thickness_patterns"]: + match = re.search(pattern, desc_upper) + if match: + thickness = match.group(1) + return { + "schedule": f"{thickness}mm THK", + "wall_thickness": f"{thickness}mm", + "material_type": material_type, + "confidence": 0.9, + "matched_pattern": pattern + } + + # 3. 기본값 + return { + "schedule": "UNKNOWN", + "material_type": material_type, + "confidence": 0.0 + } + +def extract_pipe_length_info(length: Optional[float], description: str) -> Dict: + """파이프 길이(절단 치수) 정보 추출""" + + length_info = { + "length_mm": None, + "source": None, + "confidence": 0.0, + "note": "" + } + + # 1. LENGTH 필드에서 추출 (우선) + if length and length > 0: + length_info.update({ + "length_mm": round(length, 1), + "source": "LENGTH_FIELD", + "confidence": 0.95, + "note": f"도면 명기 길이: {length}mm" + }) + + # 2. DESCRIPTION에서 백업 추출 + else: + desc_length = extract_length_from_description(description) + if desc_length: + length_info.update({ + "length_mm": desc_length, + "source": "DESCRIPTION_PARSED", + "confidence": 0.8, + "note": f"설명란에서 추출: {desc_length}mm" + }) + else: + length_info.update({ + "source": "NO_LENGTH_INFO", + "confidence": 0.0, + "note": "길이 정보 없음 - 도면 확인 필요" + }) + + return length_info + +def extract_length_from_description(description: str) -> Optional[float]: + """DESCRIPTION에서 길이 정보 추출""" + + length_patterns = [ + r'(\d+(?:\.\d+)?)\s*mm', + r'(\d+(?:\.\d+)?)\s*M', + r'(\d+(?:\.\d+)?)\s*LG', + r'L\s*=\s*(\d+(?:\.\d+)?)', + r'길이\s*(\d+(?:\.\d+)?)' + ] + + for pattern in length_patterns: + match = re.search(pattern, description.upper()) + if match: + return float(match.group(1)) + + return None + +def calculate_pipe_confidence(confidence_scores: Dict) -> float: + """파이프 분류 전체 신뢰도 계산""" + + scores = [score for score in confidence_scores.values() if score > 0] + + if not scores: + return 0.0 + + # 가중 평균 (재질이 가장 중요) + weights = { + "material": 0.4, + "manufacturing": 0.25, + "end_prep": 0.2, + "schedule": 0.15 + } + + weighted_sum = sum( + confidence_scores.get(key, 0) * weight + for key, weight in weights.items() + ) + + return round(weighted_sum, 2) + +def generate_pipe_cutting_plan(pipe_data: Dict) -> Dict: + """파이프 절단 계획 생성""" + + cutting_plan = { + "material_spec": f"{pipe_data['material']['grade']} {pipe_data['schedule']['schedule']}", + "length_mm": pipe_data['length_info']['length_mm'], + "end_preparation": pipe_data['end_preparation']['cutting_note'], + "machining_required": pipe_data['end_preparation']['machining_required'] + } + + # 절단 지시서 생성 + if cutting_plan["length_mm"]: + cutting_plan["cutting_instruction"] = f""" +재질: {cutting_plan['material_spec']} +절단길이: {cutting_plan['length_mm']}mm +끝가공: {cutting_plan['end_preparation']} +가공여부: {'베벨가공 필요' if cutting_plan['machining_required'] else '직각절단만'} + """.strip() + else: + cutting_plan["cutting_instruction"] = "도면 확인 후 길이 정보 입력 필요" + + return cutting_plan diff --git a/tkeg/api/app/services/plate_classifier.py b/tkeg/api/app/services/plate_classifier.py new file mode 100644 index 0000000..098e8db --- /dev/null +++ b/tkeg/api/app/services/plate_classifier.py @@ -0,0 +1,50 @@ + +import re +from typing import Dict, Optional + +def classify_plate(tag: str, description: str, main_nom: str = "") -> Dict: + """ + 판재(PLATE) 분류기 + 규격 예: PLATE 10T x 1219 x 2438 + """ + desc_upper = description.upper() + + # 1. 두께(Thickness) 추출 + # 패턴: 10T, 10.5T, THK 10, THK. 10, t=10 + thickness = None + t_match = re.search(r'(\d+(?:\.\d+)?)\s*T\b', desc_upper) + if not t_match: + t_match = re.search(r'(?:THK\.?|t=)\s*(\d+(?:\.\d+)?)', desc_upper, re.IGNORECASE) + + if t_match: + thickness = t_match.group(1) + + # 2. 규격(Dimensions) 추출 + # 패턴: 1219x2438, 4'x8', 1000*2000 + dimensions = "" + dim_match = re.search(r'(\d+(?:\.\d+)?)\s*[X\*]\s*(\d+(?:\.\d+)?)(?:\s*[X\*]\s*(\d+(?:\.\d+)?))?', desc_upper) + if dim_match: + groups = [g for g in dim_match.groups() if g] + dimensions = " x ".join(groups) + + # 3. 재질 추출 + material = "UNKNOWN" + # 압력용기용 및 일반 구조용 강판 재질 추가 + plate_materials = [ + "SUS304", "SUS316", "SUS321", "SS400", "A36", "SM490", + "SA516", "A516", "SA283", "A283", "SA537", "A537", "POS-M" + ] + for mat in plate_materials: + if mat in desc_upper: + material = mat + break + + return { + "category": "PLATE", + "overall_confidence": 0.9, + "details": { + "thickness": thickness, + "dimensions": dimensions, + "material": material + } + } diff --git a/tkeg/api/app/services/purchase_calculator.py b/tkeg/api/app/services/purchase_calculator.py new file mode 100644 index 0000000..e69ee6f --- /dev/null +++ b/tkeg/api/app/services/purchase_calculator.py @@ -0,0 +1,754 @@ +""" +구매 수량 계산 서비스 +- 자재별 여유율 적용 +- PIPE: 절단 손실 + 6M 단위 계산 +- 기타: 최소 주문 수량 적용 +""" + +import math +from typing import Dict, List, Tuple +from sqlalchemy.orm import Session +from sqlalchemy import text + +# 자재별 기본 여유율 (올바른 규칙으로 수정) +SAFETY_FACTORS = { + 'PIPE': 1.00, # 0% 추가 (절단 손실은 별도 계산) + 'FITTING': 1.00, # 0% 추가 (BOM 수량 그대로) + 'VALVE': 1.00, # 0% 추가 (BOM 수량 그대로) + 'FLANGE': 1.00, # 0% 추가 (BOM 수량 그대로) + 'BOLT': 1.05, # 5% 추가 (분실율) + 'GASKET': 1.00, # 0% 추가 (5의 배수 올림으로 처리) + 'INSTRUMENT': 1.00, # 0% 추가 (BOM 수량 그대로) + 'DEFAULT': 1.00 # 기본 0% 추가 +} + +# 최소 주문 수량 (자재별) - 올바른 규칙으로 수정 +MINIMUM_ORDER_QTY = { + 'PIPE': 6000, # 6M 단위 + 'FITTING': 1, # 개별 주문 가능 + 'VALVE': 1, # 개별 주문 가능 + 'FLANGE': 1, # 개별 주문 가능 + 'BOLT': 4, # 4의 배수 단위 + 'GASKET': 5, # 5의 배수 단위 + 'INSTRUMENT': 1, # 개별 주문 가능 + 'DEFAULT': 1 +} + +def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict: + """ + PIPE 구매 수량 계산 + - 각 절단마다 2mm 손실 (올바른 규칙) + - 6,000mm (6M) 단위로 올림 + """ + total_bom_length = 0 + cutting_count = 0 + pipe_details = [] + + for material in materials: + # 길이 정보 추출 (Decimal 타입 처리) + length_mm = float(material.get('length_mm', 0) or 0) + quantity = float(material.get('quantity', 1) or 1) + + if length_mm > 0: + total_length = length_mm * quantity # 총 길이 = 단위길이 × 수량 + total_bom_length += total_length + cutting_count += quantity # 절단 횟수 = 수량 + pipe_details.append({ + 'description': material.get('original_description', ''), + 'length_mm': length_mm, + 'quantity': quantity, + 'total_length': total_length + }) + + # 절단 손실 계산 (각 절단마다 2mm - 올바른 규칙) + cutting_loss = cutting_count * 2 + + # 총 필요 길이 = BOM 길이 + 절단 손실 + required_length = total_bom_length + cutting_loss + + # 6M 단위로 올림 계산 + standard_length = 6000 # 6M = 6,000mm + pipes_needed = math.ceil(required_length / standard_length) if required_length > 0 else 0 + total_purchase_length = pipes_needed * standard_length + waste_length = total_purchase_length - required_length if pipes_needed > 0 else 0 + + return { + 'bom_quantity': total_bom_length, + 'cutting_count': cutting_count, + 'cutting_loss': cutting_loss, + 'required_length': required_length, + 'pipes_count': pipes_needed, + 'calculated_qty': total_purchase_length, + 'waste_length': waste_length, + 'utilization_rate': (required_length / total_purchase_length * 100) if total_purchase_length > 0 else 0, + 'unit': 'mm', + 'pipe_details': pipe_details + } + +def calculate_standard_purchase_quantity(category: str, bom_quantity: float, + safety_factor: float = None) -> Dict: + """ + 일반 자재 구매 수량 계산 + - 여유율 적용 + - 최소 주문 수량 적용 + """ + # 여유율 결정 + if safety_factor is None: + safety_factor = SAFETY_FACTORS.get(category, SAFETY_FACTORS['DEFAULT']) + + # 1단계: 여유율 적용 (Decimal 타입 처리) + bom_quantity = float(bom_quantity) if bom_quantity else 0.0 + safety_qty = bom_quantity * safety_factor + + # 2단계: 최소 주문 수량 확인 + min_order_qty = MINIMUM_ORDER_QTY.get(category, MINIMUM_ORDER_QTY['DEFAULT']) + + # 3단계: 최소 주문 수량과 비교하여 큰 값 선택 + calculated_qty = max(safety_qty, min_order_qty) + + # 4단계: 특별 처리 (올바른 규칙 적용) + if category == 'BOLT': + # BOLT: 5% 여유율 후 4의 배수로 올림 + calculated_qty = math.ceil(safety_qty / min_order_qty) * min_order_qty + elif category == 'GASKET': + # GASKET: 5의 배수로 올림 (여유율 없음) + calculated_qty = math.ceil(bom_quantity / min_order_qty) * min_order_qty + + return { + 'bom_quantity': bom_quantity, + 'safety_factor': safety_factor, + 'safety_qty': safety_qty, + 'min_order_qty': min_order_qty, + 'calculated_qty': calculated_qty, + 'waste_quantity': calculated_qty - bom_quantity, + 'utilization_rate': (bom_quantity / calculated_qty * 100) if calculated_qty > 0 else 0 + } + +def generate_purchase_items_from_materials(db: Session, file_id: int, + job_no: str, revision: str) -> List[Dict]: + """ + 자재 데이터로부터 구매 품목 생성 + """ + # 1. 파일의 모든 자재 조회 (상세 테이블 정보 포함) + materials_query = text(""" + SELECT m.*, + pd.length_mm, pd.outer_diameter, pd.schedule, pd.material_spec as pipe_material_spec, + fd.fitting_type, fd.connection_method as fitting_connection, fd.main_size as fitting_main_size, + fd.reduced_size as fitting_reduced_size, fd.material_grade as fitting_material_grade, + vd.valve_type, vd.connection_method as valve_connection, vd.pressure_rating as valve_pressure, + vd.size_inches as valve_size, + fl.flange_type, fl.pressure_rating as flange_pressure, fl.size_inches as flange_size, + gd.gasket_type, gd.gasket_subtype, gd.material_type as gasket_material, gd.filler_material, + gd.size_inches as gasket_size, gd.pressure_rating as gasket_pressure, gd.thickness as gasket_thickness, + bd.bolt_type, bd.material_standard, bd.diameter as bolt_diameter, bd.length as bolt_length, + id.instrument_type, id.connection_size as instrument_size + FROM materials m + LEFT JOIN pipe_details pd ON m.id = pd.material_id + LEFT JOIN fitting_details fd ON m.id = fd.material_id + LEFT JOIN valve_details vd ON m.id = vd.material_id + LEFT JOIN flange_details fl ON m.id = fl.material_id + LEFT JOIN gasket_details gd ON m.id = gd.material_id + LEFT JOIN bolt_details bd ON m.id = bd.material_id + LEFT JOIN instrument_details id ON m.id = id.material_id + WHERE m.file_id = :file_id + ORDER BY m.classified_category, m.original_description + """) + + materials = db.execute(materials_query, {"file_id": file_id}).fetchall() + + + + # 2. 카테고리별로 그룹핑 + grouped_materials = {} + for material in materials: + category = material.classified_category or 'OTHER' + if category not in grouped_materials: + grouped_materials[category] = [] + + # Row 객체를 딕셔너리로 안전하게 변환 + material_dict = { + 'id': material.id, + 'file_id': material.file_id, + 'original_description': material.original_description, + 'quantity': material.quantity, + 'unit': material.unit, + 'size_spec': material.size_spec, + 'material_grade': material.material_grade, + 'classified_category': material.classified_category, + 'line_number': material.line_number, + # PIPE 상세 정보 + 'length_mm': getattr(material, 'length_mm', None), + 'outer_diameter': getattr(material, 'outer_diameter', None), + 'schedule': getattr(material, 'schedule', None), + 'pipe_material_spec': getattr(material, 'pipe_material_spec', None), + # FITTING 상세 정보 + 'fitting_type': getattr(material, 'fitting_type', None), + 'fitting_connection': getattr(material, 'fitting_connection', None), + 'fitting_main_size': getattr(material, 'fitting_main_size', None), + 'fitting_reduced_size': getattr(material, 'fitting_reduced_size', None), + 'fitting_material_grade': getattr(material, 'fitting_material_grade', None), + # VALVE 상세 정보 + 'valve_type': getattr(material, 'valve_type', None), + 'valve_connection': getattr(material, 'valve_connection', None), + 'valve_pressure': getattr(material, 'valve_pressure', None), + 'valve_size': getattr(material, 'valve_size', None), + # FLANGE 상세 정보 + 'flange_type': getattr(material, 'flange_type', None), + 'flange_pressure': getattr(material, 'flange_pressure', None), + 'flange_size': getattr(material, 'flange_size', None), + # GASKET 상세 정보 + 'gasket_type': getattr(material, 'gasket_type', None), + 'gasket_subtype': getattr(material, 'gasket_subtype', None), + 'gasket_material': getattr(material, 'gasket_material', None), + 'filler_material': getattr(material, 'filler_material', None), + 'gasket_size': getattr(material, 'gasket_size', None), + 'gasket_pressure': getattr(material, 'gasket_pressure', None), + 'gasket_thickness': getattr(material, 'gasket_thickness', None), + # BOLT 상세 정보 + 'bolt_type': getattr(material, 'bolt_type', None), + 'material_standard': getattr(material, 'material_standard', None), + 'bolt_diameter': getattr(material, 'bolt_diameter', None), + 'bolt_length': getattr(material, 'bolt_length', None), + # INSTRUMENT 상세 정보 + 'instrument_type': getattr(material, 'instrument_type', None), + 'instrument_size': getattr(material, 'instrument_size', None) + } + + grouped_materials[category].append(material_dict) + + # 3. 각 카테고리별로 구매 품목 생성 + purchase_items = [] + + for category, category_materials in grouped_materials.items(): + if category == 'PIPE': + # PIPE는 재질+사이즈+스케줄별로 그룹핑 + pipe_groups = {} + for material in category_materials: + # 그룹핑 키 생성 + material_spec = material.get('pipe_material_spec') or material.get('material_grade', '') + outer_diameter = material.get('outer_diameter') or material.get('main_nom', '') + schedule = material.get('schedule', '') + + group_key = f"{material_spec}|{outer_diameter}|{schedule}" + + if group_key not in pipe_groups: + pipe_groups[group_key] = [] + pipe_groups[group_key].append(material) + + # 각 PIPE 그룹별로 구매 수량 계산 + for group_key, group_materials in pipe_groups.items(): + pipe_calc = calculate_pipe_purchase_quantity(group_materials) + + if pipe_calc['calculated_qty'] > 0: + material_spec, outer_diameter, schedule = group_key.split('|') + + # 품목 코드 생성 + item_code = generate_item_code('PIPE', material_spec, outer_diameter, schedule) + + # 사양 생성 + spec_parts = [f"PIPE {outer_diameter}"] + if schedule: spec_parts.append(schedule) + if material_spec: spec_parts.append(material_spec) + specification = ', '.join(spec_parts) + + purchase_item = { + 'item_code': item_code, + 'category': 'PIPE', + 'specification': specification, + 'material_spec': material_spec, + 'size_spec': outer_diameter, + 'unit': 'mm', + **pipe_calc, + 'job_no': job_no, + 'revision': revision, + 'file_id': file_id, + 'materials': group_materials + } + purchase_items.append(purchase_item) + + else: + # 기타 자재들은 사양별로 그룹핑 + spec_groups = generate_material_specs_for_category(category_materials, category) + + for spec_key, spec_data in spec_groups.items(): + if spec_data['totalQuantity'] > 0: + # 구매 수량 계산 + calc_result = calculate_standard_purchase_quantity( + category, + spec_data['totalQuantity'] + ) + + # 품목 코드 생성 + item_code = generate_item_code(category, spec_data.get('material_spec', ''), + spec_data.get('size_display', '')) + + purchase_item = { + 'item_code': item_code, + 'category': category, + 'specification': spec_data.get('full_spec', spec_key), + 'material_spec': spec_data.get('material_spec', ''), + 'size_spec': spec_data.get('size_display', ''), + 'size_fraction': spec_data.get('size_fraction', ''), + 'surface_treatment': spec_data.get('surface_treatment', ''), + 'special_applications': spec_data.get('special_applications', {}), + 'unit': spec_data.get('unit', 'EA'), + **calc_result, + 'job_no': job_no, + 'revision': revision, + 'file_id': file_id, + 'materials': spec_data['items'] + } + purchase_items.append(purchase_item) + + return purchase_items + +def generate_material_specs_for_category(materials: List[Dict], category: str) -> Dict: + """카테고리별 자재 사양 그룹핑 (MaterialsPage.jsx 로직과 동일)""" + specs = {} + + for material in materials: + spec_key = '' + spec_data = {} + + if category == 'FITTING': + fitting_type = material.get('fitting_type', 'FITTING') + connection_method = material.get('fitting_connection', '') + # 상세 테이블의 재질 정보 우선 사용 + material_spec = material.get('fitting_material_grade') or material.get('material_grade', '') + # 상세 테이블의 사이즈 정보 사용 + main_size = material.get('fitting_main_size', '') + reduced_size = material.get('fitting_reduced_size', '') + + # 사이즈 표시 생성 (축소형인 경우 main x reduced 형태) + if main_size and reduced_size and main_size != reduced_size: + size_display = f"{main_size} x {reduced_size}" + else: + size_display = main_size or material.get('size_spec', '') + + # 기존 분류기 방식: 피팅 타입 + 연결방식 + 압력등급 + # 예: "ELBOW, SOCKET WELD, 3000LB" + fitting_display = fitting_type.replace('_', ' ') if fitting_type else 'FITTING' + + spec_parts = [fitting_display] + + # 연결방식 추가 + if connection_method and connection_method != 'UNKNOWN': + connection_display = connection_method.replace('_', ' ') + spec_parts.append(connection_display) + + # 압력등급 추출 (description에서) + description = material.get('original_description', '').upper() + import re + pressure_match = re.search(r'(\d+)LB', description) + if pressure_match: + spec_parts.append(f"{pressure_match.group(1)}LB") + + # 스케줄 정보 추출 (니플 등에 중요) + schedule_match = re.search(r'SCH\s*(\d+)', description) + if schedule_match: + spec_parts.append(f"SCH {schedule_match.group(1)}") + + full_spec = ', '.join(spec_parts) + + spec_key = f"FITTING|{full_spec}|{material_spec}|{size_display}" + spec_data = { + 'category': 'FITTING', + 'full_spec': full_spec, + 'material_spec': material_spec, + 'size_display': size_display, + 'unit': 'EA' + } + + elif category == 'VALVE': + valve_type = material.get('valve_type', 'VALVE') + connection_method = material.get('valve_connection', '') + pressure_rating = material.get('valve_pressure', '') + material_spec = material.get('material_grade', '') + # 상세 테이블의 사이즈 정보 우선 사용 + size_display = material.get('valve_size') or material.get('size_spec', '') + + spec_parts = [valve_type.replace('_', ' ')] + if connection_method: spec_parts.append(connection_method.replace('_', ' ')) + if pressure_rating: spec_parts.append(pressure_rating) + full_spec = ', '.join(spec_parts) + + spec_key = f"VALVE|{full_spec}|{material_spec}|{size_display}" + spec_data = { + 'category': 'VALVE', + 'full_spec': full_spec, + 'material_spec': material_spec, + 'size_display': size_display, + 'unit': 'EA' + } + + elif category == 'FLANGE': + flange_type = material.get('flange_type', 'FLANGE') + pressure_rating = material.get('flange_pressure', '') + material_spec = material.get('material_grade', '') + # 상세 테이블의 사이즈 정보 우선 사용 + size_display = material.get('flange_size') or material.get('size_spec', '') + + spec_parts = [flange_type.replace('_', ' ')] + if pressure_rating: spec_parts.append(pressure_rating) + full_spec = ', '.join(spec_parts) + + spec_key = f"FLANGE|{full_spec}|{material_spec}|{size_display}" + spec_data = { + 'category': 'FLANGE', + 'full_spec': full_spec, + 'material_spec': material_spec, + 'size_display': size_display, + 'unit': 'EA' + } + + elif category == 'BOLT': + bolt_type = material.get('bolt_type', 'BOLT') + material_standard = material.get('material_standard', '') + # 상세 테이블의 사이즈 정보 우선 사용 + diameter = material.get('bolt_diameter') or material.get('size_spec', '') + length = material.get('bolt_length', '') + material_spec = material_standard or material.get('material_grade', '') + + # 기존 분류기 방식에 따른 사이즈 표시 (분수 형태) + # 소수점을 분수로 변환 (예: 0.625 -> 5/8) + size_display = diameter + if diameter and '.' in diameter: + try: + decimal_val = float(diameter) + # 일반적인 볼트 사이즈 분수 변환 + fraction_map = { + 0.25: "1/4\"", 0.3125: "5/16\"", 0.375: "3/8\"", + 0.4375: "7/16\"", 0.5: "1/2\"", 0.5625: "9/16\"", + 0.625: "5/8\"", 0.6875: "11/16\"", 0.75: "3/4\"", + 0.8125: "13/16\"", 0.875: "7/8\"", 1.0: "1\"" + } + if decimal_val in fraction_map: + size_display = fraction_map[decimal_val] + except: + pass + + # 길이 정보 포함한 사이즈 표시 (예: 5/8" x 165L) + if length: + # 길이에서 숫자만 추출 + import re + length_match = re.search(r'(\d+(?:\.\d+)?)', str(length)) + if length_match: + length_num = length_match.group(1) + size_display_with_length = f"{size_display} x {length_num}L" + else: + size_display_with_length = f"{size_display} x {length}" + else: + size_display_with_length = size_display + + spec_parts = [bolt_type.replace('_', ' ')] + if material_standard: spec_parts.append(material_standard) + full_spec = ', '.join(spec_parts) + + # 사이즈+길이로 그룹핑 + spec_key = f"BOLT|{full_spec}|{material_spec}|{size_display_with_length}" + spec_data = { + 'category': 'BOLT', + 'full_spec': full_spec, + 'material_spec': material_spec, + 'size_display': size_display_with_length, + 'unit': 'EA' + } + + elif category == 'GASKET': + # 상세 테이블 정보 우선 사용 + gasket_type = material.get('gasket_type', 'GASKET') + gasket_subtype = material.get('gasket_subtype', '') + gasket_material = material.get('gasket_material', '') + filler_material = material.get('filler_material', '') + gasket_pressure = material.get('gasket_pressure', '') + gasket_thickness = material.get('gasket_thickness', '') + + # 상세 테이블의 사이즈 정보 우선 사용 + size_display = material.get('gasket_size') or material.get('size_spec', '') + + # 기존 분류기 방식: 가스켓 타입 + 압력등급 + 재질 + # 예: "SPIRAL WOUND, 150LB, 304SS + GRAPHITE" + spec_parts = [gasket_type.replace('_', ' ')] + + # 서브타입 추가 (있는 경우) + if gasket_subtype and gasket_subtype != gasket_type: + spec_parts.append(gasket_subtype.replace('_', ' ')) + + # 상세 테이블의 압력등급 우선 사용, 없으면 description에서 추출 + if gasket_pressure: + spec_parts.append(gasket_pressure) + else: + description = material.get('original_description', '').upper() + import re + pressure_match = re.search(r'(\d+)LB', description) + if pressure_match: + spec_parts.append(f"{pressure_match.group(1)}LB") + + # 재질 정보 구성 (상세 테이블 정보 활용) + material_spec_parts = [] + + # SWG의 경우 메탈 + 필러 형태로 구성 + if gasket_type == 'SPIRAL_WOUND': + # 기존 저장된 데이터가 부정확한 경우, 원본 description에서 직접 파싱 + description = material.get('original_description', '').upper() + + # SS304/GRAPHITE/CS/CS 패턴 파싱 (H/F/I/O 다음에 오는 재질 정보) + import re + material_spec = None + + # H/F/I/O SS304/GRAPHITE/CS/CS 패턴 (전체 구성 표시) + hfio_material_match = re.search(r'H/F/I/O\s+([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)', description) + if hfio_material_match: + part1 = hfio_material_match.group(1) # SS304 + part2 = hfio_material_match.group(2) # GRAPHITE + part3 = hfio_material_match.group(3) # CS + part4 = hfio_material_match.group(4) # CS + material_spec = f"{part1}/{part2}/{part3}/{part4}" + else: + # 단순 SS304/GRAPHITE/CS/CS 패턴 (전체 구성 표시) + simple_material_match = re.search(r'([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)', description) + if simple_material_match: + part1 = simple_material_match.group(1) # SS304 + part2 = simple_material_match.group(2) # GRAPHITE + part3 = simple_material_match.group(3) # CS + part4 = simple_material_match.group(4) # CS + material_spec = f"{part1}/{part2}/{part3}/{part4}" + + if not material_spec: + # 상세 테이블 정보 사용 + if gasket_material and gasket_material != 'GRAPHITE': # 메탈 부분 + material_spec_parts.append(gasket_material) + elif gasket_material == 'GRAPHITE': + # GRAPHITE만 있는 경우 description에서 메탈 부분 찾기 + metal_match = re.search(r'(SS\d+|CS|INCONEL\d*)', description) + if metal_match: + material_spec_parts.append(metal_match.group(1)) + + if filler_material and filler_material != gasket_material: # 필러 부분 + material_spec_parts.append(filler_material) + elif 'GRAPHITE' in description and 'GRAPHITE' not in material_spec_parts: + material_spec_parts.append('GRAPHITE') + + if material_spec_parts: + material_spec = ' + '.join(material_spec_parts) # SS304 + GRAPHITE + else: + material_spec = material.get('material_grade', '') + else: + # 일반 가스켓의 경우 + if gasket_material: + material_spec_parts.append(gasket_material) + if filler_material and filler_material != gasket_material: + material_spec_parts.append(filler_material) + + if material_spec_parts: + material_spec = ', '.join(material_spec_parts) + else: + material_spec = material.get('material_grade', '') + + if material_spec: + spec_parts.append(material_spec) + + # 두께 정보 추가 (있는 경우) + if gasket_thickness: + spec_parts.append(f"THK {gasket_thickness}") + + full_spec = ', '.join(spec_parts) + + spec_key = f"GASKET|{full_spec}|{material_spec}|{size_display}" + spec_data = { + 'category': 'GASKET', + 'full_spec': full_spec, + 'material_spec': material_spec, + 'size_display': size_display, + 'unit': 'EA' + } + + elif category == 'INSTRUMENT': + instrument_type = material.get('instrument_type', 'INSTRUMENT') + material_spec = material.get('material_grade', '') + # 상세 테이블의 사이즈 정보 우선 사용 + size_display = material.get('instrument_size') or material.get('size_spec', '') + + full_spec = instrument_type.replace('_', ' ') + + spec_key = f"INSTRUMENT|{full_spec}|{material_spec}|{size_display}" + spec_data = { + 'category': 'INSTRUMENT', + 'full_spec': full_spec, + 'material_spec': material_spec, + 'size_display': size_display, + 'unit': 'EA' + } + + else: + # 기타 자재 + material_spec = material.get('material_grade', '') + size_display = material.get('main_nom') or material.get('size_spec', '') + + spec_key = f"{category}|{material_spec}|{size_display}" + spec_data = { + 'category': category, + 'full_spec': material_spec, + 'material_spec': material_spec, + 'size_display': size_display, + 'unit': 'EA' + } + + # 스펙별 수량 집계 + if spec_key not in specs: + specs[spec_key] = { + **spec_data, + 'totalQuantity': 0, + 'count': 0, + 'items': [], + 'special_applications': {'PSV': 0, 'LT': 0, 'CK': 0} if category == 'BOLT' else None + } + + specs[spec_key]['totalQuantity'] += material.get('quantity', 0) + specs[spec_key]['count'] += 1 + specs[spec_key]['items'].append(material) + + # 볼트의 경우 특수 용도 정보 누적 + if category == 'BOLT' and 'special_applications' in locals(): + for app_type, count in special_applications.items(): + specs[spec_key]['special_applications'][app_type] += count + + return specs + +def generate_item_code(category: str, material_spec: str = '', size_spec: str = '', + schedule: str = '') -> str: + """구매 품목 코드 생성""" + import hashlib + + # 기본 접두사 + prefix = f"PI-{category}" + + # 재질 약어 생성 + material_abbr = '' + if 'A106' in material_spec: + material_abbr = 'A106' + elif 'A333' in material_spec: + material_abbr = 'A333' + elif 'SS316' in material_spec or '316' in material_spec: + material_abbr = 'SS316' + elif 'A105' in material_spec: + material_abbr = 'A105' + elif material_spec: + material_abbr = material_spec.replace(' ', '')[:6] + + # 사이즈 약어 + size_abbr = size_spec.replace('"', 'IN').replace(' ', '').replace('x', 'X')[:10] + + # 스케줄 (PIPE용) + schedule_abbr = schedule.replace(' ', '')[:6] + + # 유니크 해시 생성 (중복 방지) + unique_str = f"{category}|{material_spec}|{size_spec}|{schedule}" + hash_suffix = hashlib.md5(unique_str.encode()).hexdigest()[:4].upper() + + # 최종 코드 조합 + code_parts = [prefix] + if material_abbr: code_parts.append(material_abbr) + if size_abbr: code_parts.append(size_abbr) + if schedule_abbr: code_parts.append(schedule_abbr) + code_parts.append(hash_suffix) + + return '-'.join(code_parts) + +def save_purchase_items_to_db(db: Session, purchase_items: List[Dict]) -> List[int]: + """구매 품목을 데이터베이스에 저장""" + saved_ids = [] + + for item in purchase_items: + # 기존 품목 확인 (동일 사양이 있는지) + existing_query = text(""" + SELECT id FROM purchase_items + WHERE job_no = :job_no AND revision = :revision AND item_code = :item_code + """) + existing = db.execute(existing_query, { + 'job_no': item['job_no'], + 'revision': item['revision'], + 'item_code': item['item_code'] + }).fetchone() + + if existing: + # 기존 품목 업데이트 + update_query = text(""" + UPDATE purchase_items SET + bom_quantity = :bom_quantity, + calculated_qty = :calculated_qty, + safety_factor = :safety_factor, + cutting_loss = :cutting_loss, + pipes_count = :pipes_count, + waste_length = :waste_length, + detailed_spec = :detailed_spec, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id + """) + db.execute(update_query, { + 'id': existing.id, + 'bom_quantity': item['bom_quantity'], + 'calculated_qty': item['calculated_qty'], + 'safety_factor': item.get('safety_factor', 1.0), + 'cutting_loss': item.get('cutting_loss', 0), + 'pipes_count': item.get('pipes_count'), + 'waste_length': item.get('waste_length'), + 'detailed_spec': item.get('detailed_spec', '{}') + }) + saved_ids.append(existing.id) + else: + # 새 품목 생성 + insert_query = text(""" + INSERT INTO purchase_items ( + item_code, category, specification, material_spec, size_spec, unit, + bom_quantity, safety_factor, minimum_order_qty, calculated_qty, + cutting_loss, standard_length, pipes_count, waste_length, + job_no, revision, file_id, is_active, created_by + ) VALUES ( + :item_code, :category, :specification, :material_spec, :size_spec, :unit, + :bom_quantity, :safety_factor, :minimum_order_qty, :calculated_qty, + :cutting_loss, :standard_length, :pipes_count, :waste_length, + :job_no, :revision, :file_id, :is_active, :created_by + ) RETURNING id + """) + + result = db.execute(insert_query, { + 'item_code': item['item_code'], + 'category': item['category'], + 'specification': item['specification'], + 'material_spec': item['material_spec'], + 'size_spec': item['size_spec'], + 'unit': item['unit'], + 'bom_quantity': item['bom_quantity'], + 'safety_factor': item.get('safety_factor', 1.0), + 'minimum_order_qty': item.get('min_order_qty', 0), + 'calculated_qty': item['calculated_qty'], + 'cutting_loss': item.get('cutting_loss', 0), + 'standard_length': item.get('standard_length', 6000 if item['category'] == 'PIPE' else None), + 'pipes_count': item.get('pipes_count'), + 'waste_length': item.get('waste_length'), + 'job_no': item['job_no'], + 'revision': item['revision'], + 'file_id': item['file_id'], + 'is_active': True, + 'created_by': 'system' + }) + + result_row = result.fetchone() + new_id = result_row[0] if result_row else None + saved_ids.append(new_id) + + # 개별 자재와 구매 품목 연결 + for material in item['materials']: + mapping_query = text(""" + INSERT INTO material_purchase_mapping (material_id, purchase_item_id) + VALUES (:material_id, :purchase_item_id) + ON CONFLICT (material_id, purchase_item_id) DO NOTHING + """) + db.execute(mapping_query, { + 'material_id': material['id'], + 'purchase_item_id': new_id + }) + + db.commit() + return saved_ids \ No newline at end of file diff --git a/tkeg/api/app/services/revision_comparator.py b/tkeg/api/app/services/revision_comparator.py new file mode 100644 index 0000000..393c8c8 --- /dev/null +++ b/tkeg/api/app/services/revision_comparator.py @@ -0,0 +1,417 @@ +""" +리비전 비교 서비스 +- 기존 확정 자재와 신규 자재 비교 +- 변경된 자재만 분류 처리 +- 리비전 업로드 최적화 +""" + +from sqlalchemy.orm import Session +from sqlalchemy import text +from typing import List, Dict, Tuple, Optional +import hashlib +import logging + +logger = logging.getLogger(__name__) + +class RevisionComparator: + """리비전 비교 및 차이 분석 클래스""" + + def __init__(self, db: Session): + self.db = db + + def get_previous_confirmed_materials(self, job_no: str, current_revision: str) -> Optional[Dict]: + """ + 이전 확정된 자재 목록 조회 + + Args: + job_no: 프로젝트 번호 + current_revision: 현재 리비전 (예: Rev.1) + + Returns: + 확정된 자재 정보 딕셔너리 또는 None + """ + try: + # 현재 리비전 번호 추출 + current_rev_num = self._extract_revision_number(current_revision) + + # 이전 리비전들 중 확정된 것 찾기 (역순으로 검색) + for prev_rev_num in range(current_rev_num - 1, -1, -1): + prev_revision = f"Rev.{prev_rev_num}" + + # 해당 리비전의 확정 데이터 조회 + query = text(""" + SELECT pc.id, pc.revision, pc.confirmed_at, pc.confirmed_by, + COUNT(cpi.id) as confirmed_items_count + FROM purchase_confirmations pc + LEFT JOIN confirmed_purchase_items cpi ON pc.id = cpi.confirmation_id + WHERE pc.job_no = :job_no + AND pc.revision = :revision + AND pc.is_active = TRUE + GROUP BY pc.id, pc.revision, pc.confirmed_at, pc.confirmed_by + ORDER BY pc.confirmed_at DESC + LIMIT 1 + """) + + result = self.db.execute(query, { + "job_no": job_no, + "revision": prev_revision + }).fetchone() + + if result and result.confirmed_items_count > 0: + logger.info(f"이전 확정 자료 발견: {job_no} {prev_revision} ({result.confirmed_items_count}개 품목)") + + # 확정된 품목들 상세 조회 + items_query = text(""" + SELECT cpi.item_code, cpi.category, cpi.specification, + cpi.size, cpi.material, cpi.bom_quantity, + cpi.calculated_qty, cpi.unit, cpi.safety_factor + FROM confirmed_purchase_items cpi + WHERE cpi.confirmation_id = :confirmation_id + ORDER BY cpi.category, cpi.specification + """) + + items_result = self.db.execute(items_query, { + "confirmation_id": result.id + }).fetchall() + + return { + "confirmation_id": result.id, + "revision": result.revision, + "confirmed_at": result.confirmed_at, + "confirmed_by": result.confirmed_by, + "items": [dict(item) for item in items_result], + "items_count": len(items_result) + } + + logger.info(f"이전 확정 자료 없음: {job_no} (현재: {current_revision})") + return None + + except Exception as e: + logger.error(f"이전 확정 자료 조회 실패: {str(e)}") + return None + + def compare_materials(self, previous_confirmed: Dict, new_materials: List[Dict]) -> Dict: + """ + 기존 확정 자재와 신규 자재 비교 + """ + try: + from rapidfuzz import fuzz + + # 이전 확정 자재 해시맵 생성 + confirmed_materials = {} + for item in previous_confirmed["items"]: + material_hash = self._generate_material_hash( + item["specification"], + item["size"], + item["material"] + ) + confirmed_materials[material_hash] = item + + # 해시 역참조 맵 (유사도 비교용) + # 해시 -> 정규화된 설명 문자열 (비교 대상) + # 여기서는 specification 자체를 비교 대상으로 사용 (가장 정보량이 많음) + confirmed_specs = { + h: item["specification"] for h, item in confirmed_materials.items() + } + + # 신규 자재 분석 + unchanged_materials = [] + changed_materials = [] + new_materials_list = [] + + for new_material in new_materials: + description = new_material.get("description", "") + size = self._extract_size_from_description(description) + material = self._extract_material_from_description(description) + + material_hash = self._generate_material_hash(description, size, material) + + if material_hash in confirmed_materials: + # 정확히 일치하는 자재 발견 (해시 일치) + confirmed_item = confirmed_materials[material_hash] + + new_qty = float(new_material.get("quantity", 0)) + confirmed_qty = float(confirmed_item["bom_quantity"]) + + if abs(new_qty - confirmed_qty) > 0.001: + changed_materials.append({ + **new_material, + "change_type": "QUANTITY_CHANGED", + "previous_quantity": confirmed_qty, + "previous_item": confirmed_item + }) + else: + unchanged_materials.append({ + **new_material, + "reuse_classification": True, + "previous_item": confirmed_item + }) + else: + # 해시 불일치 - 유사도 검사 (Fuzzy Matching) + # 신규 자재 설명과 기존 확정 자재들의 스펙 비교 + best_match_hash = None + best_match_score = 0 + + # 성능을 위해 간단한 필터링 후 정밀 비교 권장되나, + # 현재는 전체 비교 (데이터량이 많지 않다고 가정) + for h, spec in confirmed_specs.items(): + score = fuzz.ratio(description.lower(), spec.lower()) + if score > 85: # 85점 이상이면 매우 유사 + if score > best_match_score: + best_match_score = score + best_match_hash = h + + if best_match_hash: + # 유사한 자재 발견 (오타 또는 미세 변경 가능성) + similar_item = confirmed_materials[best_match_hash] + new_materials_list.append({ + **new_material, + "change_type": "NEW_BUT_SIMILAR", + "similarity_score": best_match_score, + "similar_to": similar_item + }) + else: + # 완전히 새로운 자재 + new_materials_list.append({ + **new_material, + "change_type": "NEW_MATERIAL" + }) + + # 삭제된 자재 찾기 + new_material_hashes = set() + for material in new_materials: + d = material.get("description", "") + s = self._extract_size_from_description(d) + m = self._extract_material_from_description(d) + new_material_hashes.add(self._generate_material_hash(d, s, m)) + + removed_materials = [] + for hash_key, confirmed_item in confirmed_materials.items(): + if hash_key not in new_material_hashes: + removed_materials.append({ + "change_type": "REMOVED", + "previous_item": confirmed_item + }) + + comparison_result = { + "has_previous_confirmation": True, + "previous_revision": previous_confirmed["revision"], + "previous_confirmed_at": previous_confirmed["confirmed_at"], + "unchanged_count": len(unchanged_materials), + "changed_count": len(changed_materials), + "new_count": len(new_materials_list), + "removed_count": len(removed_materials), + "total_materials": len(new_materials), + "classification_needed": len(changed_materials) + len(new_materials_list), + "unchanged_materials": unchanged_materials, + "changed_materials": changed_materials, + "new_materials": new_materials_list, + "removed_materials": removed_materials + } + + logger.info(f"리비전 비교 완료 (Fuzzy 적용): 변경없음 {len(unchanged_materials)}, " + f"변경됨 {len(changed_materials)}, 신규 {len(new_materials_list)}, " + f"삭제됨 {len(removed_materials)}") + + return comparison_result + + except Exception as e: + logger.error(f"자재 비교 실패: {str(e)}") + raise + + def _extract_revision_number(self, revision: str) -> int: + """리비전 문자열에서 숫자 추출 (Rev.1 → 1)""" + try: + if revision.startswith("Rev."): + return int(revision.replace("Rev.", "")) + return 0 + except ValueError: + return 0 + + def _generate_material_hash(self, description: str, size: str, material: str) -> str: + """ + 자재 고유성 판단을 위한 해시 생성 + + Args: + description: 자재 설명 + size: 자재 규격/크기 + material: 자재 재질 + + Returns: + MD5 해시 문자열 + """ + import re + + def normalize(s: Optional[str]) -> str: + if s is None: + return "" + # 다중 공백을 단일 공백으로 치환하고 앞뒤 공백 제거 + s = re.sub(r'\s+', ' ', str(s)) + return s.strip().lower() + + # 각 컴포넌트 정규화 + d_norm = normalize(description) + s_norm = normalize(size) + m_norm = normalize(material) + + # RULES.md의 코딩 컨벤션 준수 (pipe separator 사용) + # 값이 없는 경우에도 구분자를 포함하여 구조 유지 (예: "desc||mat") + hash_input = f"{d_norm}|{s_norm}|{m_norm}" + + return hashlib.md5(hash_input.encode()).hexdigest() + + def _extract_size_from_description(self, description: str) -> str: + """ + 자재 설명에서 사이즈 정보 추출 + + 지원하는 패턴 (단어 경계 \b 추가하여 정확도 향상): + - 1/2" (인치) + - 100A (A단위) + - 50mm (밀리미터) + - 10x20 (가로x세로) + - DN100 (DN단위) + """ + if not description: + return "" + + import re + size_patterns = [ + # 인치 패턴 (분수 포함): 1/2", 1.5", 1-1/2" + r'\b(\d+(?:[-/.]\d+)?)\s*(?:inch|인치|")', + # 밀리미터 패턴: 100mm, 100.5 MM + r'\b(\d+(?:\.\d+)?)\s*(?:mm|MM)\b', + # A단위 패턴: 100A, 100 A + r'\b(\d+)\s*A\b', + # DN단위 패턴: DN100, DN 100 + r'DN\s*(\d+)\b', + # 치수 패턴: 10x20, 10*20 + r'\b(\d+(?:\.\d+)?)\s*[xX*]\s*(\d+(?:\.\d+)?)\b' + ] + + for pattern in size_patterns: + match = re.search(pattern, description, re.IGNORECASE) + if match: + return match.group(0).strip() + + return "" + + def _load_materials_from_db(self) -> List[str]: + """DB에서 자재 목록 동적 로딩 (캐싱 적용 고려 가능)""" + try: + # MaterialSpecification 및 SpecialMaterial 테이블에서 자재 코드 조회 + query = text(""" + SELECT spec_code FROM material_specifications + WHERE is_active = TRUE + UNION + SELECT grade_code FROM material_grades + WHERE is_active = TRUE + UNION + SELECT material_name FROM special_materials + WHERE is_active = TRUE + """) + result = self.db.execute(query).fetchall() + db_materials = [row[0] for row in result] + + # 기본 하드코딩 리스트 (DB 조회 실패 시 또는 보완용) + default_materials = [ + "SUS316L", "SUS316", "SUS304L", "SUS304", + "SS316L", "SS316", "SS304L", "SS304", + "A105N", "A105", + "A234 WPB", "A234", + "A106 Gr.B", "A106", + "WCB", "CF8M", "CF8", + "CS", "STS", "PVC", "PP", "PE" + ] + + # 합치고 중복 제거 후 길이 역순 정렬 (긴 단어 우선 매칭) + combined = list(set(db_materials + default_materials)) + combined.sort(key=len, reverse=True) + + return combined + + except Exception as e: + logger.warning(f"DB 자재 로딩 실패 (기본값 사용): {str(e)}") + materials = [ + "SUS316L", "SUS316", "SUS304L", "SUS304", + "SS316L", "SS316", "SS304L", "SS304", + "A105N", "A105", + "A234 WPB", "A234", + "A106 Gr.B", "A106", + "WCB", "CF8M", "CF8", + "CS", "STS", "PVC", "PP", "PE" + ] + return materials + + def _extract_material_from_description(self, description: str) -> str: + """ + 자재 설명에서 재질 정보 추출 + 우선순위에 따라 매칭 (구체적인 재질 먼저) + """ + if not description: + return "" + + # 자재 목록 로딩 (메모리 캐싱을 위해 클래스 속성으로 저장 고려 가능하지만 여기선 매번 호출로 단순화) + # 성능이 중요하다면 __init__ 시점에 로드하거나 lru_cache 사용 권장 + materials = self._load_materials_from_db() + + description_upper = description.upper() + + for material in materials: + # 단어 매칭을 위해 간단한 검사 수행 (부분 문자열이 다른 단어의 일부가 아닌지) + if material.upper() in description_upper: + return material + + return "" + +def get_revision_comparison(db: Session, job_no: str, current_revision: str, + new_materials: List[Dict]) -> Dict: + """ + 리비전 비교 수행 (편의 함수) + + Args: + db: 데이터베이스 세션 + job_no: 프로젝트 번호 + current_revision: 현재 리비전 + new_materials: 신규 자재 목록 + + Returns: + 비교 결과 또는 전체 분류 필요 정보 + """ + comparator = RevisionComparator(db) + + # 이전 확정 자료 조회 + previous_confirmed = comparator.get_previous_confirmed_materials(job_no, current_revision) + + if previous_confirmed is None: + # 이전 확정 자료가 없으면 전체 분류 필요 + return { + "has_previous_confirmation": False, + "classification_needed": len(new_materials), + "all_materials_need_classification": True, + "materials_to_classify": new_materials, + "message": "이전 확정 자료가 없어 전체 자재를 분류합니다." + } + + # 이전 확정 자료가 있으면 비교 수행 + return comparator.compare_materials(previous_confirmed, new_materials) + + + + + + + + + + + + + + + + + + + + + diff --git a/tkeg/api/app/services/revision_comparison_service.py b/tkeg/api/app/services/revision_comparison_service.py new file mode 100644 index 0000000..e827418 --- /dev/null +++ b/tkeg/api/app/services/revision_comparison_service.py @@ -0,0 +1,457 @@ +""" +리비전 비교 및 변경 처리 서비스 +- 자재 비교 로직 (구매된/미구매 자재 구분) +- 리비전 액션 결정 (추가구매, 재고이관, 수량변경 등) +- GASKET/BOLT 특별 처리 (BOM 원본 수량 기준) +""" + +import logging +from typing import Dict, List, Optional, Any, Tuple +from decimal import Decimal +from sqlalchemy.orm import Session +from sqlalchemy import text, and_, or_ + +from ..models import Material + +logger = logging.getLogger(__name__) + + +class RevisionComparisonService: + """리비전 비교 및 변경 처리 서비스""" + + def __init__(self, db: Session): + self.db = db + + def compare_materials_by_category( + self, + current_file_id: int, + previous_file_id: int, + category: str, + session_id: int + ) -> Dict[str, Any]: + """카테고리별 자재 비교 및 변경사항 기록""" + + try: + logger.info(f"카테고리 {category} 자재 비교 시작 (현재: {current_file_id}, 이전: {previous_file_id})") + + # 현재 파일의 자재 조회 + current_materials = self._get_materials_by_category(current_file_id, category) + previous_materials = self._get_materials_by_category(previous_file_id, category) + + logger.info(f"현재 자재: {len(current_materials)}개, 이전 자재: {len(previous_materials)}개") + + # 자재 그룹화 (동일 자재 식별) + current_grouped = self._group_materials_by_key(current_materials, category) + previous_grouped = self._group_materials_by_key(previous_materials, category) + + # 비교 결과 저장 + comparison_results = { + "added": [], + "removed": [], + "changed": [], + "unchanged": [] + } + + # 현재 자재 기준으로 비교 + for key, current_group in current_grouped.items(): + if key in previous_grouped: + previous_group = previous_grouped[key] + + # 수량 비교 (GASKET/BOLT는 BOM 원본 수량 기준) + current_qty = self._get_comparison_quantity(current_group, category) + previous_qty = self._get_comparison_quantity(previous_group, category) + + if current_qty != previous_qty: + # 수량 변경됨 + change_record = self._create_change_record( + current_group, previous_group, "quantity_changed", + current_qty, previous_qty, category, session_id + ) + comparison_results["changed"].append(change_record) + else: + # 수량 동일 + unchanged_record = self._create_change_record( + current_group, previous_group, "unchanged", + current_qty, previous_qty, category, session_id + ) + comparison_results["unchanged"].append(unchanged_record) + else: + # 새로 추가된 자재 + current_qty = self._get_comparison_quantity(current_group, category) + added_record = self._create_change_record( + current_group, None, "added", + current_qty, 0, category, session_id + ) + comparison_results["added"].append(added_record) + + # 제거된 자재 확인 + for key, previous_group in previous_grouped.items(): + if key not in current_grouped: + previous_qty = self._get_comparison_quantity(previous_group, category) + removed_record = self._create_change_record( + None, previous_group, "removed", + 0, previous_qty, category, session_id + ) + comparison_results["removed"].append(removed_record) + + # DB에 변경사항 저장 + self._save_material_changes(comparison_results, session_id) + + # 통계 정보 + summary = { + "category": category, + "added_count": len(comparison_results["added"]), + "removed_count": len(comparison_results["removed"]), + "changed_count": len(comparison_results["changed"]), + "unchanged_count": len(comparison_results["unchanged"]), + "total_changes": len(comparison_results["added"]) + len(comparison_results["removed"]) + len(comparison_results["changed"]) + } + + logger.info(f"카테고리 {category} 비교 완료: {summary}") + + return { + "summary": summary, + "changes": comparison_results + } + + except Exception as e: + logger.error(f"카테고리 {category} 자재 비교 실패: {e}") + raise + + def _get_materials_by_category(self, file_id: int, category: str) -> List[Material]: + """파일의 특정 카테고리 자재 조회""" + + return self.db.query(Material).filter( + and_( + Material.file_id == file_id, + Material.classified_category == category, + Material.is_active == True + ) + ).all() + + def _group_materials_by_key(self, materials: List[Material], category: str) -> Dict[str, Dict]: + """자재를 고유 키로 그룹화""" + + grouped = {} + + for material in materials: + # 카테고리별 고유 키 생성 전략 + if category == "PIPE": + # PIPE: description + material_grade + main_nom + key_parts = [ + material.original_description.strip().upper(), + material.material_grade or '', + material.main_nom or '' + ] + elif category in ["GASKET", "BOLT"]: + # GASKET/BOLT: description + main_nom (집계 공식 적용 전 원본 기준) + key_parts = [ + material.original_description.strip().upper(), + material.main_nom or '' + ] + else: + # 기타: description + drawing + main_nom + red_nom + key_parts = [ + material.original_description.strip().upper(), + material.drawing_name or '', + material.main_nom or '', + material.red_nom or '' + ] + + key = "|".join(key_parts) + + if key in grouped: + # 동일한 자재가 있으면 수량 합산 + grouped[key]['total_quantity'] += float(material.quantity) + grouped[key]['materials'].append(material) + + # 구매 확정 상태 확인 (하나라도 구매 확정되면 전체가 구매 확정) + if getattr(material, 'purchase_confirmed', False): + grouped[key]['purchase_confirmed'] = True + grouped[key]['purchase_confirmed_at'] = getattr(material, 'purchase_confirmed_at', None) + + else: + grouped[key] = { + 'key': key, + 'representative_material': material, + 'materials': [material], + 'total_quantity': float(material.quantity), + 'purchase_confirmed': getattr(material, 'purchase_confirmed', False), + 'purchase_confirmed_at': getattr(material, 'purchase_confirmed_at', None), + 'category': category + } + + return grouped + + def _get_comparison_quantity(self, material_group: Dict, category: str) -> Decimal: + """비교용 수량 계산 (GASKET/BOLT는 집계 공식 적용 전 원본 수량)""" + + if category in ["GASKET", "BOLT"]: + # GASKET/BOLT: BOM 원본 수량 기준 (집계 공식 적용 전) + # 실제 BOM에서 읽은 원본 수량을 사용 + original_quantity = 0 + for material in material_group['materials']: + # classification_details에서 원본 수량 추출 시도 + details = getattr(material, 'classification_details', {}) + if isinstance(details, dict) and 'original_quantity' in details: + original_quantity += float(details['original_quantity']) + else: + # 원본 수량 정보가 없으면 현재 수량 사용 + original_quantity += float(material.quantity) + + return Decimal(str(original_quantity)) + else: + # 기타 카테고리: 현재 수량 사용 + return Decimal(str(material_group['total_quantity'])) + + def _create_change_record( + self, + current_group: Optional[Dict], + previous_group: Optional[Dict], + change_type: str, + current_qty: Decimal, + previous_qty: Decimal, + category: str, + session_id: int + ) -> Dict[str, Any]: + """변경 기록 생성""" + + # 대표 자재 정보 + if current_group: + material = current_group['representative_material'] + material_id = material.id + description = material.original_description + purchase_status = 'purchased' if current_group['purchase_confirmed'] else 'not_purchased' + purchase_confirmed_at = current_group.get('purchase_confirmed_at') + else: + material = previous_group['representative_material'] + material_id = None # 제거된 자재는 현재 material_id가 없음 + description = material.original_description + purchase_status = 'purchased' if previous_group['purchase_confirmed'] else 'not_purchased' + purchase_confirmed_at = previous_group.get('purchase_confirmed_at') + + # 리비전 액션 결정 + revision_action = self._determine_revision_action( + change_type, current_qty, previous_qty, purchase_status, category + ) + + return { + "session_id": session_id, + "material_id": material_id, + "previous_material_id": material.id if previous_group else None, + "material_description": description, + "category": category, + "change_type": change_type, + "current_quantity": float(current_qty), + "previous_quantity": float(previous_qty), + "quantity_difference": float(current_qty - previous_qty), + "purchase_status": purchase_status, + "purchase_confirmed_at": purchase_confirmed_at, + "revision_action": revision_action + } + + def _determine_revision_action( + self, + change_type: str, + current_qty: Decimal, + previous_qty: Decimal, + purchase_status: str, + category: str + ) -> str: + """리비전 액션 결정 로직""" + + if change_type == "added": + return "new_material" + elif change_type == "removed": + if purchase_status == "purchased": + return "inventory_transfer" # 구매된 자재 → 재고 이관 + else: + return "purchase_cancel" # 미구매 자재 → 구매 취소 + elif change_type == "quantity_changed": + quantity_diff = current_qty - previous_qty + + if purchase_status == "purchased": + if quantity_diff > 0: + return "additional_purchase" # 구매된 자재 수량 증가 → 추가 구매 + else: + return "inventory_transfer" # 구매된 자재 수량 감소 → 재고 이관 + else: + return "quantity_update" # 미구매 자재 → 수량 업데이트 + else: + return "maintain" # 변경 없음 + + def _save_material_changes(self, comparison_results: Dict, session_id: int): + """변경사항을 DB에 저장""" + + try: + all_changes = [] + for change_type, changes in comparison_results.items(): + all_changes.extend(changes) + + if not all_changes: + return + + # 배치 삽입 + insert_query = """ + INSERT INTO revision_material_changes ( + session_id, material_id, previous_material_id, material_description, + category, change_type, current_quantity, previous_quantity, + quantity_difference, purchase_status, purchase_confirmed_at, revision_action + ) VALUES ( + :session_id, :material_id, :previous_material_id, :material_description, + :category, :change_type, :current_quantity, :previous_quantity, + :quantity_difference, :purchase_status, :purchase_confirmed_at, :revision_action + ) + """ + + self.db.execute(text(insert_query), all_changes) + self.db.commit() + + logger.info(f"변경사항 {len(all_changes)}건 저장 완료 (세션: {session_id})") + + except Exception as e: + self.db.rollback() + logger.error(f"변경사항 저장 실패: {e}") + raise + + def get_session_changes(self, session_id: int, category: str = None) -> List[Dict[str, Any]]: + """세션의 변경사항 조회""" + + try: + query = """ + SELECT + id, material_id, material_description, category, + change_type, current_quantity, previous_quantity, quantity_difference, + purchase_status, revision_action, action_status, + processed_by, processed_at, processing_notes + FROM revision_material_changes + WHERE session_id = :session_id + """ + params = {"session_id": session_id} + + if category: + query += " AND category = :category" + params["category"] = category + + query += " ORDER BY category, material_description" + + changes = self.db.execute(text(query), params).fetchall() + + return [dict(change._mapping) for change in changes] + + except Exception as e: + logger.error(f"세션 변경사항 조회 실패: {e}") + raise + + def process_revision_action( + self, + change_id: int, + action: str, + username: str, + notes: str = None + ) -> Dict[str, Any]: + """리비전 액션 처리""" + + try: + # 변경사항 조회 + change = self.db.execute(text(""" + SELECT * FROM revision_material_changes WHERE id = :change_id + """), {"change_id": change_id}).fetchone() + + if not change: + raise ValueError(f"변경사항을 찾을 수 없습니다: {change_id}") + + result = {"success": False, "message": ""} + + # 액션별 처리 + if action == "additional_purchase": + result = self._process_additional_purchase(change, username, notes) + elif action == "inventory_transfer": + result = self._process_inventory_transfer(change, username, notes) + elif action == "purchase_cancel": + result = self._process_purchase_cancel(change, username, notes) + elif action == "quantity_update": + result = self._process_quantity_update(change, username, notes) + else: + result = {"success": True, "message": "처리 완료"} + + # 처리 상태 업데이트 + status = "completed" if result["success"] else "failed" + self.db.execute(text(""" + UPDATE revision_material_changes + SET action_status = :status, processed_by = :username, + processed_at = CURRENT_TIMESTAMP, processing_notes = :notes + WHERE id = :change_id + """), { + "change_id": change_id, + "status": status, + "username": username, + "notes": notes or result["message"] + }) + + # 액션 로그 기록 + self.db.execute(text(""" + INSERT INTO revision_action_logs ( + session_id, revision_change_id, action_type, action_description, + executed_by, result, result_message + ) VALUES ( + :session_id, :change_id, :action, :description, + :username, :result, :message + ) + """), { + "session_id": change.session_id, + "change_id": change_id, + "action": action, + "description": f"{change.material_description} - {action}", + "username": username, + "result": "success" if result["success"] else "failed", + "message": result["message"] + }) + + self.db.commit() + + return result + + except Exception as e: + self.db.rollback() + logger.error(f"리비전 액션 처리 실패: {e}") + raise + + def _process_additional_purchase(self, change, username: str, notes: str) -> Dict[str, Any]: + """추가 구매 처리""" + # 구매 요청 생성 로직 구현 + return {"success": True, "message": f"추가 구매 요청 생성: {change.quantity_difference}개"} + + def _process_inventory_transfer(self, change, username: str, notes: str) -> Dict[str, Any]: + """재고 이관 처리""" + # 재고 이관 로직 구현 + try: + self.db.execute(text(""" + INSERT INTO inventory_transfers ( + revision_change_id, material_description, category, + quantity, unit, transferred_by, storage_notes + ) VALUES ( + :change_id, :description, :category, + :quantity, 'EA', :username, :notes + ) + """), { + "change_id": change.id, + "description": change.material_description, + "category": change.category, + "quantity": abs(change.quantity_difference), + "username": username, + "notes": notes + }) + + return {"success": True, "message": f"재고 이관 완료: {abs(change.quantity_difference)}개"} + + except Exception as e: + return {"success": False, "message": f"재고 이관 실패: {str(e)}"} + + def _process_purchase_cancel(self, change, username: str, notes: str) -> Dict[str, Any]: + """구매 취소 처리""" + return {"success": True, "message": "구매 취소 완료"} + + def _process_quantity_update(self, change, username: str, notes: str) -> Dict[str, Any]: + """수량 업데이트 처리""" + return {"success": True, "message": f"수량 업데이트 완료: {change.current_quantity}개"} diff --git a/tkeg/api/app/services/revision_session_service.py b/tkeg/api/app/services/revision_session_service.py new file mode 100644 index 0000000..20b45dd --- /dev/null +++ b/tkeg/api/app/services/revision_session_service.py @@ -0,0 +1,289 @@ +""" +리비전 세션 관리 서비스 +- 리비전 세션 생성, 관리, 완료 처리 +- 자재 변경 사항 추적 및 처리 +""" + +import logging +from typing import Dict, List, Optional, Any +from datetime import datetime +from sqlalchemy.orm import Session +from sqlalchemy import text, and_, or_ + +from ..models import File, Material +from ..database import get_db + +logger = logging.getLogger(__name__) + + +class RevisionSessionService: + """리비전 세션 관리 서비스""" + + def __init__(self, db: Session): + self.db = db + + def create_revision_session( + self, + job_no: str, + current_file_id: int, + previous_file_id: int, + username: str + ) -> Dict[str, Any]: + """새로운 리비전 세션 생성""" + + try: + # 파일 정보 조회 + current_file = self.db.query(File).filter(File.id == current_file_id).first() + previous_file = self.db.query(File).filter(File.id == previous_file_id).first() + + if not current_file or not previous_file: + raise ValueError("파일 정보를 찾을 수 없습니다") + + # 기존 진행 중인 세션이 있는지 확인 + existing_session = self.db.execute(text(""" + SELECT id FROM revision_sessions + WHERE job_no = :job_no AND status = 'processing' + """), {"job_no": job_no}).fetchone() + + if existing_session: + logger.warning(f"기존 진행 중인 리비전 세션이 있습니다: {existing_session[0]}") + return {"session_id": existing_session[0], "status": "existing"} + + # 새 세션 생성 + session_data = { + "job_no": job_no, + "current_file_id": current_file_id, + "previous_file_id": previous_file_id, + "current_revision": current_file.revision, + "previous_revision": previous_file.revision, + "status": "processing", + "created_by": username + } + + result = self.db.execute(text(""" + INSERT INTO revision_sessions ( + job_no, current_file_id, previous_file_id, + current_revision, previous_revision, status, created_by + ) VALUES ( + :job_no, :current_file_id, :previous_file_id, + :current_revision, :previous_revision, :status, :created_by + ) RETURNING id + """), session_data) + + session_id = result.fetchone()[0] + self.db.commit() + + logger.info(f"새 리비전 세션 생성: {session_id} (Job: {job_no})") + + return { + "session_id": session_id, + "status": "created", + "job_no": job_no, + "current_revision": current_file.revision, + "previous_revision": previous_file.revision + } + + except Exception as e: + self.db.rollback() + logger.error(f"리비전 세션 생성 실패: {e}") + raise + + def get_session_status(self, session_id: int) -> Dict[str, Any]: + """리비전 세션 상태 조회""" + + try: + session_info = self.db.execute(text(""" + SELECT + id, job_no, current_file_id, previous_file_id, + current_revision, previous_revision, status, + total_materials, processed_materials, + added_count, removed_count, changed_count, unchanged_count, + purchase_cancel_count, inventory_transfer_count, additional_purchase_count, + created_by, created_at, completed_at + FROM revision_sessions + WHERE id = :session_id + """), {"session_id": session_id}).fetchone() + + if not session_info: + raise ValueError(f"리비전 세션을 찾을 수 없습니다: {session_id}") + + # 변경 사항 상세 조회 + changes = self.db.execute(text(""" + SELECT + category, change_type, revision_action, action_status, + COUNT(*) as count, + SUM(CASE WHEN purchase_status = 'purchased' THEN 1 ELSE 0 END) as purchased_count, + SUM(CASE WHEN purchase_status = 'not_purchased' THEN 1 ELSE 0 END) as unpurchased_count + FROM revision_material_changes + WHERE session_id = :session_id + GROUP BY category, change_type, revision_action, action_status + ORDER BY category, change_type + """), {"session_id": session_id}).fetchall() + + return { + "session_info": dict(session_info._mapping), + "changes_summary": [dict(change._mapping) for change in changes], + "progress_percentage": ( + (session_info.processed_materials / session_info.total_materials * 100) + if session_info.total_materials > 0 else 0 + ) + } + + except Exception as e: + logger.error(f"리비전 세션 상태 조회 실패: {e}") + raise + + def update_session_progress( + self, + session_id: int, + total_materials: int = None, + processed_materials: int = None, + **counts + ) -> bool: + """리비전 세션 진행 상황 업데이트""" + + try: + update_fields = [] + update_values = {"session_id": session_id} + + if total_materials is not None: + update_fields.append("total_materials = :total_materials") + update_values["total_materials"] = total_materials + + if processed_materials is not None: + update_fields.append("processed_materials = :processed_materials") + update_values["processed_materials"] = processed_materials + + # 카운트 필드들 업데이트 + count_fields = [ + "added_count", "removed_count", "changed_count", "unchanged_count", + "purchase_cancel_count", "inventory_transfer_count", "additional_purchase_count" + ] + + for field in count_fields: + if field in counts: + update_fields.append(f"{field} = :{field}") + update_values[field] = counts[field] + + if not update_fields: + return True # 업데이트할 내용이 없음 + + query = f""" + UPDATE revision_sessions + SET {', '.join(update_fields)} + WHERE id = :session_id + """ + + self.db.execute(text(query), update_values) + self.db.commit() + + logger.info(f"리비전 세션 진행 상황 업데이트: {session_id}") + return True + + except Exception as e: + self.db.rollback() + logger.error(f"리비전 세션 진행 상황 업데이트 실패: {e}") + raise + + def complete_session(self, session_id: int, username: str) -> Dict[str, Any]: + """리비전 세션 완료 처리""" + + try: + # 세션 상태를 완료로 변경 + self.db.execute(text(""" + UPDATE revision_sessions + SET status = 'completed', completed_at = CURRENT_TIMESTAMP + WHERE id = :session_id AND status = 'processing' + """), {"session_id": session_id}) + + # 완료 로그 기록 + self.db.execute(text(""" + INSERT INTO revision_action_logs ( + session_id, action_type, action_description, + executed_by, result, result_message + ) VALUES ( + :session_id, 'session_complete', '리비전 세션 완료', + :username, 'success', '모든 리비전 처리 완료' + ) + """), { + "session_id": session_id, + "username": username + }) + + self.db.commit() + + # 최종 상태 조회 + final_status = self.get_session_status(session_id) + + logger.info(f"리비전 세션 완료: {session_id}") + + return { + "status": "completed", + "session_id": session_id, + "final_status": final_status + } + + except Exception as e: + self.db.rollback() + logger.error(f"리비전 세션 완료 처리 실패: {e}") + raise + + def cancel_session(self, session_id: int, username: str, reason: str = None) -> bool: + """리비전 세션 취소""" + + try: + # 세션 상태를 취소로 변경 + self.db.execute(text(""" + UPDATE revision_sessions + SET status = 'cancelled', completed_at = CURRENT_TIMESTAMP + WHERE id = :session_id AND status = 'processing' + """), {"session_id": session_id}) + + # 취소 로그 기록 + self.db.execute(text(""" + INSERT INTO revision_action_logs ( + session_id, action_type, action_description, + executed_by, result, result_message + ) VALUES ( + :session_id, 'session_cancel', '리비전 세션 취소', + :username, 'cancelled', :reason + ) + """), { + "session_id": session_id, + "username": username, + "reason": reason or "사용자 요청에 의한 취소" + }) + + self.db.commit() + + logger.info(f"리비전 세션 취소: {session_id}, 사유: {reason}") + return True + + except Exception as e: + self.db.rollback() + logger.error(f"리비전 세션 취소 실패: {e}") + raise + + def get_job_revision_history(self, job_no: str) -> List[Dict[str, Any]]: + """Job의 리비전 히스토리 조회""" + + try: + sessions = self.db.execute(text(""" + SELECT + rs.id, rs.current_revision, rs.previous_revision, + rs.status, rs.created_by, rs.created_at, rs.completed_at, + rs.added_count, rs.removed_count, rs.changed_count, + cf.filename as current_filename, + pf.filename as previous_filename + FROM revision_sessions rs + LEFT JOIN files cf ON rs.current_file_id = cf.id + LEFT JOIN files pf ON rs.previous_file_id = pf.id + WHERE rs.job_no = :job_no + ORDER BY rs.created_at DESC + """), {"job_no": job_no}).fetchall() + + return [dict(session._mapping) for session in sessions] + + except Exception as e: + logger.error(f"리비전 히스토리 조회 실패: {e}") + raise diff --git a/tkeg/api/app/services/spool_manager.py b/tkeg/api/app/services/spool_manager.py new file mode 100644 index 0000000..8289a71 --- /dev/null +++ b/tkeg/api/app/services/spool_manager.py @@ -0,0 +1,256 @@ +""" +스풀 관리 시스템 +도면별 에리어 넘버와 스풀 넘버 관리 +""" + +import re +from typing import Dict, List, Optional, Tuple +from datetime import datetime + +# ========== 스풀 넘버링 규칙 ========== +SPOOL_NUMBERING_RULES = { + "AREA_NUMBER": { + "pattern": r"#(\d{2})", # #01, #02, #03... + "format": "#{:02d}", # 2자리 숫자 + "range": (1, 99), # 01~99 + "description": "에리어 넘버" + }, + "SPOOL_NUMBER": { + "pattern": r"([A-Z]{1,2})", # A, B, C... AA, AB... + "format": "{}", + "sequence": ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", + "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", + "U", "V", "W", "X", "Y", "Z", + "AA", "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ"], + "description": "스풀 넘버" + } +} + +# ========== 프로젝트별 넘버링 설정 ========== +PROJECT_SPOOL_SETTINGS = { + "DEFAULT": { + "area_prefix": "#", + "area_digits": 2, + "spool_sequence": "ALPHABETIC", # A, B, C... + "separator": "-", + "format": "{dwg_name}-{area}-{spool}" # 1-IAR-3B1D0-0129-N-#01-A + }, + "CUSTOM": { + # 프로젝트별 커스텀 설정 가능 + } +} + +class SpoolManager: + """스풀 관리 클래스""" + + def __init__(self, project_id: int = None): + self.project_id = project_id + self.settings = PROJECT_SPOOL_SETTINGS["DEFAULT"] + + def parse_spool_identifier(self, spool_id: str) -> Dict: + """기존 스풀 식별자 파싱""" + + # 패턴: DWG_NAME-AREA-SPOOL + # 예: 1-IAR-3B1D0-0129-N-#01-A + + parts = spool_id.split("-") + + area_number = None + spool_number = None + dwg_base = None + + for i, part in enumerate(parts): + # 에리어 넘버 찾기 (#01, #02...) + if re.match(r"#\d{2}", part): + area_number = part + # 스풀 넘버는 다음 파트 + if i + 1 < len(parts): + spool_number = parts[i + 1] + # 도면명은 에리어 넘버 앞까지 + dwg_base = "-".join(parts[:i]) + break + + return { + "original_id": spool_id, + "dwg_base": dwg_base, + "area_number": area_number, + "spool_number": spool_number, + "is_valid": bool(area_number and spool_number), + "parsed_parts": parts + } + + def generate_spool_identifier(self, dwg_name: str, area_number: str, + spool_number: str) -> str: + """새로운 스풀 식별자 생성""" + + # 에리어 넘버 포맷 검증 + area_formatted = self.format_area_number(area_number) + + # 스풀 넘버 포맷 검증 + spool_formatted = self.format_spool_number(spool_number) + + # 조합 + return f"{dwg_name}-{area_formatted}-{spool_formatted}" + + def format_area_number(self, area_input: str) -> str: + """에리어 넘버 포맷팅""" + + # 숫자만 추출 + numbers = re.findall(r'\d+', area_input) + if numbers: + area_num = int(numbers[0]) + if 1 <= area_num <= 99: + return f"#{area_num:02d}" + + raise ValueError(f"유효하지 않은 에리어 넘버: {area_input}") + + def format_spool_number(self, spool_input: str) -> str: + """스풀 넘버 포맷팅""" + + spool_clean = spool_input.upper().strip() + + # 유효한 스풀 넘버인지 확인 + if re.match(r'^[A-Z]{1,2}$', spool_clean): + return spool_clean + + raise ValueError(f"유효하지 않은 스풀 넘버: {spool_input}") + + def get_next_spool_number(self, dwg_name: str, area_number: str, + existing_spools: List[str] = None) -> str: + """다음 사용 가능한 스풀 넘버 추천""" + + if not existing_spools: + return "A" # 첫 번째 스풀 + + # 해당 도면+에리어의 기존 스풀들 파싱 + used_spools = set() + area_formatted = self.format_area_number(area_number) + + for spool_id in existing_spools: + parsed = self.parse_spool_identifier(spool_id) + if (parsed["dwg_base"] == dwg_name and + parsed["area_number"] == area_formatted): + used_spools.add(parsed["spool_number"]) + + # 다음 사용 가능한 넘버 찾기 + sequence = SPOOL_NUMBERING_RULES["SPOOL_NUMBER"]["sequence"] + for spool_num in sequence: + if spool_num not in used_spools: + return spool_num + + raise ValueError("사용 가능한 스풀 넘버가 없습니다") + + def validate_spool_identifier(self, spool_id: str) -> Dict: + """스풀 식별자 유효성 검증""" + + parsed = self.parse_spool_identifier(spool_id) + + validation_result = { + "is_valid": True, + "errors": [], + "warnings": [], + "parsed": parsed + } + + # 도면명 확인 + if not parsed["dwg_base"]: + validation_result["is_valid"] = False + validation_result["errors"].append("도면명이 없습니다") + + # 에리어 넘버 확인 + if not parsed["area_number"]: + validation_result["is_valid"] = False + validation_result["errors"].append("에리어 넘버가 없습니다") + elif not re.match(r"#\d{2}", parsed["area_number"]): + validation_result["is_valid"] = False + validation_result["errors"].append("에리어 넘버 형식이 잘못되었습니다 (#01 형태)") + + # 스풀 넘버 확인 + if not parsed["spool_number"]: + validation_result["is_valid"] = False + validation_result["errors"].append("스풀 넘버가 없습니다") + elif not re.match(r"^[A-Z]{1,2}$", parsed["spool_number"]): + validation_result["is_valid"] = False + validation_result["errors"].append("스풀 넘버 형식이 잘못되었습니다 (A, B, AA 형태)") + + return validation_result + +def classify_pipe_with_spool(dat_file: str, description: str, main_nom: str, + length: float = None, dwg_name: str = None, + area_number: str = None, spool_number: str = None) -> Dict: + """파이프 분류 + 스풀 정보 통합""" + + # 기본 파이프 분류 (기존 함수 사용) + from .pipe_classifier import classify_pipe + pipe_result = classify_pipe(dat_file, description, main_nom, length) + + # 스풀 관리자 생성 + spool_manager = SpoolManager() + + # 스풀 정보 추가 + spool_info = { + "dwg_name": dwg_name, + "area_number": None, + "spool_number": None, + "spool_identifier": None, + "manual_input_required": True, + "validation": None + } + + # 에리어/스풀 넘버가 제공된 경우 + if area_number and spool_number: + try: + area_formatted = spool_manager.format_area_number(area_number) + spool_formatted = spool_manager.format_spool_number(spool_number) + spool_identifier = spool_manager.generate_spool_identifier( + dwg_name, area_formatted, spool_formatted + ) + + spool_info.update({ + "area_number": area_formatted, + "spool_number": spool_formatted, + "spool_identifier": spool_identifier, + "manual_input_required": False, + "validation": {"is_valid": True, "errors": []} + }) + + except ValueError as e: + spool_info["validation"] = { + "is_valid": False, + "errors": [str(e)] + } + + # 기존 결과에 스풀 정보 추가 + pipe_result["spool_info"] = spool_info + + return pipe_result + +def group_pipes_by_spool(pipes_data: List[Dict]) -> Dict: + """파이프들을 스풀별로 그룹핑""" + + spool_groups = {} + + for pipe in pipes_data: + spool_info = pipe.get("spool_info", {}) + spool_id = spool_info.get("spool_identifier", "UNGROUPED") + + if spool_id not in spool_groups: + spool_groups[spool_id] = { + "spool_identifier": spool_id, + "dwg_name": spool_info.get("dwg_name"), + "area_number": spool_info.get("area_number"), + "spool_number": spool_info.get("spool_number"), + "pipes": [], + "total_length": 0, + "pipe_count": 0 + } + + spool_groups[spool_id]["pipes"].append(pipe) + spool_groups[spool_id]["pipe_count"] += 1 + + # 길이 합계 + pipe_length = pipe.get("cutting_dimensions", {}).get("length_mm", 0) + if pipe_length: + spool_groups[spool_id]["total_length"] += pipe_length + + return spool_groups diff --git a/tkeg/api/app/services/spool_manager_v2.py b/tkeg/api/app/services/spool_manager_v2.py new file mode 100644 index 0000000..2d33836 --- /dev/null +++ b/tkeg/api/app/services/spool_manager_v2.py @@ -0,0 +1,229 @@ +""" +수정된 스풀 관리 시스템 +도면별 스풀 넘버링 + 에리어는 별도 관리 +""" + +import re +from typing import Dict, List, Optional, Tuple +from datetime import datetime + +# ========== 스풀 넘버링 규칙 ========== +SPOOL_NUMBERING_RULES = { + "SPOOL_NUMBER": { + "sequence": ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", + "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", + "U", "V", "W", "X", "Y", "Z"], + "description": "도면별 스풀 넘버" + }, + "AREA_NUMBER": { + "pattern": r"#(\d{2})", # #01, #02, #03... + "format": "#{:02d}", # 2자리 숫자 + "range": (1, 99), # 01~99 + "description": "물리적 구역 넘버 (별도 관리)" + } +} + +class SpoolManagerV2: + """수정된 스풀 관리 클래스""" + + def __init__(self, project_id: int = None): + self.project_id = project_id + + def generate_spool_identifier(self, dwg_name: str, spool_number: str) -> str: + """ + 스풀 식별자 생성 (도면명 + 스풀넘버) + + Args: + dwg_name: 도면명 (예: "A-1", "B-3") + spool_number: 스풀넘버 (예: "A", "B") + + Returns: + 스풀 식별자 (예: "A-1-A", "B-3-B") + """ + + # 스풀 넘버 포맷 검증 + spool_formatted = self.format_spool_number(spool_number) + + # 조합: {도면명}-{스풀넘버} + return f"{dwg_name}-{spool_formatted}" + + def parse_spool_identifier(self, spool_id: str) -> Dict: + """스풀 식별자 파싱""" + + # 패턴: DWG_NAME-SPOOL_NUMBER + # 예: A-1-A, B-3-B, 1-IAR-3B1D0-0129-N-A + + # 마지막 '-' 기준으로 분리 + parts = spool_id.rsplit('-', 1) + + if len(parts) == 2: + dwg_base = parts[0] + spool_number = parts[1] + + return { + "original_id": spool_id, + "dwg_name": dwg_base, + "spool_number": spool_number, + "is_valid": self.validate_spool_number(spool_number), + "format": "CORRECT" + } + else: + return { + "original_id": spool_id, + "dwg_name": None, + "spool_number": None, + "is_valid": False, + "format": "INVALID" + } + + def format_spool_number(self, spool_input: str) -> str: + """스풀 넘버 포맷팅 및 검증""" + + spool_clean = spool_input.upper().strip() + + # 유효한 스풀 넘버인지 확인 (A-Z 단일 문자) + if re.match(r'^[A-Z]$', spool_clean): + return spool_clean + + raise ValueError(f"유효하지 않은 스풀 넘버: {spool_input} (A-Z 단일 문자만 가능)") + + def validate_spool_number(self, spool_number: str) -> bool: + """스풀 넘버 유효성 검증""" + return bool(re.match(r'^[A-Z]$', spool_number)) + + def get_next_spool_number(self, dwg_name: str, existing_spools: List[str] = None) -> str: + """해당 도면의 다음 사용 가능한 스풀 넘버 추천""" + + if not existing_spools: + return "A" # 첫 번째 스풀 + + # 해당 도면의 기존 스풀들 파싱 + used_spools = set() + + for spool_id in existing_spools: + parsed = self.parse_spool_identifier(spool_id) + if parsed["dwg_name"] == dwg_name and parsed["is_valid"]: + used_spools.add(parsed["spool_number"]) + + # 다음 사용 가능한 넘버 찾기 + sequence = SPOOL_NUMBERING_RULES["SPOOL_NUMBER"]["sequence"] + for spool_num in sequence: + if spool_num not in used_spools: + return spool_num + + raise ValueError(f"도면 {dwg_name}에서 사용 가능한 스풀 넘버가 없습니다") + + def validate_spool_identifier(self, spool_id: str) -> Dict: + """스풀 식별자 전체 유효성 검증""" + + parsed = self.parse_spool_identifier(spool_id) + + validation_result = { + "is_valid": True, + "errors": [], + "warnings": [], + "parsed": parsed + } + + # 도면명 확인 + if not parsed["dwg_name"]: + validation_result["is_valid"] = False + validation_result["errors"].append("도면명이 없습니다") + + # 스풀 넘버 확인 + if not parsed["spool_number"]: + validation_result["is_valid"] = False + validation_result["errors"].append("스풀 넘버가 없습니다") + elif not self.validate_spool_number(parsed["spool_number"]): + validation_result["is_valid"] = False + validation_result["errors"].append("스풀 넘버 형식이 잘못되었습니다 (A-Z 단일 문자)") + + return validation_result + +# ========== 에리어 관리 (별도 시스템) ========== +class AreaManager: + """에리어 관리 클래스 (물리적 구역)""" + + def __init__(self, project_id: int = None): + self.project_id = project_id + + def format_area_number(self, area_input: str) -> str: + """에리어 넘버 포맷팅""" + + # 숫자만 추출 + numbers = re.findall(r'\d+', area_input) + if numbers: + area_num = int(numbers[0]) + if 1 <= area_num <= 99: + return f"#{area_num:02d}" + + raise ValueError(f"유효하지 않은 에리어 넘버: {area_input} (#01-#99)") + + def assign_drawings_to_area(self, area_number: str, drawing_names: List[str]) -> Dict: + """도면들을 에리어에 할당""" + + area_formatted = self.format_area_number(area_number) + + return { + "area_number": area_formatted, + "assigned_drawings": drawing_names, + "assignment_count": len(drawing_names), + "assignment_date": datetime.now().isoformat() + } + +def classify_pipe_with_corrected_spool(dat_file: str, description: str, main_nom: str, + length: float = None, dwg_name: str = None, + spool_number: str = None, area_number: str = None) -> Dict: + """파이프 분류 + 수정된 스풀 정보""" + + # 기본 파이프 분류 + from .pipe_classifier import classify_pipe + pipe_result = classify_pipe(dat_file, description, main_nom, length) + + # 스풀 관리자 생성 + spool_manager = SpoolManagerV2() + area_manager = AreaManager() + + # 스풀 정보 처리 + spool_info = { + "dwg_name": dwg_name, + "spool_number": None, + "spool_identifier": None, + "area_number": None, # 별도 관리 + "manual_input_required": True, + "validation": None + } + + # 스풀 넘버가 제공된 경우 + if dwg_name and spool_number: + try: + spool_identifier = spool_manager.generate_spool_identifier(dwg_name, spool_number) + + spool_info.update({ + "spool_number": spool_manager.format_spool_number(spool_number), + "spool_identifier": spool_identifier, + "manual_input_required": False, + "validation": {"is_valid": True, "errors": []} + }) + + except ValueError as e: + spool_info["validation"] = { + "is_valid": False, + "errors": [str(e)] + } + + # 에리어 정보 처리 (별도) + if area_number: + try: + area_formatted = area_manager.format_area_number(area_number) + spool_info["area_number"] = area_formatted + except ValueError as e: + spool_info["area_validation"] = { + "is_valid": False, + "errors": [str(e)] + } + + # 기존 결과에 스풀 정보 추가 + pipe_result["spool_info"] = spool_info + + return pipe_result diff --git a/tkeg/api/app/services/structural_classifier.py b/tkeg/api/app/services/structural_classifier.py new file mode 100644 index 0000000..be601bc --- /dev/null +++ b/tkeg/api/app/services/structural_classifier.py @@ -0,0 +1,34 @@ + +import re +from typing import Dict + +def classify_structural(tag: str, description: str, main_nom: str = "") -> Dict: + """ + 형강(STRUCTURAL) 분류기 + 규격 예: H-BEAM 100x100x6x8 + """ + desc_upper = description.upper() + + # 1. 타입 식별 + struct_type = "UNKNOWN" + if "H-BEAM" in desc_upper or "H-SECTION" in desc_upper: struct_type = "H-BEAM" + elif "ANGLE" in desc_upper or "L-SECTION" in desc_upper: struct_type = "ANGLE" + elif "CHANNEL" in desc_upper or "C-SECTION" in desc_upper: struct_type = "CHANNEL" + elif "BEAM" in desc_upper: struct_type = "I-BEAM" + + # 2. 규격 추출 + # 패턴: 100x100, 100x100x6x8, 50x50x5T, H-200x200 + dimension = "" + # 숫자와 x 또는 *로 구성된 패턴, 앞에 H- 등이 붙을 수 있음 + dim_match = re.search(r'(?:H-|L-|C-)?(\d+(?:\.\d+)?(?:\s*[X\*]\s*\d+(?:\.\d+)?){1,3})', desc_upper) + if dim_match: + dimension = dim_match.group(1).replace("*", "x") + + return { + "category": "STRUCTURAL", + "overall_confidence": 0.9, + "details": { + "type": struct_type, + "dimension": dimension + } + } diff --git a/tkeg/api/app/services/support_classifier.py b/tkeg/api/app/services/support_classifier.py new file mode 100644 index 0000000..47b53a2 --- /dev/null +++ b/tkeg/api/app/services/support_classifier.py @@ -0,0 +1,329 @@ +""" +SUPPORT 분류 시스템 +배관 지지재, 우레탄 블록, 클램프 등 지지 부품 분류 +""" + +import re +from typing import Dict, List, Optional +from .material_classifier import classify_material + +# ========== 서포트 타입별 분류 ========== +SUPPORT_TYPES = { + "URETHANE_BLOCK": { + "dat_file_patterns": ["URETHANE", "BLOCK", "SHOE"], + "description_keywords": ["URETHANE BLOCK", "BLOCK SHOE", "우레탄 블록", "우레탄", "URETHANE"], + "characteristics": "우레탄 블록 슈", + "applications": "배관 지지, 진동 흡수", + "material_type": "URETHANE" + }, + + "CLAMP": { + "dat_file_patterns": ["CLAMP", "CL-"], + "description_keywords": ["CLAMP", "클램프", "CL-1", "CL-2", "CL-3"], + "characteristics": "배관 클램프", + "applications": "배관 고정, 지지", + "material_type": "STEEL" + }, + + "HANGER": { + "dat_file_patterns": ["HANGER", "HANG", "SUPP"], + "description_keywords": ["HANGER", "SUPPORT", "행거", "서포트", "PIPE HANGER"], + "characteristics": "배관 행거", + "applications": "배관 매달기, 지지", + "material_type": "STEEL" + }, + + "SPRING_HANGER": { + "dat_file_patterns": ["SPRING", "SPR_"], + "description_keywords": ["SPRING HANGER", "SPRING", "스프링", "스프링 행거"], + "characteristics": "스프링 행거", + "applications": "가변 하중 지지", + "material_type": "STEEL" + }, + + "GUIDE": { + "dat_file_patterns": ["GUIDE", "GD_"], + "description_keywords": ["GUIDE", "가이드", "PIPE GUIDE"], + "characteristics": "배관 가이드", + "applications": "배관 방향 제어", + "material_type": "STEEL" + }, + + "ANCHOR": { + "dat_file_patterns": ["ANCHOR", "ANCH"], + "description_keywords": ["ANCHOR", "앵커", "PIPE ANCHOR"], + "characteristics": "배관 앵커", + "applications": "배관 고정점", + "material_type": "STEEL" + } +} + +# ========== 하중 등급 분류 ========== +LOAD_RATINGS = { + "LIGHT": { + "patterns": [r"(\d+)T", r"(\d+)TON"], + "range": (0, 5), # 5톤 이하 + "description": "경하중용" + }, + "MEDIUM": { + "patterns": [r"(\d+)T", r"(\d+)TON"], + "range": (5, 20), # 5-20톤 + "description": "중하중용" + }, + "HEAVY": { + "patterns": [r"(\d+)T", r"(\d+)TON"], + "range": (20, 100), # 20-100톤 + "description": "중하중용" + } +} + +def classify_support(dat_file: str, description: str, main_nom: str, + length: Optional[float] = None) -> Dict: + """ + SUPPORT 분류 메인 함수 + + Args: + dat_file: DAT 파일명 + description: 자재 설명 + main_nom: 주 사이즈 + length: 길이 (옵션) + + Returns: + 분류 결과 딕셔너리 + """ + + dat_upper = dat_file.upper() + desc_upper = description.upper() + combined_text = f"{dat_upper} {desc_upper}" + + # 1. 서포트 타입 분류 + support_type_result = classify_support_type(dat_file, description) + + # 2. 재질 분류 (공통 모듈 사용) + material_result = classify_material(description) + + # 3. 하중 등급 분류 + load_result = classify_load_rating(description) + + # 4. 사이즈 정보 추출 + size_result = extract_support_size(description, main_nom) + + # 5. 사용자 요구사항 추출 + user_requirements = extract_support_user_requirements(description) + + # 6. 우레탄 블럭슈 두께 정보 추출 및 Material Grade 보강 + enhanced_material_grade = material_result.get('grade', 'UNKNOWN') + if support_type_result.get("support_type") == "URETHANE_BLOCK": + # 두께 정보 추출 (40t, 27t 등) + thickness_match = re.search(r'(\d+)\s*[tT](?![oO])', description.upper()) + if thickness_match: + thickness = f"{thickness_match.group(1)}t" + if enhanced_material_grade == 'UNKNOWN' or not enhanced_material_grade: + enhanced_material_grade = thickness + elif thickness not in enhanced_material_grade: + enhanced_material_grade = f"{enhanced_material_grade} {thickness}" + + # 7. 최종 결과 조합 + return { + "category": "SUPPORT", + + # 서포트 특화 정보 + "support_type": support_type_result.get("support_type", "UNKNOWN"), + "support_subtype": support_type_result.get("subtype", ""), + "load_rating": load_result.get("load_rating", ""), + "load_capacity": load_result.get("capacity", ""), + + # 재질 정보 (공통 모듈) - 우레탄 블럭슈 두께 정보 포함 + "material": { + "standard": material_result.get('standard', 'UNKNOWN'), + "grade": enhanced_material_grade, + "material_type": material_result.get('material_type', 'UNKNOWN'), + "confidence": material_result.get('confidence', 0.0) + }, + + # 사이즈 정보 + "size_info": size_result, + + # 사용자 요구사항 + "user_requirements": user_requirements, + + # 전체 신뢰도 + "overall_confidence": calculate_support_confidence({ + "type": support_type_result.get('confidence', 0), + "material": material_result.get('confidence', 0), + "load": load_result.get('confidence', 0), + "size": size_result.get('confidence', 0) + }), + + # 증거 + "evidence": [ + f"SUPPORT_TYPE: {support_type_result.get('support_type', 'UNKNOWN')}", + f"MATERIAL: {material_result.get('standard', 'UNKNOWN')}", + f"LOAD: {load_result.get('load_rating', 'UNKNOWN')}" + ] + } + +def classify_support_type(dat_file: str, description: str) -> Dict: + """서포트 타입 분류""" + + dat_upper = dat_file.upper() + desc_upper = description.upper() + combined_text = f"{dat_upper} {desc_upper}" + + for support_type, type_data in SUPPORT_TYPES.items(): + # DAT 파일 패턴 확인 + for pattern in type_data["dat_file_patterns"]: + if pattern in dat_upper: + return { + "support_type": support_type, + "subtype": type_data["characteristics"], + "applications": type_data["applications"], + "confidence": 0.95, + "evidence": [f"DAT_PATTERN: {pattern}"] + } + + # 설명 키워드 확인 + for keyword in type_data["description_keywords"]: + if keyword in desc_upper: + return { + "support_type": support_type, + "subtype": type_data["characteristics"], + "applications": type_data["applications"], + "confidence": 0.9, + "evidence": [f"DESC_KEYWORD: {keyword}"] + } + + return { + "support_type": "UNKNOWN", + "subtype": "", + "applications": "", + "confidence": 0.0, + "evidence": ["NO_SUPPORT_TYPE_FOUND"] + } + +def extract_support_user_requirements(description: str) -> List[str]: + """서포트 사용자 요구사항 추출""" + + desc_upper = description.upper() + requirements = [] + + # 표면처리 관련 + if 'GALV' in desc_upper or 'GALVANIZED' in desc_upper: + requirements.append('GALVANIZED') + if 'HDG' in desc_upper or 'HOT DIP' in desc_upper: + requirements.append('HOT DIP GALVANIZED') + if 'PAINT' in desc_upper or 'PAINTED' in desc_upper: + requirements.append('PAINTED') + + # 재질 관련 + if 'SS' in desc_upper or 'STAINLESS' in desc_upper: + requirements.append('STAINLESS STEEL') + if 'CARBON' in desc_upper: + requirements.append('CARBON STEEL') + + # 특수 요구사항 + if 'FIRE SAFE' in desc_upper: + requirements.append('FIRE SAFE') + if 'SEISMIC' in desc_upper or '내진' in desc_upper: + requirements.append('SEISMIC') + + return requirements + +def classify_load_rating(description: str) -> Dict: + """하중 등급 분류""" + + desc_upper = description.upper() + + # 하중 패턴 찾기 (40T, 50TON 등) + for rating, rating_data in LOAD_RATINGS.items(): + for pattern in rating_data["patterns"]: + match = re.search(pattern, desc_upper) + if match: + capacity = int(match.group(1)) + min_load, max_load = rating_data["range"] + + if min_load <= capacity <= max_load: + return { + "load_rating": rating, + "capacity": f"{capacity}T", + "description": rating_data["description"], + "confidence": 0.9, + "evidence": [f"LOAD_PATTERN: {match.group(0)}"] + } + + # 특정 하중 값이 있지만 등급을 모르는 경우 + load_match = re.search(r'(\d+)\s*[T톤]', desc_upper) + if load_match: + capacity = int(load_match.group(1)) + return { + "load_rating": "CUSTOM", + "capacity": f"{capacity}T", + "description": f"{capacity}톤 하중", + "confidence": 0.7, + "evidence": [f"CUSTOM_LOAD: {load_match.group(0)}"] + } + + return { + "load_rating": "UNKNOWN", + "capacity": "", + "description": "", + "confidence": 0.0, + "evidence": ["NO_LOAD_RATING_FOUND"] + } + +def extract_support_size(description: str, main_nom: str) -> Dict: + """서포트 사이즈 정보 추출""" + + desc_upper = description.upper() + + # 파이프 사이즈 (서포트가 지지하는 파이프 크기) + pipe_size = main_nom if main_nom else "" + + # 서포트 자체 치수 (길이x폭x높이 등) + dimension_patterns = [ + r'(\d+)\s*[X×]\s*(\d+)\s*[X×]\s*(\d+)', # 100x50x20 + r'(\d+)\s*[X×]\s*(\d+)', # 100x50 + r'L\s*(\d+)', # L100 (길이) + r'W\s*(\d+)', # W50 (폭) + r'H\s*(\d+)' # H20 (높이) + ] + + dimensions = {} + for pattern in dimension_patterns: + match = re.search(pattern, desc_upper) + if match: + if len(match.groups()) == 3: + dimensions = { + "length": f"{match.group(1)}mm", + "width": f"{match.group(2)}mm", + "height": f"{match.group(3)}mm" + } + elif len(match.groups()) == 2: + dimensions = { + "length": f"{match.group(1)}mm", + "width": f"{match.group(2)}mm" + } + break + + return { + "pipe_size": pipe_size, + "dimensions": dimensions, + "confidence": 0.8 if dimensions else 0.3 + } + +def calculate_support_confidence(confidence_scores: Dict) -> float: + """서포트 분류 전체 신뢰도 계산""" + + weights = { + "type": 0.4, # 타입이 가장 중요 + "material": 0.2, # 재질 + "load": 0.2, # 하중 + "size": 0.2 # 사이즈 + } + + weighted_sum = sum( + confidence_scores.get(key, 0) * weight + for key, weight in weights.items() + ) + + return round(weighted_sum, 2) diff --git a/tkeg/api/app/services/test_bolt_classifier.py b/tkeg/api/app/services/test_bolt_classifier.py new file mode 100644 index 0000000..4dc8a93 --- /dev/null +++ b/tkeg/api/app/services/test_bolt_classifier.py @@ -0,0 +1,143 @@ +""" +BOLT 분류 테스트 +""" + +from .bolt_classifier import ( + classify_bolt, + get_bolt_purchase_info, + is_high_strength_bolt, + is_stainless_bolt +) + +def test_bolt_classification(): + """BOLT 분류 테스트""" + + test_cases = [ + { + "name": "육각 볼트 (미터)", + "dat_file": "BOLT_HEX_M12", + "description": "HEX BOLT, M12 X 50MM, GRADE 8.8, ZINC PLATED", + "main_nom": "M12" + }, + { + "name": "소켓 헤드 캡 스크류", + "dat_file": "SHCS_M8", + "description": "SOCKET HEAD CAP SCREW, M8 X 25MM, SS316", + "main_nom": "M8" + }, + { + "name": "스터드 볼트", + "dat_file": "STUD_M16", + "description": "STUD BOLT, M16 X 100MM, ASTM A193 B7", + "main_nom": "M16" + }, + { + "name": "플랜지 볼트", + "dat_file": "FLG_BOLT_M20", + "description": "FLANGE BOLT, M20 X 80MM, GRADE 10.9", + "main_nom": "M20" + }, + { + "name": "인치 볼트", + "dat_file": "BOLT_HEX_1/2", + "description": "HEX BOLT, 1/2-13 UNC X 2 INCH, ASTM A325", + "main_nom": "1/2\"" + }, + { + "name": "육각 너트", + "dat_file": "NUT_HEX_M12", + "description": "HEX NUT, M12, GRADE 8, ZINC PLATED", + "main_nom": "M12" + }, + { + "name": "헤비 너트", + "dat_file": "HEAVY_NUT_M16", + "description": "HEAVY HEX NUT, M16, SS316", + "main_nom": "M16" + }, + { + "name": "평 와셔", + "dat_file": "WASH_FLAT_M12", + "description": "FLAT WASHER, M12, STAINLESS STEEL", + "main_nom": "M12" + }, + { + "name": "스프링 와셔", + "dat_file": "SPRING_WASH_M10", + "description": "SPRING WASHER, M10, CARBON STEEL", + "main_nom": "M10" + }, + { + "name": "U볼트", + "dat_file": "U_BOLT_M8", + "description": "U-BOLT, M8 X 50MM, GALVANIZED", + "main_nom": "M8" + } + ] + + print("🔩 BOLT 분류 테스트 시작\n") + print("=" * 80) + + for i, test in enumerate(test_cases, 1): + print(f"\n테스트 {i}: {test['name']}") + print("-" * 60) + + result = classify_bolt( + test["dat_file"], + test["description"], + test["main_nom"] + ) + + purchase_info = get_bolt_purchase_info(result) + + print(f"📋 입력:") + print(f" DAT_FILE: {test['dat_file']}") + print(f" DESCRIPTION: {test['description']}") + print(f" SIZE: {result['dimensions']['dimension_description']}") + + print(f"\n🔩 분류 결과:") + print(f" 카테고리: {result['fastener_category']['category']}") + print(f" 타입: {result['fastener_type']['type']}") + print(f" 특성: {result['fastener_type']['characteristics']}") + print(f" 나사규격: {result['thread_specification']['standard']} {result['thread_specification']['size']}") + if result['thread_specification']['pitch']: + print(f" 피치: {result['thread_specification']['pitch']}") + print(f" 치수: {result['dimensions']['dimension_description']}") + print(f" 등급: {result['grade_strength']['grade']}") + if result['grade_strength']['tensile_strength']: + print(f" 인장강도: {result['grade_strength']['tensile_strength']}") + + # 특수 조건 표시 + conditions = [] + if is_high_strength_bolt(result): + conditions.append("💪 고강도") + if is_stainless_bolt(result): + conditions.append("✨ 스테인리스") + if conditions: + print(f" 특수조건: {' '.join(conditions)}") + + print(f"\n📊 신뢰도:") + print(f" 전체신뢰도: {result['overall_confidence']}") + print(f" 재질: {result['material']['confidence']}") + print(f" 타입: {result['fastener_type']['confidence']}") + print(f" 나사규격: {result['thread_specification']['confidence']}") + print(f" 등급: {result['grade_strength']['confidence']}") + + print(f"\n🛒 구매 정보:") + print(f" 공급업체: {purchase_info['supplier_type']}") + print(f" 예상납기: {purchase_info['lead_time_estimate']}") + print(f" 구매단위: {purchase_info['purchase_unit']}") + print(f" 구매카테고리: {purchase_info['purchase_category']}") + + print(f"\n💾 저장될 데이터:") + print(f" FASTENER_CATEGORY: {result['fastener_category']['category']}") + print(f" FASTENER_TYPE: {result['fastener_type']['type']}") + print(f" THREAD_STANDARD: {result['thread_specification']['standard']}") + print(f" THREAD_SIZE: {result['thread_specification']['size']}") + print(f" GRADE: {result['grade_strength']['grade']}") + + if i < len(test_cases): + print("\n" + "=" * 80) + +if __name__ == "__main__": + test_bolt_classification() diff --git a/tkeg/api/app/services/test_fitting_classifier.py b/tkeg/api/app/services/test_fitting_classifier.py new file mode 100644 index 0000000..768255b --- /dev/null +++ b/tkeg/api/app/services/test_fitting_classifier.py @@ -0,0 +1,184 @@ +""" +FITTING 분류 시스템 V2 테스트 (크기 정보 추가) +""" + +from .fitting_classifier import classify_fitting, get_fitting_purchase_info + +def test_fitting_classification(): + """실제 BOM 데이터로 FITTING 분류 테스트""" + + test_cases = [ + { + "name": "90도 엘보 (BW)", + "dat_file": "90L_BW", + "description": "90 LR ELL, SCH 40, ASTM A234 GR WPB, SMLS", + "main_nom": "3\"", + "red_nom": None + }, + { + "name": "리듀싱 티 (BW)", + "dat_file": "TEE_RD_BW", + "description": "TEE RED, SCH 40 x SCH 40, ASTM A234 GR WPB, SMLS", + "main_nom": "4\"", + "red_nom": "2\"" + }, + { + "name": "동심 리듀서 (BW)", + "dat_file": "CNC_BW", + "description": "RED CONC, SCH 40 x SCH 40, ASTM A234 GR WPB, SMLS", + "main_nom": "3\"", + "red_nom": "2\"" + }, + { + "name": "편심 리듀서 (BW)", + "dat_file": "ECC_BW", + "description": "RED ECC, SCH 40 x SCH 40, ASTM A234 GR WPB, SMLS", + "main_nom": "6\"", + "red_nom": "3\"" + }, + { + "name": "소켓웰드 티 (고압)", + "dat_file": "TEE_SW_3000", + "description": "TEE, SW, 3000LB, ASTM A105", + "main_nom": "1\"", + "red_nom": None + }, + { + "name": "리듀싱 소켓웰드 티 (고압)", + "dat_file": "TEE_RD_SW_3000", + "description": "TEE RED, SW, 3000LB, ASTM A105", + "main_nom": "1\"", + "red_nom": "1/2\"" + }, + { + "name": "소켓웰드 캡 (고압)", + "dat_file": "CAP_SW_3000", + "description": "CAP, NPT(F), 3000LB, ASTM A105", + "main_nom": "1\"", + "red_nom": None + }, + { + "name": "소켓오렛 (고압)", + "dat_file": "SOL_SW_3000", + "description": "SOCK-O-LET, SW, 3000LB, ASTM A105", + "main_nom": "3\"", + "red_nom": "1\"" + }, + { + "name": "동심 스웨지 (BW)", + "dat_file": "SWG_CN_BW", + "description": "SWAGE CONC, SCH 40 x SCH 80, ASTM A105 GR , SMLS BBE", + "main_nom": "2\"", + "red_nom": "1\"" + }, + { + "name": "편심 스웨지 (BW)", + "dat_file": "SWG_EC_BW", + "description": "SWAGE ECC, SCH 80 x SCH 80, ASTM A105 GR , SMLS PBE", + "main_nom": "1\"", + "red_nom": "3/4\"" + } + ] + + print("🔧 FITTING 분류 시스템 V2 테스트 시작\n") + print("=" * 80) + + for i, test in enumerate(test_cases, 1): + print(f"\n테스트 {i}: {test['name']}") + print("-" * 60) + + result = classify_fitting( + test["dat_file"], + test["description"], + test["main_nom"], + test["red_nom"] + ) + + purchase_info = get_fitting_purchase_info(result) + + print(f"📋 입력:") + print(f" DAT_FILE: {test['dat_file']}") + print(f" DESCRIPTION: {test['description']}") + print(f" SIZE: {result['size_info']['size_description']}") + + print(f"\n🔧 분류 결과:") + print(f" 재질: {result['material']['standard']} | {result['material']['grade']}") + print(f" 피팅타입: {result['fitting_type']['type']} - {result['fitting_type']['subtype']}") + print(f" 크기정보: {result['size_info']['size_description']}") # ← 추가! + if result['size_info']['reduced_size']: + print(f" 주사이즈: {result['size_info']['main_size']}") + print(f" 축소사이즈: {result['size_info']['reduced_size']}") + print(f" 연결방식: {result['connection_method']['method']}") + print(f" 압력등급: {result['pressure_rating']['rating']} ({result['pressure_rating']['common_use']})") + print(f" 제작방법: {result['manufacturing']['method']} ({result['manufacturing']['characteristics']})") + + print(f"\n📊 신뢰도:") + print(f" 전체신뢰도: {result['overall_confidence']}") + print(f" 재질: {result['material']['confidence']}") + print(f" 피팅타입: {result['fitting_type']['confidence']}") + print(f" 연결방식: {result['connection_method']['confidence']}") + print(f" 압력등급: {result['pressure_rating']['confidence']}") + + print(f"\n🛒 구매 정보:") + print(f" 공급업체: {purchase_info['supplier_type']}") + print(f" 예상납기: {purchase_info['lead_time_estimate']}") + print(f" 구매카테고리: {purchase_info['purchase_category']}") + + # 크기 정보 저장 확인 + print(f"\n💾 저장될 데이터:") + print(f" MAIN_NOM: {result['size_info']['main_size']}") + print(f" RED_NOM: {result['size_info']['reduced_size'] or 'NULL'}") + print(f" SIZE_DESCRIPTION: {result['size_info']['size_description']}") + + if i < len(test_cases): + print("\n" + "=" * 80) + +def test_fitting_edge_cases(): + """예외 케이스 테스트""" + + edge_cases = [ + { + "name": "DAT_FILE만 있는 경우", + "dat_file": "90L_BW", + "description": "", + "main_nom": "2\"", + "red_nom": None + }, + { + "name": "DESCRIPTION만 있는 경우", + "dat_file": "", + "description": "90 DEGREE ELBOW, ASTM A234 WPB", + "main_nom": "3\"", + "red_nom": None + }, + { + "name": "알 수 없는 DAT_FILE", + "dat_file": "UNKNOWN_CODE", + "description": "SPECIAL FITTING, CUSTOM MADE", + "main_nom": "4\"", + "red_nom": None + } + ] + + print("\n🧪 예외 케이스 테스트\n") + print("=" * 50) + + for i, test in enumerate(edge_cases, 1): + print(f"\n예외 테스트 {i}: {test['name']}") + print("-" * 40) + + result = classify_fitting( + test["dat_file"], + test["description"], + test["main_nom"], + test["red_nom"] + ) + + print(f"결과: {result['fitting_type']['type']} - {result['fitting_type']['subtype']}") + print(f"크기: {result['size_info']['size_description']}") # ← 추가! + print(f"신뢰도: {result['overall_confidence']}") + print(f"증거: {result['fitting_type']['evidence']}") + +if __name__ == "__main__": + test_fitting_classification() + test_fitting_edge_cases() diff --git a/tkeg/api/app/services/test_flange_classifier.py b/tkeg/api/app/services/test_flange_classifier.py new file mode 100644 index 0000000..ee53793 --- /dev/null +++ b/tkeg/api/app/services/test_flange_classifier.py @@ -0,0 +1,126 @@ +""" +FLANGE 분류 테스트 +""" + +from .flange_classifier import classify_flange, get_flange_purchase_info + +def test_flange_classification(): + """FLANGE 분류 테스트""" + + test_cases = [ + { + "name": "웰드넥 플랜지 (일반)", + "dat_file": "FLG_WN_150", + "description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105", + "main_nom": "4\"", + "red_nom": None + }, + { + "name": "슬립온 플랜지 (일반)", + "dat_file": "FLG_SO_300", + "description": "FLG SLIP ON RF, 300LB, ASTM A105", + "main_nom": "3\"", + "red_nom": None + }, + { + "name": "블라인드 플랜지 (일반)", + "dat_file": "FLG_BL_150", + "description": "FLG BLIND RF, 150LB, ASTM A105", + "main_nom": "6\"", + "red_nom": None + }, + { + "name": "소켓웰드 플랜지 (고압)", + "dat_file": "FLG_SW_3000", + "description": "FLG SW, 3000LB, ASTM A105", + "main_nom": "1\"", + "red_nom": None + }, + { + "name": "오리피스 플랜지 (SPECIAL)", + "dat_file": "FLG_ORI_150", + "description": "FLG ORIFICE RF, 150LB, 0.5 INCH BORE, ASTM A105", + "main_nom": "4\"", + "red_nom": None + }, + { + "name": "스펙터클 블라인드 (SPECIAL)", + "dat_file": "FLG_SPB_300", + "description": "FLG SPECTACLE BLIND, 300LB, ASTM A105", + "main_nom": "6\"", + "red_nom": None + }, + { + "name": "리듀싱 플랜지 (SPECIAL)", + "dat_file": "FLG_RED_300", + "description": "FLG REDUCING, 300LB, ASTM A105", + "main_nom": "6\"", + "red_nom": "4\"" + }, + { + "name": "스페이서 플랜지 (SPECIAL)", + "dat_file": "FLG_SPC_600", + "description": "FLG SPACER, 600LB, 2 INCH THK, ASTM A105", + "main_nom": "3\"", + "red_nom": None + } + ] + + print("🔩 FLANGE 분류 테스트 시작\n") + print("=" * 80) + + for i, test in enumerate(test_cases, 1): + print(f"\n테스트 {i}: {test['name']}") + print("-" * 60) + + result = classify_flange( + test["dat_file"], + test["description"], + test["main_nom"], + test["red_nom"] + ) + + purchase_info = get_flange_purchase_info(result) + + print(f"📋 입력:") + print(f" DAT_FILE: {test['dat_file']}") + print(f" DESCRIPTION: {test['description']}") + print(f" SIZE: {result['size_info']['size_description']}") + + print(f"\n🔩 분류 결과:") + print(f" 재질: {result['material']['standard']} | {result['material']['grade']}") + print(f" 카테고리: {'🌟 SPECIAL' if result['flange_category']['is_special'] else '📋 STANDARD'}") + print(f" 플랜지타입: {result['flange_type']['type']}") + print(f" 특성: {result['flange_type']['characteristics']}") + print(f" 면가공: {result['face_finish']['finish']}") + print(f" 압력등급: {result['pressure_rating']['rating']} ({result['pressure_rating']['common_use']})") + print(f" 제작방법: {result['manufacturing']['method']} ({result['manufacturing']['characteristics']})") + + if result['flange_type']['special_features']: + print(f" 특수기능: {', '.join(result['flange_type']['special_features'])}") + + print(f"\n📊 신뢰도:") + print(f" 전체신뢰도: {result['overall_confidence']}") + print(f" 재질: {result['material']['confidence']}") + print(f" 플랜지타입: {result['flange_type']['confidence']}") + print(f" 면가공: {result['face_finish']['confidence']}") + print(f" 압력등급: {result['pressure_rating']['confidence']}") + + print(f"\n🛒 구매 정보:") + print(f" 공급업체: {purchase_info['supplier_type']}") + print(f" 예상납기: {purchase_info['lead_time_estimate']}") + print(f" 구매카테고리: {purchase_info['purchase_category']}") + if purchase_info['special_requirements']: + print(f" 특수요구사항: {', '.join(purchase_info['special_requirements'])}") + + print(f"\n💾 저장될 데이터:") + print(f" MAIN_NOM: {result['size_info']['main_size']}") + print(f" RED_NOM: {result['size_info']['reduced_size'] or 'NULL'}") + print(f" FLANGE_CATEGORY: {'SPECIAL' if result['flange_category']['is_special'] else 'STANDARD'}") + print(f" FLANGE_TYPE: {result['flange_type']['type']}") + + if i < len(test_cases): + print("\n" + "=" * 80) + +if __name__ == "__main__": + test_flange_classification() diff --git a/tkeg/api/app/services/test_gasket_classifier.py b/tkeg/api/app/services/test_gasket_classifier.py new file mode 100644 index 0000000..375e7e2 --- /dev/null +++ b/tkeg/api/app/services/test_gasket_classifier.py @@ -0,0 +1,127 @@ +""" +GASKET 분류 테스트 +""" + +from .gasket_classifier import ( + classify_gasket, + get_gasket_purchase_info, + is_high_temperature_gasket, + is_high_pressure_gasket +) + +def test_gasket_classification(): + """GASKET 분류 테스트""" + + test_cases = [ + { + "name": "스파이럴 와운드 가스켓", + "dat_file": "SWG_150", + "description": "SPIRAL WOUND GASKET, GRAPHITE FILLER, SS316 WINDING, 150LB", + "main_nom": "4\"" + }, + { + "name": "링 조인트 가스켓", + "dat_file": "RTJ_600", + "description": "RING JOINT GASKET, RTJ, SS316, 600LB", + "main_nom": "6\"" + }, + { + "name": "풀 페이스 가스켓", + "dat_file": "FF_150", + "description": "FULL FACE GASKET, RUBBER, 150LB", + "main_nom": "8\"" + }, + { + "name": "레이즈드 페이스 가스켓", + "dat_file": "RF_300", + "description": "RAISED FACE GASKET, PTFE, 300LB, -200°C TO 260°C", + "main_nom": "3\"" + }, + { + "name": "오링", + "dat_file": "OR_VITON", + "description": "O-RING, VITON, ID 50MM, THK 3MM", + "main_nom": "50mm" + }, + { + "name": "시트 가스켓", + "dat_file": "SHEET_150", + "description": "SHEET GASKET, GRAPHITE, 150LB, MAX 650°C", + "main_nom": "10\"" + }, + { + "name": "캄프로파일 가스켓", + "dat_file": "KAMM_600", + "description": "KAMMPROFILE GASKET, GRAPHITE FACING, SS304 CORE, 600LB", + "main_nom": "12\"" + }, + { + "name": "특수 가스켓", + "dat_file": "CUSTOM_SPEC", + "description": "CUSTOM GASKET, SPECIAL SHAPE, PTFE, -50°C TO 200°C", + "main_nom": "특수" + } + ] + + print("🔧 GASKET 분류 테스트 시작\n") + print("=" * 80) + + for i, test in enumerate(test_cases, 1): + print(f"\n테스트 {i}: {test['name']}") + print("-" * 60) + + result = classify_gasket( + test["dat_file"], + test["description"], + test["main_nom"] + ) + + purchase_info = get_gasket_purchase_info(result) + + print(f"📋 입력:") + print(f" DAT_FILE: {test['dat_file']}") + print(f" DESCRIPTION: {test['description']}") + print(f" SIZE: {result['size_info']['size_description']}") + + print(f"\n🔧 분류 결과:") + print(f" 가스켓타입: {result['gasket_type']['type']}") + print(f" 특성: {result['gasket_type']['characteristics']}") + print(f" 가스켓재질: {result['gasket_material']['material']}") + print(f" 재질특성: {result['gasket_material']['characteristics']}") + print(f" 압력등급: {result['pressure_rating']['rating']}") + print(f" 온도범위: {result['temperature_info']['range']}") + print(f" 용도: {result['gasket_type']['applications']}") + + # 특수 조건 표시 + conditions = [] + if is_high_temperature_gasket(result): + conditions.append("🔥 고온용") + if is_high_pressure_gasket(result): + conditions.append("💪 고압용") + if conditions: + print(f" 특수조건: {' '.join(conditions)}") + + print(f"\n📊 신뢰도:") + print(f" 전체신뢰도: {result['overall_confidence']}") + print(f" 가스켓타입: {result['gasket_type']['confidence']}") + print(f" 가스켓재질: {result['gasket_material']['confidence']}") + print(f" 압력등급: {result['pressure_rating']['confidence']}") + + print(f"\n🛒 구매 정보:") + print(f" 공급업체: {purchase_info['supplier_type']}") + print(f" 예상납기: {purchase_info['lead_time_estimate']}") + print(f" 구매단위: {purchase_info['purchase_unit']}") + print(f" 구매카테고리: {purchase_info['purchase_category']}") + + print(f"\n💾 저장될 데이터:") + print(f" GASKET_TYPE: {result['gasket_type']['type']}") + print(f" GASKET_MATERIAL: {result['gasket_material']['material']}") + print(f" PRESSURE_RATING: {result['pressure_rating']['rating']}") + print(f" SIZE_INFO: {result['size_info']['size_description']}") + print(f" TEMPERATURE_RANGE: {result['temperature_info']['range']}") + + if i < len(test_cases): + print("\n" + "=" * 80) + +if __name__ == "__main__": + test_gasket_classification() diff --git a/tkeg/api/app/services/test_instrument_classifier.py b/tkeg/api/app/services/test_instrument_classifier.py new file mode 100644 index 0000000..375b357 --- /dev/null +++ b/tkeg/api/app/services/test_instrument_classifier.py @@ -0,0 +1,48 @@ +""" +INSTRUMENT 간단 테스트 +""" + +from .instrument_classifier import classify_instrument + +def test_instrument_classification(): + """간단한 계기류 테스트""" + + test_cases = [ + { + "name": "압력 게이지", + "dat_file": "PG_001", + "description": "PRESSURE GAUGE, 0-100 PSI, 1/4 NPT", + "main_nom": "1/4\"" + }, + { + "name": "온도 트랜스미터", + "dat_file": "TT_001", + "description": "TEMPERATURE TRANSMITTER, 4-20mA, 0-200°C", + "main_nom": "1/2\"" + }, + { + "name": "유량계", + "dat_file": "FM_001", + "description": "FLOW METER, MAGNETIC TYPE", + "main_nom": "3\"" + } + ] + + print("🔧 INSTRUMENT 간단 테스트\n") + + for i, test in enumerate(test_cases, 1): + result = classify_instrument( + test["dat_file"], + test["description"], + test["main_nom"] + ) + + print(f"테스트 {i}: {test['name']}") + print(f" 계기타입: {result['instrument_type']['type']}") + print(f" 측정범위: {result['measurement_info']['range']} {result['measurement_info']['unit']}") + print(f" 연결사이즈: {result['size_info']['connection_size']}") + print(f" 구매정보: {result['purchase_info']['category']}") + print() + +if __name__ == "__main__": + test_instrument_classification() diff --git a/tkeg/api/app/services/test_material_classifier.py b/tkeg/api/app/services/test_material_classifier.py new file mode 100644 index 0000000..db4e6a9 --- /dev/null +++ b/tkeg/api/app/services/test_material_classifier.py @@ -0,0 +1,53 @@ +""" +재질 분류 테스트 +""" + +from .material_classifier import classify_material, get_manufacturing_method_from_material + +def test_material_classification(): + """재질 분류 테스트""" + + test_cases = [ + { + "description": "PIPE, SMLS, SCH 80, ASTM A106 GR B BOE-POE", + "expected_standard": "ASTM A106" + }, + { + "description": "TEE, SW, 3000LB, ASTM A182 F304", + "expected_standard": "ASTM A182" + }, + { + "description": "90 LR ELL, SCH 40, ASTM A234 GR WPB, SMLS", + "expected_standard": "ASTM A234" + }, + { + "description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105", + "expected_standard": "ASTM A105" + }, + { + "description": "GATE VALVE, ASTM A216 WCB", + "expected_standard": "ASTM A216" + }, + { + "description": "SPECIAL FITTING, INCONEL 625", + "expected_standard": "INCONEL" + } + ] + + print("🔧 재질 분류 테스트 시작\n") + + for i, test in enumerate(test_cases, 1): + result = classify_material(test["description"]) + manufacturing = get_manufacturing_method_from_material(result) + + print(f"테스트 {i}:") + print(f" 입력: {test['description']}") + print(f" 결과: {result['standard']} | {result['grade']}") + print(f" 재질타입: {result['material_type']}") + print(f" 제작방법: {manufacturing}") + print(f" 신뢰도: {result['confidence']}") + print(f" 증거: {result.get('evidence', [])}") + print() + +if __name__ == "__main__": + test_material_classification() diff --git a/tkeg/api/app/services/test_pipe_classifier.py b/tkeg/api/app/services/test_pipe_classifier.py new file mode 100644 index 0000000..4ed815c --- /dev/null +++ b/tkeg/api/app/services/test_pipe_classifier.py @@ -0,0 +1,55 @@ +""" +PIPE 분류 테스트 +""" + +from .pipe_classifier import classify_pipe, generate_pipe_cutting_plan + +def test_pipe_classification(): + """PIPE 분류 테스트""" + + test_cases = [ + { + "dat_file": "PIP_PE", + "description": "PIPE, SMLS, SCH 80, ASTM A106 GR B BOE-POE", + "main_nom": "1\"", + "length": 798.1965 + }, + { + "dat_file": "NIP_TR", + "description": "NIPPLE, SMLS, SCH 80, ASTM A106 GR B PBE", + "main_nom": "1\"", + "length": 75.0 + }, + { + "dat_file": "PIPE_SPOOL", + "description": "PIPE SPOOL, WELDED, SCH 40, CS", + "main_nom": "2\"", + "length": None + } + ] + + print("🔧 PIPE 분류 테스트 시작\n") + + for i, test in enumerate(test_cases, 1): + result = classify_pipe( + test["dat_file"], + test["description"], + test["main_nom"], + test["length"] + ) + + cutting_plan = generate_pipe_cutting_plan(result) + + print(f"테스트 {i}:") + print(f" 입력: {test['description']}") + print(f" 재질: {result['material']['standard']} | {result['material']['grade']}") + print(f" 제조방법: {result['manufacturing']['method']}") + print(f" 끝가공: {result['end_preparation']['cutting_note']}") + print(f" 스케줄: {result['schedule']['schedule']}") + print(f" 절단치수: {result['cutting_dimensions']['length_mm']}mm") + print(f" 전체신뢰도: {result['overall_confidence']}") + print(f" 절단지시: {cutting_plan['cutting_instruction']}") + print() + +if __name__ == "__main__": + test_pipe_classification() diff --git a/tkeg/api/app/services/test_valve_classifier.py b/tkeg/api/app/services/test_valve_classifier.py new file mode 100644 index 0000000..baae847 --- /dev/null +++ b/tkeg/api/app/services/test_valve_classifier.py @@ -0,0 +1,130 @@ +""" +VALVE 분류 테스트 +""" + +from .valve_classifier import classify_valve, get_valve_purchase_info, is_forged_valve + +def test_valve_classification(): + """VALVE 분류 테스트""" + + test_cases = [ + { + "name": "게이트 밸브 (주조)", + "dat_file": "GATE_FL_150", + "description": "GATE VALVE, FLANGED, 150LB, WCB, OS&Y", + "main_nom": "6\"" + }, + { + "name": "볼 밸브 (주조)", + "dat_file": "BALL_FL_300", + "description": "BALL VALVE, FULL PORT, FLANGED, 300LB, WCB", + "main_nom": "4\"" + }, + { + "name": "볼 밸브 (단조)", + "dat_file": "BALL_SW_1500", + "description": "BALL VALVE, FORGED, SW, 1500LB, A105", + "main_nom": "1\"" + }, + { + "name": "글로브 밸브 (단조)", + "dat_file": "GLOBE_SW_800", + "description": "GLOBE VALVE, FORGED, SW, 800LB, A182 F316", + "main_nom": "2\"" + }, + { + "name": "체크 밸브 (주조)", + "dat_file": "CHK_FL_150", + "description": "CHECK VALVE, SWING TYPE, FLANGED, 150LB, WCB", + "main_nom": "8\"" + }, + { + "name": "체크 밸브 (단조)", + "dat_file": "CHK_SW_3000", + "description": "CHECK VALVE, LIFT TYPE, SW, 3000LB, A105", + "main_nom": "1\"" + }, + { + "name": "니들 밸브 (단조)", + "dat_file": "NEEDLE_THD_6000", + "description": "NEEDLE VALVE, FORGED, THD, 6000LB, SS316", + "main_nom": "1/2\"" + }, + { + "name": "버터플라이 밸브", + "dat_file": "BUTTERFLY_WAF_150", + "description": "BUTTERFLY VALVE, WAFER TYPE, 150LB, GEAR OPERATED", + "main_nom": "12\"" + }, + { + "name": "릴리프 밸브", + "dat_file": "RELIEF_FL_150", + "description": "RELIEF VALVE, SET PRESSURE 150 PSI, FLANGED, 150LB", + "main_nom": "3\"" + }, + { + "name": "솔레노이드 밸브", + "dat_file": "SOLENOID_THD", + "description": "SOLENOID VALVE, 2-WAY, 24VDC, 1/4 NPT", + "main_nom": "1/4\"" + } + ] + + print("🔧 VALVE 분류 테스트 시작\n") + print("=" * 80) + + for i, test in enumerate(test_cases, 1): + print(f"\n테스트 {i}: {test['name']}") + print("-" * 60) + + result = classify_valve( + test["dat_file"], + test["description"], + test["main_nom"] + ) + + purchase_info = get_valve_purchase_info(result) + + print(f"📋 입력:") + print(f" DAT_FILE: {test['dat_file']}") + print(f" DESCRIPTION: {test['description']}") + print(f" SIZE: {result['size_info']['size_description']}") + + print(f"\n🔧 분류 결과:") + print(f" 재질: {result['material']['standard']} | {result['material']['grade']}") + print(f" 밸브타입: {result['valve_type']['type']}") + print(f" 특성: {result['valve_type']['characteristics']}") + print(f" 연결방식: {result['connection_method']['method']}") + print(f" 압력등급: {result['pressure_rating']['rating']}") + print(f" 작동방식: {result['actuation']['method']}") + print(f" 제작방법: {result['manufacturing']['method']} ({'🔨 단조' if is_forged_valve(result) else '🏭 주조'})") + + if result['special_features']: + print(f" 특수기능: {', '.join(result['special_features'])}") + + print(f"\n📊 신뢰도:") + print(f" 전체신뢰도: {result['overall_confidence']}") + print(f" 재질: {result['material']['confidence']}") + print(f" 밸브타입: {result['valve_type']['confidence']}") + print(f" 연결방식: {result['connection_method']['confidence']}") + print(f" 압력등급: {result['pressure_rating']['confidence']}") + + print(f"\n🛒 구매 정보:") + print(f" 공급업체: {purchase_info['supplier_type']}") + print(f" 예상납기: {purchase_info['lead_time_estimate']}") + print(f" 구매카테고리: {purchase_info['purchase_category']}") + if purchase_info['special_requirements']: + print(f" 특수요구사항: {', '.join(purchase_info['special_requirements'])}") + + print(f"\n💾 저장될 데이터:") + print(f" VALVE_TYPE: {result['valve_type']['type']}") + print(f" CONNECTION: {result['connection_method']['method']}") + print(f" PRESSURE_RATING: {result['pressure_rating']['rating']}") + print(f" MANUFACTURING: {result['manufacturing']['method']}") + print(f" ACTUATION: {result['actuation']['method']}") + + if i < len(test_cases): + print("\n" + "=" * 80) + +if __name__ == "__main__": + test_valve_classification() diff --git a/tkeg/api/app/services/valve_classifier.py b/tkeg/api/app/services/valve_classifier.py new file mode 100644 index 0000000..66d3f3a --- /dev/null +++ b/tkeg/api/app/services/valve_classifier.py @@ -0,0 +1,759 @@ +""" +VALVE 분류 시스템 +주조 밸브 + 단조 밸브 구분 포함 +""" + +import re +from typing import Dict, List, Optional +from .material_classifier import classify_material, get_manufacturing_method_from_material + +# ========== 밸브 타입별 분류 ========== +VALVE_TYPES = { + "GATE_VALVE": { + "dat_file_patterns": ["GATE_", "GV_"], + "description_keywords": ["GATE VALVE", "GATE", "게이트"], + "characteristics": "완전 개폐용, 직선 유로", + "typical_connections": ["FLANGED", "SOCKET_WELD", "BUTT_WELD"], + "pressure_range": "150LB ~ 2500LB", + "special_features": ["OS&Y", "RS", "NRS"] + }, + + "BALL_VALVE": { + "dat_file_patterns": ["BALL_", "BV_"], + "description_keywords": ["BALL VALVE", "BALL", "볼밸브"], + "characteristics": "빠른 개폐, 낮은 압력손실", + "typical_connections": ["FLANGED", "THREADED", "SOCKET_WELD"], + "pressure_range": "150LB ~ 6000LB", + "special_features": ["FULL_PORT", "REDUCED_PORT", "3_WAY", "4_WAY"] + }, + + "GLOBE_VALVE": { + "dat_file_patterns": ["GLOBE_", "GLV_"], + "description_keywords": ["GLOBE VALVE", "GLOBE", "글로브"], + "characteristics": "유량 조절용, 정밀 제어", + "typical_connections": ["FLANGED", "SOCKET_WELD", "BUTT_WELD"], + "pressure_range": "150LB ~ 2500LB", + "special_features": ["ANGLE_TYPE", "Y_TYPE"] + }, + + "CHECK_VALVE": { + "dat_file_patterns": ["CHK_", "CHECK_", "CV_"], + "description_keywords": ["CHECK VALVE", "CHECK", "체크", "역지"], + "characteristics": "역류 방지용", + "typical_connections": ["FLANGED", "SOCKET_WELD", "WAFER"], + "pressure_range": "150LB ~ 2500LB", + "special_features": ["SWING_TYPE", "LIFT_TYPE", "DUAL_PLATE", "PISTON_TYPE"] + }, + + "BUTTERFLY_VALVE": { + "dat_file_patterns": ["BUTTERFLY_", "BFV_"], + "description_keywords": ["BUTTERFLY VALVE", "BUTTERFLY", "버터플라이"], + "characteristics": "대구경용, 경량", + "typical_connections": ["WAFER", "LUG", "FLANGED"], + "pressure_range": "150LB ~ 600LB", + "special_features": ["GEAR_OPERATED", "LEVER_OPERATED"] + }, + + "NEEDLE_VALVE": { + "dat_file_patterns": ["NEEDLE_", "NV_"], + "description_keywords": ["NEEDLE VALVE", "NEEDLE", "니들"], + "characteristics": "정밀 유량 조절용", + "typical_connections": ["THREADED", "SOCKET_WELD"], + "pressure_range": "800LB ~ 6000LB", + "special_features": ["FINE_ADJUSTMENT"], + "typically_forged": True + }, + + "RELIEF_VALVE": { + "dat_file_patterns": ["RELIEF_", "RV_", "PSV_"], + "description_keywords": ["RELIEF VALVE", "PRESSURE RELIEF", "SAFETY VALVE", "PSV", "압력 안전", "릴리프"], + "characteristics": "안전 압력 방출용", + "typical_connections": ["FLANGED", "THREADED"], + "pressure_range": "150LB ~ 2500LB", + "special_features": ["SET_PRESSURE", "PILOT_OPERATED"] + }, + + "SOLENOID_VALVE": { + "dat_file_patterns": ["SOLENOID_", "SOL_"], + "description_keywords": ["SOLENOID VALVE", "SOLENOID", "솔레노이드"], + "characteristics": "전기 제어용", + "typical_connections": ["THREADED"], + "pressure_range": "150LB ~ 600LB", + "special_features": ["2_WAY", "3_WAY", "NC", "NO"] + }, + + "PLUG_VALVE": { + "dat_file_patterns": ["PLUG_", "PV_"], + "description_keywords": ["PLUG VALVE", "PLUG", "플러그"], + "characteristics": "다방향 제어용", + "typical_connections": ["FLANGED", "THREADED"], + "pressure_range": "150LB ~ 600LB", + "special_features": ["LUBRICATED", "NON_LUBRICATED"] + }, + + "SIGHT_GLASS": { + "dat_file_patterns": ["SIGHT_", "SG_"], + "description_keywords": ["SIGHT GLASS", "SIGHT", "사이트글라스", "사이트 글라스"], + "characteristics": "유체 확인용 관찰창", + "typical_connections": ["FLANGED", "THREADED"], + "pressure_range": "150LB ~ 600LB", + "special_features": ["TRANSPARENT", "VISUAL_INSPECTION"] + }, + + "STRAINER": { + "dat_file_patterns": ["STRAINER_", "STR_"], + "description_keywords": ["STRAINER", "스트레이너", "여과기"], + "characteristics": "이물질 여과용", + "typical_connections": ["FLANGED", "THREADED"], + "pressure_range": "150LB ~ 600LB", + "special_features": ["MESH_FILTER", "Y_TYPE", "BASKET_TYPE"] + } +} + +# ========== 연결 방식별 분류 ========== +VALVE_CONNECTIONS = { + "FLANGED": { + "codes": ["FL", "FLANGED", "플랜지"], + "dat_patterns": ["_FL_"], + "size_range": "1\" ~ 48\"", + "pressure_range": "150LB ~ 2500LB", + "manufacturing": "CAST", + "confidence": 0.95 + }, + "THREADED": { + "codes": ["THD", "THRD", "NPT", "THREADED", "나사"], + "dat_patterns": ["_THD_", "_TR_"], + "size_range": "1/4\" ~ 4\"", + "pressure_range": "150LB ~ 6000LB", + "manufacturing": "FORGED_OR_CAST", + "confidence": 0.95 + }, + "SOCKET_WELD": { + "codes": ["SW", "SOCKET WELD", "소켓웰드"], + "dat_patterns": ["_SW_"], + "size_range": "1/4\" ~ 4\"", + "pressure_range": "800LB ~ 9000LB", + "manufacturing": "FORGED", + "confidence": 0.95 + }, + "BUTT_WELD": { + "codes": ["BW", "BUTT WELD", "맞대기용접"], + "dat_patterns": ["_BW_"], + "size_range": "1/2\" ~ 48\"", + "pressure_range": "150LB ~ 2500LB", + "manufacturing": "CAST_OR_FORGED", + "confidence": 0.95 + }, + "WAFER": { + "codes": ["WAFER", "WAFER TYPE", "웨이퍼"], + "dat_patterns": ["_WAF_"], + "size_range": "2\" ~ 48\"", + "pressure_range": "150LB ~ 600LB", + "manufacturing": "CAST", + "confidence": 0.9 + }, + "LUG": { + "codes": ["LUG", "LUG TYPE", "러그"], + "dat_patterns": ["_LUG_"], + "size_range": "2\" ~ 48\"", + "pressure_range": "150LB ~ 600LB", + "manufacturing": "CAST", + "confidence": 0.9 + } +} + +# ========== 작동 방식별 분류 ========== +VALVE_ACTUATION = { + "MANUAL": { + "keywords": ["MANUAL", "HAND WHEEL", "LEVER", "수동"], + "characteristics": "수동 조작", + "applications": "일반 개폐용" + }, + "GEAR_OPERATED": { + "keywords": ["GEAR OPERATED", "GEAR", "기어"], + "characteristics": "기어 구동", + "applications": "대구경 수동 조작" + }, + "PNEUMATIC": { + "keywords": ["PNEUMATIC", "AIR OPERATED", "공압"], + "characteristics": "공압 구동", + "applications": "자동 제어" + }, + "ELECTRIC": { + "keywords": ["ELECTRIC", "MOTOR OPERATED", "전동"], + "characteristics": "전동 구동", + "applications": "원격 제어" + }, + "SOLENOID": { + "keywords": ["SOLENOID", "솔레노이드"], + "characteristics": "전자 밸브", + "applications": "빠른 전기 제어" + } +} + +# ========== 압력 등급별 분류 ========== +VALVE_PRESSURE_RATINGS = { + "patterns": [ + r"(\d+)LB", + r"CLASS\s*(\d+)", + r"CL\s*(\d+)", + r"(\d+)#", + r"(\d+)\s*WOG" # Water Oil Gas + ], + "standard_ratings": { + "150LB": {"max_pressure": "285 PSI", "typical_manufacturing": "CAST"}, + "300LB": {"max_pressure": "740 PSI", "typical_manufacturing": "CAST"}, + "600LB": {"max_pressure": "1480 PSI", "typical_manufacturing": "CAST_OR_FORGED"}, + "800LB": {"max_pressure": "2000 PSI", "typical_manufacturing": "FORGED"}, + "900LB": {"max_pressure": "2220 PSI", "typical_manufacturing": "CAST_OR_FORGED"}, + "1500LB": {"max_pressure": "3705 PSI", "typical_manufacturing": "FORGED"}, + "2500LB": {"max_pressure": "6170 PSI", "typical_manufacturing": "FORGED"}, + "3000LB": {"max_pressure": "7400 PSI", "typical_manufacturing": "FORGED"}, + "6000LB": {"max_pressure": "14800 PSI", "typical_manufacturing": "FORGED"}, + "9000LB": {"max_pressure": "22200 PSI", "typical_manufacturing": "FORGED"} + } +} + +def classify_valve(dat_file: str, description: str, main_nom: str, length: float = None) -> Dict: + """ + 완전한 VALVE 분류 + + Args: + dat_file: DAT_FILE 필드 + description: DESCRIPTION 필드 + main_nom: MAIN_NOM 필드 (사이즈) + + Returns: + 완전한 밸브 분류 결과 + """ + + desc_upper = description.upper() + dat_upper = dat_file.upper() + + # 1. 사이트 글라스와 스트레이너 우선 확인 + if 'SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or '사이트글라스' in desc_upper or '스트레이너' in desc_upper: + # 사이트 글라스와 스트레이너는 항상 밸브로 분류 + pass + + # 밸브 키워드 확인 (재질만 있어도 통합 분류기가 이미 밸브로 분류했으므로 진행) + valve_keywords = ['VALVE', 'GATE', 'BALL', 'GLOBE', 'CHECK', 'BUTTERFLY', 'NEEDLE', 'RELIEF', 'SOLENOID', 'SIGHT GLASS', 'STRAINER', '밸브', '게이트', '볼', '글로브', '체크', '버터플라이', '니들', '릴리프', '솔레노이드', '사이트글라스', '스트레이너'] + has_valve_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in valve_keywords) + + # 밸브 재질 확인 (A216, A217, A351, A352) + valve_materials = ['A216', 'A217', 'A351', 'A352'] + has_valve_material = any(material in desc_upper for material in valve_materials) + + # 밸브 키워드도 없고 밸브 재질도 없으면 UNKNOWN + if not has_valve_keyword and not has_valve_material: + return { + "category": "UNKNOWN", + "overall_confidence": 0.0, + "reason": "밸브 키워드 및 재질 없음" + } + + # 2. 재질 분류 (공통 모듈 사용) + material_result = classify_material(description) + + # 2. 밸브 타입 분류 + valve_type_result = classify_valve_type(dat_file, description) + + # 3. 연결 방식 분류 + connection_result = classify_valve_connection(dat_file, description) + + # 4. 압력 등급 분류 + pressure_result = classify_valve_pressure_rating(dat_file, description) + + # 5. 작동 방식 분류 + actuation_result = classify_valve_actuation(description) + + # 6. 제작 방법 결정 (주조 vs 단조) + manufacturing_result = determine_valve_manufacturing( + material_result, valve_type_result, connection_result, + pressure_result, main_nom + ) + + # 7. 특수 기능 추출 + special_features = extract_valve_special_features(description, valve_type_result) + + # 8. 최종 결과 조합 + return { + "category": "VALVE", + + # 재질 정보 (공통 모듈) + "material": { + "standard": material_result.get('standard', 'UNKNOWN'), + "grade": material_result.get('grade', 'UNKNOWN'), + "material_type": material_result.get('material_type', 'UNKNOWN'), + "confidence": material_result.get('confidence', 0.0) + }, + + # 밸브 분류 정보 + "valve_type": { + "type": valve_type_result.get('type', 'UNKNOWN'), + "characteristics": valve_type_result.get('characteristics', ''), + "confidence": valve_type_result.get('confidence', 0.0), + "evidence": valve_type_result.get('evidence', []) + }, + + "connection_method": { + "method": connection_result.get('method', 'UNKNOWN'), + "confidence": connection_result.get('confidence', 0.0), + "size_range": connection_result.get('size_range', ''), + "pressure_range": connection_result.get('pressure_range', '') + }, + + "pressure_rating": { + "rating": pressure_result.get('rating', 'UNKNOWN'), + "confidence": pressure_result.get('confidence', 0.0), + "max_pressure": pressure_result.get('max_pressure', ''), + "typical_manufacturing": pressure_result.get('typical_manufacturing', '') + }, + + "actuation": { + "method": actuation_result.get('method', 'MANUAL'), + "characteristics": actuation_result.get('characteristics', ''), + "confidence": actuation_result.get('confidence', 0.6) + }, + + "manufacturing": { + "method": manufacturing_result.get('method', 'UNKNOWN'), + "confidence": manufacturing_result.get('confidence', 0.0), + "evidence": manufacturing_result.get('evidence', []), + "characteristics": manufacturing_result.get('characteristics', '') + }, + + "special_features": special_features, + + "size_info": { + "valve_size": main_nom, + "size_description": main_nom + }, + + # 전체 신뢰도 + "overall_confidence": calculate_valve_confidence({ + "material": material_result.get('confidence', 0), + "valve_type": valve_type_result.get('confidence', 0), + "connection": connection_result.get('confidence', 0), + "pressure": pressure_result.get('confidence', 0) + }) + } + +def classify_valve_type(dat_file: str, description: str) -> Dict: + """밸브 타입 분류""" + + dat_upper = dat_file.upper() + desc_upper = description.upper() + + # 1. DAT_FILE 패턴으로 1차 분류 (가장 신뢰도 높음) + for valve_type, type_data in VALVE_TYPES.items(): + for pattern in type_data["dat_file_patterns"]: + if pattern in dat_upper: + return { + "type": valve_type, + "characteristics": type_data["characteristics"], + "confidence": 0.95, + "evidence": [f"DAT_FILE_PATTERN: {pattern}"], + "typical_connections": type_data["typical_connections"], + "special_features": type_data.get("special_features", []) + } + + # 2. DESCRIPTION 키워드로 2차 분류 + for valve_type, type_data in VALVE_TYPES.items(): + for keyword in type_data["description_keywords"]: + if keyword in desc_upper: + return { + "type": valve_type, + "characteristics": type_data["characteristics"], + "confidence": 0.85, + "evidence": [f"DESCRIPTION_KEYWORD: {keyword}"], + "typical_connections": type_data["typical_connections"], + "special_features": type_data.get("special_features", []) + } + + # 3. 분류 실패 + return { + "type": "UNKNOWN", + "characteristics": "", + "confidence": 0.0, + "evidence": ["NO_VALVE_TYPE_IDENTIFIED"], + "typical_connections": [], + "special_features": [] + } + +def classify_valve_connection(dat_file: str, description: str) -> Dict: + """밸브 연결 방식 분류""" + + dat_upper = dat_file.upper() + desc_upper = description.upper() + combined_text = f"{dat_upper} {desc_upper}" + + # 1. DAT_FILE 패턴 우선 확인 + for connection_type, conn_data in VALVE_CONNECTIONS.items(): + for pattern in conn_data["dat_patterns"]: + if pattern in dat_upper: + return { + "method": connection_type, + "confidence": 0.95, + "matched_pattern": pattern, + "source": "DAT_FILE_PATTERN", + "size_range": conn_data["size_range"], + "pressure_range": conn_data["pressure_range"], + "typical_manufacturing": conn_data["manufacturing"] + } + + # 2. 키워드 확인 + for connection_type, conn_data in VALVE_CONNECTIONS.items(): + for code in conn_data["codes"]: + if code in combined_text: + return { + "method": connection_type, + "confidence": conn_data["confidence"], + "matched_code": code, + "source": "KEYWORD_MATCH", + "size_range": conn_data["size_range"], + "pressure_range": conn_data["pressure_range"], + "typical_manufacturing": conn_data["manufacturing"] + } + + return { + "method": "UNKNOWN", + "confidence": 0.0, + "matched_code": "", + "source": "NO_CONNECTION_METHOD_FOUND" + } + +def classify_valve_pressure_rating(dat_file: str, description: str) -> Dict: + """밸브 압력 등급 분류""" + + combined_text = f"{dat_file} {description}".upper() + + # 패턴 매칭으로 압력 등급 추출 + for pattern in VALVE_PRESSURE_RATINGS["patterns"]: + match = re.search(pattern, combined_text) + if match: + rating_num = match.group(1) + + # WOG 처리 (Water Oil Gas) + if "WOG" in pattern: + rating = f"{rating_num}WOG" + # WOG를 LB로 변환 (대략적) + if int(rating_num) <= 600: + equivalent_lb = "150LB" + elif int(rating_num) <= 1000: + equivalent_lb = "300LB" + else: + equivalent_lb = "600LB" + + return { + "rating": f"{rating} ({equivalent_lb} 상당)", + "confidence": 0.8, + "matched_pattern": pattern, + "max_pressure": f"{rating_num} PSI", + "typical_manufacturing": "CAST_OR_FORGED" + } + else: + rating = f"{rating_num}LB" + + # 표준 등급 정보 확인 + rating_info = VALVE_PRESSURE_RATINGS["standard_ratings"].get(rating, {}) + + if rating_info: + confidence = 0.95 + else: + confidence = 0.8 + rating_info = { + "max_pressure": "확인 필요", + "typical_manufacturing": "UNKNOWN" + } + + return { + "rating": rating, + "confidence": confidence, + "matched_pattern": pattern, + "matched_value": rating_num, + "max_pressure": rating_info.get("max_pressure", ""), + "typical_manufacturing": rating_info.get("typical_manufacturing", "") + } + + return { + "rating": "UNKNOWN", + "confidence": 0.0, + "matched_pattern": "", + "max_pressure": "", + "typical_manufacturing": "" + } + +def classify_valve_actuation(description: str) -> Dict: + """밸브 작동 방식 분류""" + + desc_upper = description.upper() + + # 키워드 기반 작동 방식 분류 + for actuation_type, act_data in VALVE_ACTUATION.items(): + for keyword in act_data["keywords"]: + if keyword in desc_upper: + return { + "method": actuation_type, + "characteristics": act_data["characteristics"], + "confidence": 0.9, + "matched_keyword": keyword, + "applications": act_data["applications"] + } + + # 기본값: MANUAL + return { + "method": "MANUAL", + "characteristics": "수동 조작 (기본값)", + "confidence": 0.6, + "matched_keyword": "DEFAULT", + "applications": "일반 수동 조작" + } + +def determine_valve_manufacturing(material_result: Dict, valve_type_result: Dict, + connection_result: Dict, pressure_result: Dict, + main_nom: str) -> Dict: + """밸브 제작 방법 결정 (주조 vs 단조)""" + + evidence = [] + + # 1. 재질 기반 제작방법 (가장 확실) + material_manufacturing = get_manufacturing_method_from_material(material_result) + if material_manufacturing in ["FORGED", "CAST"]: + evidence.append(f"MATERIAL_STANDARD: {material_result.get('standard')}") + + characteristics = { + "FORGED": "단조품 - 고강도, 소구경, 고압용", + "CAST": "주조품 - 대구경, 복잡형상, 중저압용" + }.get(material_manufacturing, "") + + return { + "method": material_manufacturing, + "confidence": 0.9, + "evidence": evidence, + "characteristics": characteristics + } + + # 2. 단조 밸브 조건 확인 + forged_indicators = 0 + + # 연결방식이 소켓웰드 + connection_method = connection_result.get('method', '') + if connection_method == "SOCKET_WELD": + forged_indicators += 2 + evidence.append(f"SOCKET_WELD_CONNECTION") + + # 고압 등급 + pressure_rating = pressure_result.get('rating', '') + high_pressure = ["800LB", "1500LB", "2500LB", "3000LB", "6000LB", "9000LB"] + if any(pressure in pressure_rating for pressure in high_pressure): + forged_indicators += 2 + evidence.append(f"HIGH_PRESSURE: {pressure_rating}") + + # 소구경 + try: + size_num = float(re.findall(r'(\d+(?:\.\d+)?)', main_nom)[0]) + if size_num <= 4.0: + forged_indicators += 1 + evidence.append(f"SMALL_SIZE: {main_nom}") + except: + pass + + # 니들 밸브는 일반적으로 단조 + valve_type = valve_type_result.get('type', '') + if valve_type == "NEEDLE_VALVE": + forged_indicators += 2 + evidence.append("NEEDLE_VALVE_TYPICALLY_FORGED") + + # 단조 결정 + if forged_indicators >= 3: + return { + "method": "FORGED", + "confidence": 0.85, + "evidence": evidence, + "characteristics": "단조품 - 고압, 소구경용" + } + + # 3. 압력등급별 일반적 제작방법 + pressure_manufacturing = pressure_result.get('typical_manufacturing', '') + if pressure_manufacturing: + if pressure_manufacturing == "FORGED": + evidence.append(f"PRESSURE_BASED: {pressure_rating}") + return { + "method": "FORGED", + "confidence": 0.75, + "evidence": evidence, + "characteristics": "고압용 단조품" + } + elif pressure_manufacturing == "CAST": + evidence.append(f"PRESSURE_BASED: {pressure_rating}") + return { + "method": "CAST", + "confidence": 0.75, + "evidence": evidence, + "characteristics": "저중압용 주조품" + } + + # 4. 연결방식별 일반적 제작방법 + connection_manufacturing = connection_result.get('typical_manufacturing', '') + if connection_manufacturing: + evidence.append(f"CONNECTION_BASED: {connection_method}") + + if connection_manufacturing == "FORGED": + return { + "method": "FORGED", + "confidence": 0.7, + "evidence": evidence, + "characteristics": "소구경 단조품" + } + elif connection_manufacturing == "CAST": + return { + "method": "CAST", + "confidence": 0.7, + "evidence": evidence, + "characteristics": "대구경 주조품" + } + + # 5. 기본 추정 (사이즈 기반) + try: + size_num = float(re.findall(r'(\d+(?:\.\d+)?)', main_nom)[0]) + if size_num <= 2.0: + return { + "method": "FORGED", + "confidence": 0.6, + "evidence": ["SIZE_BASED_SMALL"], + "characteristics": "소구경 - 일반적으로 단조품" + } + else: + return { + "method": "CAST", + "confidence": 0.6, + "evidence": ["SIZE_BASED_LARGE"], + "characteristics": "대구경 - 일반적으로 주조품" + } + except: + pass + + return { + "method": "UNKNOWN", + "confidence": 0.0, + "evidence": ["INSUFFICIENT_MANUFACTURING_INFO"], + "characteristics": "" + } + +def extract_valve_special_features(description: str, valve_type_result: Dict) -> List[str]: + """밸브 특수 기능 추출""" + + desc_upper = description.upper() + features = [] + + # 밸브 타입별 특수 기능 + valve_special_features = valve_type_result.get('special_features', []) + for feature in valve_special_features: + # 기능별 키워드 매핑 + feature_keywords = { + "OS&Y": ["OS&Y", "OUTSIDE SCREW"], + "FULL_PORT": ["FULL PORT", "FULL BORE"], + "REDUCED_PORT": ["REDUCED PORT", "REDUCED BORE"], + "3_WAY": ["3 WAY", "3-WAY", "THREE WAY"], + "SWING_TYPE": ["SWING", "SWING TYPE"], + "LIFT_TYPE": ["LIFT", "LIFT TYPE"], + "GEAR_OPERATED": ["GEAR OPERATED", "GEAR"], + "SET_PRESSURE": ["SET PRESSURE", "SET @"] + } + + keywords = feature_keywords.get(feature, [feature]) + for keyword in keywords: + if keyword in desc_upper: + features.append(feature) + break + + # 일반적인 특수 기능들 + general_features = { + "FIRE_SAFE": ["FIRE SAFE", "FIRE-SAFE"], + "ANTI_STATIC": ["ANTI STATIC", "ANTI-STATIC"], + "BLOW_OUT_PROOF": ["BLOW OUT PROOF", "BOP"], + "EXTENDED_STEM": ["EXTENDED STEM", "EXT STEM"], + "CRYOGENIC": ["CRYOGENIC", "CRYO"], + "HIGH_TEMPERATURE": ["HIGH TEMP", "HT"] + } + + for feature, keywords in general_features.items(): + for keyword in keywords: + if keyword in desc_upper: + features.append(feature) + break + + return list(set(features)) # 중복 제거 + +def calculate_valve_confidence(confidence_scores: Dict) -> float: + """밸브 분류 전체 신뢰도 계산""" + + scores = [score for score in confidence_scores.values() if score > 0] + + if not scores: + return 0.0 + + # 가중 평균 + weights = { + "material": 0.2, + "valve_type": 0.4, + "connection": 0.25, + "pressure": 0.15 + } + + weighted_sum = sum( + confidence_scores.get(key, 0) * weight + for key, weight in weights.items() + ) + + return round(weighted_sum, 2) + +# ========== 특수 기능들 ========== + +def is_forged_valve(valve_result: Dict) -> bool: + """단조 밸브 여부 판단""" + return valve_result.get("manufacturing", {}).get("method") == "FORGED" + +def is_high_pressure_valve(valve_result: Dict) -> bool: + """고압 밸브 여부 판단""" + pressure_rating = valve_result.get("pressure_rating", {}).get("rating", "") + high_pressure_ratings = ["800LB", "1500LB", "2500LB", "3000LB", "6000LB", "9000LB"] + return any(pressure in pressure_rating for pressure in high_pressure_ratings) + +def get_valve_purchase_info(valve_result: Dict) -> Dict: + """밸브 구매 정보 생성""" + + valve_type = valve_result["valve_type"]["type"] + connection = valve_result["connection_method"]["method"] + pressure = valve_result["pressure_rating"]["rating"] + manufacturing = valve_result["manufacturing"]["method"] + actuation = valve_result["actuation"]["method"] + + # 공급업체 타입 결정 + if manufacturing == "FORGED": + supplier_type = "단조 밸브 전문업체" + elif valve_type == "BUTTERFLY_VALVE": + supplier_type = "버터플라이 밸브 전문업체" + elif actuation in ["PNEUMATIC", "ELECTRIC"]: + supplier_type = "자동 밸브 전문업체" + else: + supplier_type = "일반 밸브 업체" + + # 납기 추정 + if manufacturing == "FORGED" and is_high_pressure_valve(valve_result): + lead_time = "8-12주 (단조 고압용)" + elif actuation in ["PNEUMATIC", "ELECTRIC"]: + lead_time = "6-10주 (자동 밸브)" + elif manufacturing == "FORGED": + lead_time = "6-8주 (단조품)" + else: + lead_time = "4-8주 (일반품)" + + return { + "supplier_type": supplier_type, + "lead_time_estimate": lead_time, + "purchase_category": f"{valve_type} {connection} {pressure}", + "manufacturing_note": valve_result["manufacturing"]["characteristics"], + "actuation_note": valve_result["actuation"]["characteristics"], + "special_requirements": valve_result["special_features"] + } diff --git a/tkeg/api/app/utils/__init__.py b/tkeg/api/app/utils/__init__.py new file mode 100644 index 0000000..691c31b --- /dev/null +++ b/tkeg/api/app/utils/__init__.py @@ -0,0 +1,12 @@ +""" +유틸리티 모듈 +""" +from .logger import get_logger, setup_logger, app_logger +from .file_validator import file_validator, validate_uploaded_file +from .error_handlers import ErrorResponse, TKMPException, setup_error_handlers + +__all__ = [ + "get_logger", "setup_logger", "app_logger", + "file_validator", "validate_uploaded_file", + "ErrorResponse", "TKMPException", "setup_error_handlers" +] diff --git a/tkeg/api/app/utils/cache_manager.py b/tkeg/api/app/utils/cache_manager.py new file mode 100644 index 0000000..43ae1a2 --- /dev/null +++ b/tkeg/api/app/utils/cache_manager.py @@ -0,0 +1,266 @@ +""" +Redis 캐시 관리 유틸리티 +성능 향상을 위한 캐싱 전략 구현 +""" +import json +import redis +from typing import Any, Optional, Dict, List +from datetime import timedelta +import hashlib +import pickle + +from ..config import get_settings +from .logger import get_logger + +settings = get_settings() +logger = get_logger(__name__) + + +class CacheManager: + """Redis 캐시 관리 클래스""" + + def __init__(self): + try: + # Redis 연결 설정 + self.redis_client = redis.from_url( + settings.redis.url, + decode_responses=False, # 바이너리 데이터 지원 + socket_connect_timeout=5, + socket_timeout=5, + retry_on_timeout=True + ) + + # 연결 테스트 + self.redis_client.ping() + logger.info("Redis 연결 성공") + + except Exception as e: + logger.error(f"Redis 연결 실패: {e}") + self.redis_client = None + + def _generate_key(self, prefix: str, *args, **kwargs) -> str: + """캐시 키 생성""" + # 인자들을 문자열로 변환하여 해시 생성 + key_parts = [str(arg) for arg in args] + key_parts.extend([f"{k}:{v}" for k, v in sorted(kwargs.items())]) + + if key_parts: + key_hash = hashlib.md5("|".join(key_parts).encode()).hexdigest()[:8] + return f"tkmp:{prefix}:{key_hash}" + else: + return f"tkmp:{prefix}" + + def get(self, key: str) -> Optional[Any]: + """캐시에서 데이터 조회""" + if not self.redis_client: + return None + + try: + data = self.redis_client.get(key) + if data: + return pickle.loads(data) + return None + except Exception as e: + logger.warning(f"캐시 조회 실패 - key: {key}, error: {e}") + return None + + def set(self, key: str, value: Any, expire: int = 3600) -> bool: + """캐시에 데이터 저장""" + if not self.redis_client: + return False + + try: + serialized_data = pickle.dumps(value) + result = self.redis_client.setex(key, expire, serialized_data) + logger.debug(f"캐시 저장 - key: {key}, expire: {expire}s") + return result + except Exception as e: + logger.warning(f"캐시 저장 실패 - key: {key}, error: {e}") + return False + + def delete(self, key: str) -> bool: + """캐시에서 데이터 삭제""" + if not self.redis_client: + return False + + try: + result = self.redis_client.delete(key) + logger.debug(f"캐시 삭제 - key: {key}") + return bool(result) + except Exception as e: + logger.warning(f"캐시 삭제 실패 - key: {key}, error: {e}") + return False + + def delete_pattern(self, pattern: str) -> int: + """패턴에 맞는 캐시 키들 삭제""" + if not self.redis_client: + return 0 + + try: + keys = self.redis_client.keys(pattern) + if keys: + deleted = self.redis_client.delete(*keys) + logger.info(f"패턴 캐시 삭제 - pattern: {pattern}, deleted: {deleted}") + return deleted + return 0 + except Exception as e: + logger.warning(f"패턴 캐시 삭제 실패 - pattern: {pattern}, error: {e}") + return 0 + + def exists(self, key: str) -> bool: + """캐시 키 존재 여부 확인""" + if not self.redis_client: + return False + + try: + return bool(self.redis_client.exists(key)) + except Exception as e: + logger.warning(f"캐시 존재 확인 실패 - key: {key}, error: {e}") + return False + + def get_ttl(self, key: str) -> int: + """캐시 TTL 조회""" + if not self.redis_client: + return -1 + + try: + return self.redis_client.ttl(key) + except Exception as e: + logger.warning(f"캐시 TTL 조회 실패 - key: {key}, error: {e}") + return -1 + + +class TKMPCache: + """TK-MP 프로젝트 전용 캐시 래퍼""" + + def __init__(self): + self.cache = CacheManager() + + # 캐시 TTL 설정 (초 단위) + self.ttl_config = { + "file_list": 300, # 5분 - 파일 목록 + "material_list": 600, # 10분 - 자재 목록 + "job_list": 1800, # 30분 - 작업 목록 + "classification": 3600, # 1시간 - 분류 결과 + "statistics": 900, # 15분 - 통계 데이터 + "comparison": 1800, # 30분 - 리비전 비교 + } + + def get_file_list(self, job_no: Optional[str] = None, show_history: bool = False) -> Optional[List[Dict]]: + """파일 목록 캐시 조회""" + key = self.cache._generate_key("files", job_no=job_no, history=show_history) + return self.cache.get(key) + + def set_file_list(self, files: List[Dict], job_no: Optional[str] = None, show_history: bool = False) -> bool: + """파일 목록 캐시 저장""" + key = self.cache._generate_key("files", job_no=job_no, history=show_history) + return self.cache.set(key, files, self.ttl_config["file_list"]) + + def get_material_list(self, file_id: int) -> Optional[List[Dict]]: + """자재 목록 캐시 조회""" + key = self.cache._generate_key("materials", file_id=file_id) + return self.cache.get(key) + + def set_material_list(self, materials: List[Dict], file_id: int) -> bool: + """자재 목록 캐시 저장""" + key = self.cache._generate_key("materials", file_id=file_id) + return self.cache.set(key, materials, self.ttl_config["material_list"]) + + def get_job_list(self) -> Optional[List[Dict]]: + """작업 목록 캐시 조회""" + key = self.cache._generate_key("jobs") + return self.cache.get(key) + + def set_job_list(self, jobs: List[Dict]) -> bool: + """작업 목록 캐시 저장""" + key = self.cache._generate_key("jobs") + return self.cache.set(key, jobs, self.ttl_config["job_list"]) + + def get_classification_result(self, description: str, category: str) -> Optional[Dict]: + """분류 결과 캐시 조회""" + key = self.cache._generate_key("classification", desc=description, cat=category) + return self.cache.get(key) + + def set_classification_result(self, result: Dict, description: str, category: str) -> bool: + """분류 결과 캐시 저장""" + key = self.cache._generate_key("classification", desc=description, cat=category) + return self.cache.set(key, result, self.ttl_config["classification"]) + + def get_statistics(self, job_no: str, stat_type: str) -> Optional[Dict]: + """통계 데이터 캐시 조회""" + key = self.cache._generate_key("stats", job_no=job_no, type=stat_type) + return self.cache.get(key) + + def set_statistics(self, stats: Dict, job_no: str, stat_type: str) -> bool: + """통계 데이터 캐시 저장""" + key = self.cache._generate_key("stats", job_no=job_no, type=stat_type) + return self.cache.set(key, stats, self.ttl_config["statistics"]) + + def get_revision_comparison(self, job_no: str, rev1: str, rev2: str) -> Optional[Dict]: + """리비전 비교 결과 캐시 조회""" + key = self.cache._generate_key("comparison", job_no=job_no, rev1=rev1, rev2=rev2) + return self.cache.get(key) + + def set_revision_comparison(self, comparison: Dict, job_no: str, rev1: str, rev2: str) -> bool: + """리비전 비교 결과 캐시 저장""" + key = self.cache._generate_key("comparison", job_no=job_no, rev1=rev1, rev2=rev2) + return self.cache.set(key, comparison, self.ttl_config["comparison"]) + + def invalidate_job_cache(self, job_no: str): + """특정 작업의 모든 캐시 무효화""" + patterns = [ + f"tkmp:files:*job_no:{job_no}*", + f"tkmp:materials:*job_no:{job_no}*", + f"tkmp:stats:*job_no:{job_no}*", + f"tkmp:comparison:*job_no:{job_no}*" + ] + + total_deleted = 0 + for pattern in patterns: + deleted = self.cache.delete_pattern(pattern) + total_deleted += deleted + + logger.info(f"작업 캐시 무효화 완료 - job_no: {job_no}, deleted: {total_deleted}") + return total_deleted + + def invalidate_file_cache(self, file_id: int): + """특정 파일의 모든 캐시 무효화""" + patterns = [ + f"tkmp:materials:*file_id:{file_id}*", + f"tkmp:files:*" # 파일 목록도 갱신 필요 + ] + + total_deleted = 0 + for pattern in patterns: + deleted = self.cache.delete_pattern(pattern) + total_deleted += deleted + + logger.info(f"파일 캐시 무효화 완료 - file_id: {file_id}, deleted: {total_deleted}") + return total_deleted + + def get_cache_info(self) -> Dict[str, Any]: + """캐시 상태 정보 조회""" + if not self.cache.redis_client: + return {"status": "disconnected"} + + try: + info = self.cache.redis_client.info() + return { + "status": "connected", + "used_memory": info.get("used_memory_human", "N/A"), + "connected_clients": info.get("connected_clients", 0), + "total_commands_processed": info.get("total_commands_processed", 0), + "keyspace_hits": info.get("keyspace_hits", 0), + "keyspace_misses": info.get("keyspace_misses", 0), + "hit_rate": round( + info.get("keyspace_hits", 0) / + max(info.get("keyspace_hits", 0) + info.get("keyspace_misses", 0), 1) * 100, 2 + ) + } + except Exception as e: + logger.error(f"캐시 정보 조회 실패: {e}") + return {"status": "error", "error": str(e)} + + +# 전역 캐시 인스턴스 +tkmp_cache = TKMPCache() diff --git a/tkeg/api/app/utils/error_handlers.py b/tkeg/api/app/utils/error_handlers.py new file mode 100644 index 0000000..090ca60 --- /dev/null +++ b/tkeg/api/app/utils/error_handlers.py @@ -0,0 +1,139 @@ +""" +에러 처리 유틸리티 +표준화된 에러 응답 및 예외 처리 +""" +from fastapi import HTTPException, Request +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from sqlalchemy.exc import SQLAlchemyError +from typing import Dict, Any +import traceback + +from .logger import get_logger + +logger = get_logger(__name__) + + +class TKMPException(Exception): + """TK-MP 프로젝트 커스텀 예외""" + + def __init__(self, message: str, error_code: str = "TKMP_ERROR", status_code: int = 500): + self.message = message + self.error_code = error_code + self.status_code = status_code + super().__init__(self.message) + + +class ErrorResponse: + """표준화된 에러 응답 생성기""" + + @staticmethod + def create_error_response( + message: str, + error_code: str = "INTERNAL_ERROR", + status_code: int = 500, + details: Dict[str, Any] = None + ) -> Dict[str, Any]: + """표준화된 에러 응답 생성""" + response = { + "success": False, + "error": { + "code": error_code, + "message": message, + "timestamp": "2025-01-01T00:00:00Z" # 실제로는 datetime.utcnow().isoformat() + } + } + + if details: + response["error"]["details"] = details + + return response + + @staticmethod + def validation_error_response(errors: list) -> Dict[str, Any]: + """검증 에러 응답""" + return ErrorResponse.create_error_response( + message="입력 데이터 검증에 실패했습니다.", + error_code="VALIDATION_ERROR", + status_code=422, + details={"validation_errors": errors} + ) + + @staticmethod + def database_error_response(error: str) -> Dict[str, Any]: + """데이터베이스 에러 응답""" + return ErrorResponse.create_error_response( + message="데이터베이스 작업 중 오류가 발생했습니다.", + error_code="DATABASE_ERROR", + status_code=500, + details={"db_error": error} + ) + + @staticmethod + def file_error_response(error: str) -> Dict[str, Any]: + """파일 처리 에러 응답""" + return ErrorResponse.create_error_response( + message="파일 처리 중 오류가 발생했습니다.", + error_code="FILE_ERROR", + status_code=400, + details={"file_error": error} + ) + + +async def tkmp_exception_handler(request: Request, exc: TKMPException): + """TK-MP 커스텀 예외 핸들러""" + logger.error(f"TK-MP 예외 발생: {exc.message} (코드: {exc.error_code})") + + return JSONResponse( + status_code=exc.status_code, + content=ErrorResponse.create_error_response( + message=exc.message, + error_code=exc.error_code, + status_code=exc.status_code + ) + ) + + +async def validation_exception_handler(request: Request, exc: RequestValidationError): + """검증 예외 핸들러""" + logger.warning(f"검증 오류: {exc.errors()}") + + return JSONResponse( + status_code=422, + content=ErrorResponse.validation_error_response(exc.errors()) + ) + + +async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError): + """SQLAlchemy 예외 핸들러""" + logger.error(f"데이터베이스 오류: {str(exc)}", exc_info=True) + + return JSONResponse( + status_code=500, + content=ErrorResponse.database_error_response(str(exc)) + ) + + +async def general_exception_handler(request: Request, exc: Exception): + """일반 예외 핸들러""" + logger.error(f"예상치 못한 오류: {str(exc)}", exc_info=True) + + return JSONResponse( + status_code=500, + content=ErrorResponse.create_error_response( + message="서버 내부 오류가 발생했습니다.", + error_code="INTERNAL_SERVER_ERROR", + status_code=500, + details={"error": str(exc)} if logger.level <= 10 else None # DEBUG 레벨일 때만 상세 에러 표시 + ) + ) + + +def setup_error_handlers(app): + """FastAPI 앱에 에러 핸들러 등록""" + app.add_exception_handler(TKMPException, tkmp_exception_handler) + app.add_exception_handler(RequestValidationError, validation_exception_handler) + app.add_exception_handler(SQLAlchemyError, sqlalchemy_exception_handler) + app.add_exception_handler(Exception, general_exception_handler) + + logger.info("에러 핸들러 등록 완료") diff --git a/tkeg/api/app/utils/file_processor.py b/tkeg/api/app/utils/file_processor.py new file mode 100644 index 0000000..e83aec0 --- /dev/null +++ b/tkeg/api/app/utils/file_processor.py @@ -0,0 +1,335 @@ +""" +대용량 파일 처리 최적화 유틸리티 +메모리 효율적인 파일 처리 및 청크 기반 처리 +""" +import pandas as pd +import asyncio +from typing import Iterator, List, Dict, Any, Optional, Callable +from pathlib import Path +import tempfile +import os +from concurrent.futures import ThreadPoolExecutor +import gc + +from .logger import get_logger +from ..config import get_settings + +logger = get_logger(__name__) +settings = get_settings() + + +class FileProcessor: + """대용량 파일 처리 최적화 클래스""" + + def __init__(self, chunk_size: int = 1000, max_workers: int = 4): + self.chunk_size = chunk_size + self.max_workers = max_workers + self.executor = ThreadPoolExecutor(max_workers=max_workers) + + def read_excel_chunks(self, file_path: str, sheet_name: str = None) -> Iterator[pd.DataFrame]: + """ + 엑셀 파일을 청크 단위로 읽기 + + Args: + file_path: 파일 경로 + sheet_name: 시트명 (None이면 첫 번째 시트) + + Yields: + DataFrame: 청크 단위 데이터 + """ + try: + # 파일 크기 확인 + file_size = os.path.getsize(file_path) + logger.info(f"엑셀 파일 처리 시작 - 파일: {file_path}, 크기: {file_size} bytes") + + # 전체 행 수 확인 (메모리 효율적으로) + with pd.ExcelFile(file_path) as xls: + if sheet_name is None: + sheet_name = xls.sheet_names[0] + + # 첫 번째 청크로 컬럼 정보 확인 + first_chunk = pd.read_excel(xls, sheet_name=sheet_name, nrows=self.chunk_size) + total_rows = len(first_chunk) + + # 전체 데이터를 청크로 나누어 처리 + processed_rows = 0 + chunk_num = 0 + + while processed_rows < total_rows: + try: + # 청크 읽기 + chunk = pd.read_excel( + xls, + sheet_name=sheet_name, + skiprows=processed_rows + 1 if processed_rows > 0 else 0, + nrows=self.chunk_size, + header=0 if processed_rows == 0 else None + ) + + if chunk.empty: + break + + # 첫 번째 청크가 아닌 경우 컬럼명 설정 + if processed_rows > 0: + chunk.columns = first_chunk.columns + + chunk_num += 1 + processed_rows += len(chunk) + + logger.debug(f"청크 {chunk_num} 처리 - 행 수: {len(chunk)}, 누적: {processed_rows}") + + yield chunk + + # 메모리 정리 + del chunk + gc.collect() + + except Exception as e: + logger.error(f"청크 {chunk_num} 처리 중 오류: {e}") + break + + logger.info(f"엑셀 파일 처리 완료 - 총 {chunk_num}개 청크, {processed_rows}행 처리") + + except Exception as e: + logger.error(f"엑셀 파일 읽기 실패: {e}") + raise + + def read_csv_chunks(self, file_path: str, encoding: str = 'utf-8') -> Iterator[pd.DataFrame]: + """ + CSV 파일을 청크 단위로 읽기 + + Args: + file_path: 파일 경로 + encoding: 인코딩 (기본: utf-8) + + Yields: + DataFrame: 청크 단위 데이터 + """ + try: + file_size = os.path.getsize(file_path) + logger.info(f"CSV 파일 처리 시작 - 파일: {file_path}, 크기: {file_size} bytes") + + chunk_num = 0 + total_rows = 0 + + # pandas의 chunksize 옵션 사용 + for chunk in pd.read_csv(file_path, chunksize=self.chunk_size, encoding=encoding): + chunk_num += 1 + total_rows += len(chunk) + + logger.debug(f"CSV 청크 {chunk_num} 처리 - 행 수: {len(chunk)}, 누적: {total_rows}") + + yield chunk + + # 메모리 정리 + gc.collect() + + logger.info(f"CSV 파일 처리 완료 - 총 {chunk_num}개 청크, {total_rows}행 처리") + + except Exception as e: + logger.error(f"CSV 파일 읽기 실패: {e}") + raise + + async def process_file_async( + self, + file_path: str, + processor_func: Callable[[pd.DataFrame], List[Dict]], + file_type: str = "excel" + ) -> List[Dict]: + """ + 파일을 비동기적으로 처리 + + Args: + file_path: 파일 경로 + processor_func: 각 청크를 처리할 함수 + file_type: 파일 타입 ("excel" 또는 "csv") + + Returns: + List[Dict]: 처리된 결과 리스트 + """ + try: + logger.info(f"비동기 파일 처리 시작 - {file_path}") + + results = [] + chunk_futures = [] + + # 파일 타입에 따른 청크 리더 선택 + if file_type.lower() == "csv": + chunk_reader = self.read_csv_chunks(file_path) + else: + chunk_reader = self.read_excel_chunks(file_path) + + # 청크별 비동기 처리 + for chunk in chunk_reader: + # 스레드 풀에서 청크 처리 + future = asyncio.get_event_loop().run_in_executor( + self.executor, + processor_func, + chunk + ) + chunk_futures.append(future) + + # 너무 많은 청크가 동시에 처리되지 않도록 제한 + if len(chunk_futures) >= self.max_workers: + # 완료된 작업들 수집 + completed_results = await asyncio.gather(*chunk_futures) + for result in completed_results: + if result: + results.extend(result) + + chunk_futures = [] + gc.collect() + + # 남은 청크들 처리 + if chunk_futures: + completed_results = await asyncio.gather(*chunk_futures) + for result in completed_results: + if result: + results.extend(result) + + logger.info(f"비동기 파일 처리 완료 - 총 {len(results)}개 항목 처리") + return results + + except Exception as e: + logger.error(f"비동기 파일 처리 실패: {e}") + raise + + def optimize_dataframe_memory(self, df: pd.DataFrame) -> pd.DataFrame: + """ + DataFrame 메모리 사용량 최적화 + + Args: + df: 최적화할 DataFrame + + Returns: + DataFrame: 최적화된 DataFrame + """ + try: + original_memory = df.memory_usage(deep=True).sum() + + # 수치형 컬럼 최적화 + for col in df.select_dtypes(include=['int64']).columns: + col_min = df[col].min() + col_max = df[col].max() + + if col_min >= -128 and col_max <= 127: + df[col] = df[col].astype('int8') + elif col_min >= -32768 and col_max <= 32767: + df[col] = df[col].astype('int16') + elif col_min >= -2147483648 and col_max <= 2147483647: + df[col] = df[col].astype('int32') + + # 실수형 컬럼 최적화 + for col in df.select_dtypes(include=['float64']).columns: + df[col] = pd.to_numeric(df[col], downcast='float') + + # 문자열 컬럼 최적화 (카테고리형으로 변환) + for col in df.select_dtypes(include=['object']).columns: + if df[col].nunique() / len(df) < 0.5: # 고유값이 50% 미만인 경우 + df[col] = df[col].astype('category') + + optimized_memory = df.memory_usage(deep=True).sum() + memory_reduction = (original_memory - optimized_memory) / original_memory * 100 + + logger.debug(f"DataFrame 메모리 최적화 완료 - 감소율: {memory_reduction:.1f}%") + + return df + + except Exception as e: + logger.warning(f"DataFrame 메모리 최적화 실패: {e}") + return df + + def create_temp_file(self, suffix: str = '.tmp') -> str: + """ + 임시 파일 생성 + + Args: + suffix: 파일 확장자 + + Returns: + str: 임시 파일 경로 + """ + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) + temp_file.close() + logger.debug(f"임시 파일 생성: {temp_file.name}") + return temp_file.name + + def cleanup_temp_file(self, file_path: str): + """ + 임시 파일 정리 + + Args: + file_path: 삭제할 파일 경로 + """ + try: + if os.path.exists(file_path): + os.unlink(file_path) + logger.debug(f"임시 파일 삭제: {file_path}") + except Exception as e: + logger.warning(f"임시 파일 삭제 실패: {file_path}, error: {e}") + + def get_file_info(self, file_path: str) -> Dict[str, Any]: + """ + 파일 정보 조회 + + Args: + file_path: 파일 경로 + + Returns: + Dict: 파일 정보 + """ + try: + file_stat = os.stat(file_path) + file_ext = Path(file_path).suffix.lower() + + info = { + "file_path": file_path, + "file_size": file_stat.st_size, + "file_size_mb": round(file_stat.st_size / (1024 * 1024), 2), + "file_extension": file_ext, + "is_large_file": file_stat.st_size > 10 * 1024 * 1024, # 10MB 이상 + "recommended_chunk_size": self._calculate_optimal_chunk_size(file_stat.st_size) + } + + # 파일 타입별 추가 정보 + if file_ext in ['.xlsx', '.xls']: + info["file_type"] = "excel" + info["processing_method"] = "chunk_based" if info["is_large_file"] else "full_load" + elif file_ext == '.csv': + info["file_type"] = "csv" + info["processing_method"] = "chunk_based" if info["is_large_file"] else "full_load" + + return info + + except Exception as e: + logger.error(f"파일 정보 조회 실패: {e}") + return {"error": str(e)} + + def _calculate_optimal_chunk_size(self, file_size: int) -> int: + """ + 파일 크기에 따른 최적 청크 크기 계산 + + Args: + file_size: 파일 크기 (bytes) + + Returns: + int: 최적 청크 크기 + """ + # 파일 크기에 따른 청크 크기 조정 + if file_size < 1024 * 1024: # 1MB 미만 + return 500 + elif file_size < 10 * 1024 * 1024: # 10MB 미만 + return 1000 + elif file_size < 50 * 1024 * 1024: # 50MB 미만 + return 2000 + else: # 50MB 이상 + return 5000 + + def __del__(self): + """소멸자 - 스레드 풀 정리""" + if hasattr(self, 'executor'): + self.executor.shutdown(wait=True) + + +# 전역 파일 프로세서 인스턴스 +file_processor = FileProcessor() diff --git a/tkeg/api/app/utils/file_validator.py b/tkeg/api/app/utils/file_validator.py new file mode 100644 index 0000000..b3f5f96 --- /dev/null +++ b/tkeg/api/app/utils/file_validator.py @@ -0,0 +1,169 @@ +""" +파일 업로드 검증 유틸리티 +보안 강화를 위한 파일 검증 로직 +""" +import os +import magic +from pathlib import Path +from typing import List, Optional, Tuple +from fastapi import UploadFile, HTTPException + +from ..config import get_settings +from .logger import get_logger + +settings = get_settings() +logger = get_logger(__name__) + + +class FileValidator: + """파일 업로드 검증 클래스""" + + def __init__(self): + self.max_file_size = settings.security.max_file_size + self.allowed_extensions = settings.security.allowed_file_extensions + + # MIME 타입 매핑 + self.mime_type_mapping = { + '.xlsx': [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/octet-stream' # 일부 브라우저에서 xlsx를 이렇게 인식 + ], + '.xls': [ + 'application/vnd.ms-excel', + 'application/octet-stream' + ], + '.csv': [ + 'text/csv', + 'text/plain', + 'application/csv' + ] + } + + def validate_file_extension(self, filename: str) -> bool: + """파일 확장자 검증""" + file_ext = Path(filename).suffix.lower() + is_valid = file_ext in self.allowed_extensions + + if not is_valid: + logger.warning(f"허용되지 않은 파일 확장자: {file_ext}, 파일: {filename}") + + return is_valid + + def validate_file_size(self, file_size: int) -> bool: + """파일 크기 검증""" + is_valid = file_size <= self.max_file_size + + if not is_valid: + logger.warning(f"파일 크기 초과: {file_size} bytes (최대: {self.max_file_size} bytes)") + + return is_valid + + def validate_filename(self, filename: str) -> bool: + """파일명 검증 (보안 위험 문자 체크)""" + # 위험한 문자들 + dangerous_chars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|'] + + for char in dangerous_chars: + if char in filename: + logger.warning(f"위험한 문자 포함된 파일명: {filename}") + return False + + # 파일명 길이 체크 (255자 제한) + if len(filename) > 255: + logger.warning(f"파일명이 너무 긺: {len(filename)} 문자") + return False + + return True + + def validate_mime_type(self, file_content: bytes, filename: str) -> bool: + """MIME 타입 검증 (파일 내용 기반)""" + try: + # python-magic을 사용한 MIME 타입 검증 + detected_mime = magic.from_buffer(file_content, mime=True) + file_ext = Path(filename).suffix.lower() + + expected_mimes = self.mime_type_mapping.get(file_ext, []) + + if detected_mime in expected_mimes: + return True + + logger.warning(f"MIME 타입 불일치 - 파일: {filename}, 감지된 타입: {detected_mime}, 예상 타입: {expected_mimes}") + return False + + except Exception as e: + logger.error(f"MIME 타입 검증 실패: {e}") + # magic 라이브러리 오류 시 확장자 검증으로 대체 + return self.validate_file_extension(filename) + + def sanitize_filename(self, filename: str) -> str: + """파일명 정화 (안전한 파일명으로 변환)""" + # 위험한 문자들을 언더스코어로 대체 + dangerous_chars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|'] + sanitized = filename + + for char in dangerous_chars: + sanitized = sanitized.replace(char, '_') + + # 연속된 언더스코어 제거 + while '__' in sanitized: + sanitized = sanitized.replace('__', '_') + + # 앞뒤 공백 및 점 제거 + sanitized = sanitized.strip(' .') + + return sanitized + + async def validate_upload_file(self, file: UploadFile) -> Tuple[bool, Optional[str]]: + """ + 업로드 파일 종합 검증 + + Returns: + Tuple[bool, Optional[str]]: (검증 성공 여부, 에러 메시지) + """ + try: + # 1. 파일명 검증 + if not self.validate_filename(file.filename): + return False, f"유효하지 않은 파일명: {file.filename}" + + # 2. 확장자 검증 + if not self.validate_file_extension(file.filename): + return False, f"허용되지 않은 파일 형식입니다. 허용 형식: {', '.join(self.allowed_extensions)}" + + # 3. 파일 내용 읽기 + file_content = await file.read() + await file.seek(0) # 파일 포인터 리셋 + + # 4. 파일 크기 검증 + if not self.validate_file_size(len(file_content)): + return False, f"파일 크기가 너무 큽니다. 최대 크기: {self.max_file_size // (1024*1024)}MB" + + # 5. MIME 타입 검증 + if not self.validate_mime_type(file_content, file.filename): + return False, "파일 형식이 올바르지 않습니다." + + logger.info(f"파일 검증 성공: {file.filename} ({len(file_content)} bytes)") + return True, None + + except Exception as e: + logger.error(f"파일 검증 중 오류 발생: {e}", exc_info=True) + return False, f"파일 검증 중 오류가 발생했습니다: {str(e)}" + + +# 전역 파일 검증기 인스턴스 +file_validator = FileValidator() + + +async def validate_uploaded_file(file: UploadFile) -> None: + """ + 파일 검증 헬퍼 함수 (HTTPException 발생) + + Args: + file: 업로드된 파일 + + Raises: + HTTPException: 검증 실패 시 + """ + is_valid, error_message = await file_validator.validate_upload_file(file) + + if not is_valid: + raise HTTPException(status_code=400, detail=error_message) diff --git a/tkeg/api/app/utils/logger.py b/tkeg/api/app/utils/logger.py new file mode 100644 index 0000000..aad30a4 --- /dev/null +++ b/tkeg/api/app/utils/logger.py @@ -0,0 +1,87 @@ +""" +로깅 유틸리티 모듈 +중앙화된 로깅 설정 및 관리 +""" +import logging +import os +from logging.handlers import RotatingFileHandler +from typing import Optional + +from ..config import get_settings + +settings = get_settings() + + +def setup_logger( + name: str, + log_file: Optional[str] = None, + level: str = None +) -> logging.Logger: + """ + 로거 설정 및 반환 + + Args: + name: 로거 이름 + log_file: 로그 파일 경로 (선택사항) + level: 로그 레벨 (선택사항) + + Returns: + 설정된 로거 인스턴스 + """ + logger = logging.getLogger(name) + + # 이미 핸들러가 설정된 경우 중복 방지 + if logger.handlers: + return logger + + # 로그 레벨 설정 + log_level = level or settings.logging.level + logger.setLevel(getattr(logging, log_level.upper())) + + # 포맷터 설정 + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s' + ) + + # 콘솔 핸들러 + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + # 파일 핸들러 (선택사항) + if log_file or settings.logging.file_path: + file_path = log_file or settings.logging.file_path + + # 로그 디렉토리 생성 + log_dir = os.path.dirname(file_path) + if log_dir and not os.path.exists(log_dir): + os.makedirs(log_dir, exist_ok=True) + + # 로테이팅 파일 핸들러 (10MB, 5개 파일 유지) + file_handler = RotatingFileHandler( + file_path, + maxBytes=10*1024*1024, # 10MB + backupCount=5, + encoding='utf-8' + ) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger + + +def get_logger(name: str) -> logging.Logger: + """ + 로거 인스턴스 반환 (간편 함수) + + Args: + name: 로거 이름 + + Returns: + 로거 인스턴스 + """ + return setup_logger(name) + + +# 애플리케이션 전역 로거 +app_logger = setup_logger("tk_mp_app", settings.logging.file_path) diff --git a/tkeg/api/app/utils/tkuser_client.py b/tkeg/api/app/utils/tkuser_client.py new file mode 100644 index 0000000..12ba777 --- /dev/null +++ b/tkeg/api/app/utils/tkuser_client.py @@ -0,0 +1,56 @@ +"""tkuser API 프록시 클라이언트 — tkeg""" +import httpx +import os +from typing import Optional +from fastapi import HTTPException, Request + +TKUSER_API_URL = os.getenv("TKUSER_API_URL", "http://tkuser-api:3000") + + +def get_token_from_request(request: Request) -> str: + auth = request.headers.get("Authorization", "") + if auth.startswith("Bearer "): + return auth[7:] + return request.cookies.get("sso_token", "") + + +def _headers(token: str) -> dict: + if not token: + raise HTTPException(status_code=401, detail="인증 토큰이 필요합니다.") + return {"Authorization": f"Bearer {token}"} + + +def _map_project(data: dict) -> dict: + return { + "id": data.get("project_id"), + "job_no": data.get("job_no"), + "project_name": data.get("project_name"), + "client_name": data.get("client_name"), + "is_active": data.get("is_active", True), + "created_at": data.get("created_at"), + } + + +async def get_projects(token: str, active_only: bool = True) -> list: + endpoint = "/api/projects/active" if active_only else "/api/projects" + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(f"{TKUSER_API_URL}{endpoint}", headers=_headers(token)) + if resp.status_code != 200: + raise HTTPException(status_code=resp.status_code, detail="프로젝트 목록 조회 실패") + body = resp.json() + if not body.get("success"): + raise HTTPException(status_code=500, detail=body.get("error", "알 수 없는 오류")) + return [_map_project(p) for p in body.get("data", [])] + + +async def get_project(token: str, project_id: int) -> Optional[dict]: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(f"{TKUSER_API_URL}/api/projects/{project_id}", headers=_headers(token)) + if resp.status_code == 404: + return None + if resp.status_code != 200: + raise HTTPException(status_code=resp.status_code, detail="프로젝트 조회 실패") + body = resp.json() + if not body.get("success"): + raise HTTPException(status_code=500, detail=body.get("error", "알 수 없는 오류")) + return _map_project(body.get("data")) diff --git a/tkeg/api/app/utils/transaction_manager.py b/tkeg/api/app/utils/transaction_manager.py new file mode 100644 index 0000000..8a2dfa4 --- /dev/null +++ b/tkeg/api/app/utils/transaction_manager.py @@ -0,0 +1,355 @@ +""" +트랜잭션 관리 유틸리티 +데이터 일관성을 위한 트랜잭션 관리 및 데코레이터 +""" +import functools +from typing import Any, Callable, Optional, TypeVar, Generic +from contextlib import contextmanager +from sqlalchemy.orm import Session +from sqlalchemy.exc import SQLAlchemyError +import asyncio + +from .logger import get_logger + +logger = get_logger(__name__) + +T = TypeVar('T') + + +class TransactionManager: + """트랜잭션 관리 클래스""" + + def __init__(self, db: Session): + self.db = db + + @contextmanager + def transaction(self, rollback_on_exception: bool = True): + """ + 트랜잭션 컨텍스트 매니저 + + Args: + rollback_on_exception: 예외 발생 시 롤백 여부 + """ + try: + logger.debug("트랜잭션 시작") + yield self.db + self.db.commit() + logger.debug("트랜잭션 커밋 완료") + + except Exception as e: + if rollback_on_exception: + self.db.rollback() + logger.warning(f"트랜잭션 롤백 - 에러: {str(e)}") + else: + logger.error(f"트랜잭션 에러 (롤백 안함) - 에러: {str(e)}") + raise + + @contextmanager + def savepoint(self, name: Optional[str] = None): + """ + 세이브포인트 컨텍스트 매니저 + + Args: + name: 세이브포인트 이름 + """ + savepoint_name = name or f"sp_{id(self)}" + + try: + # 세이브포인트 생성 + savepoint = self.db.begin_nested() + logger.debug(f"세이브포인트 생성: {savepoint_name}") + + yield self.db + + # 세이브포인트 커밋 + savepoint.commit() + logger.debug(f"세이브포인트 커밋: {savepoint_name}") + + except Exception as e: + # 세이브포인트 롤백 + savepoint.rollback() + logger.warning(f"세이브포인트 롤백: {savepoint_name} - 에러: {str(e)}") + raise + + def execute_in_transaction(self, func: Callable[..., T], *args, **kwargs) -> T: + """ + 함수를 트랜잭션 내에서 실행 + + Args: + func: 실행할 함수 + *args: 함수 인자 + **kwargs: 함수 키워드 인자 + + Returns: + 함수 실행 결과 + """ + with self.transaction(): + return func(*args, **kwargs) + + async def execute_in_transaction_async(self, func: Callable[..., T], *args, **kwargs) -> T: + """ + 비동기 함수를 트랜잭션 내에서 실행 + + Args: + func: 실행할 비동기 함수 + *args: 함수 인자 + **kwargs: 함수 키워드 인자 + + Returns: + 함수 실행 결과 + """ + with self.transaction(): + if asyncio.iscoroutinefunction(func): + return await func(*args, **kwargs) + else: + return func(*args, **kwargs) + + +def transactional(rollback_on_exception: bool = True): + """ + 트랜잭션 데코레이터 + + Args: + rollback_on_exception: 예외 발생 시 롤백 여부 + """ + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(*args, **kwargs): + # 첫 번째 인자가 Session인지 확인 + if args and isinstance(args[0], Session): + db = args[0] + transaction_manager = TransactionManager(db) + + try: + with transaction_manager.transaction(rollback_on_exception): + return func(*args, **kwargs) + except Exception as e: + logger.error(f"트랜잭션 함수 실행 실패: {func.__name__} - {str(e)}") + raise + else: + # Session이 없으면 일반 함수로 실행 + return func(*args, **kwargs) + + return wrapper + return decorator + + +def async_transactional(rollback_on_exception: bool = True): + """ + 비동기 트랜잭션 데코레이터 + + Args: + rollback_on_exception: 예외 발생 시 롤백 여부 + """ + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def wrapper(*args, **kwargs): + # 첫 번째 인자가 Session인지 확인 + if args and isinstance(args[0], Session): + db = args[0] + transaction_manager = TransactionManager(db) + + try: + with transaction_manager.transaction(rollback_on_exception): + if asyncio.iscoroutinefunction(func): + return await func(*args, **kwargs) + else: + return func(*args, **kwargs) + except Exception as e: + logger.error(f"비동기 트랜잭션 함수 실행 실패: {func.__name__} - {str(e)}") + raise + else: + # Session이 없으면 일반 함수로 실행 + if asyncio.iscoroutinefunction(func): + return await func(*args, **kwargs) + else: + return func(*args, **kwargs) + + return wrapper + return decorator + + +class BatchProcessor: + """배치 처리를 위한 트랜잭션 관리""" + + def __init__(self, db: Session, batch_size: int = 1000): + self.db = db + self.batch_size = batch_size + self.transaction_manager = TransactionManager(db) + + def process_in_batches( + self, + items: list, + process_func: Callable, + commit_per_batch: bool = True + ): + """ + 아이템들을 배치 단위로 처리 + + Args: + items: 처리할 아이템 리스트 + process_func: 각 아이템을 처리할 함수 + commit_per_batch: 배치마다 커밋 여부 + """ + total_items = len(items) + processed_count = 0 + failed_count = 0 + + logger.info(f"배치 처리 시작 - 총 {total_items}개 아이템, 배치 크기: {self.batch_size}") + + for i in range(0, total_items, self.batch_size): + batch = items[i:i + self.batch_size] + batch_num = (i // self.batch_size) + 1 + + try: + if commit_per_batch: + with self.transaction_manager.transaction(): + self._process_batch(batch, process_func) + else: + self._process_batch(batch, process_func) + + processed_count += len(batch) + logger.debug(f"배치 {batch_num} 처리 완료 - {len(batch)}개 아이템") + + except Exception as e: + failed_count += len(batch) + logger.error(f"배치 {batch_num} 처리 실패 - {str(e)}") + + # 개별 아이템 처리 시도 + if commit_per_batch: + self._process_batch_individually(batch, process_func) + + # 전체 커밋 (배치마다 커밋하지 않은 경우) + if not commit_per_batch: + try: + self.db.commit() + logger.info("전체 배치 처리 커밋 완료") + except Exception as e: + self.db.rollback() + logger.error(f"전체 배치 처리 커밋 실패: {str(e)}") + raise + + logger.info(f"배치 처리 완료 - 성공: {processed_count}, 실패: {failed_count}") + + return { + "total_items": total_items, + "processed_count": processed_count, + "failed_count": failed_count, + "success_rate": (processed_count / total_items) * 100 if total_items > 0 else 0 + } + + def _process_batch(self, batch: list, process_func: Callable): + """배치 처리""" + for item in batch: + process_func(item) + + def _process_batch_individually(self, batch: list, process_func: Callable): + """배치 내 아이템을 개별적으로 처리 (에러 복구용)""" + for item in batch: + try: + with self.transaction_manager.savepoint(): + process_func(item) + except Exception as e: + logger.warning(f"개별 아이템 처리 실패: {str(e)}") + + +class DatabaseLock: + """데이터베이스 레벨 락 관리""" + + def __init__(self, db: Session): + self.db = db + + @contextmanager + def advisory_lock(self, lock_id: int): + """ + PostgreSQL Advisory Lock + + Args: + lock_id: 락 ID + """ + try: + # Advisory Lock 획득 + result = self.db.execute(f"SELECT pg_advisory_lock({lock_id})") + logger.debug(f"Advisory Lock 획득: {lock_id}") + + yield + + finally: + # Advisory Lock 해제 + self.db.execute(f"SELECT pg_advisory_unlock({lock_id})") + logger.debug(f"Advisory Lock 해제: {lock_id}") + + @contextmanager + def table_lock(self, table_name: str, lock_mode: str = "ACCESS EXCLUSIVE"): + """ + 테이블 레벨 락 + + Args: + table_name: 테이블명 + lock_mode: 락 모드 + """ + try: + # 테이블 락 획득 + self.db.execute(f"LOCK TABLE {table_name} IN {lock_mode} MODE") + logger.debug(f"테이블 락 획득: {table_name} ({lock_mode})") + + yield + + except Exception as e: + logger.error(f"테이블 락 실패: {table_name} - {str(e)}") + raise + + +class TransactionStats: + """트랜잭션 통계 수집""" + + def __init__(self): + self.stats = { + "total_transactions": 0, + "successful_transactions": 0, + "failed_transactions": 0, + "rollback_count": 0, + "savepoint_count": 0 + } + + def record_transaction_start(self): + """트랜잭션 시작 기록""" + self.stats["total_transactions"] += 1 + + def record_transaction_success(self): + """트랜잭션 성공 기록""" + self.stats["successful_transactions"] += 1 + + def record_transaction_failure(self): + """트랜잭션 실패 기록""" + self.stats["failed_transactions"] += 1 + + def record_rollback(self): + """롤백 기록""" + self.stats["rollback_count"] += 1 + + def record_savepoint(self): + """세이브포인트 기록""" + self.stats["savepoint_count"] += 1 + + def get_stats(self) -> dict: + """통계 반환""" + total = self.stats["total_transactions"] + if total > 0: + self.stats["success_rate"] = (self.stats["successful_transactions"] / total) * 100 + self.stats["failure_rate"] = (self.stats["failed_transactions"] / total) * 100 + else: + self.stats["success_rate"] = 0 + self.stats["failure_rate"] = 0 + + return self.stats.copy() + + def reset_stats(self): + """통계 초기화""" + for key in self.stats: + if key not in ["success_rate", "failure_rate"]: + self.stats[key] = 0 + + +# 전역 트랜잭션 통계 인스턴스 +transaction_stats = TransactionStats() diff --git a/tkeg/api/database/init/01_schema.sql b/tkeg/api/database/init/01_schema.sql new file mode 100644 index 0000000..f28750f --- /dev/null +++ b/tkeg/api/database/init/01_schema.sql @@ -0,0 +1,131 @@ +-- TK-MP BOM 시스템 데이터베이스 스키마 +-- 생성일: 2025.07.14 + +-- ================================ +-- 1. 프로젝트 관리 +-- ================================ + +-- 프로젝트 기본 정보 +CREATE TABLE projects ( + id SERIAL PRIMARY KEY, + + -- 회사 공식 정보 + official_project_code VARCHAR(50) UNIQUE, -- PP5-5701-DRYING (정식 코드) + project_name VARCHAR(200) NOT NULL, + client_name VARCHAR(100), + + -- 설계팀 사용 정보 + design_project_code VARCHAR(50), -- 설계팀이 실제 사용한 코드 + design_project_name VARCHAR(200), -- 설계팀이 사용한 프로젝트명 + + -- 매칭 정보 + is_code_matched BOOLEAN DEFAULT false, -- 정식 코드 매칭 완료 여부 + matched_by VARCHAR(100), -- 매칭 작업자 + matched_at TIMESTAMP, -- 매칭 완료 시점 + + -- 기본 정보 + status VARCHAR(20) DEFAULT 'active', -- active/completed/on_hold + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + description TEXT, + notes TEXT +); + +-- ================================ +-- 2. 파일 관리 +-- ================================ + +-- 업로드된 자재 목록 파일들 +CREATE TABLE files ( + id SERIAL PRIMARY KEY, + project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE, + filename VARCHAR(255) NOT NULL, + original_filename VARCHAR(255) NOT NULL, + file_path VARCHAR(500) NOT NULL, + revision VARCHAR(20) DEFAULT 'Rev.0', -- Rev.0, Rev.1, Rev.2 + upload_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + uploaded_by VARCHAR(100), + file_type VARCHAR(10), -- excel/csv/txt + file_size INTEGER, + is_active BOOLEAN DEFAULT true +); + +-- ================================ +-- 3. 자재 정보 (핵심 테이블) +-- ================================ + +-- 개별 자재 상세 정보 +CREATE TABLE materials ( + id SERIAL PRIMARY KEY, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + line_number INTEGER, -- Excel 행 번호 + original_description TEXT NOT NULL, -- 원본 품명 + + -- 분류 정보 + classified_category VARCHAR(50), -- 파이프/피팅/볼트/밸브/계기 + classified_subcategory VARCHAR(100), -- 엘보우/플랜지/벤드 등 + + -- 재질 및 스펙 + material_grade VARCHAR(50), -- A333-6, A105, SS316 + schedule VARCHAR(20), -- SCH40, SCH80, STD + size_spec VARCHAR(50), -- 6인치, 150A, M16 + + -- 수량 정보 + quantity DECIMAL(10,3) NOT NULL, + unit VARCHAR(10) NOT NULL, -- EA/mm/SET/kg + + -- 도면 정보 + drawing_name VARCHAR(100), -- P&ID-001-TANK + area_code VARCHAR(20), -- #01, #02, #03 + line_no VARCHAR(50), -- LINE-001-A + + -- 분류 신뢰도 및 검증 + classification_confidence DECIMAL(3,2), -- 0.00-1.00 분류 신뢰도 + classification_details JSONB, -- 분류 상세 정보 (JSON) + is_verified BOOLEAN DEFAULT false, -- 사용자 검증 여부 + verified_by VARCHAR(100), + verified_at TIMESTAMP, + + -- 기타 + drawing_reference VARCHAR(100), + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- ================================ +-- 4. 인덱스 생성 (성능 최적화) +-- ================================ + +-- 프로젝트 관련 인덱스 +CREATE INDEX idx_projects_official_code ON projects(official_project_code); +CREATE INDEX idx_projects_design_code ON projects(design_project_code); + +-- 파일 관련 인덱스 +CREATE INDEX idx_files_project ON files(project_id); +CREATE INDEX idx_files_active ON files(is_active); + +-- 자재 관련 인덱스 +CREATE INDEX idx_materials_file ON materials(file_id); +CREATE INDEX idx_materials_category ON materials(classified_category, classified_subcategory); +CREATE INDEX idx_materials_material_size ON materials(material_grade, size_spec); + +-- ================================ +-- 5. 기본 뷰 생성 +-- ================================ + +-- 프로젝트 상태 요약 뷰 +CREATE VIEW project_status_view AS +SELECT + p.id, + COALESCE(p.official_project_code, p.design_project_code) as display_code, + p.project_name, + p.status, + COUNT(f.id) as file_count, + COUNT(m.id) as material_count, + p.created_at +FROM projects p +LEFT JOIN files f ON p.id = f.project_id AND f.is_active = true +LEFT JOIN materials m ON f.id = m.file_id +GROUP BY p.id, p.official_project_code, p.design_project_code, + p.project_name, p.status, p.created_at +ORDER BY p.created_at DESC; diff --git a/tkeg/api/database/init/02_add_classification_details.sql b/tkeg/api/database/init/02_add_classification_details.sql new file mode 100644 index 0000000..0faf0b6 --- /dev/null +++ b/tkeg/api/database/init/02_add_classification_details.sql @@ -0,0 +1,8 @@ +-- classification_details 컬럼 추가 마이그레이션 +-- 생성일: 2025.01.27 + +-- materials 테이블에 classification_details 컬럼 추가 +ALTER TABLE materials ADD COLUMN IF NOT EXISTS classification_details JSONB; + +-- 인덱스 추가 (JSONB 컬럼 검색 최적화) +CREATE INDEX IF NOT EXISTS idx_materials_classification_details ON materials USING GIN (classification_details); \ No newline at end of file diff --git a/tkeg/api/database/init/02_seed_data.sql b/tkeg/api/database/init/02_seed_data.sql new file mode 100644 index 0000000..e69de29 diff --git a/tkeg/api/database/init/20_purchase_confirmations.sql b/tkeg/api/database/init/20_purchase_confirmations.sql new file mode 100644 index 0000000..08e3953 --- /dev/null +++ b/tkeg/api/database/init/20_purchase_confirmations.sql @@ -0,0 +1,78 @@ +-- 구매 수량 확정 관련 테이블 생성 + +-- 1. 구매 확정 마스터 테이블 +CREATE TABLE IF NOT EXISTS purchase_confirmations ( + id SERIAL PRIMARY KEY, + job_no VARCHAR(50) NOT NULL, + file_id INTEGER REFERENCES files(id), + bom_name VARCHAR(255) NOT NULL, + revision VARCHAR(50) NOT NULL DEFAULT 'Rev.0', + confirmed_at TIMESTAMP NOT NULL, + confirmed_by VARCHAR(100) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 2. 확정된 구매 품목 테이블 +CREATE TABLE IF NOT EXISTS confirmed_purchase_items ( + id SERIAL PRIMARY KEY, + confirmation_id INTEGER REFERENCES purchase_confirmations(id) ON DELETE CASCADE, + item_code VARCHAR(100) NOT NULL, + category VARCHAR(50) NOT NULL, + specification TEXT, + size VARCHAR(100), + material VARCHAR(100), + bom_quantity DECIMAL(15,3) NOT NULL DEFAULT 0, + calculated_qty DECIMAL(15,3) NOT NULL DEFAULT 0, + unit VARCHAR(20) NOT NULL DEFAULT 'EA', + safety_factor DECIMAL(5,3) NOT NULL DEFAULT 1.0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 3. files 테이블에 확정 관련 컬럼 추가 (이미 있으면 무시) +ALTER TABLE files +ADD COLUMN IF NOT EXISTS purchase_confirmed BOOLEAN DEFAULT FALSE, +ADD COLUMN IF NOT EXISTS confirmed_at TIMESTAMP, +ADD COLUMN IF NOT EXISTS confirmed_by VARCHAR(100); + +-- 인덱스 생성 +CREATE INDEX IF NOT EXISTS idx_purchase_confirmations_job_revision +ON purchase_confirmations(job_no, revision, is_active); + +CREATE INDEX IF NOT EXISTS idx_confirmed_purchase_items_confirmation +ON confirmed_purchase_items(confirmation_id); + +CREATE INDEX IF NOT EXISTS idx_confirmed_purchase_items_category +ON confirmed_purchase_items(category); + +CREATE INDEX IF NOT EXISTS idx_files_purchase_confirmed +ON files(purchase_confirmed); + +-- 코멘트 추가 +COMMENT ON TABLE purchase_confirmations IS '구매 수량 확정 마스터 테이블'; +COMMENT ON TABLE confirmed_purchase_items IS '확정된 구매 품목 상세 테이블'; +COMMENT ON COLUMN files.purchase_confirmed IS '구매 수량 확정 여부'; +COMMENT ON COLUMN files.confirmed_at IS '구매 수량 확정 시간'; +COMMENT ON COLUMN files.confirmed_by IS '구매 수량 확정자'; + + + + + + + + + + + + + + + + + + + + + diff --git a/tkeg/api/database/init/99_complete_schema.sql b/tkeg/api/database/init/99_complete_schema.sql new file mode 100644 index 0000000..e4e7f01 --- /dev/null +++ b/tkeg/api/database/init/99_complete_schema.sql @@ -0,0 +1,1419 @@ +-- ================================ +-- TK-MP-Project 통합 데이터베이스 스키마 +-- 다른 환경 배포용 완전한 초기화 스크립트 +-- 생성일: 2025.09.09 +-- ================================ + +-- ================================ +-- 1. 기본 스키마 (01_schema.sql 기반) +-- ================================ + +-- 프로젝트 기본 정보 +CREATE TABLE IF NOT EXISTS projects ( + id SERIAL PRIMARY KEY, + + -- 회사 공식 정보 + official_project_code VARCHAR(50) UNIQUE, -- PP5-5701-DRYING (정식 코드) + project_name VARCHAR(200) NOT NULL, + client_name VARCHAR(100), + + -- 설계팀 사용 정보 + design_project_code VARCHAR(50), -- 설계팀이 실제 사용한 코드 + design_project_name VARCHAR(200), -- 설계팀이 사용한 프로젝트명 + + -- 매칭 정보 + is_code_matched BOOLEAN DEFAULT false, -- 정식 코드 매칭 완료 여부 + matched_by VARCHAR(100), -- 매칭 작업자 + matched_at TIMESTAMP, -- 매칭 완료 시점 + + -- 기본 정보 + status VARCHAR(20) DEFAULT 'active', -- active/completed/on_hold + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + description TEXT, + notes TEXT +); + +-- 업로드된 자재 목록 파일들 +CREATE TABLE IF NOT EXISTS files ( + id SERIAL PRIMARY KEY, + project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE, + filename VARCHAR(255) NOT NULL, + original_filename VARCHAR(255) NOT NULL, + file_path VARCHAR(500) NOT NULL, + revision VARCHAR(20) DEFAULT 'Rev.0', -- Rev.0, Rev.1, Rev.2 + upload_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + uploaded_by VARCHAR(100), + file_type VARCHAR(10), -- excel/csv/txt + file_size INTEGER, + is_active BOOLEAN DEFAULT true, + + -- 추가 필드 (19_add_user_tracking_fields.sql) + updated_by VARCHAR(100), + bom_name VARCHAR(200), -- BOM 이름 필드 추가 + + -- 분류 관련 필드 (05_add_classification_columns.sql) + classification_stats JSONB, + classification_completed BOOLEAN DEFAULT FALSE, + classification_timestamp TIMESTAMP, + + -- Job 연결 필드 (02_modify_files_table.sql) + job_no VARCHAR(50), + + -- 파싱 통계 필드 + parsed_count INTEGER DEFAULT 0 +); + +-- 개별 자재 상세 정보 +CREATE TABLE IF NOT EXISTS materials ( + id SERIAL PRIMARY KEY, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + line_number INTEGER, -- Excel 행 번호 + row_number INTEGER, -- 실제 행 번호 + original_description TEXT NOT NULL, -- 원본 품명 + + -- 분류 정보 + classified_category VARCHAR(50), -- 파이프/피팅/볼트/밸브/계기 + classified_subcategory VARCHAR(100), -- 엘보우/플랜지/벤드 등 + + -- 재질 및 스펙 + material_grade VARCHAR(50), -- A333-6, A105, SS316 + schedule VARCHAR(20), -- SCH40, SCH80, STD + size_spec VARCHAR(50), -- 6인치, 150A, M16 + main_nom VARCHAR(50), -- 주 호칭 사이즈 + red_nom VARCHAR(50), -- 축소 호칭 사이즈 + + -- 수량 정보 + quantity DECIMAL(10,3) NOT NULL, + unit VARCHAR(10) NOT NULL, -- EA/mm/SET/kg + + -- 도면 정보 + drawing_name VARCHAR(100), -- P&ID-001-TANK + area_code VARCHAR(20), -- #01, #02, #03 + line_no VARCHAR(50), -- LINE-001-A + + -- 분류 신뢰도 및 검증 + classification_confidence DECIMAL(3,2), -- 0.00-1.00 분류 신뢰도 + classification_details JSONB, -- 분류 상세 정보 (JSON) + is_verified BOOLEAN DEFAULT false, -- 사용자 검증 여부 + verified_by VARCHAR(100), + verified_at TIMESTAMP, + + -- 구매 관련 필드 + purchase_confirmed BOOLEAN DEFAULT false, + confirmed_quantity DECIMAL(10,3), + purchase_status VARCHAR(20), + purchase_confirmed_by VARCHAR(100), + purchase_confirmed_at TIMESTAMP, + + -- 길이 정보 (05_add_length_to_materials.sql) + length NUMERIC(10, 3), + + -- 사용자 추적 필드 (19_add_user_tracking_fields.sql) + classified_by VARCHAR(100), + classified_at TIMESTAMP, + updated_by VARCHAR(100), + + -- 분류 상세 필드 (05_add_classification_columns.sql) + subcategory VARCHAR(100), + standard VARCHAR(200), + grade VARCHAR(200), + + -- 해시 필드 (10_add_material_comparison_system.sql) + material_hash VARCHAR(64), + normalized_description TEXT, + + -- 기타 + drawing_reference VARCHAR(100), + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- ================================ +-- 2. 자재 상세 정보 테이블들 (create_material_detail_tables.sql) +-- ================================ + +-- PIPE 상세 테이블 +CREATE TABLE IF NOT EXISTS pipe_details ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + + -- 파이프 기본 정보 + outer_diameter VARCHAR(50), + schedule VARCHAR(50), + material_spec VARCHAR(100), + manufacturing_method VARCHAR(50), + end_preparation VARCHAR(50), + + -- 길이 정보 + length_mm DECIMAL(10,3), + + -- 신뢰도 + classification_confidence FLOAT, + + -- 추가 정보 + additional_info JSONB, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- FITTING 상세 테이블 +CREATE TABLE IF NOT EXISTS fitting_details ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + + -- 피팅 타입 정보 + fitting_type VARCHAR(50), + fitting_subtype VARCHAR(50), + + -- 연결 방식 + connection_method VARCHAR(50), + connection_code VARCHAR(50), + + -- 압력 등급 + pressure_rating VARCHAR(50), + max_pressure VARCHAR(50), + + -- 제작 방법 + manufacturing_method VARCHAR(50), + + -- 재질 정보 + material_standard VARCHAR(100), + material_grade VARCHAR(100), + material_type VARCHAR(50), + + -- 사이즈 정보 + main_size VARCHAR(50), + reduced_size VARCHAR(50), + + -- 길이 정보 (니플용) + length_mm DECIMAL(10,3), + schedule VARCHAR(50), + + -- 신뢰도 + classification_confidence FLOAT, + + -- 추가 정보 + additional_info JSONB, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- VALVE 상세 테이블 +CREATE TABLE IF NOT EXISTS valve_details ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + + -- 밸브 타입 정보 + valve_type VARCHAR(50), + valve_subtype VARCHAR(50), + actuator_type VARCHAR(50), + + -- 연결 방식 + connection_method VARCHAR(50), + + -- 압력 등급 + pressure_rating VARCHAR(50), + pressure_class VARCHAR(50), + + -- 재질 정보 + body_material VARCHAR(100), + trim_material VARCHAR(100), + + -- 사이즈 정보 + size_inches VARCHAR(50), + + -- 특수 사양 + fire_safe BOOLEAN, + low_temp_service BOOLEAN, + special_features JSONB, + + -- 신뢰도 + classification_confidence FLOAT, + + -- 추가 정보 + additional_info JSONB, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- FLANGE 상세 테이블 +CREATE TABLE IF NOT EXISTS flange_details ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + + -- 플랜지 타입 + flange_type VARCHAR(50), + facing_type VARCHAR(50), + + -- 압력 등급 + pressure_rating VARCHAR(50), + + -- 재질 정보 + material_standard VARCHAR(100), + material_grade VARCHAR(100), + + -- 사이즈 정보 + size_inches VARCHAR(50), + bolt_hole_count INTEGER, + bolt_hole_size VARCHAR(50), + + -- 신뢰도 + classification_confidence FLOAT, + + -- 추가 정보 + additional_info JSONB, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- BOLT 상세 테이블 +CREATE TABLE IF NOT EXISTS bolt_details ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + + -- 볼트 타입 + bolt_type VARCHAR(50), + thread_type VARCHAR(50), + + -- 사양 정보 + diameter VARCHAR(50), + length VARCHAR(50), + + -- 재질 정보 + material_standard VARCHAR(100), + material_grade VARCHAR(100), + coating_type VARCHAR(100), + + -- 압력 등급 (06_add_pressure_rating_to_bolt_details.sql) + pressure_rating VARCHAR(50), + + -- 너트/와셔 정보 + includes_nut BOOLEAN, + includes_washer BOOLEAN, + nut_type VARCHAR(50), + washer_type VARCHAR(50), + + -- 신뢰도 + classification_confidence FLOAT, + + -- 추가 정보 + additional_info JSONB, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- GASKET 상세 테이블 +CREATE TABLE IF NOT EXISTS gasket_details ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + + -- 가스켓 타입 + gasket_type VARCHAR(50), + gasket_subtype VARCHAR(50), + + -- 재질 정보 + material_type VARCHAR(100), + filler_material VARCHAR(100), + + -- 사이즈 및 등급 + size_inches VARCHAR(50), + pressure_rating VARCHAR(50), + thickness VARCHAR(50), + + -- 특수 사양 + temperature_range VARCHAR(100), + fire_safe BOOLEAN, + + -- 신뢰도 + classification_confidence FLOAT, + + -- 추가 정보 + additional_info JSONB, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- INSTRUMENT 상세 테이블 +CREATE TABLE IF NOT EXISTS instrument_details ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + + -- 계장품 타입 + instrument_type VARCHAR(50), + instrument_subtype VARCHAR(50), + + -- 측정 사양 + measurement_type VARCHAR(50), + measurement_range VARCHAR(100), + accuracy VARCHAR(50), + + -- 연결 정보 + connection_type VARCHAR(50), + connection_size VARCHAR(50), + + -- 재질 정보 + body_material VARCHAR(100), + wetted_parts_material VARCHAR(100), + + -- 전기 사양 + electrical_rating VARCHAR(100), + output_signal VARCHAR(50), + + -- 신뢰도 + classification_confidence FLOAT, + + -- 추가 정보 + additional_info JSONB, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- ================================ +-- 3. 파이프 끝단 가공 테이블 (20_add_pipe_end_preparation_table.sql) +-- ================================ + +CREATE TABLE IF NOT EXISTS pipe_end_preparations ( + id SERIAL PRIMARY KEY, + material_id INTEGER NOT NULL REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + + -- 끝단 가공 정보 + end_preparation_type VARCHAR(50) DEFAULT 'PBE', -- PBE(양쪽무개선), BBE(양쪽개선), POE(한쪽개선), PE(무개선) + end_preparation_code VARCHAR(20), -- 원본 코드 (BBE, POE, PBE 등) + machining_required BOOLEAN DEFAULT FALSE, -- 가공 필요 여부 + cutting_note TEXT, -- 가공 메모 + + -- 원본 정보 보존 + original_description TEXT NOT NULL, -- 끝단 가공 포함된 원본 설명 + clean_description TEXT NOT NULL, -- 끝단 가공 제외한 구매용 설명 + + -- 메타데이터 + confidence FLOAT DEFAULT 0.0, -- 분류 신뢰도 + matched_pattern VARCHAR(100), -- 매칭된 패턴 + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- ================================ +-- 4. 구매 관리 테이블들 (08_create_purchase_tables.sql) +-- ================================ + +-- 구매 품목 마스터 테이블 +CREATE TABLE IF NOT EXISTS purchase_items ( + id SERIAL PRIMARY KEY, + + -- 품목 식별 + item_code VARCHAR(100) UNIQUE NOT NULL, -- PI-PIPE-A106-4IN-001 + category VARCHAR(50) NOT NULL, -- PIPE, VALVE, FITTING 등 + specification TEXT NOT NULL, -- 상세 사양 + + -- 기본 정보 + material_spec VARCHAR(200), -- ASTM A106 GR B + size_spec VARCHAR(100), -- 4", 1-1/2" x 1" + unit VARCHAR(10) NOT NULL, -- EA, mm, SET + + -- BOM 수량 정보 + bom_quantity DECIMAL(10,3) NOT NULL, -- BOM상 필요 수량 + + -- 구매 계산 정보 + safety_factor DECIMAL(3,2) DEFAULT 1.10, -- 여유율 (1.1 = 10% 추가) + minimum_order_qty DECIMAL(10,3) DEFAULT 0, -- 최소 주문 수량 + order_unit_qty DECIMAL(10,3) DEFAULT 1, -- 주문 단위 (박스, 롤 등) + calculated_qty DECIMAL(10,3), -- 최종 계산된 구매 수량 + + -- PIPE 전용 정보 + cutting_loss DECIMAL(10,3) DEFAULT 0, -- 절단 손실 (mm) + standard_length DECIMAL(10,3), -- 표준 길이 (PIPE: 6000mm) + pipes_count INTEGER, -- 필요한 파이프 본수 + waste_length DECIMAL(10,3), -- 여유분 길이 + + -- 상세 스펙 (JSON) + detailed_spec JSONB, -- 압력등급, 연결방식 등 상세 정보 + + -- 구매 정보 + preferred_supplier VARCHAR(200), -- 선호 공급업체 + last_unit_price DECIMAL(10,2), -- 최근 단가 + currency VARCHAR(10) DEFAULT 'KRW', -- 통화 + lead_time_days INTEGER DEFAULT 30, -- 리드타임 + + -- 연결 정보 + job_no VARCHAR(50) NOT NULL, -- Job 번호 + revision VARCHAR(20) DEFAULT 'Rev.0', -- 리비전 + file_id INTEGER, -- 원본 파일 ID + + -- 관리 정보 + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100), + updated_by VARCHAR(100), + approved_by VARCHAR(100), + approved_at TIMESTAMP, + + -- 외래키 + FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE SET NULL +); + +-- 개별 자재와 구매품목 연결 테이블 +CREATE TABLE IF NOT EXISTS material_purchase_mapping ( + id SERIAL PRIMARY KEY, + material_id INTEGER NOT NULL, -- materials 테이블 ID + purchase_item_id INTEGER NOT NULL, -- purchase_items 테이블 ID + quantity_ratio DECIMAL(5,2) DEFAULT 1.0, -- 변환 비율 + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- 외래키 + FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE CASCADE, + FOREIGN KEY (purchase_item_id) REFERENCES purchase_items(id) ON DELETE CASCADE, + + -- 유니크 제약 + UNIQUE(material_id, purchase_item_id) +); + +-- 구매 추적 테이블 +CREATE TABLE IF NOT EXISTS material_purchase_tracking ( + id SERIAL PRIMARY KEY, + material_hash VARCHAR(64) NOT NULL, + + -- 기본 정보 + original_description TEXT NOT NULL, + size_spec VARCHAR(50), + material_grade VARCHAR(50), + + -- 수량 정보 + bom_quantity DECIMAL(10,3) NOT NULL, + confirmed_quantity DECIMAL(10,3), + purchase_quantity DECIMAL(10,3), + + -- 상태 관리 + status VARCHAR(20) DEFAULT 'pending', + confirmed_by VARCHAR(100), + confirmed_at TIMESTAMP, + ordered_by VARCHAR(100), + ordered_at TIMESTAMP, + approved_by VARCHAR(100), + approved_at TIMESTAMP, + + -- 메타데이터 + job_no VARCHAR(50), + revision VARCHAR(20), + file_id INTEGER, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE SET NULL +); + +-- ================================ +-- 6. Jobs 테이블 (01_create_jobs_table.sql) +-- ================================ + +CREATE TABLE IF NOT EXISTS jobs ( + -- 기본 정보 + job_no VARCHAR(50) PRIMARY KEY, + job_name VARCHAR(200) NOT NULL, + + -- 계약 관계 (핵심) + client_name VARCHAR(100) NOT NULL, + + -- 프로젝트 정보 + end_user VARCHAR(100), + epc_company VARCHAR(100), + project_site VARCHAR(200), + + -- 상업 정보 + contract_date DATE, + delivery_date DATE, + delivery_terms VARCHAR(100), + + -- 상태 관리 (핵심) + status VARCHAR(20) DEFAULT '진행중', + delivery_completed_date DATE, + project_closed_date DATE, + + -- 관리 정보 + description TEXT, + created_by VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT true, + + -- 사용자 추적 필드 (19_add_user_tracking_fields.sql) + updated_by VARCHAR(100), + assigned_to VARCHAR(100), + + -- 프로젝트 타입 필드 (17_add_project_type_column.sql) + project_type VARCHAR(50) DEFAULT '냉동기' NOT NULL +); + +-- ================================ +-- 7. 자재 규격/재질 기준표 테이블들 (05_create_material_standards_tables.sql) +-- ================================ + +-- 자재 규격 표준 테이블 +CREATE TABLE IF NOT EXISTS material_standards ( + id SERIAL PRIMARY KEY, + standard_code VARCHAR(20) UNIQUE NOT NULL, + standard_name VARCHAR(100) NOT NULL, + description TEXT, + country VARCHAR(50), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 제조방식별 카테고리 테이블 +CREATE TABLE IF NOT EXISTS material_categories ( + id SERIAL PRIMARY KEY, + standard_id INTEGER REFERENCES material_standards(id), + category_code VARCHAR(50) NOT NULL, + category_name VARCHAR(100) NOT NULL, + description TEXT, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 구체적인 규격 테이블 +CREATE TABLE IF NOT EXISTS material_specifications ( + id SERIAL PRIMARY KEY, + category_id INTEGER REFERENCES material_categories(id), + spec_code VARCHAR(20) NOT NULL, + spec_name VARCHAR(100) NOT NULL, + description TEXT, + material_type VARCHAR(50), + manufacturing VARCHAR(50), + pressure_rating VARCHAR(100), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 등급별 상세 정보 테이블 +CREATE TABLE IF NOT EXISTS material_grades ( + id SERIAL PRIMARY KEY, + specification_id INTEGER REFERENCES material_specifications(id), + grade_code VARCHAR(20) NOT NULL, + grade_name VARCHAR(100), + composition VARCHAR(200), + applications VARCHAR(200), + temp_max VARCHAR(50), + temp_range VARCHAR(100), + yield_strength VARCHAR(50), + tensile_strength VARCHAR(50), + corrosion_resistance VARCHAR(50), + stabilizer VARCHAR(50), + base_grade VARCHAR(20), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 정규식 패턴 테이블 +CREATE TABLE IF NOT EXISTS material_patterns ( + id SERIAL PRIMARY KEY, + specification_id INTEGER REFERENCES material_specifications(id), + pattern TEXT NOT NULL, + description VARCHAR(200), + priority INTEGER DEFAULT 1, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 특수 재질 테이블 +CREATE TABLE IF NOT EXISTS special_materials ( + id SERIAL PRIMARY KEY, + material_type VARCHAR(50) NOT NULL, + material_name VARCHAR(100) NOT NULL, + description TEXT, + composition VARCHAR(200), + applications TEXT, + temp_max VARCHAR(50), + manufacturing VARCHAR(50), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 특수 재질 등급 테이블 +CREATE TABLE IF NOT EXISTS special_material_grades ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES special_materials(id), + grade_code VARCHAR(20) NOT NULL, + composition VARCHAR(200), + applications VARCHAR(200), + temp_max VARCHAR(50), + strength VARCHAR(50), + purity VARCHAR(100), + corrosion VARCHAR(50), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 특수 재질 정규식 패턴 테이블 +CREATE TABLE IF NOT EXISTS special_material_patterns ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES special_materials(id), + pattern TEXT NOT NULL, + description VARCHAR(200), + priority INTEGER DEFAULT 1, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- ================================ +-- 8. 자재 비교 및 발주 추적 시스템 (10_add_material_comparison_system.sql) +-- ================================ + +-- 자재 비교 결과 저장 테이블 +CREATE TABLE IF NOT EXISTS material_revisions_comparison ( + id SERIAL PRIMARY KEY, + + -- 비교 기본 정보 + job_no VARCHAR(50) NOT NULL, + current_revision VARCHAR(20) NOT NULL, + previous_revision VARCHAR(20) NOT NULL, + current_file_id INTEGER NOT NULL, + previous_file_id INTEGER NOT NULL, + + -- 비교 결과 요약 + total_current_items INTEGER DEFAULT 0, + total_previous_items INTEGER DEFAULT 0, + new_items_count INTEGER DEFAULT 0, + modified_items_count INTEGER DEFAULT 0, + removed_items_count INTEGER DEFAULT 0, + unchanged_items_count INTEGER DEFAULT 0, + + -- 상세 결과 (JSON) + comparison_details JSONB, + + -- 관리 정보 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100), + + -- 외래키 + FOREIGN KEY (current_file_id) REFERENCES files(id), + FOREIGN KEY (previous_file_id) REFERENCES files(id), + + -- 유니크 제약 (같은 비교는 한 번만) + UNIQUE(job_no, current_revision, previous_revision) +); + +-- 개별 자재 비교 상세 테이블 +CREATE TABLE IF NOT EXISTS material_comparison_details ( + id SERIAL PRIMARY KEY, + + comparison_id INTEGER NOT NULL, + material_hash VARCHAR(32) NOT NULL, + + -- 비교 타입 + change_type VARCHAR(20) NOT NULL, -- 'NEW', 'MODIFIED', 'REMOVED', 'UNCHANGED' + + -- 자재 정보 + description TEXT NOT NULL, + size_spec VARCHAR(100), + material_grade VARCHAR(100), + + -- 수량 비교 + previous_quantity DECIMAL(10,3) DEFAULT 0, + current_quantity DECIMAL(10,3) DEFAULT 0, + quantity_diff DECIMAL(10,3) DEFAULT 0, + + -- 추가 구매 필요량 (핵심!) + additional_purchase_needed DECIMAL(10,3) DEFAULT 0, + + -- 분류 정보 + classified_category VARCHAR(50), + classification_confidence DECIMAL(3,2), + + -- 관리 정보 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- 외래키 + FOREIGN KEY (comparison_id) REFERENCES material_revisions_comparison(id) ON DELETE CASCADE +); + +-- ================================ +-- 9. Tubing 시스템 테이블들 (15_create_tubing_system.sql) +-- ================================ + +-- Tubing 카테고리 테이블 +CREATE TABLE IF NOT EXISTS tubing_categories ( + id SERIAL PRIMARY KEY, + category_code VARCHAR(20) UNIQUE NOT NULL, + category_name VARCHAR(100) NOT NULL, + description TEXT, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Tubing 규격 마스터 테이블 +CREATE TABLE IF NOT EXISTS tubing_specifications ( + id SERIAL PRIMARY KEY, + category_id INTEGER REFERENCES tubing_categories(id), + spec_code VARCHAR(50) UNIQUE NOT NULL, + spec_name VARCHAR(200) NOT NULL, + + -- 물리적 규격 + outer_diameter_mm DECIMAL(8,3), -- 외경 (mm) + wall_thickness_mm DECIMAL(6,3), -- 두께 (mm) + inner_diameter_mm DECIMAL(8,3), -- 내경 (mm, 계산 또는 실측) + + -- 재질 정보 + material_grade VARCHAR(100), -- SS316, SS316L, Inconel625 등 + material_standard VARCHAR(100), -- ASTM A269, JIS G3463 등 + + -- 압력/온도 등급 + max_pressure_bar DECIMAL(8,2), -- 최대 압력 (bar) + max_temperature_c DECIMAL(6,2), -- 최대 온도 (°C) + min_temperature_c DECIMAL(6,2), -- 최소 온도 (°C) + + -- 표준 규격 + standard_length_m DECIMAL(8,3), -- 표준 길이 (m) + bend_radius_min_mm DECIMAL(8,2), -- 최소 벤딩 반경 (mm) + + -- 기타 정보 + surface_finish VARCHAR(100), -- 표면 마감 (BA, #4, 2B 등) + hardness VARCHAR(50), -- 경도 + notes TEXT, + + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 제조사 정보 테이블 +CREATE TABLE IF NOT EXISTS tubing_manufacturers ( + id SERIAL PRIMARY KEY, + manufacturer_code VARCHAR(20) UNIQUE NOT NULL, + manufacturer_name VARCHAR(200) NOT NULL, + country VARCHAR(100), + website VARCHAR(500), + contact_info JSONB, -- 연락처 정보 (JSON) + quality_certs JSONB, -- 품질 인증서 정보 (ISO, API 등) + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 제조사별 제품 테이블 +CREATE TABLE IF NOT EXISTS tubing_products ( + id SERIAL PRIMARY KEY, + specification_id INTEGER REFERENCES tubing_specifications(id), + manufacturer_id INTEGER REFERENCES tubing_manufacturers(id), + + -- 제조사 품목번호 정보 + manufacturer_part_number VARCHAR(200) NOT NULL, -- 제조사 품목번호 + manufacturer_product_name VARCHAR(300), -- 제조사 제품명 + + -- 가격/공급 정보 + list_price DECIMAL(12,2), -- 정가 + currency VARCHAR(10) DEFAULT 'KRW', -- 통화 + lead_time_days INTEGER, -- 리드타임 (일) + minimum_order_qty DECIMAL(10,3), -- 최소 주문 수량 + standard_packaging_qty DECIMAL(10,3), -- 표준 포장 수량 + + -- 가용성 정보 + availability_status VARCHAR(50), -- 재고 상태 + last_price_update DATE, -- 마지막 가격 업데이트 + + -- 추가 정보 + datasheet_url VARCHAR(500), -- 데이터시트 URL + catalog_page VARCHAR(100), -- 카탈로그 페이지 + notes TEXT, + + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- 유니크 제약 (같은 규격의 같은 제조사 제품은 하나만) + UNIQUE(specification_id, manufacturer_id, manufacturer_part_number) +); + +-- BOM에서 사용되는 Tubing 매핑 테이블 +CREATE TABLE IF NOT EXISTS material_tubing_mapping ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + tubing_product_id INTEGER REFERENCES tubing_products(id), + + -- 매핑 정보 + confidence_score DECIMAL(3,2), -- 매핑 신뢰도 (0.00-1.00) + mapping_method VARCHAR(50), -- 매핑 방법 (auto/manual) + mapped_by VARCHAR(100), -- 매핑한 사용자 + mapped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- 수량 정보 + required_length_m DECIMAL(10,3), -- 필요 길이 (m) + calculated_quantity DECIMAL(10,3), -- 계산된 주문 수량 + + -- 검증 정보 + is_verified BOOLEAN DEFAULT FALSE, + verified_by VARCHAR(100), + verified_at TIMESTAMP, + + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- ================================ +-- 10. 사용자 요구사항 테이블들 (05_create_pipe_details_and_requirements_postgres.sql) +-- ================================ + +-- 요구사항 타입 마스터 테이블 +CREATE TABLE IF NOT EXISTS requirement_types ( + id SERIAL PRIMARY KEY, + type_code VARCHAR(50) UNIQUE NOT NULL, -- 'IMPACT_TEST', 'HEAT_TREATMENT' 등 + type_name VARCHAR(100) NOT NULL, -- '임팩테스트', '열처리' 등 + category VARCHAR(50) NOT NULL, -- 'TEST', 'TREATMENT', 'CERTIFICATION', 'CUSTOM' 등 + description TEXT, -- 타입 설명 + is_active BOOLEAN DEFAULT TRUE, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 사용자 추가 요구사항 테이블 +CREATE TABLE IF NOT EXISTS user_requirements ( + id SERIAL PRIMARY KEY, + file_id INTEGER NOT NULL, + material_id INTEGER, -- 자재 ID (개별 자재별 요구사항 연결) + + -- 요구사항 타입 + requirement_type VARCHAR(50) NOT NULL, -- 'IMPACT_TEST', 'HEAT_TREATMENT', 'CUSTOM_SPEC', 'CERTIFICATION' 등 + + -- 요구사항 내용 + requirement_title VARCHAR(200) NOT NULL, -- '임팩테스트', '열처리', '인증서' 등 + requirement_description TEXT, -- 상세 설명 + requirement_spec TEXT, -- 구체적 스펙 (예: "Charpy V-notch -20°C") + + -- 상태 관리 + status VARCHAR(20) DEFAULT 'PENDING', -- 'PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED' + priority VARCHAR(20) DEFAULT 'NORMAL', -- 'LOW', 'NORMAL', 'HIGH', 'URGENT' + + -- 담당자 정보 + assigned_to VARCHAR(100), -- 담당자명 + due_date DATE, -- 완료 예정일 + + -- 메타데이터 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE, + FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE CASCADE +); + +-- ================================ +-- 11. 사용자 활동 로그 테이블 (19_add_user_tracking_fields.sql) +-- ================================ + +CREATE TABLE IF NOT EXISTS user_activity_logs ( + id SERIAL PRIMARY KEY, + user_id INTEGER, -- users 테이블 참조 (외래키 제약 없음 - 유연성) + username VARCHAR(100) NOT NULL, -- 사용자명 (필수) + + -- 활동 정보 + activity_type VARCHAR(50) NOT NULL, -- 'FILE_UPLOAD', 'PROJECT_CREATE', 'PURCHASE_CONFIRM' 등 + activity_description TEXT, -- 상세 활동 내용 + + -- 대상 정보 + target_id INTEGER, -- 대상 ID (파일, 프로젝트 등) + target_type VARCHAR(50), -- 'FILE', 'PROJECT', 'MATERIAL', 'PURCHASE' 등 + + -- 세션 정보 + ip_address VARCHAR(45), -- IP 주소 + user_agent TEXT, -- 브라우저 정보 + + -- 추가 메타데이터 (JSON) + metadata JSONB, -- 추가 정보 (파일 크기, 처리 시간 등) + + -- 시간 정보 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- ================================ +-- 7. 인덱스 생성 (성능 최적화) +-- ================================ + +-- 프로젝트 관련 인덱스 +CREATE INDEX IF NOT EXISTS idx_projects_official_code ON projects(official_project_code); +CREATE INDEX IF NOT EXISTS idx_projects_design_code ON projects(design_project_code); + +-- 파일 관련 인덱스 +CREATE INDEX IF NOT EXISTS idx_files_project ON files(project_id); +CREATE INDEX IF NOT EXISTS idx_files_active ON files(is_active); +CREATE INDEX IF NOT EXISTS idx_files_uploaded_by ON files(uploaded_by); +CREATE INDEX IF NOT EXISTS idx_files_updated_by ON files(updated_by); + +-- 자재 관련 인덱스 +CREATE INDEX IF NOT EXISTS idx_materials_file ON materials(file_id); +CREATE INDEX IF NOT EXISTS idx_materials_category ON materials(classified_category, classified_subcategory); +CREATE INDEX IF NOT EXISTS idx_materials_material_size ON materials(material_grade, size_spec); +CREATE INDEX IF NOT EXISTS idx_materials_classified_by ON materials(classified_by); +CREATE INDEX IF NOT EXISTS idx_materials_hash ON materials(material_hash); + +-- 자재 상세 테이블 인덱스 +CREATE INDEX IF NOT EXISTS idx_pipe_details_material_id ON pipe_details(material_id); +CREATE INDEX IF NOT EXISTS idx_pipe_details_file_id ON pipe_details(file_id); + +CREATE INDEX IF NOT EXISTS idx_fitting_details_material_id ON fitting_details(material_id); +CREATE INDEX IF NOT EXISTS idx_fitting_details_file_id ON fitting_details(file_id); +CREATE INDEX IF NOT EXISTS idx_fitting_details_type ON fitting_details(fitting_type); + +CREATE INDEX IF NOT EXISTS idx_valve_details_material_id ON valve_details(material_id); +CREATE INDEX IF NOT EXISTS idx_valve_details_file_id ON valve_details(file_id); +CREATE INDEX IF NOT EXISTS idx_valve_details_type ON valve_details(valve_type); + +CREATE INDEX IF NOT EXISTS idx_flange_details_material_id ON flange_details(material_id); +CREATE INDEX IF NOT EXISTS idx_flange_details_file_id ON flange_details(file_id); + +CREATE INDEX IF NOT EXISTS idx_bolt_details_material_id ON bolt_details(material_id); +CREATE INDEX IF NOT EXISTS idx_bolt_details_file_id ON bolt_details(file_id); + +CREATE INDEX IF NOT EXISTS idx_gasket_details_material_id ON gasket_details(material_id); +CREATE INDEX IF NOT EXISTS idx_gasket_details_file_id ON gasket_details(file_id); + +CREATE INDEX IF NOT EXISTS idx_instrument_details_material_id ON instrument_details(material_id); +CREATE INDEX IF NOT EXISTS idx_instrument_details_file_id ON instrument_details(file_id); + +-- 파이프 끝단 가공 테이블 인덱스 +CREATE INDEX IF NOT EXISTS idx_pipe_end_preparations_material_id ON pipe_end_preparations(material_id); +CREATE INDEX IF NOT EXISTS idx_pipe_end_preparations_file_id ON pipe_end_preparations(file_id); +CREATE INDEX IF NOT EXISTS idx_pipe_end_preparations_type ON pipe_end_preparations(end_preparation_type); + +-- 구매 관련 인덱스 +CREATE INDEX IF NOT EXISTS idx_purchase_items_job_revision ON purchase_items(job_no, revision); +CREATE INDEX IF NOT EXISTS idx_purchase_items_category ON purchase_items(category); +CREATE INDEX IF NOT EXISTS idx_purchase_items_item_code ON purchase_items(item_code); +CREATE INDEX IF NOT EXISTS idx_purchase_items_active ON purchase_items(is_active); + +CREATE INDEX IF NOT EXISTS idx_material_purchase_mapping_material ON material_purchase_mapping(material_id); +CREATE INDEX IF NOT EXISTS idx_material_purchase_mapping_purchase ON material_purchase_mapping(purchase_item_id); + +CREATE INDEX IF NOT EXISTS idx_material_purchase_tracking_hash ON material_purchase_tracking(material_hash); +CREATE INDEX IF NOT EXISTS idx_material_purchase_tracking_job_revision ON material_purchase_tracking(job_no, revision); + +-- Jobs 테이블 인덱스 +CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); +CREATE INDEX IF NOT EXISTS idx_jobs_client ON jobs(client_name); +CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at); +CREATE INDEX IF NOT EXISTS idx_jobs_created_by ON jobs(created_by); +CREATE INDEX IF NOT EXISTS idx_jobs_assigned_to ON jobs(assigned_to); + +-- 자재 규격/재질 기준표 인덱스 +CREATE INDEX IF NOT EXISTS idx_material_standards_code ON material_standards(standard_code); +CREATE INDEX IF NOT EXISTS idx_material_categories_standard ON material_categories(standard_id); +CREATE INDEX IF NOT EXISTS idx_material_specifications_category ON material_specifications(category_id); +CREATE INDEX IF NOT EXISTS idx_material_grades_specification ON material_grades(specification_id); +CREATE INDEX IF NOT EXISTS idx_material_patterns_specification ON material_patterns(specification_id); +CREATE INDEX IF NOT EXISTS idx_special_materials_type ON special_materials(material_type); +CREATE INDEX IF NOT EXISTS idx_special_material_grades_material ON special_material_grades(material_id); +CREATE INDEX IF NOT EXISTS idx_special_material_patterns_material ON special_material_patterns(material_id); + +-- 활성 상태 인덱스 +CREATE INDEX IF NOT EXISTS idx_material_standards_active ON material_standards(is_active); +CREATE INDEX IF NOT EXISTS idx_material_categories_active ON material_categories(is_active); +CREATE INDEX IF NOT EXISTS idx_material_specifications_active ON material_specifications(is_active); +CREATE INDEX IF NOT EXISTS idx_material_grades_active ON material_grades(is_active); +CREATE INDEX IF NOT EXISTS idx_material_patterns_active ON material_patterns(is_active); +CREATE INDEX IF NOT EXISTS idx_special_materials_active ON special_materials(is_active); +CREATE INDEX IF NOT EXISTS idx_special_material_grades_active ON special_material_grades(is_active); +CREATE INDEX IF NOT EXISTS idx_special_material_patterns_active ON special_material_patterns(is_active); + +-- 자재 비교 시스템 인덱스 +CREATE INDEX IF NOT EXISTS idx_material_revisions_job ON material_revisions_comparison(job_no); +CREATE INDEX IF NOT EXISTS idx_material_revisions_current ON material_revisions_comparison(current_revision); +CREATE INDEX IF NOT EXISTS idx_material_comparison_hash ON material_comparison_details(material_hash); +CREATE INDEX IF NOT EXISTS idx_material_comparison_type ON material_comparison_details(change_type); + +-- Tubing 시스템 인덱스 +CREATE INDEX IF NOT EXISTS idx_tubing_specs_category ON tubing_specifications(category_id); +CREATE INDEX IF NOT EXISTS idx_tubing_specs_material ON tubing_specifications(material_grade); +CREATE INDEX IF NOT EXISTS idx_tubing_specs_diameter ON tubing_specifications(outer_diameter_mm, wall_thickness_mm); +CREATE INDEX IF NOT EXISTS idx_tubing_products_spec ON tubing_products(specification_id); +CREATE INDEX IF NOT EXISTS idx_tubing_products_manufacturer ON tubing_products(manufacturer_id); +CREATE INDEX IF NOT EXISTS idx_tubing_products_part_number ON tubing_products(manufacturer_part_number); +CREATE INDEX IF NOT EXISTS idx_material_tubing_mapping_material ON material_tubing_mapping(material_id); +CREATE INDEX IF NOT EXISTS idx_material_tubing_mapping_product ON material_tubing_mapping(tubing_product_id); + +-- 사용자 활동 로그 인덱스 +CREATE INDEX IF NOT EXISTS idx_user_activity_logs_username ON user_activity_logs(username); +CREATE INDEX IF NOT EXISTS idx_user_activity_logs_activity_type ON user_activity_logs(activity_type); +CREATE INDEX IF NOT EXISTS idx_user_activity_logs_created_at ON user_activity_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_user_activity_logs_target ON user_activity_logs(target_type, target_id); + +-- ================================ +-- SPECIAL 카테고리 지원 테이블 +-- ================================ + +-- SPECIAL 키워드 패턴 테이블 +CREATE TABLE IF NOT EXISTS special_classification_patterns ( + id SERIAL PRIMARY KEY, + pattern_type VARCHAR(20) NOT NULL, -- 'KEYWORD', 'REGEX', 'EXACT' + pattern_value VARCHAR(200) NOT NULL, + description TEXT, + priority INTEGER DEFAULT 1, -- 우선순위 (낮을수록 높은 우선순위) + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- SPECIAL 자재 상세 정보 테이블 (도면 업로드 관련) +CREATE TABLE IF NOT EXISTS special_material_details ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + + -- 도면 정보 + drawing_number VARCHAR(100), -- 도면 번호 + drawing_revision VARCHAR(20), -- 도면 리비전 + drawing_uploaded BOOLEAN DEFAULT FALSE, -- 도면 업로드 여부 + drawing_file_path TEXT, -- 도면 파일 경로 + + -- 특수 요구사항 + special_requirements TEXT, -- 특수 제작 요구사항 + manufacturing_notes TEXT, -- 제작 참고사항 + approval_required BOOLEAN DEFAULT TRUE, -- 승인 필요 여부 + approved_by VARCHAR(100), -- 승인자 + approved_at TIMESTAMP, -- 승인 일시 + + -- 분류 정보 + classification_confidence FLOAT DEFAULT 1.0, + classification_reason TEXT, -- 분류 근거 + + -- 관리 정보 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- ================================ +-- 추가 성능 최적화 인덱스 (16_performance_indexes.sql) +-- ================================ + +-- 복합 인덱스 (자주 함께 사용되는 컬럼들) +CREATE INDEX IF NOT EXISTS idx_files_job_revision ON files(job_no, revision) WHERE is_active = true; +CREATE INDEX IF NOT EXISTS idx_files_job_date ON files(job_no, upload_date DESC) WHERE is_active = true; +CREATE INDEX IF NOT EXISTS idx_materials_file_category ON materials(file_id, classified_category); +CREATE INDEX IF NOT EXISTS idx_materials_category_grade ON materials(classified_category, material_grade); + +-- 검색 성능 향상 인덱스 +CREATE INDEX IF NOT EXISTS idx_materials_description_gin ON materials USING gin(to_tsvector('english', original_description)); + +-- 정렬 성능 향상 인덱스 +CREATE INDEX IF NOT EXISTS idx_jobs_status_created ON jobs(status, created_at DESC) WHERE is_active = true; +CREATE INDEX IF NOT EXISTS idx_materials_quantity_desc ON materials(quantity DESC); + +-- 조건부 인덱스 (특정 조건에서만 사용) +CREATE INDEX IF NOT EXISTS idx_materials_unverified ON materials(classified_category, classification_confidence) WHERE is_verified = false; +CREATE INDEX IF NOT EXISTS idx_materials_low_confidence ON materials(file_id, classified_category) WHERE classification_confidence < 0.8; + +-- SPECIAL 카테고리 전용 인덱스 +CREATE INDEX IF NOT EXISTS idx_materials_special_category ON materials(classified_category) WHERE classified_category = 'SPECIAL'; +CREATE INDEX IF NOT EXISTS idx_special_patterns_type ON special_classification_patterns(pattern_type); +CREATE INDEX IF NOT EXISTS idx_special_patterns_active ON special_classification_patterns(is_active); +CREATE INDEX IF NOT EXISTS idx_special_details_material ON special_material_details(material_id); +CREATE INDEX IF NOT EXISTS idx_special_details_drawing_uploaded ON special_material_details(drawing_uploaded); + +-- ================================ +-- SPECIAL 카테고리 기본 데이터 삽입 +-- ================================ + +-- 기본 SPECIAL 키워드 패턴 삽입 +INSERT INTO special_classification_patterns (pattern_type, pattern_value, description, priority) VALUES +('KEYWORD', 'SPECIAL', '영문 SPECIAL 키워드', 1), +('KEYWORD', '스페셜', '한글 스페셜 키워드', 1), +('KEYWORD', 'SPEC', '영문 SPEC 축약어', 2), +('KEYWORD', 'SPL', '영문 SPL 축약어', 2) +ON CONFLICT DO NOTHING; + +-- 분류 관련 인덱스 (05_add_classification_columns.sql) +CREATE INDEX IF NOT EXISTS idx_materials_subcategory ON materials(subcategory); +CREATE INDEX IF NOT EXISTS idx_materials_standard ON materials(standard); +CREATE INDEX IF NOT EXISTS idx_materials_grade ON materials(grade); + +-- Job 연결 인덱스 (02_modify_files_table.sql) +CREATE INDEX IF NOT EXISTS idx_files_job_no ON files(job_no); + +-- 프로젝트 타입 인덱스 (17_add_project_type_column.sql) +CREATE INDEX IF NOT EXISTS idx_jobs_project_type ON jobs(project_type); + +-- 사용자 요구사항 테이블 인덱스 (05_create_pipe_details_and_requirements_postgres.sql) +CREATE INDEX IF NOT EXISTS idx_user_requirements_file_id ON user_requirements(file_id); +CREATE INDEX IF NOT EXISTS idx_user_requirements_material_id ON user_requirements(material_id); +CREATE INDEX IF NOT EXISTS idx_user_requirements_status ON user_requirements(status); +CREATE INDEX IF NOT EXISTS idx_user_requirements_type ON user_requirements(requirement_type); + +-- ================================ +-- 8. 트리거 함수 및 트리거 생성 +-- ================================ + +-- updated_at 자동 업데이트 함수 +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- 파이프 끝단 가공 테이블 트리거 +CREATE OR REPLACE FUNCTION update_pipe_end_preparations_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- 구매 관련 트리거 함수들 +CREATE OR REPLACE FUNCTION update_purchase_items_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION update_files_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- 트리거 적용 +CREATE TRIGGER IF NOT EXISTS update_pipe_end_preparations_updated_at BEFORE UPDATE ON pipe_end_preparations + FOR EACH ROW EXECUTE FUNCTION update_pipe_end_preparations_updated_at(); + +CREATE TRIGGER IF NOT EXISTS trigger_update_purchase_items_timestamp BEFORE UPDATE ON purchase_items + FOR EACH ROW EXECUTE FUNCTION update_purchase_items_timestamp(); + +CREATE TRIGGER IF NOT EXISTS trigger_files_updated_at BEFORE UPDATE ON files + FOR EACH ROW EXECUTE FUNCTION update_files_updated_at(); + +-- ================================ +-- 해시 생성 함수 및 트리거 (10_add_material_comparison_system.sql) +-- ================================ + +-- 해시 생성 함수 +CREATE OR REPLACE FUNCTION generate_material_hash( + description TEXT, + size_spec TEXT DEFAULT '', + material_grade TEXT DEFAULT '' +) RETURNS VARCHAR(32) AS $$ +BEGIN + -- 정규화: 대소문자 통일, 공백 정리 + description := UPPER(TRIM(REGEXP_REPLACE(description, '\s+', ' ', 'g'))); + size_spec := UPPER(TRIM(COALESCE(size_spec, ''))); + material_grade := UPPER(TRIM(COALESCE(material_grade, ''))); + + -- MD5 해시 생성 (32자리) + RETURN MD5(description || '|' || size_spec || '|' || material_grade); +END; +$$ LANGUAGE plpgsql; + +-- 자동 해시 생성 트리거 함수 +CREATE OR REPLACE FUNCTION auto_generate_material_hash() +RETURNS TRIGGER AS $$ +BEGIN + -- 새로 삽입되거나 업데이트될 때 자동으로 해시 생성 + NEW.material_hash := generate_material_hash( + NEW.original_description, + NEW.size_spec, + NEW.material_grade + ); + NEW.normalized_description := UPPER(TRIM(REGEXP_REPLACE(NEW.original_description, '\s+', ' ', 'g'))); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- 자동 해시 생성 트리거 적용 +CREATE TRIGGER IF NOT EXISTS trigger_auto_material_hash + BEFORE INSERT OR UPDATE ON materials + FOR EACH ROW + EXECUTE FUNCTION auto_generate_material_hash(); + +-- ================================ +-- 9. 기본 데이터 삽입 +-- ================================ + +-- 기본 프로젝트 생성 +INSERT INTO projects (official_project_code, project_name, client_name, status, description) VALUES +('TK-MP-DEV-001', 'TK-MP 개발 프로젝트', 'TK Engineering', 'active', '개발 및 테스트용 프로젝트'), +('TK-MP-DEMO-001', 'TK-MP 데모 프로젝트', 'Demo Client', 'active', '데모 및 시연용 프로젝트'), +('TK-MP-TEST-001', 'TK-MP 테스트 프로젝트', 'Test Client', 'active', '기능 테스트용 프로젝트') +ON CONFLICT (official_project_code) DO NOTHING; + +-- Tubing 카테고리 기초 데이터 +INSERT INTO tubing_categories (category_code, category_name, description) VALUES +('GENERAL', '일반 Tubing', '일반적인 스테인리스 스틸 튜빙'), +('VCR', 'VCR Tubing', 'VCR (Vacuum Coupling Radiation) 연결용 튜빙'), +('SANITARY', 'Sanitary Tubing', '위생용 튜빙 (식품, 제약 등)'), +('HVAC', 'HVAC Tubing', '공조용 튜빙'), +('HYDRAULIC', 'Hydraulic Tubing', '유압용 튜빙'), +('PNEUMATIC', 'Pneumatic Tubing', '공압용 튜빙'), +('PROCESS', 'Process Tubing', '공정용 특수 튜빙'), +('EXOTIC', 'Exotic Material', '특수 재질 튜빙 (Hastelloy, Inconel 등)') +ON CONFLICT (category_code) DO NOTHING; + +-- 주요 제조사 기초 데이터 +INSERT INTO tubing_manufacturers (manufacturer_code, manufacturer_name, country) VALUES +('SWAGELOK', 'Swagelok Company', 'USA'), +('PARKER', 'Parker Hannifin', 'USA'), +('HAM_LET', 'Ham-Let Group', 'Israel'), +('SUPERLOK', 'Superlok USA', 'USA'), +('FITOK', 'Fitok Group', 'China'), +('DK_LOK', 'DK-Lok Corporation', 'South Korea'), +('GYROLOK', 'Gyrolok (Oliver Valves)', 'UK'), +('AS_ONE', 'AS ONE Corporation', 'Japan') +ON CONFLICT (manufacturer_code) DO NOTHING; + +-- 기본 요구사항 타입 데이터 삽입 (05_create_pipe_details_and_requirements_postgres.sql) +INSERT INTO requirement_types (type_code, type_name, category, description) VALUES +('IMPACT_TEST', '임팩테스트', 'TEST', 'Charpy V-notch, Izod 등의 충격 시험'), +('HEAT_TREATMENT', '열처리', 'TREATMENT', '정규화, 어닐링, 템퍼링 등의 열처리'), +('CERTIFICATION', '인증서', 'CERTIFICATION', '재질증명서, 시험성적서 등'), +('CUSTOM_SPEC', '특수사양', 'CUSTOM', '고객 특별 요구사항'), +('NDT_TEST', '비파괴검사', 'TEST', '초음파, 방사선, 자분탐상 등'), +('CHEMICAL_ANALYSIS', '화학분석', 'TEST', '화학성분 분석'), +('MECHANICAL_TEST', '기계적성질', 'TEST', '인장, 압축, 굽힘 시험'), +('SURFACE_TREATMENT', '표면처리', 'TREATMENT', '도금, 도장, 패시베이션 등'), +('DIMENSIONAL_CHECK', '치수검사', 'INSPECTION', '치수, 형상 검사'), +('WELDING_SPEC', '용접사양', 'SPEC', '용접 방법, 용접재 등') +ON CONFLICT (type_code) DO NOTHING; + +-- ================================ +-- 10. 뷰 생성 +-- ================================ + +-- 프로젝트 상태 요약 뷰 +CREATE OR REPLACE VIEW project_status_view AS +SELECT + p.id, + COALESCE(p.official_project_code, p.design_project_code) as display_code, + p.project_name, + p.status, + COUNT(f.id) as file_count, + COUNT(m.id) as material_count, + p.created_at +FROM projects p +LEFT JOIN files f ON p.id = f.project_id AND f.is_active = true +LEFT JOIN materials m ON f.id = m.file_id +GROUP BY p.id, p.official_project_code, p.design_project_code, + p.project_name, p.status, p.created_at +ORDER BY p.created_at DESC; + +-- ================================ +-- 추가 뷰들 (05_add_classification_columns.sql, 10_add_material_comparison_system.sql) +-- ================================ + +-- 분류 결과 통계 조회용 뷰 +CREATE OR REPLACE VIEW classification_summary AS +SELECT + f.job_no, + f.original_filename, + f.parsed_count, + f.classification_completed, + f.classification_timestamp, + COUNT(*) as total_materials, + COUNT(CASE WHEN m.classified_category = 'BOLT' THEN 1 END) as bolt_count, + COUNT(CASE WHEN m.classified_category = 'FLANGE' THEN 1 END) as flange_count, + COUNT(CASE WHEN m.classified_category = 'FITTING' THEN 1 END) as fitting_count, + COUNT(CASE WHEN m.classified_category = 'GASKET' THEN 1 END) as gasket_count, + COUNT(CASE WHEN m.classified_category = 'INSTRUMENT' THEN 1 END) as instrument_count, + COUNT(CASE WHEN m.classified_category = 'PIPE' THEN 1 END) as pipe_count, + COUNT(CASE WHEN m.classified_category = 'VALVE' THEN 1 END) as valve_count, + COUNT(CASE WHEN m.classified_category = 'MATERIAL' THEN 1 END) as material_count, + COUNT(CASE WHEN m.classified_category = 'OTHER' THEN 1 END) as other_count, + AVG(m.classification_confidence) as avg_confidence, + COUNT(CASE WHEN m.is_verified = TRUE THEN 1 END) as verified_count +FROM files f +LEFT JOIN materials m ON f.id = m.file_id +WHERE f.is_active = TRUE +GROUP BY f.id, f.job_no, f.original_filename, f.parsed_count, f.classification_completed, f.classification_timestamp; + +-- 분류 성능 통계 뷰 +CREATE OR REPLACE VIEW classification_performance AS +SELECT + classified_category, + subcategory, + standard, + COUNT(*) as total_count, + AVG(classification_confidence) as avg_confidence, + COUNT(CASE WHEN is_verified = TRUE THEN 1 END) as verified_count, + COUNT(CASE WHEN is_verified = FALSE THEN 1 END) as unverified_count, + ROUND( + (COUNT(CASE WHEN is_verified = TRUE THEN 1 END)::DECIMAL / COUNT(*) * 100), 2 + ) as verification_rate +FROM materials +WHERE classified_category IS NOT NULL +GROUP BY classified_category, subcategory, standard +ORDER BY total_count DESC; + +-- 누적 재고 현황 뷰 (10_add_material_comparison_system.sql) +CREATE OR REPLACE VIEW material_inventory_status AS +SELECT + mpt.job_no, + mpt.material_hash, + mpt.description, + mpt.size_spec, + mpt.unit, + + -- 누적 수량 + SUM(mpt.confirmed_quantity) as total_confirmed, + SUM(mpt.ordered_quantity) as total_ordered, + SUM(mpt.received_quantity) as total_received, + + -- 현재 가용 재고 + SUM(mpt.received_quantity) as available_stock, + + -- 최신 리비전 정보 + MAX(mpt.revision) as latest_revision, + MAX(mpt.updated_at) as last_updated + +FROM material_purchase_tracking mpt +WHERE mpt.purchase_status != 'CANCELLED' +GROUP BY mpt.job_no, mpt.material_hash, mpt.description, mpt.size_spec, mpt.unit; + +-- ================================ +-- 완료 메시지 +-- ================================ + +DO $$ +BEGIN + RAISE NOTICE 'TK-EG BOM 자재관리 데이터베이스 스키마가 성공적으로 생성되었습니다!'; + RAISE NOTICE '생성된 주요 테이블:'; + RAISE NOTICE ' - 기본: projects, files, materials, jobs'; + RAISE NOTICE ' - 자재상세: pipe_details, fitting_details, valve_details, flange_details, bolt_details, gasket_details, instrument_details'; + RAISE NOTICE ' - 파이프: pipe_end_preparations'; + RAISE NOTICE ' - 구매: purchase_items, material_purchase_mapping, material_purchase_tracking'; + RAISE NOTICE ' - 자재규격: material_standards, material_categories, material_specifications, material_grades, material_patterns'; + RAISE NOTICE ' - 특수재질: special_materials, special_material_grades, special_material_patterns'; + RAISE NOTICE ' - 비교시스템: material_revisions_comparison, material_comparison_details'; + RAISE NOTICE ' - Tubing: tubing_categories, tubing_specifications, tubing_manufacturers, tubing_products, material_tubing_mapping'; + RAISE NOTICE ' - 요구사항: requirement_types, user_requirements'; + RAISE NOTICE ' - 로그: user_activity_logs'; + RAISE NOTICE '인증은 SSO(tkuser)에서 처리됩니다.'; +END $$; diff --git a/tkeg/api/requirements.txt b/tkeg/api/requirements.txt new file mode 100644 index 0000000..fc67ce1 --- /dev/null +++ b/tkeg/api/requirements.txt @@ -0,0 +1,62 @@ +alembic==1.13.1 +annotated-types==0.7.0 +anyio==3.7.1 +async-timeout==5.0.1 +black==23.11.0 +certifi==2026.1.4 +click==8.1.8 +coverage==7.10.7 +dnspython==2.7.0 +email-validator==2.3.0 +et_xmlfile==2.0.0 +exceptiongroup==1.3.1 +fastapi==0.104.1 +flake8==6.1.0 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.25.2 +idna==3.11 +iniconfig==2.1.0 +Mako==1.3.10 +MarkupSafe==3.0.3 +mccabe==0.7.0 +mypy_extensions==1.1.0 +numpy==1.26.4 +openpyxl==3.1.2 +packaging==25.0 +pandas==2.1.4 +pathspec==1.0.1 +platformdirs==4.4.0 +pluggy==1.6.0 +psycopg2-binary==2.9.9 +pycodestyle==2.11.1 +pydantic==2.5.2 +pydantic-settings==2.1.0 +pydantic_core==2.14.5 +pyflakes==3.1.0 +PyJWT==2.8.0 +pytest==7.4.3 +pytest-asyncio==0.21.1 +pytest-cov==4.1.0 +pytest-mock==3.12.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.0 +python-magic==0.4.27 +python-multipart==0.0.6 +pytz==2025.2 +PyYAML==6.0.3 +RapidFuzz==3.13.0 +redis==5.0.1 +six==1.17.0 +sniffio==1.3.1 +SQLAlchemy==2.0.23 +starlette==0.27.0 +tomli==2.3.0 +typing_extensions==4.15.0 +tzdata==2025.3 +uvicorn==0.24.0 +uvloop==0.22.1 +watchfiles==1.1.1 +websockets==15.0.1 +xlrd==2.0.1 diff --git a/tkeg/web/.gitignore b/tkeg/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/tkeg/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/tkeg/web/Dockerfile b/tkeg/web/Dockerfile new file mode 100644 index 0000000..343a455 --- /dev/null +++ b/tkeg/web/Dockerfile @@ -0,0 +1,17 @@ +FROM node:18-alpine AS build +WORKDIR /app + +ARG VITE_API_URL=/api +ENV VITE_API_URL=$VITE_API_URL + +COPY package*.json ./ +RUN npm ci --force + +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/tkeg/web/PAGES_GUIDE.md b/tkeg/web/PAGES_GUIDE.md new file mode 100644 index 0000000..28032db --- /dev/null +++ b/tkeg/web/PAGES_GUIDE.md @@ -0,0 +1,348 @@ +# 프론트엔드 페이지 가이드 + +이 문서는 TK-MP 프로젝트의 프론트엔드 페이지들의 역할과 기능을 정리한 가이드입니다. + +## 📋 목차 + +- [인증 관련 페이지](#인증-관련-페이지) +- [대시보드 및 메인 페이지](#대시보드-및-메인-페이지) +- [프로젝트 관리 페이지](#프로젝트-관리-페이지) +- [BOM 관리 페이지](#bom-관리-페이지) +- [구매 관리 페이지](#구매-관리-페이지) +- [시스템 관리 페이지](#시스템-관리-페이지) +- [컴포넌트 구조](#컴포넌트-구조) + +--- + +## 인증 관련 페이지 + +### `LoginPage.jsx` +- **역할**: 사용자 로그인 페이지 +- **기능**: + - 사용자 인증 (이메일/비밀번호) + - 로그인 상태 관리 + - 인증 실패 시 에러 메시지 표시 +- **라우팅**: `/login` +- **접근 권한**: 모든 사용자 (비인증) + +--- + +## 대시보드 및 메인 페이지 + +### `DashboardPage.jsx` +- **역할**: 메인 대시보드 페이지 +- **기능**: + - 프로젝트 선택 드롭다운 + - **새로운 3개 BOM 카드**: 📤 BOM Upload, 📊 Revision Management, 📋 BOM Management + - 구매신청 관리 카드 + - 관리자 전용 기능 (사용자 관리, 로그 관리) + - 프로젝트 생성/편집/삭제/비활성화 +- **라우팅**: `/dashboard` +- **접근 권한**: 인증된 사용자 +- **디자인**: 데본씽크 스타일, 글래스모피즘 효과 +- **업데이트**: BOM 기능을 3개 전용 페이지로 분리 + +### `MainPage.jsx` +- **역할**: 초기 랜딩 페이지 +- **기능**: 기본 페이지 구조 및 네비게이션 +- **라우팅**: `/` +- **접근 권한**: 인증된 사용자 + +--- + +## 프로젝트 관리 페이지 + +### `ProjectsPage.jsx` +- **역할**: 프로젝트 목록 및 관리 +- **기능**: + - 프로젝트 목록 조회 + - 프로젝트 생성/수정/삭제 + - 프로젝트 상태 관리 +- **라우팅**: `/projects` +- **접근 권한**: 관리자 + +### `InactiveProjectsPage.jsx` +- **역할**: 비활성화된 프로젝트 관리 +- **기능**: + - 비활성 프로젝트 목록 조회 + - 프로젝트 활성화/삭제 + - 전체 선택/해제 기능 +- **라우팅**: `/inactive-projects` +- **접근 권한**: 관리자 + +### `JobRegistrationPage.jsx` +- **역할**: 새로운 작업(Job) 등록 +- **기능**: + - 작업 정보 입력 및 등록 + - 프로젝트 연결 +- **라우팅**: `/job-registration` +- **접근 권한**: 관리자 + +### `JobSelectionPage.jsx` +- **역할**: 작업 선택 페이지 +- **기능**: + - 등록된 작업 목록 조회 + - 작업 선택 및 이동 +- **라우팅**: `/job-selection` +- **접근 권한**: 인증된 사용자 + +--- + +## BOM 관리 페이지 + +### `BOMUploadPage.jsx` ⭐ 신규 +- **역할**: BOM 파일 업로드 전용 페이지 +- **기능**: + - 드래그 앤 드롭 파일 업로드 + - 파일 검증 (형식: .xlsx, .xls, .csv / 최대 50MB) + - 실시간 업로드 진행률 표시 + - 자동 BOM 이름 설정 + - 업로드 완료 후 BOM 관리 페이지로 자동 이동 +- **라우팅**: `/bom-upload` +- **접근 권한**: 인증된 사용자 +- **디자인**: 모던 UI, 글래스모피즘 효과 + +### `BOMRevisionPage.jsx` ⭐ 신규 +- **역할**: BOM 리비전 관리 전용 페이지 +- **현재 상태**: 기본 구조 완성, 고급 기능 개발 예정 +- **기능**: + - BOM 파일 목록 표시 + - 리비전 히스토리 개요 + - 개발 예정 기능 안내 (타임라인, 비교, 롤백) +- **라우팅**: `/bom-revision` +- **접근 권한**: 인증된 사용자 +- **향후 계획**: 📊 리비전 타임라인, 🔍 변경사항 비교, ⏪ 롤백 시스템 + +### `BOMManagementPage.jsx` +- **역할**: BOM(Bill of Materials) 자재 관리 페이지 +- **기능**: + - 카테고리별 자재 조회 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT, SPECIAL, UNCLASSIFIED) + - 자재 선택 및 구매신청 (엑셀 내보내기) + - 구매신청된 자재 비활성화 표시 + - 사용자 요구사항 입력 및 저장 (Brand, Additional Request) + - 카테고리별 전용 컴포넌트 구조 +- **라우팅**: `/bom-management` +- **접근 권한**: 인증된 사용자 +- **특징**: 카테고리별 컴포넌트로 완전 분리된 구조 + +### `NewMaterialsPage.jsx` (레거시) +- **역할**: 기존 자재 관리 페이지 (현재 백업용) +- **상태**: 사용 중단, `BOMManagementPage`로 대체됨 +- **기능**: 자재 분류, 편집, 내보내기 (기존 로직 보존) + +### `BOMStatusPage.jsx` +- **역할**: BOM 상태 조회 페이지 +- **기능**: + - BOM 파일 상태 확인 + - 처리 진행률 표시 +- **라우팅**: `/bom-status` +- **접근 권한**: 인증된 사용자 + +### `_deprecated/BOMWorkspacePage.jsx` (사용 중단) +- **역할**: 기존 BOM 작업 공간 (사용 중단) +- **상태**: `BOMUploadPage`와 `BOMRevisionPage`로 분리됨 +- **이유**: 업로드와 리비전 관리 기능을 별도 페이지로 분리하여 사용성 개선 + +--- + +## 구매 관리 페이지 + +### `PurchaseRequestPage.jsx` +- **역할**: 구매신청 관리 페이지 +- **기능**: + - 구매신청 목록 조회 + - 구매신청 제목 편집 (인라인 편집) + - 원본 파일 정보 표시 + - 엑셀 파일 다운로드 + - 구매신청 자재 상세 조회 +- **라우팅**: `/purchase-requests` +- **접근 권한**: 인증된 사용자 +- **특징**: BOM 페이지와 연동된 구매 워크플로우 + +### `PurchaseBatchPage.jsx` +- **역할**: 구매 배치 처리 페이지 +- **기능**: + - 대량 구매 처리 + - 배치 작업 관리 +- **라우팅**: `/purchase-batch` +- **접근 권한**: 관리자 + +--- + +## 시스템 관리 페이지 + +### `UserManagementPage.jsx` +- **역할**: 사용자 관리 페이지 +- **기능**: + - 사용자 목록 조회 + - 사용자 생성/수정/삭제 + - 권한 관리 + - 사용자 상태 관리 +- **라우팅**: `/user-management` +- **접근 권한**: 관리자 + +### `SystemSettingsPage.jsx` +- **역할**: 시스템 설정 페이지 +- **기능**: + - 시스템 전반 설정 관리 + - 환경 변수 설정 +- **라우팅**: `/system-settings` +- **접근 권한**: 관리자 + +### `SystemSetupPage.jsx` +- **역할**: 시스템 초기 설정 페이지 +- **기능**: + - 시스템 초기 구성 + - 기본 데이터 설정 +- **라우팅**: `/system-setup` +- **접근 권한**: 관리자 + +### `SystemLogsPage.jsx` +- **역할**: 시스템 로그 조회 페이지 +- **기능**: + - 시스템 로그 조회 + - 로그 필터링 및 검색 +- **라우팅**: `/system-logs` +- **접근 권한**: 관리자 + +### `LogMonitoringPage.jsx` +- **역할**: 로그 모니터링 페이지 +- **기능**: + - 실시간 로그 모니터링 + - 로그 분석 및 알림 +- **라우팅**: `/log-monitoring` +- **접근 권한**: 관리자 + +### `AccountSettingsPage.jsx` +- **역할**: 개인 계정 설정 페이지 +- **기능**: + - 개인 정보 수정 + - 비밀번호 변경 + - 계정 설정 관리 +- **라우팅**: `/account-settings` +- **접근 권한**: 인증된 사용자 + +--- + +## 워크스페이스 페이지 + +### `ProjectWorkspacePage.jsx` +- **역할**: 프로젝트 작업 공간 +- **기능**: + - 프로젝트별 작업 환경 + - 파일 관리 및 협업 도구 +- **라우팅**: `/project-workspace` +- **접근 권한**: 인증된 사용자 + +--- + +## 컴포넌트 구조 + +### `components/common/` +- **ErrorBoundary.jsx**: 에러 경계 컴포넌트 +- **UserMenu.jsx**: 사용자 드롭다운 메뉴 + +### `components/bom/` +- **shared/**: 공통 BOM 컴포넌트 + - `FilterableHeader.jsx`: 필터링 가능한 테이블 헤더 + - `MaterialTable.jsx`: 자재 테이블 공통 컴포넌트 +- **materials/**: 카테고리별 자재 뷰 컴포넌트 + - `PipeMaterialsView.jsx`: 파이프 자재 관리 + - `FittingMaterialsView.jsx`: 피팅 자재 관리 + - `FlangeMaterialsView.jsx`: 플랜지 자재 관리 + - `ValveMaterialsView.jsx`: 밸브 자재 관리 + - `GasketMaterialsView.jsx`: 가스켓 자재 관리 + - `BoltMaterialsView.jsx`: 볼트 자재 관리 + - `SupportMaterialsView.jsx`: 서포트 자재 관리 + - `SpecialMaterialsView.jsx`: 특수 제작 자재 관리 + +#### SPECIAL 카테고리 상세 기능 +`SpecialMaterialsView.jsx`는 특수 제작이 필요한 자재들을 관리하는 컴포넌트입니다: + +**주요 기능:** +- **자동 타입 분류**: FLANGE, OIL PUMP, COMPRESSOR, VALVE, FITTING, PIPE 등 큰 범주 자동 인식 +- **정보 파싱**: 자재 설명을 도면, 항목1-4로 체계적 분리 +- **테이블 구조**: `Type | Drawing | Item 1 | Item 2 | Item 3 | Item 4 | Additional Request | Purchase Quantity` +- **엑셀 내보내기**: P열 납기일 규칙 준수, 관리항목 자동 채움 +- **저장 기능**: 추가요청사항 저장/편집 (다른 카테고리와 동일) + +**처리 예시:** +- `SAE SPECIAL FF, OIL PUMP, ASTM A105` → Type: OIL PUMP, Item1: SAE SPECIAL FF, Item2: OIL PUMP, Item3: ASTM A105 +- `FLG SPECIAL FF, COMPRESSOR(N11), ASTM A105` → Type: FLANGE, Item1: FLG SPECIAL FF, Item2: COMPRESSOR(N11), Item3: ASTM A105 + +**분류 조건:** +- `SPECIAL` 키워드 포함 (단, `SPECIFICATION` 제외) +- 한글 `스페셜` 또는 `SPL` 키워드 포함 + +### 기타 컴포넌트 +- **NavigationMenu.jsx**: 사이드바 네비게이션 +- **NavigationBar.jsx**: 상단 네비게이션 바 +- **FileUpload.jsx**: 파일 업로드 컴포넌트 +- **ProtectedRoute.jsx**: 권한 기반 라우트 보호 + +--- + +## 페이지 추가 시 규칙 + +1. **새 페이지 생성 시 이 문서 업데이트 필수** +2. **페이지 역할과 기능을 명확히 문서화** +3. **라우팅 경로와 접근 권한 명시** +4. **관련 컴포넌트와의 연관성 설명** +5. **디자인 시스템 준수 (데본씽크 스타일, 글래스모피즘)** + +--- + +## 디자인 시스템 + +### 색상 팔레트 +- **Primary**: 블루 그라데이션 (#3b82f6 → #1d4ed8) +- **Background**: 글래스 효과 (backdrop-filter: blur) +- **Cards**: 20px 둥근 모서리, 그림자 효과 + +### BOM 카테고리 색상 +- **PIPE**: #3b82f6 (파란색) +- **FITTING**: #10b981 (초록색) +- **FLANGE**: #f59e0b (주황색) +- **VALVE**: #ef4444 (빨간색) +- **GASKET**: #8b5cf6 (보라색) +- **BOLT**: #6b7280 (회색) +- **SUPPORT**: #f97316 (주황색) +- **SPECIAL**: #ec4899 (핑크색) + +### 반응형 디자인 +- **Desktop**: 3-4열 그리드 +- **Tablet**: 2열 그리드 +- **Mobile**: 1열 그리드 + +### 타이포그래피 +- **Font Family**: Apple 시스템 폰트 +- **Weight**: 다양한 weight 활용 (400, 500, 600, 700) + +--- + +*마지막 업데이트: 2024-10-17* +*다음 페이지 추가 시 반드시 이 문서를 업데이트하세요.* + +## 최근 업데이트 내역 + +### 2024-10-17: BOM 페이지 구조 개편 ⭐ 주요 업데이트 +- **새로운 페이지 추가**: + - `BOMUploadPage.jsx`: 전용 업로드 페이지 (드래그 앤 드롭, 파일 검증) + - `BOMRevisionPage.jsx`: 리비전 관리 페이지 (기본 구조, 향후 고급 기능 예정) +- **기존 페이지 정리**: + - `BOMWorkspacePage.jsx` → `_deprecated/` 폴더로 이동 (사용 중단) + - 업로드와 리비전 기능을 별도 페이지로 분리하여 사용성 개선 +- **대시보드 개편**: + - BOM 관리를 3개 카드로 분리: 📤 Upload, 📊 Revision, 📋 Management + - 각 기능별 전용 페이지로 명확한 역할 분담 +- **라우팅 업데이트**: + - `/bom-upload`: 새 파일 업로드 + - `/bom-revision`: 리비전 관리 + - `/bom-management`: 자재 관리 + +### 2024-10-17: SPECIAL 카테고리 추가 +- `SpecialMaterialsView.jsx` 컴포넌트 추가 +- 특수 제작 자재 관리 기능 구현 +- 자동 타입 분류 및 정보 파싱 시스템 +- 엑셀 내보내기 규칙 적용 (P열 납기일, 관리항목 자동 채움) +- BOM 카테고리 색상 팔레트에 SPECIAL (#ec4899) 추가 diff --git a/tkeg/web/README.md b/tkeg/web/README.md new file mode 100644 index 0000000..d1df511 --- /dev/null +++ b/tkeg/web/README.md @@ -0,0 +1,161 @@ +# TK-MP BOM 관리 시스템 - 프론트엔드 + +React + Material-UI 기반의 BOM(Bill of Materials) 관리 시스템 프론트엔드입니다. + +## 🚀 기능 + +- **대시보드**: 프로젝트 현황 및 통계 확인 +- **프로젝트 관리**: 프로젝트 생성, 수정, 삭제 +- **파일 업로드**: 드래그&드롭으로 Excel 파일 업로드 +- **자재 목록**: 검색, 필터링, 정렬이 가능한 자재 테이블 + +## 🛠️ 기술 스택 + +- **React 18** - 컴포넌트 기반 UI 라이브러리 +- **Material-UI (MUI)** - 현대적인 UI 컴포넌트 +- **Vite** - 빠른 개발 서버 및 빌드 도구 +- **React Dropzone** - 드래그&드롭 파일 업로드 +- **MUI DataGrid** - 고성능 데이터 테이블 + +## 📦 설치 및 실행 + +```bash +# 의존성 설치 +npm install + +# 개발 서버 실행 (http://localhost:3000) +npm run dev + +# 프로덕션 빌드 +npm run build + +# 빌드 미리보기 +npm run preview +``` + +## 🔗 백엔드 연동 + +백엔드 서버가 `http://localhost:8000`에서 실행되고 있어야 합니다. + +```bash +# 백엔드 서버 실행 (별도 터미널) +cd ../backend +source venv/bin/activate +uvicorn app.main:app --reload +``` + +## 📁 프로젝트 구조 + +``` +frontend/ +├── src/ +│ ├── components/ +│ │ ├── common/ # 공통 컴포넌트 +│ │ ├── bom/ # BOM 관련 컴포넌트 +│ │ │ ├── materials/ # 카테고리별 자재 뷰 +│ │ │ └── shared/ # BOM 공통 컴포넌트 +│ │ └── ... # 기타 컴포넌트 +│ ├── pages/ # 페이지 컴포넌트 +│ │ ├── DashboardPage.jsx # 메인 대시보드 +│ │ ├── BOMManagementPage.jsx # BOM 관리 +│ │ ├── PurchaseRequestPage.jsx # 구매신청 관리 +│ │ └── ... # 기타 페이지들 +│ ├── App.jsx # 메인 앱 +│ ├── main.jsx # 엔트리 포인트 +│ └── index.css # 전역 스타일 +├── PAGES_GUIDE.md # 📋 페이지 역할 가이드 +├── package.json +└── vite.config.js +``` + +## 📋 페이지 가이드 + +**모든 페이지의 역할과 기능은 [`PAGES_GUIDE.md`](./PAGES_GUIDE.md)에 상세히 문서화되어 있습니다.** + +### 🔄 페이지 개발 규칙 + +1. **새 페이지 생성 시 `PAGES_GUIDE.md` 업데이트 필수** +2. **페이지 역할, 기능, 라우팅, 접근 권한을 명확히 문서화** +3. **관련 컴포넌트와의 연관성 설명** +4. **디자인 시스템 준수 (데본씽크 스타일, 글래스모피즘)** + +### ⚠️ **Docker 배포 시 주의사항** + +**프론트엔드 변경사항이 반영되지 않을 때:** + +```bash +# 1. 프론트엔드 컨테이너 완전 재빌드 (캐시 문제 해결) +docker-compose stop frontend +docker-compose rm -f frontend +docker-compose build --no-cache frontend +docker-compose up -d frontend + +# 2. 배포 후 index 파일 버전 확인 +docker exec tk-mp-frontend find /usr/share/nginx/html -name "index-*.js" + +# 3. 로컬 빌드 버전과 비교 +ls -la frontend/dist/assets/index-*.js +``` + +**주의:** Docker 컨테이너는 이전 빌드를 캐시할 수 있어 최신 변경사항이 반영되지 않을 수 있습니다. +변경사항이 보이지 않으면 반드시 `--no-cache` 옵션으로 재빌드하세요. + +### 🚀 **빠른 배포 명령어** + +```bash +# 프론트엔드 빠른 재배포 (한 줄 명령어) +docker-compose stop frontend && docker-compose rm -f frontend && docker-compose build --no-cache frontend && docker-compose up -d frontend + +# 전체 시스템 재시작 +docker-compose restart + +# 특정 서비스만 재시작 +docker-compose restart backend +docker-compose restart frontend +``` + +## 🎯 주요 페이지 + +### DashboardPage +- 프로젝트 선택 드롭다운 +- 프로젝트별 기능 카드 (BOM 관리, 구매신청 관리) +- 관리자 전용 기능 (사용자 관리, 로그 관리) +- 데본씽크 스타일 디자인 + +### BOMManagementPage +- 카테고리별 자재 관리 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT) +- 구매신청된 자재 비활성화 표시 +- 엑셀 내보내기 및 서버 저장 +- 사용자 요구사항 입력 + +### PurchaseRequestPage +- 구매신청 목록 조회 및 관리 +- 구매신청 제목 인라인 편집 +- 원본 파일 정보 표시 +- 엑셀 파일 다운로드 + +### 카테고리별 자재 뷰 컴포넌트 +- 각 자재 카테고리별 전용 뷰 컴포넌트 +- 통일된 테이블 형태 UI +- 정렬, 필터링, 전체 선택 기능 +- 구매신청된 자재 비활성화 처리 + +## 📱 반응형 디자인 + +- 모바일, 태블릿, 데스크톱 지원 +- Material-UI의 Grid 시스템 활용 +- 적응형 네비게이션 + +## 🔧 개발 도구 + +- **ESLint** - 코드 품질 검사 +- **Vite HMR** - 빠른 핫 리로드 +- **React DevTools** - 디버깅 지원 + +## 🚀 성능 최적화 + +- 컴포넌트 지연 로딩 +- 메모이제이션 적용 +- 번들 크기 최적화 +- API 요청 최적화 + diff --git a/tkeg/web/eslint.config.js b/tkeg/web/eslint.config.js new file mode 100644 index 0000000..cee1e2c --- /dev/null +++ b/tkeg/web/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/tkeg/web/index.html b/tkeg/web/index.html new file mode 100644 index 0000000..0c589ec --- /dev/null +++ b/tkeg/web/index.html @@ -0,0 +1,13 @@ + + +
+ + + ++ 💡 이 이름은 엑셀 내보내기 파일명과 자재 관리에 사용됩니다. + 동일한 BOM 이름으로 재업로드 시 리비전이 자동 증가합니다. +
++ 선택된 파일: {selectedFile.name} +
+ )} + + {error && ( +