feat(tkeg): tkeg BOM 자재관리 서비스 초기 세팅 (api + web + docker-compose)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-16 15:41:58 +09:00
parent 2699242d1f
commit 1e1d2f631a
160 changed files with 60367 additions and 0 deletions

View File

@@ -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

18
tkeg/api/Dockerfile Normal file
View File

@@ -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"]

0
tkeg/api/app/__init__.py Normal file
View File

View File

@@ -0,0 +1,4 @@
"""
API 모듈
분리된 API 엔드포인트들
"""

231
tkeg/api/app/api/spools.py Normal file
View File

@@ -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
]
}

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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"])

290
tkeg/api/app/config.py Normal file
View File

@@ -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

35
tkeg/api/app/database.py Normal file
View File

@@ -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()

265
tkeg/api/app/main.py Normal file
View File

@@ -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)

471
tkeg/api/app/models.py Normal file
View File

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

View File

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

47
tkeg/api/app/schemas.py Normal file
View File

@@ -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

View File

@@ -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"
]

View File

@@ -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
]

View File

@@ -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'
]

View File

@@ -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
)

File diff suppressed because it is too large Load Diff

View File

@@ -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", "스프링"]
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

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

View File

@@ -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"]
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

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

View File

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

View File

@@ -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", "구상흑연주철", "덕타일"
]
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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)

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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"]
}

View File

@@ -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"
]

View File

@@ -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()

View File

@@ -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("에러 핸들러 등록 완료")

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

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

View File

@@ -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()

View File

@@ -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;

View File

@@ -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);

View File

View File

@@ -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 '구매 수량 확정자';

File diff suppressed because it is too large Load Diff

62
tkeg/api/requirements.txt Normal file
View File

@@ -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

24
tkeg/web/.gitignore vendored Normal file
View File

@@ -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?

17
tkeg/web/Dockerfile Normal file
View File

@@ -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;"]

348
tkeg/web/PAGES_GUIDE.md Normal file
View File

@@ -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) 추가

161
tkeg/web/README.md Normal file
View File

@@ -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 요청 최적화

29
tkeg/web/eslint.config.js Normal file
View File

@@ -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_]' }],
},
},
])

13
tkeg/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

27
tkeg/web/nginx.conf Normal file
View File

@@ -0,0 +1,27 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 100M;
location /api/ {
proxy_pass http://tkeg-api:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_request_buffering off;
client_max_body_size 100M;
}
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

5344
tkeg/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
tkeg/web/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "tk-mp-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.19",
"@mui/material": "^5.14.20",
"axios": "^1.6.2",
"chart.js": "^4.4.0",
"file-saver": "^2.0.5",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-router-dom": "^6.20.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.1.1",
"eslint": "^8.53.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"vite": "^4.5.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1
tkeg/web/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

260
tkeg/web/src/App.css Normal file
View File

@@ -0,0 +1,260 @@
/* 전역 스타일 리셋 */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #2d3748;
background-color: #f7fafc;
}
#root {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
padding: 0;
background: #f7fafc;
}
.page-container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
/* 로딩 스피너 */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f7fafc;
color: #718096;
}
.loading-spinner-large {
width: 48px;
height: 48px;
border: 4px solid #e2e8f0;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid #e2e8f0;
border-top: 2px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.05);
}
}
/* 접근 거부 페이지 */
.access-denied-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f7fafc;
padding: 24px;
}
.access-denied-content {
text-align: center;
max-width: 500px;
background: white;
padding: 48px 32px;
border-radius: 16px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
.access-denied-icon {
font-size: 64px;
margin-bottom: 24px;
}
.access-denied-content h2 {
color: #2d3748;
font-size: 24px;
font-weight: 700;
margin-bottom: 16px;
}
.access-denied-content p {
color: #718096;
font-size: 16px;
margin-bottom: 16px;
line-height: 1.6;
}
.permission-info,
.role-info {
background: #f7fafc;
padding: 12px 16px;
border-radius: 8px;
margin: 16px 0;
font-size: 14px;
}
.permission-info code,
.role-info code {
background: #e2e8f0;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 13px;
color: #2d3748;
}
.user-info {
background: #edf2f7;
padding: 16px;
border-radius: 8px;
margin: 20px 0;
text-align: left;
}
.user-info p {
margin-bottom: 8px;
font-size: 14px;
}
.user-info strong {
color: #2d3748;
}
.back-button {
padding: 12px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 24px;
}
.back-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
}
/* 유틸리티 클래스 */
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: 8px; }
.mb-2 { margin-bottom: 16px; }
.mb-3 { margin-bottom: 24px; }
.mb-4 { margin-bottom: 32px; }
.mt-0 { margin-top: 0; }
.mt-1 { margin-top: 8px; }
.mt-2 { margin-top: 16px; }
.mt-3 { margin-top: 24px; }
.mt-4 { margin-top: 32px; }
.p-0 { padding: 0; }
.p-1 { padding: 8px; }
.p-2 { padding: 16px; }
.p-3 { padding: 24px; }
.p-4 { padding: 32px; }
/* 반응형 유틸리티 */
.hidden-mobile {
display: block;
}
.hidden-desktop {
display: none;
}
@media (max-width: 768px) {
.hidden-mobile {
display: none;
}
.hidden-desktop {
display: block;
}
.page-container {
padding: 16px;
}
}
/* 스크롤바 스타일링 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e0;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a0aec0;
}
/* 포커스 스타일 */
*:focus {
outline: 2px solid #667eea;
outline-offset: 2px;
}
button:focus,
input:focus,
select:focus,
textarea:focus {
outline: 2px solid #667eea;
outline-offset: 2px;
}
/* 선택 스타일 */
::selection {
background: rgba(102, 126, 234, 0.2);
color: #2d3748;
}

84
tkeg/web/src/api.js Normal file
View File

@@ -0,0 +1,84 @@
import axios from 'axios';
import { config } from './config';
const API_BASE_URL = '/api';
export const api = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
});
// SSO 쿠키에서 토큰 읽기
function getSSOToken() {
const match = document.cookie.match(/sso_token=([^;]*)/);
return match ? match[1] : null;
}
// 요청 인터셉터: SSO 토큰 자동 추가
api.interceptors.request.use(config => {
const token = getSSOToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 응답 인터셉터: 401 시 SSO 로그인 리다이렉트
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
window.location.href = config.ssoLoginUrl(window.location.href);
return new Promise(() => {}); // 리다이렉트 중 pending
}
return Promise.reject(error);
}
);
// 파일 업로드
export function uploadFile(formData, options = {}) {
return api.post('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
...options,
});
}
export function fetchMaterials(params) { return api.get('/files/materials-v2', { params }); }
export function fetchMaterialsSummary(params) { return api.get('/files/materials/summary', { params }); }
export function fetchFiles(params) { return api.get('/files', { params }); }
export function deleteFile(fileId) { return api.delete(`/files/delete/${fileId}`); }
export function fetchJobs(params) { return api.get('/jobs/', { params }); }
export function createJob(data) { return api.post('/jobs/', data); }
export function compareRevisions(jobNo, filename, oldRevision, newRevision) {
return api.get('/files/materials/compare-revisions', {
params: { job_no: jobNo, filename, old_revision: oldRevision, new_revision: newRevision }
});
}
export function compareMaterialRevisions(jobNo, currentRevision, previousRevision = null, saveResult = true) {
return api.post('/materials/compare-revisions', null, {
params: { job_no: jobNo, current_revision: currentRevision, previous_revision: previousRevision, save_result: saveResult }
});
}
export function getMaterialComparisonHistory(jobNo, limit = 10) {
return api.get('/materials/comparison-history', { params: { job_no: jobNo, limit } });
}
export function getMaterialInventoryStatus(jobNo, materialHash = null) {
return api.get('/materials/inventory-status', { params: { job_no: jobNo, material_hash: materialHash } });
}
export function confirmMaterialPurchase(jobNo, revision, confirmations, confirmedBy = 'user') {
return api.post('/materials/confirm-purchase', confirmations, {
params: { job_no: jobNo, revision, confirmed_by: confirmedBy }
});
}
export function getMaterialPurchaseStatus(jobNo, revision = null, status = null) {
return api.get('/materials/purchase-status', { params: { job_no: jobNo, revision, status } });
}
export default api;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,136 @@
import React from 'react';
const BOMFileUpload = ({
bomName,
setBomName,
selectedFile,
setSelectedFile,
uploading,
handleUpload,
error
}) => {
return (
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
padding: '24px',
marginBottom: '24px'
}}>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 16px 0'
}}>
BOM 업로드
</h3>
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '8px'
}}>
BOM 이름 <span style={{ color: '#e53e3e' }}>*</span>
</label>
<input
type="text"
value={bomName}
onChange={(e) => setBomName(e.target.value)}
placeholder="예: PIPING_BOM_A구역, 배관자재_1차, VALVE_LIST_Rev0"
style={{
width: '100%',
padding: '12px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
fontSize: '14px',
background: bomName ? '#f0fff4' : 'white'
}}
/>
<p style={{
fontSize: '12px',
color: '#718096',
margin: '4px 0 0 0'
}}>
💡 이름은 엑셀 내보내기 파일명과 자재 관리에 사용됩니다.
동일한 BOM 이름으로 재업로드 리비전이 자동 증가합니다.
</p>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '16px'
}}>
<input
id="file-input"
type="file"
accept=".csv,.xlsx,.xls"
onChange={(e) => setSelectedFile(e.target.files[0])}
style={{ flex: 1 }}
/>
<button
onClick={handleUpload}
disabled={!selectedFile || !bomName.trim() || uploading}
style={{
padding: '12px 24px',
background: (!selectedFile || !bomName.trim() || uploading) ? '#e2e8f0' : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: (!selectedFile || !bomName.trim() || uploading) ? '#a0aec0' : 'white',
border: 'none',
borderRadius: '8px',
cursor: (!selectedFile || !bomName.trim() || uploading) ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
{uploading ? '업로드 중...' : '업로드'}
</button>
</div>
{selectedFile && (
<p style={{
fontSize: '14px',
color: '#718096',
margin: '0'
}}>
선택된 파일: {selectedFile.name}
</p>
)}
{error && (
<div style={{
background: '#fed7d7',
border: '1px solid #fc8181',
borderRadius: '8px',
padding: '12px 16px',
marginTop: '16px',
color: '#c53030'
}}>
{error}
</div>
)}
</div>
);
};
export default BOMFileUpload;

View File

@@ -0,0 +1,440 @@
import React, { useState, useEffect } from 'react';
import {
Typography,
Box,
Card,
CardContent,
Grid,
CircularProgress,
Chip,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
FormControl,
InputLabel,
Select,
MenuItem
} from '@mui/material';
import { fetchMaterials } from '../api';
import { Bar, Pie, Line } from 'react-chartjs-2';
import 'chart.js/auto';
import Toast from './Toast';
function Dashboard({ selectedProject, projects }) {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(false);
const [materials, setMaterials] = useState([]);
const [barData, setBarData] = useState(null);
const [pieData, setPieData] = useState(null);
const [materialGradeData, setMaterialGradeData] = useState(null);
const [sizeData, setSizeData] = useState(null);
const [topMaterials, setTopMaterials] = useState([]);
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
useEffect(() => {
if (selectedProject) {
fetchMaterialStats();
fetchMaterialList();
}
}, [selectedProject]);
const fetchMaterialStats = async () => {
setLoading(true);
try {
const response = await fetch(`/files/materials/summary?project_id=${selectedProject.id}`);
if (response.ok) {
const data = await response.json();
setStats(data.summary);
}
} catch (error) {
setToast({
open: true,
message: '자재 통계 로드 실패',
type: 'error'
});
} finally {
setLoading(false);
}
};
const fetchMaterialList = async () => {
try {
// 최대 1000개까지 조회(실무에서는 서버 페이징/집계 API 권장)
const params = { project_id: selectedProject.id, skip: 0, limit: 1000 };
const response = await fetchMaterials(params);
setMaterials(response.data.materials || []);
} catch (error) {
setToast({
open: true,
message: '자재 목록 로드 실패',
type: 'error'
});
}
};
useEffect(() => {
if (materials.length > 0) {
// 분류별 집계
const typeCounts = {};
const typeQuantities = {};
const materialGrades = {};
const sizes = {};
const materialQuantities = {};
materials.forEach(mat => {
const type = mat.item_type || 'OTHER';
const grade = mat.material_grade || '미분류';
const size = mat.size_spec || '미분류';
const desc = mat.original_description;
typeCounts[type] = (typeCounts[type] || 0) + 1;
typeQuantities[type] = (typeQuantities[type] || 0) + (mat.quantity || 0);
materialGrades[grade] = (materialGrades[grade] || 0) + 1;
sizes[size] = (sizes[size] || 0) + 1;
materialQuantities[desc] = (materialQuantities[desc] || 0) + (mat.quantity || 0);
});
// Bar 차트 데이터
setBarData({
labels: Object.keys(typeCounts),
datasets: [
{
label: '자재 수',
data: Object.values(typeCounts),
backgroundColor: 'rgba(25, 118, 210, 0.6)',
borderColor: 'rgba(25, 118, 210, 1)',
borderWidth: 1
},
{
label: '총 수량',
data: Object.values(typeQuantities),
backgroundColor: 'rgba(220, 0, 78, 0.4)',
borderColor: 'rgba(220, 0, 78, 1)',
borderWidth: 1
}
]
});
// 재질별 Pie 차트
setMaterialGradeData({
labels: Object.keys(materialGrades),
datasets: [{
data: Object.values(materialGrades),
backgroundColor: [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',
'#9966FF', '#FF9F40', '#FF6384', '#C9CBCF'
],
borderWidth: 2
}]
});
// 사이즈별 Pie 차트
setSizeData({
labels: Object.keys(sizes),
datasets: [{
data: Object.values(sizes),
backgroundColor: [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',
'#9966FF', '#FF9F40', '#FF6384', '#C9CBCF'
],
borderWidth: 2
}]
});
// 상위 자재 (수량 기준)
const sortedMaterials = Object.entries(materialQuantities)
.sort(([,a], [,b]) => b - a)
.slice(0, 10)
.map(([desc, qty]) => ({ description: desc, quantity: qty }));
setTopMaterials(sortedMaterials);
} else {
setBarData(null);
setMaterialGradeData(null);
setSizeData(null);
setTopMaterials([]);
}
}, [materials]);
return (
<Box>
<Typography variant="h4" gutterBottom>
📊 대시보드
</Typography>
{/* 선택된 프로젝트 정보 */}
{selectedProject && (
<Box sx={{ mb: 3, p: 2, bgcolor: 'primary.50', borderRadius: 2, border: '1px solid', borderColor: 'primary.200' }}>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} sm={6}>
<Box>
<Typography variant="h6" color="primary">
{selectedProject.project_name} ({selectedProject.official_project_code})
</Typography>
<Typography variant="body2" color="textSecondary">
상태: {selectedProject.status} | 생성일: {new Date(selectedProject.created_at).toLocaleDateString()}
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={6}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Chip
label={selectedProject.status}
color={selectedProject.status === 'active' ? 'success' : 'default'}
size="small"
/>
<Chip
label={selectedProject.is_code_matched ? '코드 매칭됨' : '코드 미매칭'}
color={selectedProject.is_code_matched ? 'success' : 'warning'}
size="small"
/>
</Box>
</Grid>
</Grid>
</Box>
)}
{/* 전역 Toast */}
<Toast
open={toast.open}
message={toast.message}
type={toast.type}
onClose={() => setToast({ open: false, message: '', type: 'info' })}
/>
<Grid container spacing={3}>
{/* 프로젝트 현황 */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" color="primary" gutterBottom>
프로젝트 현황
</Typography>
<Typography variant="h3" component="div" sx={{ mb: 1 }}>
{projects.length}
</Typography>
<Typography variant="body2" color="textSecondary">
프로젝트
</Typography>
<Typography variant="body1" sx={{ mt: 2 }}>
선택된 프로젝트: {selectedProject.project_name}
</Typography>
</CardContent>
</Card>
</Grid>
{/* 자재 현황 */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" color="secondary" gutterBottom>
자재 현황
</Typography>
{loading ? (
<Box display="flex" justifyContent="center" py={3}>
<CircularProgress />
</Box>
) : stats ? (
<Box>
<Typography variant="h3" component="div" sx={{ mb: 1 }}>
{stats.total_items.toLocaleString()}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
자재
</Typography>
<Grid container spacing={1}>
<Grid item xs={6}>
<Chip label={`고유 품목: ${stats.unique_descriptions}`} size="small" />
</Grid>
<Grid item xs={6}>
<Chip label={`고유 사이즈: ${stats.unique_sizes}`} size="small" />
</Grid>
<Grid item xs={6}>
<Chip label={`총 수량: ${stats.total_quantity.toLocaleString()}`} size="small" color="success" />
</Grid>
<Grid item xs={6}>
<Chip label={`평균 수량: ${stats.avg_quantity}`} size="small" />
</Grid>
</Grid>
<Typography variant="body2" sx={{ mt: 2, fontSize: '0.8rem' }}>
최초 업로드: {stats.earliest_upload ? new Date(stats.earliest_upload).toLocaleString() : '-'}
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
최신 업로드: {stats.latest_upload ? new Date(stats.latest_upload).toLocaleString() : '-'}
</Typography>
</Box>
) : (
<Typography variant="body2" color="textSecondary">
프로젝트를 선택하면 자재 현황을 확인할 있습니다.
</Typography>
)}
</CardContent>
</Card>
</Grid>
{/* 분류별 자재 통계 */}
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6" color="primary" gutterBottom>
분류별 자재 통계
</Typography>
{barData ? (
<Bar data={barData} options={{
responsive: true,
plugins: {
legend: { position: 'top' },
title: { display: true, text: '분류별 자재 수/총 수량' }
},
scales: {
y: {
beginAtZero: true
}
}
}} />
) : (
<Typography variant="body2" color="textSecondary">
자재 데이터가 없습니다.
</Typography>
)}
</CardContent>
</Card>
</Grid>
{/* 재질별 분포 */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" color="primary" gutterBottom>
재질별 분포
</Typography>
{materialGradeData ? (
<Pie data={materialGradeData} options={{
responsive: true,
plugins: {
legend: { position: 'bottom' },
title: { display: true, text: '재질별 자재 분포' }
}
}} />
) : (
<Typography variant="body2" color="textSecondary">
재질 데이터가 없습니다.
</Typography>
)}
</CardContent>
</Card>
</Grid>
{/* 사이즈별 분포 */}
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" color="primary" gutterBottom>
사이즈별 분포
</Typography>
{sizeData ? (
<Pie data={sizeData} options={{
responsive: true,
plugins: {
legend: { position: 'bottom' },
title: { display: true, text: '사이즈별 자재 분포' }
}
}} />
) : (
<Typography variant="body2" color="textSecondary">
사이즈 데이터가 없습니다.
</Typography>
)}
</CardContent>
</Card>
</Grid>
{/* 상위 자재 */}
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6" color="primary" gutterBottom>
상위 자재 (수량 기준)
</Typography>
{topMaterials.length > 0 ? (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell><strong>순위</strong></TableCell>
<TableCell><strong>자재명</strong></TableCell>
<TableCell align="right"><strong> 수량</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{topMaterials.map((material, index) => (
<TableRow key={index}>
<TableCell>{index + 1}</TableCell>
<TableCell>
<Typography variant="body2" sx={{ maxWidth: 300 }}>
{material.description}
</Typography>
</TableCell>
<TableCell align="right">
<Chip
label={material.quantity.toLocaleString()}
size="small"
color="primary"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<Typography variant="body2" color="textSecondary">
자재 데이터가 없습니다.
</Typography>
)}
</CardContent>
</Card>
</Grid>
{/* 프로젝트 상세 정보 */}
{selectedProject && (
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
📋 프로젝트 상세 정보
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">프로젝트 코드</Typography>
<Typography variant="body1">{selectedProject.official_project_code}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">프로젝트명</Typography>
<Typography variant="body1">{selectedProject.project_name}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">상태</Typography>
<Chip label={selectedProject.status} size="small" />
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="textSecondary">생성일</Typography>
<Typography variant="body1">
{new Date(selectedProject.created_at).toLocaleDateString()}
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
</Grid>
)}
</Grid>
</Box>
);
}
export default Dashboard;

View File

@@ -0,0 +1,590 @@
import React, { useState, useEffect } from 'react';
import {
Typography,
Box,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
IconButton,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Chip,
Alert,
CircularProgress,
Grid,
TextField,
FormControl,
InputLabel,
Select,
MenuItem
} from '@mui/material';
import {
Delete,
Download,
Visibility,
FileUpload,
Warning,
CheckCircle,
Error,
Update
} from '@mui/icons-material';
import { fetchFiles, deleteFile, uploadFile } from '../api';
import Toast from './Toast';
function FileManager({ selectedProject }) {
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [deleteDialog, setDeleteDialog] = useState({ open: false, file: null });
const [revisionDialog, setRevisionDialog] = useState({ open: false, file: null });
const [revisionFile, setRevisionFile] = useState(null);
const [uploading, setUploading] = useState(false);
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
const [filter, setFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
useEffect(() => {
if (selectedProject) {
fetchFilesList();
} else {
setFiles([]);
}
}, [selectedProject]);
// 파일 업로드 이벤트 리스너 추가
useEffect(() => {
console.log('FileManager: 이벤트 리스너 등록 시작');
const handleFileUploaded = (event) => {
const { jobNo } = event.detail;
console.log('FileManager: 파일 업로드 이벤트 수신:', event.detail);
console.log('FileManager: 현재 선택된 프로젝트:', selectedProject);
if (selectedProject && selectedProject.job_no === jobNo) {
console.log('FileManager: 파일 업로드 감지됨, 목록 갱신 중...');
fetchFilesList();
} else {
console.log('FileManager: job_no 불일치 또는 프로젝트 미선택');
console.log('이벤트 jobNo:', jobNo);
console.log('선택된 프로젝트 jobNo:', selectedProject?.job_no);
}
};
window.addEventListener('fileUploaded', handleFileUploaded);
console.log('FileManager: fileUploaded 이벤트 리스너 등록 완료');
return () => {
window.removeEventListener('fileUploaded', handleFileUploaded);
console.log('FileManager: fileUploaded 이벤트 리스너 제거');
};
}, [selectedProject]);
const fetchFilesList = async () => {
setLoading(true);
try {
console.log('FileManager: 파일 목록 조회 시작, job_no:', selectedProject.job_no);
const response = await fetchFiles({ job_no: selectedProject.job_no });
console.log('FileManager: API 응답:', response.data);
if (response.data && response.data.files) {
setFiles(response.data.files);
console.log('FileManager: 파일 목록 업데이트 완료, 파일 수:', response.data.files.length);
} else {
console.log('FileManager: 파일 목록이 비어있음');
setFiles([]);
}
} catch (error) {
console.error('FileManager: 파일 목록 조회 실패:', error);
setToast({
open: true,
message: '파일 목록을 불러오는데 실패했습니다.',
type: 'error'
});
} finally {
setLoading(false);
}
};
const handleDeleteFile = async () => {
if (!deleteDialog.file) return;
try {
await deleteFile(deleteDialog.file.id);
setToast({
open: true,
message: '파일이 성공적으로 삭제되었습니다.',
type: 'success'
});
setDeleteDialog({ open: false, file: null });
fetchFilesList(); // 목록 새로고침
} catch (error) {
console.error('파일 삭제 실패:', error);
setToast({
open: true,
message: '파일 삭제에 실패했습니다.',
type: 'error'
});
}
};
const handleRevisionUpload = async () => {
if (!revisionFile || !revisionDialog.file) return;
setUploading(true);
try {
const formData = new FormData();
formData.append('file', revisionFile);
formData.append('job_no', selectedProject.job_no);
formData.append('parent_file_id', revisionDialog.file.id);
formData.append('bom_name', revisionDialog.file.bom_name || revisionDialog.file.original_filename);
console.log('🔄 리비전 업로드 FormData:', {
fileName: revisionFile.name,
jobNo: selectedProject.job_no,
parentFileId: revisionDialog.file.id,
parentFileIdType: typeof revisionDialog.file.id,
baseFileName: revisionDialog.file.original_filename,
bomName: revisionDialog.file.bom_name || revisionDialog.file.original_filename,
fullFileObject: revisionDialog.file
});
const response = await uploadFile(formData);
if (response.data.success) {
setToast({
open: true,
message: `리비전 업로드 성공! ${response.data.revision}`,
type: 'success'
});
setRevisionDialog({ open: false, file: null });
setRevisionFile(null);
fetchFilesList(); // 목록 새로고침
} else {
setToast({
open: true,
message: response.data.message || '리비전 업로드에 실패했습니다.',
type: 'error'
});
}
} catch (error) {
console.error('리비전 업로드 실패:', error);
setToast({
open: true,
message: '리비전 업로드에 실패했습니다.',
type: 'error'
});
} finally {
setUploading(false);
}
};
const getStatusColor = (status) => {
switch (status) {
case 'completed':
return 'success';
case 'processing':
return 'warning';
case 'failed':
return 'error';
default:
return 'default';
}
};
const getStatusIcon = (status) => {
switch (status) {
case 'completed':
return <CheckCircle />;
case 'processing':
return <CircularProgress size={16} />;
case 'failed':
return <Error />;
default:
return null;
}
};
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleString('ko-KR');
};
const filteredFiles = files.filter(file => {
const matchesFilter = !filter ||
file.original_filename.toLowerCase().includes(filter.toLowerCase()) ||
file.project_name?.toLowerCase().includes(filter.toLowerCase());
const matchesStatus = !statusFilter || file.status === statusFilter;
return matchesFilter && matchesStatus;
});
if (!selectedProject) {
return (
<Box>
<Typography variant="h4" gutterBottom>
📁 도면 관리
</Typography>
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<FileUpload sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
프로젝트를 선택해주세요
</Typography>
<Typography variant="body2" color="textSecondary">
프로젝트 관리 탭에서 프로젝트를 선택하면 도면을 관리할 있습니다.
</Typography>
</CardContent>
</Card>
</Box>
);
}
return (
<Box>
<Typography variant="h4" gutterBottom>
📁 도면 관리
</Typography>
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
{selectedProject.project_name} ({selectedProject.official_project_code})
</Typography>
{/* 필터 UI */}
<Box sx={{ mb: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} sm={6} md={4}>
<TextField
label="파일명/프로젝트명 검색"
value={filter}
onChange={e => setFilter(e.target.value)}
size="small"
fullWidth
placeholder="파일명 또는 프로젝트명으로 검색"
/>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<FormControl size="small" fullWidth>
<InputLabel>상태</InputLabel>
<Select
value={statusFilter}
label="상태"
onChange={e => setStatusFilter(e.target.value)}
>
<MenuItem value="">전체</MenuItem>
<MenuItem value="completed">완료</MenuItem>
<MenuItem value="processing">처리 </MenuItem>
<MenuItem value="failed">실패</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={12} md={4}>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button
variant="outlined"
size="small"
onClick={() => {
setFilter('');
setStatusFilter('');
}}
>
필터 초기화
</Button>
<Button
variant="contained"
size="small"
onClick={fetchFilesList}
>
새로고침
</Button>
</Box>
</Grid>
</Grid>
</Box>
{/* 전역 Toast */}
<Toast
open={toast.open}
message={toast.message}
type={toast.type}
onClose={() => setToast({ open: false, message: '', type: 'info' })}
/>
{loading ? (
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<CircularProgress size={60} />
<Typography variant="h6" sx={{ mt: 2 }}>
파일 목록 로딩 ...
</Typography>
</CardContent>
</Card>
) : filteredFiles.length === 0 ? (
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<FileUpload sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
{filter || statusFilter ? '검색 결과가 없습니다' : '업로드된 파일이 없습니다'}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
{filter || statusFilter
? '다른 검색 조건을 시도해보세요.'
: '파일 업로드 탭에서 BOM 파일을 업로드해주세요.'
}
</Typography>
{(filter || statusFilter) && (
<Button
variant="outlined"
onClick={() => {
setFilter('');
setStatusFilter('');
}}
>
필터 초기화
</Button>
)}
</CardContent>
</Card>
) : (
<Card>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">
{filteredFiles.length} 파일
</Typography>
<Chip
label={`${files.length}개 전체`}
color="primary"
variant="outlined"
/>
</Box>
<TableContainer component={Paper} variant="outlined">
<Table>
<TableHead>
<TableRow sx={{ bgcolor: 'grey.50' }}>
<TableCell><strong>번호</strong></TableCell>
<TableCell><strong>파일명</strong></TableCell>
<TableCell align="center"><strong>프로젝트</strong></TableCell>
<TableCell align="center"><strong>상태</strong></TableCell>
<TableCell align="center"><strong>파일 크기</strong></TableCell>
<TableCell align="center"><strong>업로드 일시</strong></TableCell>
<TableCell align="center"><strong>처리 완료</strong></TableCell>
<TableCell align="center"><strong>작업</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredFiles.map((file, index) => (
<TableRow
key={file.id}
sx={{ '&:hover': { bgcolor: 'grey.50' } }}
>
<TableCell>
{index + 1}
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ maxWidth: 300 }}>
{file.original_filename}
</Typography>
</TableCell>
<TableCell align="center">
<Chip
label={file.project_name || '-'}
size="small"
color="primary"
/>
</TableCell>
<TableCell align="center">
<Chip
label={file.status === 'completed' ? '완료' :
file.status === 'processing' ? '처리 중' :
file.status === 'failed' ? '실패' : '대기'}
size="small"
color={getStatusColor(file.status)}
icon={getStatusIcon(file.status)}
/>
</TableCell>
<TableCell align="center">
<Typography variant="body2">
{formatFileSize(file.file_size || 0)}
</Typography>
</TableCell>
<TableCell align="center">
<Typography variant="body2">
{formatDate(file.created_at)}
</Typography>
</TableCell>
<TableCell align="center">
<Typography variant="body2">
{file.processed_at ? formatDate(file.processed_at) : '-'}
</Typography>
</TableCell>
<TableCell align="center">
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center' }}>
<IconButton
size="small"
color="primary"
title="다운로드"
disabled={file.status !== 'completed'}
>
<Download />
</IconButton>
<IconButton
size="small"
color="info"
title="상세 보기"
>
<Visibility />
</IconButton>
<IconButton
size="small"
color="warning"
title="리비전 업로드"
onClick={() => {
console.log('🔄 리비전 버튼 클릭, 파일 정보:', file);
setRevisionDialog({ open: true, file });
}}
>
<Update />
</IconButton>
<IconButton
size="small"
color="error"
title="삭제"
onClick={() => setDeleteDialog({ open: true, file })}
>
<Delete />
</IconButton>
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
)}
{/* 삭제 확인 다이얼로그 */}
<Dialog
open={deleteDialog.open}
onClose={() => setDeleteDialog({ open: false, file: null })}
>
<DialogTitle>
<Box display="flex" alignItems="center" gap={1}>
<Warning color="error" />
파일 삭제 확인
</Box>
</DialogTitle>
<DialogContent>
<Typography variant="body1" sx={{ mb: 2 }}>
다음 파일을 삭제하시겠습니까?
</Typography>
<Alert severity="warning" sx={{ mb: 2 }}>
<Typography variant="body2">
<strong>파일명:</strong> {deleteDialog.file?.original_filename}
</Typography>
<Typography variant="body2">
<strong>프로젝트:</strong> {deleteDialog.file?.project_name}
</Typography>
<Typography variant="body2">
<strong>업로드 일시:</strong> {deleteDialog.file?.created_at ? formatDate(deleteDialog.file.created_at) : '-'}
</Typography>
</Alert>
<Typography variant="body2" color="error">
작업은 되돌릴 없습니다. 파일과 관련된 모든 자재 데이터가 함께 삭제됩니다.
</Typography>
</DialogContent>
<DialogActions>
<Button
onClick={() => setDeleteDialog({ open: false, file: null })}
>
취소
</Button>
<Button
onClick={handleDeleteFile}
color="error"
variant="contained"
startIcon={<Delete />}
>
삭제
</Button>
</DialogActions>
</Dialog>
{/* 리비전 업로드 다이얼로그 */}
<Dialog
open={revisionDialog.open}
onClose={() => {
setRevisionDialog({ open: false, file: null });
setRevisionFile(null);
}}
maxWidth="sm"
fullWidth
>
<DialogTitle>리비전 업로드</DialogTitle>
<DialogContent>
<Typography variant="body1" gutterBottom>
<strong>기준 파일:</strong> {revisionDialog.file?.original_filename}
</Typography>
<Typography variant="body2" color="textSecondary" gutterBottom>
현재 리비전: {revisionDialog.file?.revision || 'Rev.0'}
</Typography>
<Box sx={{ mt: 2 }}>
<Typography variant="body2" gutterBottom>
리비전 파일을 선택하세요:
</Typography>
<input
type="file"
accept=".xlsx,.xls,.csv"
onChange={(e) => setRevisionFile(e.target.files[0])}
style={{ width: '100%', padding: '8px' }}
/>
</Box>
{revisionFile && (
<Alert severity="info" sx={{ mt: 2 }}>
선택된 파일: {revisionFile.name}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setRevisionDialog({ open: false, file: null });
setRevisionFile(null);
}}
>
취소
</Button>
<Button
onClick={handleRevisionUpload}
variant="contained"
disabled={!revisionFile || uploading}
>
{uploading ? '업로드 중...' : '리비전 업로드'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
export default FileManager;

View File

@@ -0,0 +1,579 @@
import React, { useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import {
Typography,
Box,
Card,
CardContent,
Button,
LinearProgress,
List,
ListItem,
ListItemText,
ListItemIcon,
Chip,
Paper,
Divider,
Stepper,
Step,
StepLabel,
StepContent,
Alert,
Grid
} from '@mui/material';
import {
CloudUpload,
AttachFile,
CheckCircle,
Error as ErrorIcon,
Description,
AutoAwesome,
Category,
Science,
Compare
} from '@mui/icons-material';
import { useDropzone } from 'react-dropzone';
import { uploadFile as uploadFileApi, fetchMaterialsSummary } from '../api';
import Toast from './Toast';
import { useNavigate } from 'react-router-dom';
function FileUpload({ selectedProject, onUploadSuccess }) {
console.log('=== FileUpload 컴포넌트 렌더링 ===');
console.log('selectedProject:', selectedProject);
const navigate = useNavigate();
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadResult, setUploadResult] = useState(null);
const [error, setError] = useState('');
const [showSuccess, setShowSuccess] = useState(false);
const [showError, setShowError] = useState(false);
const [materialsSummary, setMaterialsSummary] = useState(null);
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
const [uploadSteps, setUploadSteps] = useState([
{ label: '파일 업로드', completed: false, active: false },
{ label: '데이터 파싱', completed: false, active: false },
{ label: '자재 분류', completed: false, active: false },
{ label: '분류기 실행', completed: false, active: false },
{ label: '데이터베이스 저장', completed: false, active: false }
]);
const onDrop = useCallback((acceptedFiles) => {
console.log('=== FileUpload: onDrop 함수 호출됨 ===');
console.log('받은 파일들:', acceptedFiles);
console.log('선택된 프로젝트:', selectedProject);
if (!selectedProject) {
console.log('프로젝트가 선택되지 않음');
setToast({
open: true,
message: '프로젝트를 먼저 선택해주세요.',
type: 'warning'
});
return;
}
if (acceptedFiles.length > 0) {
console.log('파일 업로드 시작');
uploadFile(acceptedFiles[0]);
} else {
console.log('선택된 파일이 없음');
}
}, [selectedProject]);
const { getRootProps, getInputProps, isDragActive, acceptedFiles } = useDropzone({
onDrop,
accept: {
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
'application/vnd.ms-excel': ['.xls'],
'text/csv': ['.csv']
},
multiple: false,
maxSize: 10 * 1024 * 1024 // 10MB
});
const updateUploadStep = (stepIndex, completed = false, active = false) => {
setUploadSteps(prev => prev.map((step, index) => ({
...step,
completed: index < stepIndex ? true : (index === stepIndex ? completed : false),
active: index === stepIndex ? active : false
})));
};
const uploadFile = async (file) => {
console.log('=== FileUpload: uploadFile 함수 시작 ===');
console.log('파일 정보:', {
name: file.name,
size: file.size,
type: file.type
});
console.log('선택된 프로젝트:', selectedProject);
setUploading(true);
setUploadProgress(0);
setError('');
setUploadResult(null);
setMaterialsSummary(null);
console.log('업로드 시작:', {
fileName: file.name,
fileSize: file.size,
jobNo: selectedProject?.job_no,
projectName: selectedProject?.project_name
});
// 업로드 단계 초기화
setUploadSteps([
{ label: '파일 업로드', completed: false, active: true },
{ label: '데이터 파싱', completed: false, active: false },
{ label: '자재 분류', completed: false, active: false },
{ label: '분류기 실행', completed: false, active: false },
{ label: '데이터베이스 저장', completed: false, active: false }
]);
const formData = new FormData();
formData.append('file', file);
formData.append('job_no', selectedProject.job_no);
formData.append('revision', 'Rev.0'); // 새 BOM은 항상 Rev.0
formData.append('bom_name', file.name); // BOM 이름으로 파일명 사용
formData.append('bom_type', 'excel'); // 파일 타입
formData.append('description', ''); // 설명 (빈 문자열)
console.log('FormData 내용:', {
fileName: file.name,
jobNo: selectedProject.job_no,
revision: 'Rev.0', // 새 BOM은 항상 Rev.0
bomName: file.name,
bomType: 'excel'
});
try {
// 1단계: 파일 업로드
updateUploadStep(0, true, false);
updateUploadStep(1, false, true);
console.log('API 호출 시작: /upload');
const response = await uploadFileApi(formData, {
onUploadProgress: (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
setUploadProgress(progress);
console.log('업로드 진행률:', progress + '%');
}
}
});
console.log('API 응답:', response.data);
const result = response.data;
console.log('응답 데이터 구조:', {
success: result.success,
file_id: result.file_id,
message: result.message,
hasFileId: 'file_id' in result
});
// 2단계: 데이터 파싱 완료
updateUploadStep(1, true, false);
updateUploadStep(2, false, true);
if (result.success) {
// 3단계: 자재 분류 완료
updateUploadStep(2, true, false);
updateUploadStep(3, false, true);
// 4단계: 분류기 실행 완료
updateUploadStep(3, true, false);
updateUploadStep(4, false, true);
// 5단계: 데이터베이스 저장 완료
updateUploadStep(4, true, false);
setUploadResult(result);
setToast({
open: true,
message: '파일 업로드 및 분류가 성공했습니다!',
type: 'success'
});
console.log('업로드 성공 결과:', result);
console.log('파일 ID:', result.file_id);
console.log('선택된 프로젝트:', selectedProject);
// 업로드 성공 후 자재 통계 미리보기 호출
try {
const summaryRes = await fetchMaterialsSummary({ file_id: result.file_id });
if (summaryRes.data && summaryRes.data.success) {
setMaterialsSummary(summaryRes.data.summary);
}
} catch (e) {
// 통계 조회 실패는 무시(UX만)
}
if (onUploadSuccess) {
console.log('onUploadSuccess 콜백 호출');
onUploadSuccess(result);
}
// 파일 목록 갱신을 위한 이벤트 발생
console.log('파일 업로드 이벤트 발생:', {
fileId: result.file_id,
jobNo: selectedProject.job_no
});
try {
window.dispatchEvent(new CustomEvent('fileUploaded', {
detail: { fileId: result.file_id, jobNo: selectedProject.job_no }
}));
console.log('CustomEvent dispatch 성공');
} catch (error) {
console.error('CustomEvent dispatch 실패:', error);
}
} else {
setToast({
open: true,
message: result.message || '업로드에 실패했습니다.',
type: 'error'
});
}
} catch (error) {
console.error('업로드 실패:', error);
// 에러 타입별 상세 메시지
let errorMessage = '업로드에 실패했습니다.';
if (error.response) {
// 서버 응답이 있는 경우
const status = error.response.status;
const data = error.response.data;
switch (status) {
case 400:
errorMessage = `잘못된 요청: ${data?.detail || '파일 형식이나 데이터를 확인해주세요.'}`;
break;
case 413:
errorMessage = '파일 크기가 너무 큽니다. (최대 10MB)';
break;
case 422:
errorMessage = `데이터 검증 실패: ${data?.detail || '파일 내용을 확인해주세요.'}`;
break;
case 500:
errorMessage = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
break;
default:
errorMessage = `서버 오류 (${status}): ${data?.detail || error.message}`;
}
} else if (error.request) {
// 네트워크 오류
errorMessage = '서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.';
} else {
// 기타 오류
errorMessage = `오류 발생: ${error.message}`;
}
setToast({
open: true,
message: errorMessage,
type: 'error'
});
} finally {
setUploading(false);
setUploadProgress(0);
}
};
const handleFileSelect = (event) => {
const file = event.target.files[0];
if (file) {
uploadFile(file);
}
};
const resetUpload = () => {
setUploadResult(null);
setUploadProgress(0);
setUploadSteps([
{ label: '파일 업로드', completed: false, active: false },
{ label: '데이터 파싱', completed: false, active: false },
{ label: '자재 분류', completed: false, active: false },
{ label: '분류기 실행', completed: false, active: false },
{ label: '데이터베이스 저장', completed: false, active: false }
]);
setToast({ open: false, message: '', type: 'info' });
};
const getClassificationStats = () => {
if (!uploadResult?.classification_stats) return null;
const stats = uploadResult.classification_stats;
const total = Object.values(stats).reduce((sum, count) => sum + count, 0);
return Object.entries(stats)
.filter(([category, count]) => count > 0)
.map(([category, count]) => ({
category,
count,
percentage: total > 0 ? Math.round((count / total) * 100) : 0
}))
.sort((a, b) => b.count - a.count);
};
return (
<Box>
<Typography variant="h4" gutterBottom>
📁 파일 업로드
</Typography>
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
{selectedProject.project_name} ({selectedProject.official_project_code})
</Typography>
{/* 전역 Toast */}
<Toast
open={toast.open}
message={toast.message}
type={toast.type}
onClose={() => setToast({ open: false, message: '', type: 'info' })}
/>
{uploading && (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}>
<AutoAwesome sx={{ mr: 1, color: 'primary.main' }} />
업로드 분류 진행 ...
</Typography>
<Stepper orientation="vertical" sx={{ mt: 2 }}>
{uploadSteps.map((step, index) => (
<Step key={index} active={step.active} completed={step.completed}>
<StepLabel>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{step.completed ? (
<CheckCircle color="success" sx={{ mr: 1 }} />
) : step.active ? (
<Science color="primary" sx={{ mr: 1 }} />
) : (
<Category color="disabled" sx={{ mr: 1 }} />
)}
{step.label}
</Box>
</StepLabel>
{step.active && (
<StepContent>
<LinearProgress sx={{ mt: 1 }} />
</StepContent>
)}
</Step>
))}
</Stepper>
{uploadProgress > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="textSecondary" gutterBottom>
파일 업로드 진행률: {uploadProgress}%
</Typography>
<LinearProgress variant="determinate" value={uploadProgress} />
</Box>
)}
</CardContent>
</Card>
)}
{uploadResult ? (
<Card sx={{ mb: 2 }}>
<CardContent>
<Box display="flex" alignItems="center" mb={2}>
<CheckCircle color="success" sx={{ mr: 1 }} />
<Typography variant="h6" color="success.main">
업로드 분류 성공!
</Typography>
</Box>
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid item xs={12} md={6}>
<Typography variant="subtitle1" gutterBottom>
📊 업로드 결과
</Typography>
<List dense>
<ListItem>
<ListItemIcon>
<Description />
</ListItemIcon>
<ListItemText
primary="파일명"
secondary={uploadResult.original_filename}
/>
</ListItem>
<ListItem>
<ListItemIcon>
<CheckCircle />
</ListItemIcon>
<ListItemText
primary="파싱된 자재 수"
secondary={`${uploadResult.parsed_materials_count}`}
/>
</ListItem>
<ListItem>
<ListItemIcon>
<CheckCircle />
</ListItemIcon>
<ListItemText
primary="저장된 자재 수"
secondary={`${uploadResult.saved_materials_count}`}
/>
</ListItem>
</List>
</Grid>
<Grid item xs={12} md={6}>
<Typography variant="subtitle1" gutterBottom>
🏷 분류 결과
</Typography>
{getClassificationStats() && (
<Box>
{getClassificationStats().map((stat, index) => (
<Box key={index} sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Chip
label={stat.category}
size="small"
color="primary"
variant="outlined"
/>
<Typography variant="body2">
{stat.count} ({stat.percentage}%)
</Typography>
</Box>
))}
</Box>
)}
</Grid>
</Grid>
{materialsSummary && (
<Alert severity="info" sx={{ mt: 2 }}>
<Typography variant="body2">
💡 <strong>자재 통계 미리보기:</strong><br/>
자재 : {materialsSummary.total_items || 0}<br/>
고유 자재: {materialsSummary.unique_descriptions || 0}종류<br/>
수량: {materialsSummary.total_quantity || 0}
</Typography>
</Alert>
)}
<Box sx={{ mt: 2, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Button
variant="contained"
onClick={() => {
// 상태 기반 라우팅을 위한 이벤트 발생
window.dispatchEvent(new CustomEvent('navigateToMaterials', {
detail: {
jobNo: selectedProject?.job_no,
revision: uploadResult?.revision || 'Rev.0',
bomName: uploadResult?.original_filename || uploadResult?.filename,
message: '파일 업로드 완료',
file_id: uploadResult?.file_id // file_id 추가
}
}));
}}
startIcon={<Description />}
>
자재 목록 보기
</Button>
{uploadResult?.revision && uploadResult.revision !== 'Rev.0' && uploadResult.revision !== undefined && (
<Button
variant="contained"
color="secondary"
onClick={() => navigate(`/material-comparison?job_no=${selectedProject.job_no}&revision=${uploadResult.revision}&filename=${encodeURIComponent(uploadResult.original_filename || uploadResult.filename)}`)}
startIcon={<Compare />}
>
이전 리비전과 비교 ({uploadResult.revision})
</Button>
)}
<Button
variant="outlined"
onClick={resetUpload}
>
새로 업로드
</Button>
</Box>
</CardContent>
</Card>
) : (
<>
<Paper
{...getRootProps()}
onClick={() => console.log('드래그 앤 드롭 영역 클릭됨')}
sx={{
p: 4,
textAlign: 'center',
border: 2,
borderStyle: 'dashed',
borderColor: isDragActive ? 'primary.main' : 'grey.300',
bgcolor: isDragActive ? 'primary.50' : 'grey.50',
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: 'primary.main',
bgcolor: 'primary.50'
}
}}
>
<input {...getInputProps()} />
<CloudUpload sx={{
fontSize: 64,
color: isDragActive ? 'primary.main' : 'grey.400',
mb: 2
}} />
<Typography variant="h6" gutterBottom>
{isDragActive
? "파일을 여기에 놓으세요!"
: "Excel 파일을 드래그하거나 클릭하여 선택"
}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
지원 형식: .xlsx, .xls, .csv (최대 10MB)
</Typography>
<Button
variant="contained"
startIcon={<AttachFile />}
component="span"
disabled={uploading}
onClick={() => console.log('파일 선택 버튼 클릭됨')}
>
{uploading ? '업로드 중...' : '파일 선택'}
</Button>
</Paper>
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="textSecondary">
💡 <strong>업로드 분류 프로세스:</strong>
</Typography>
<Typography variant="body2" color="textSecondary">
BOM(Bill of Materials) 파일을 업로드하면 자동으로 자재 정보를 추출합니다
</Typography>
<Typography variant="body2" color="textSecondary">
자재는 8가지 분류기(볼트, 플랜지, 피팅, 가스켓, 계기, 파이프, 밸브, 재질) 자동 분류됩니다
</Typography>
<Typography variant="body2" color="textSecondary">
분류 결과는 신뢰도 점수와 함께 저장되며, 필요시 수동 검증이 가능합니다
</Typography>
</Box>
</>
)}
</Box>
);
}
FileUpload.propTypes = {
selectedProject: PropTypes.shape({
id: PropTypes.string.isRequired,
project_name: PropTypes.string.isRequired,
official_project_code: PropTypes.string.isRequired,
}).isRequired,
onUploadSuccess: PropTypes.func,
};
export default FileUpload;

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { Card, CardContent, Typography, Box, Grid, Chip, Divider } from '@mui/material';
const FittingDetailsCard = ({ material }) => {
const fittingDetails = material.fitting_details || {};
return (
<Card sx={{ mt: 2, backgroundColor: '#f5f5f5' }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
🔗 FITTING 상세 정보
</Typography>
<Chip
label={`신뢰도: ${Math.round((material.classification_confidence || 0) * 100)}%`}
color={material.classification_confidence >= 0.8 ? 'success' : 'warning'}
size="small"
/>
</Box>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography variant="subtitle2" color="text.secondary">자재명</Typography>
<Typography variant="body1" sx={{ fontWeight: 'medium' }}>
{material.original_description}
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="subtitle2" color="text.secondary">피팅 타입</Typography>
<Typography variant="body1">
{fittingDetails.fitting_type || '-'}
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="subtitle2" color="text.secondary">세부 타입</Typography>
<Typography variant="body1">
{fittingDetails.fitting_subtype || '-'}
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="subtitle2" color="text.secondary">연결 방식</Typography>
<Typography variant="body1">
{fittingDetails.connection_method || '-'}
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="subtitle2" color="text.secondary">압력 등급</Typography>
<Typography variant="body1">
{fittingDetails.pressure_rating || '-'}
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="subtitle2" color="text.secondary">재질 규격</Typography>
<Typography variant="body1">
{fittingDetails.material_standard || '-'}
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="subtitle2" color="text.secondary">재질 등급</Typography>
<Typography variant="body1">
{fittingDetails.material_grade || material.material_grade || '-'}
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="subtitle2" color="text.secondary"> 사이즈</Typography>
<Typography variant="body1">
{fittingDetails.main_size || material.size_spec || '-'}
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="subtitle2" color="text.secondary">축소 사이즈</Typography>
<Typography variant="body1">
{fittingDetails.reduced_size || '-'}
</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="subtitle2" color="text.secondary">수량</Typography>
<Typography variant="body1" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
{material.quantity} {material.unit}
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
);
};
export default FittingDetailsCard;

View File

@@ -0,0 +1,516 @@
import React, { useState } from 'react';
import {
Box,
Card,
CardContent,
CardHeader,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
Alert,
Tabs,
Tab,
Button,
Checkbox,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Stack,
Divider
} from '@mui/material';
import {
Add as AddIcon,
Edit as EditIcon,
ShoppingCart as ShoppingCartIcon,
CheckCircle as CheckCircleIcon,
Warning as WarningIcon,
Remove as RemoveIcon
} from '@mui/icons-material';
const MaterialComparisonResult = ({
comparison,
onConfirmPurchase,
loading = false
}) => {
const [selectedTab, setSelectedTab] = useState(0);
const [selectedItems, setSelectedItems] = useState(new Set());
const [confirmDialog, setConfirmDialog] = useState(false);
const [purchaseConfirmations, setPurchaseConfirmations] = useState({});
if (!comparison || !comparison.success) {
return (
<Alert severity="info">
비교할 이전 리비전이 없거나 비교 데이터를 불러올 없습니다.
</Alert>
);
}
const { summary, new_items, modified_items, removed_items, purchase_summary } = comparison;
// 탭 변경 핸들러
const handleTabChange = (event, newValue) => {
setSelectedTab(newValue);
setSelectedItems(new Set()); // 탭 변경시 선택 초기화
};
// 아이템 선택 핸들러
const handleItemSelect = (materialHash, checked) => {
const newSelected = new Set(selectedItems);
if (checked) {
newSelected.add(materialHash);
} else {
newSelected.delete(materialHash);
}
setSelectedItems(newSelected);
};
// 전체 선택/해제
const handleSelectAll = (items, checked) => {
const newSelected = new Set(selectedItems);
items.forEach(item => {
if (checked) {
newSelected.add(item.material_hash);
} else {
newSelected.delete(item.material_hash);
}
});
setSelectedItems(newSelected);
};
// 발주 확정 다이얼로그 열기
const handleOpenConfirmDialog = () => {
const confirmations = {};
// 선택된 신규 항목
new_items.forEach(item => {
if (selectedItems.has(item.material_hash)) {
confirmations[item.material_hash] = {
material_hash: item.material_hash,
description: item.description,
confirmed_quantity: item.additional_needed,
supplier_name: '',
unit_price: 0
};
}
});
// 선택된 변경 항목 (추가 필요량만)
modified_items.forEach(item => {
if (selectedItems.has(item.material_hash) && item.additional_needed > 0) {
confirmations[item.material_hash] = {
material_hash: item.material_hash,
description: item.description,
confirmed_quantity: item.additional_needed,
supplier_name: '',
unit_price: 0
};
}
});
setPurchaseConfirmations(confirmations);
setConfirmDialog(true);
};
// 발주 확정 실행
const handleConfirmPurchase = () => {
const confirmationList = Object.values(purchaseConfirmations).filter(
conf => conf.confirmed_quantity > 0
);
if (confirmationList.length > 0) {
onConfirmPurchase?.(confirmationList);
}
setConfirmDialog(false);
setSelectedItems(new Set());
};
// 수량 변경 핸들러
const handleQuantityChange = (materialHash, quantity) => {
setPurchaseConfirmations(prev => ({
...prev,
[materialHash]: {
...prev[materialHash],
confirmed_quantity: parseFloat(quantity) || 0
}
}));
};
// 공급업체 변경 핸들러
const handleSupplierChange = (materialHash, supplier) => {
setPurchaseConfirmations(prev => ({
...prev,
[materialHash]: {
...prev[materialHash],
supplier_name: supplier
}
}));
};
// 단가 변경 핸들러
const handlePriceChange = (materialHash, price) => {
setPurchaseConfirmations(prev => ({
...prev,
[materialHash]: {
...prev[materialHash],
unit_price: parseFloat(price) || 0
}
}));
};
const renderSummaryCard = () => (
<Card sx={{ mb: 2 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
리비전 비교 요약
</Typography>
<Stack direction="row" spacing={2} flexWrap="wrap">
<Chip
icon={<AddIcon />}
label={`신규: ${summary.new_items_count}`}
color="success"
variant="outlined"
/>
<Chip
icon={<EditIcon />}
label={`변경: ${summary.modified_items_count}`}
color="warning"
variant="outlined"
/>
<Chip
icon={<RemoveIcon />}
label={`삭제: ${summary.removed_items_count}`}
color="error"
variant="outlined"
/>
</Stack>
{purchase_summary.additional_purchase_needed > 0 && (
<Alert severity="warning" sx={{ mt: 2 }}>
<Typography variant="body2">
<strong>추가 발주 필요:</strong> {purchase_summary.additional_purchase_needed} 항목
(신규 {purchase_summary.total_new_items} + 증량 {purchase_summary.total_increased_items})
</Typography>
</Alert>
)}
</CardContent>
</Card>
);
const renderNewItemsTable = () => (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
indeterminate={selectedItems.size > 0 && selectedItems.size < new_items.length}
checked={new_items.length > 0 && selectedItems.size === new_items.length}
onChange={(e) => handleSelectAll(new_items, e.target.checked)}
/>
</TableCell>
<TableCell>품명</TableCell>
<TableCell>사이즈</TableCell>
<TableCell align="right">필요수량</TableCell>
<TableCell align="right">기존재고</TableCell>
<TableCell align="right">추가필요</TableCell>
<TableCell>재질</TableCell>
</TableRow>
</TableHead>
<TableBody>
{new_items.map((item, index) => (
<TableRow
key={item.material_hash}
selected={selectedItems.has(item.material_hash)}
hover
>
<TableCell padding="checkbox">
<Checkbox
checked={selectedItems.has(item.material_hash)}
onChange={(e) => handleItemSelect(item.material_hash, e.target.checked)}
/>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.description}
</Typography>
</TableCell>
<TableCell>{item.size_spec}</TableCell>
<TableCell align="right">
<Chip
label={item.quantity}
size="small"
color="primary"
variant="outlined"
/>
</TableCell>
<TableCell align="right">{item.available_stock}</TableCell>
<TableCell align="right">
<Chip
label={item.additional_needed}
size="small"
color={item.additional_needed > 0 ? "error" : "success"}
/>
</TableCell>
<TableCell>
<Typography variant="caption" color="textSecondary">
{item.material_grade}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
const renderModifiedItemsTable = () => (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
indeterminate={selectedItems.size > 0 && selectedItems.size < modified_items.length}
checked={modified_items.length > 0 && selectedItems.size === modified_items.length}
onChange={(e) => handleSelectAll(modified_items, e.target.checked)}
/>
</TableCell>
<TableCell>품명</TableCell>
<TableCell>사이즈</TableCell>
<TableCell align="right">이전수량</TableCell>
<TableCell align="right">현재수량</TableCell>
<TableCell align="right">증감</TableCell>
<TableCell align="right">기존재고</TableCell>
<TableCell align="right">추가필요</TableCell>
</TableRow>
</TableHead>
<TableBody>
{modified_items.map((item, index) => (
<TableRow
key={item.material_hash}
selected={selectedItems.has(item.material_hash)}
hover
>
<TableCell padding="checkbox">
<Checkbox
checked={selectedItems.has(item.material_hash)}
onChange={(e) => handleItemSelect(item.material_hash, e.target.checked)}
disabled={item.additional_needed <= 0} // 추가 필요량이 없으면 선택 불가
/>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.description}
</Typography>
</TableCell>
<TableCell>{item.size_spec}</TableCell>
<TableCell align="right">{item.previous_quantity}</TableCell>
<TableCell align="right">{item.current_quantity}</TableCell>
<TableCell align="right">
<Chip
label={item.quantity_diff > 0 ? `+${item.quantity_diff}` : item.quantity_diff}
size="small"
color={item.quantity_diff > 0 ? "error" : "success"}
/>
</TableCell>
<TableCell align="right">{item.available_stock}</TableCell>
<TableCell align="right">
<Chip
label={item.additional_needed}
size="small"
color={item.additional_needed > 0 ? "error" : "success"}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
const renderRemovedItemsTable = () => (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>품명</TableCell>
<TableCell>사이즈</TableCell>
<TableCell align="right">삭제된 수량</TableCell>
</TableRow>
</TableHead>
<TableBody>
{removed_items.map((item, index) => (
<TableRow key={item.material_hash}>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.description}
</Typography>
</TableCell>
<TableCell>{item.size_spec}</TableCell>
<TableCell align="right">
<Chip
label={item.quantity}
size="small"
color="default"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
const renderConfirmDialog = () => (
<Dialog
open={confirmDialog}
onClose={() => setConfirmDialog(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
<Stack direction="row" alignItems="center" spacing={1}>
<ShoppingCartIcon />
<Typography variant="h6">발주 확정</Typography>
</Stack>
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
선택한 {Object.keys(purchaseConfirmations).length} 항목의 발주를 확정합니다.
수량과 공급업체 정보를 확인해주세요.
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>품명</TableCell>
<TableCell align="right">확정수량</TableCell>
<TableCell>공급업체</TableCell>
<TableCell align="right">단가</TableCell>
<TableCell align="right">총액</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.values(purchaseConfirmations).map((conf) => (
<TableRow key={conf.material_hash}>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{conf.description}
</Typography>
</TableCell>
<TableCell align="right">
<TextField
size="small"
type="number"
value={conf.confirmed_quantity}
onChange={(e) => handleQuantityChange(conf.material_hash, e.target.value)}
sx={{ width: 80 }}
/>
</TableCell>
<TableCell>
<TextField
size="small"
placeholder="공급업체"
value={conf.supplier_name}
onChange={(e) => handleSupplierChange(conf.material_hash, e.target.value)}
sx={{ width: 120 }}
/>
</TableCell>
<TableCell align="right">
<TextField
size="small"
type="number"
placeholder="0"
value={conf.unit_price}
onChange={(e) => handlePriceChange(conf.material_hash, e.target.value)}
sx={{ width: 80 }}
/>
</TableCell>
<TableCell align="right">
<Typography variant="body2">
{(conf.confirmed_quantity * conf.unit_price).toLocaleString()}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDialog(false)}>
취소
</Button>
<Button
onClick={handleConfirmPurchase}
variant="contained"
startIcon={<CheckCircleIcon />}
disabled={loading}
>
발주 확정
</Button>
</DialogActions>
</Dialog>
);
return (
<Box>
{renderSummaryCard()}
<Card>
<CardHeader
title="자재 비교 상세"
action={
selectedItems.size > 0 && (
<Button
variant="contained"
startIcon={<ShoppingCartIcon />}
onClick={handleOpenConfirmDialog}
disabled={loading}
>
선택 항목 발주 확정 ({selectedItems.size})
</Button>
)
}
/>
<CardContent>
<Tabs value={selectedTab} onChange={handleTabChange}>
<Tab
label={`신규 항목 (${new_items.length})`}
icon={<AddIcon />}
/>
<Tab
label={`수량 변경 (${modified_items.length})`}
icon={<EditIcon />}
/>
<Tab
label={`삭제 항목 (${removed_items.length})`}
icon={<RemoveIcon />}
/>
</Tabs>
<Box sx={{ mt: 2 }}>
{selectedTab === 0 && renderNewItemsTable()}
{selectedTab === 1 && renderModifiedItemsTable()}
{selectedTab === 2 && renderRemovedItemsTable()}
</Box>
</CardContent>
</Card>
{renderConfirmDialog()}
</Box>
);
};
export default MaterialComparisonResult;

View File

@@ -0,0 +1,855 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import {
Typography,
Box,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
TablePagination,
CircularProgress,
Chip,
TextField,
MenuItem,
Select,
InputLabel,
FormControl,
Grid,
IconButton,
Button,
Accordion,
AccordionSummary,
AccordionDetails,
Tabs,
Tab,
Alert
} from '@mui/material';
import {
Inventory,
Clear,
ExpandMore,
CompareArrows,
Add,
Remove,
Warning
} from '@mui/icons-material';
import SearchIcon from '@mui/icons-material/Search';
import { fetchMaterials as fetchMaterialsApi, fetchJobs } from '../api';
import { useSearchParams } from 'react-router-dom';
import Toast from './Toast';
function MaterialList({ selectedProject }) {
const [searchParams, setSearchParams] = useSearchParams();
const [materials, setMaterials] = useState([]);
const [jobs, setJobs] = useState([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(25);
const [totalCount, setTotalCount] = useState(0);
const [search, setSearch] = useState(searchParams.get('search') || '');
const [searchValue, setSearchValue] = useState(searchParams.get('searchValue') || '');
const [itemType, setItemType] = useState(searchParams.get('itemType') || '');
const [materialGrade, setMaterialGrade] = useState(searchParams.get('materialGrade') || '');
const [sizeSpec, setSizeSpec] = useState(searchParams.get('sizeSpec') || '');
const [fileFilter, setFileFilter] = useState(searchParams.get('fileFilter') || '');
const [selectedJob, setSelectedJob] = useState(searchParams.get('jobId') || '');
const [selectedRevision, setSelectedRevision] = useState(searchParams.get('revision') || '');
const [groupingType, setGroupingType] = useState(searchParams.get('grouping') || 'item');
const [sortBy, setSortBy] = useState(searchParams.get('sortBy') || '');
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
const [revisionComparison, setRevisionComparison] = useState(null);
const [files, setFiles] = useState([]);
const [fileId, setFileId] = useState(searchParams.get('file_id') || '');
const [selectedJobNo, setSelectedJobNo] = useState(searchParams.get('job_no') || '');
const [selectedFilename, setSelectedFilename] = useState(searchParams.get('filename') || '');
// URL 쿼리 파라미터 동기화
useEffect(() => {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (searchValue) params.set('searchValue', searchValue);
if (itemType) params.set('itemType', itemType);
if (materialGrade) params.set('materialGrade', materialGrade);
if (sizeSpec) params.set('sizeSpec', sizeSpec);
if (fileFilter) params.set('fileFilter', fileFilter);
if (selectedJob) params.set('jobId', selectedJob);
if (selectedRevision) params.set('revision', selectedRevision);
if (groupingType) params.set('grouping', groupingType);
if (sortBy) params.set('sortBy', sortBy);
if (page > 0) params.set('page', page.toString());
if (rowsPerPage !== 25) params.set('rowsPerPage', rowsPerPage.toString());
if (fileId) params.set('file_id', fileId);
if (selectedJobNo) params.set('job_no', selectedJobNo);
if (selectedFilename) params.set('filename', selectedFilename);
if (selectedRevision) params.set('revision', selectedRevision);
setSearchParams(params);
}, [search, searchValue, itemType, materialGrade, sizeSpec, fileFilter, selectedJob, selectedRevision, groupingType, sortBy, page, rowsPerPage, fileId, setSearchParams, selectedJobNo, selectedFilename, selectedRevision]);
// URL 파라미터로 진입 시 자동 필터 적용
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const urlJobNo = urlParams.get('job_no');
const urlFilename = urlParams.get('filename');
const urlRevision = urlParams.get('revision');
if (urlJobNo) setSelectedJobNo(urlJobNo);
if (urlFilename) setSelectedFilename(urlFilename);
if (urlRevision) setSelectedRevision(urlRevision);
}, []);
useEffect(() => {
if (selectedProject) {
fetchJobsData();
fetchMaterials();
} else {
setMaterials([]);
setTotalCount(0);
setJobs([]);
}
}, [selectedProject, page, rowsPerPage, search, searchValue, itemType, materialGrade, sizeSpec, fileFilter, selectedJob, selectedRevision, groupingType, sortBy, fileId, selectedJobNo, selectedFilename, selectedRevision]);
// 파일 업로드 이벤트 리스너 추가
useEffect(() => {
const handleFileUploaded = (event) => {
const { jobNo } = event.detail;
if (selectedProject && selectedProject.job_no === jobNo) {
console.log('파일 업로드 감지됨, 자재 목록 갱신 중...');
fetchMaterials();
}
};
window.addEventListener('fileUploaded', handleFileUploaded);
return () => {
window.removeEventListener('fileUploaded', handleFileUploaded);
};
}, [selectedProject]);
// 파일 목록 불러오기 (선택된 프로젝트가 바뀔 때마다)
useEffect(() => {
async function fetchFilesForProject() {
if (!selectedProject?.job_no) {
setFiles([]);
return;
}
try {
const response = await fetch(`/files?job_no=${selectedProject.job_no}`);
if (response.ok) {
const data = await response.json();
if (Array.isArray(data)) setFiles(data);
else if (data && Array.isArray(data.files)) setFiles(data.files);
else setFiles([]);
} else {
setFiles([]);
}
} catch {
setFiles([]);
}
}
fetchFilesForProject();
}, [selectedProject]);
const fetchJobsData = async () => {
try {
const response = await fetchJobs({ project_id: selectedProject.id });
if (response.data && response.data.jobs) {
setJobs(response.data.jobs);
}
} catch (error) {
console.error('Job 조회 실패:', error);
}
};
const fetchMaterials = async () => {
setLoading(true);
try {
const skip = page * rowsPerPage;
const params = {
job_no: selectedJobNo || undefined,
filename: selectedFilename || undefined,
revision: selectedRevision || undefined,
skip,
limit: rowsPerPage,
search: search || undefined,
search_value: searchValue || undefined,
item_type: itemType || undefined,
material_grade: materialGrade || undefined,
size_spec: sizeSpec || undefined,
// file_id, fileFilter 등은 사용하지 않음
grouping: groupingType || undefined,
sort_by: sortBy || undefined
};
// selectedProject가 없으면 API 호출하지 않음
if (!selectedProject?.job_no) {
setMaterials([]);
setTotalCount(0);
setLoading(false);
return;
}
console.log('API 요청 파라미터:', params); // 디버깅용
const response = await fetchMaterialsApi(params);
const data = response.data;
console.log('API 응답:', data); // 디버깅용
setMaterials(data.materials || []);
setTotalCount(data.total_count || 0);
// 리비전 비교 데이터가 있으면 설정
if (data.revision_comparison) {
setRevisionComparison(data.revision_comparison);
}
} catch (error) {
console.error('자재 조회 실패:', error);
console.error('에러 상세:', error.response?.data); // 디버깅용
setToast({
open: true,
message: `자재 데이터를 불러오는데 실패했습니다: ${error.response?.data?.detail || error.message}`,
type: 'error'
});
setMaterials([]);
setTotalCount(0);
} finally {
setLoading(false);
}
};
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
const clearFilters = () => {
setSearch('');
setSearchValue('');
setItemType('');
setMaterialGrade('');
setSizeSpec('');
setFileFilter('');
setSelectedJob('');
setSelectedRevision('');
setGroupingType('item');
setSortBy('');
setPage(0);
};
const getItemTypeColor = (itemType) => {
const colors = {
'PIPE': 'primary',
'FITTING': 'secondary',
'VALVE': 'success',
'FLANGE': 'warning',
'BOLT': 'info',
'GASKET': 'error',
'INSTRUMENT': 'purple',
'OTHER': 'default'
};
return colors[itemType] || 'default';
};
const getRevisionChangeColor = (change) => {
if (change > 0) return 'success';
if (change < 0) return 'error';
return 'default';
};
const getRevisionChangeIcon = (change) => {
if (change > 0) return <Add />;
if (change < 0) return <Remove />;
return null;
};
if (!selectedProject) {
return (
<Box>
<Typography variant="h4" gutterBottom>
📋 자재 목록
</Typography>
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<Inventory sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
프로젝트를 선택해주세요
</Typography>
<Typography variant="body2" color="textSecondary">
프로젝트 관리 탭에서 프로젝트를 선택하면 자재 목록을 확인할 있습니다.
</Typography>
</CardContent>
</Card>
</Box>
);
}
return (
<Box>
<Typography variant="h4" gutterBottom>
📋 자재 목록 (그룹핑)
</Typography>
<Typography variant="h6" color="primary" sx={{ mb: 2 }}>
{selectedProject.project_name} ({selectedProject.official_project_code})
</Typography>
{/* 필터/검색/정렬 UI */}
<Box sx={{ mb: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}>
<Grid container spacing={2} alignItems="center">
{/* 검색 유형 */}
<Grid item xs={12} sm={3} md={2}>
<FormControl size="small" fullWidth>
<InputLabel>검색 유형</InputLabel>
<Select
value={search}
label="검색 유형"
onChange={e => setSearch(e.target.value)}
displayEmpty
>
<MenuItem key="all-search" value="">전체</MenuItem>
<MenuItem key="project-search" value="project">프로젝트명</MenuItem>
<MenuItem key="job-search" value="job">Job No.</MenuItem>
<MenuItem key="material-search" value="material">자재명</MenuItem>
<MenuItem key="description-search" value="description">설명</MenuItem>
<MenuItem key="grade-search" value="grade">재질</MenuItem>
<MenuItem key="size-search" value="size">사이즈</MenuItem>
<MenuItem key="filename-search" value="filename">파일명</MenuItem>
</Select>
</FormControl>
</Grid>
{/* 검색어 입력/선택 */}
<Grid item xs={12} sm={3} md={2}>
{search === 'project' ? (
<FormControl size="small" fullWidth>
<InputLabel>프로젝트명 선택</InputLabel>
<Select
value={searchValue}
label="프로젝트명 선택"
onChange={e => setSearchValue(e.target.value)}
displayEmpty
>
<MenuItem key="all-projects" value="">전체 프로젝트</MenuItem>
<MenuItem key="mp7-rev2" value="MP7 PIPING PROJECT Rev.2">MP7 PIPING PROJECT Rev.2</MenuItem>
<MenuItem key="pp5-5701" value="PP5 5701">PP5 5701</MenuItem>
<MenuItem key="mp7" value="MP7">MP7</MenuItem>
</Select>
</FormControl>
) : search === 'job' ? (
<FormControl size="small" fullWidth>
<InputLabel>Job No. 선택</InputLabel>
<Select
value={searchValue}
label="Job No. 선택"
onChange={e => setSearchValue(e.target.value)}
displayEmpty
>
<MenuItem key="all-jobs-search" value="">전체 Job</MenuItem>
{jobs.map((job) => (
<MenuItem key={`job-search-${job.id}`} value={job.job_number}>
{job.job_number}
</MenuItem>
))}
</Select>
</FormControl>
) : search === 'material' || search === 'description' ? (
<TextField
label="자재명/설명 검색"
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
size="small"
fullWidth
placeholder="자재명 또는 설명 입력"
InputProps={{
endAdornment: (
<IconButton onClick={() => fetchMaterials()}>
<SearchIcon />
</IconButton>
)
}}
/>
) : search === 'grade' ? (
<TextField
label="재질 검색"
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
size="small"
fullWidth
placeholder="재질 입력 (예: SS316)"
InputProps={{
endAdornment: (
<IconButton onClick={() => fetchMaterials()}>
<SearchIcon />
</IconButton>
)
}}
/>
) : search === 'size' ? (
<TextField
label="사이즈 검색"
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
size="small"
fullWidth
placeholder="사이즈 입력 (예: 6인치)"
InputProps={{
endAdornment: (
<IconButton onClick={() => fetchMaterials()}>
<SearchIcon />
</IconButton>
)
}}
/>
) : search === 'filename' ? (
<TextField
label="파일명 검색"
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
size="small"
fullWidth
placeholder="파일명 입력"
InputProps={{
endAdornment: (
<IconButton onClick={() => fetchMaterials()}>
<SearchIcon />
</IconButton>
)
}}
/>
) : (
<TextField
label="검색어"
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
size="small"
fullWidth
placeholder="검색어 입력"
disabled={!search}
InputProps={{
endAdornment: search && (
<IconButton onClick={() => fetchMaterials()}>
<SearchIcon />
</IconButton>
)
}}
/>
)}
</Grid>
{/* Job No. 선택 */}
<Grid item xs={6} sm={2} md={2}>
<FormControl size="small" fullWidth>
<InputLabel>Job No.</InputLabel>
<Select
value={selectedJobNo}
label="Job No."
onChange={e => setSelectedJobNo(e.target.value)}
displayEmpty
>
<MenuItem value="">전체</MenuItem>
{jobs.map((job) => (
<MenuItem key={job.job_number} value={job.job_number}>
{job.job_number}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{/* 도면명(파일명) 선택 */}
<Grid item xs={6} sm={2} md={2}>
<FormControl size="small" fullWidth>
<InputLabel>도면명(파일명)</InputLabel>
<Select
value={selectedFilename}
label="도면명(파일명)"
onChange={e => setSelectedFilename(e.target.value)}
displayEmpty
>
<MenuItem value="">전체</MenuItem>
{files.map((file) => (
<MenuItem key={file.id} value={file.original_filename}>
{file.bom_name || file.original_filename || file.filename}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{/* 리비전 선택 */}
<Grid item xs={6} sm={2} md={2}>
<FormControl size="small" fullWidth>
<InputLabel>리비전</InputLabel>
<Select
value={selectedRevision}
label="리비전"
onChange={e => setSelectedRevision(e.target.value)}
displayEmpty
>
<MenuItem value="">전체</MenuItem>
{files
.filter(file => file.original_filename === selectedFilename)
.map((file) => (
<MenuItem key={file.id} value={file.revision}>
{file.revision}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{/* 그룹핑 타입 */}
<Grid item xs={6} sm={2} md={2}>
<FormControl size="small" fullWidth>
<InputLabel>그룹핑</InputLabel>
<Select
value={groupingType}
label="그룹핑"
onChange={e => setGroupingType(e.target.value)}
>
<MenuItem key="item" value="item">품목별</MenuItem>
<MenuItem key="material" value="material">재질별</MenuItem>
<MenuItem key="size" value="size">사이즈별</MenuItem>
<MenuItem key="job" value="job">Job별</MenuItem>
<MenuItem key="revision" value="revision">리비전별</MenuItem>
</Select>
</FormControl>
</Grid>
{/* 품목 필터 */}
<Grid item xs={6} sm={2} md={2}>
<FormControl size="small" fullWidth>
<InputLabel>품목</InputLabel>
<Select
value={itemType}
label="품목"
onChange={e => setItemType(e.target.value)}
>
<MenuItem key="all" value="">전체</MenuItem>
<MenuItem key="PIPE" value="PIPE">PIPE</MenuItem>
<MenuItem key="FITTING" value="FITTING">FITTING</MenuItem>
<MenuItem key="VALVE" value="VALVE">VALVE</MenuItem>
<MenuItem key="FLANGE" value="FLANGE">FLANGE</MenuItem>
<MenuItem key="BOLT" value="BOLT">BOLT</MenuItem>
<MenuItem key="GASKET" value="GASKET">GASKET</MenuItem>
<MenuItem key="INSTRUMENT" value="INSTRUMENT">INSTRUMENT</MenuItem>
<MenuItem key="OTHER" value="OTHER">기타</MenuItem>
</Select>
</FormControl>
</Grid>
{/* 재질 필터 */}
<Grid item xs={6} sm={2} md={2}>
<TextField
label="재질"
value={materialGrade}
onChange={e => setMaterialGrade(e.target.value)}
size="small"
fullWidth
placeholder="예: SS316"
/>
</Grid>
{/* 사이즈 필터 */}
<Grid item xs={6} sm={2} md={2}>
<TextField
label="사이즈"
value={sizeSpec}
onChange={e => setSizeSpec(e.target.value)}
size="small"
fullWidth
placeholder={'예: 6"'}
/>
</Grid>
{/* 파일명 필터 */}
<Grid item xs={6} sm={2} md={2}>
<TextField
label="파일명"
value={fileFilter}
onChange={e => setFileFilter(e.target.value)}
size="small"
fullWidth
placeholder="파일명 검색"
/>
</Grid>
{/* 파일(도면) 선택 드롭다운 */}
<Grid item xs={6} sm={2} md={2}>
<FormControl size="small" fullWidth>
<InputLabel>도면(파일)</InputLabel>
<Select
value={fileId}
label="도면(파일)"
onChange={e => setFileId(e.target.value)}
displayEmpty
>
<MenuItem value="">전체</MenuItem>
{files.map((file) => (
<MenuItem key={file.id} value={file.id}>
{file.bom_name || file.original_filename || file.filename}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{/* 정렬 */}
<Grid item xs={6} sm={2} md={2}>
<FormControl size="small" fullWidth>
<InputLabel>정렬</InputLabel>
<Select
value={sortBy}
label="정렬"
onChange={e => setSortBy(e.target.value)}
>
<MenuItem key="default" value="">기본</MenuItem>
<MenuItem key="quantity_desc" value="quantity_desc">수량 내림차순</MenuItem>
<MenuItem key="quantity_asc" value="quantity_asc">수량 오름차순</MenuItem>
<MenuItem key="name_asc" value="name_asc">이름 오름차순</MenuItem>
<MenuItem key="name_desc" value="name_desc">이름 내림차순</MenuItem>
<MenuItem key="created_desc" value="created_desc">최신 업로드</MenuItem>
<MenuItem key="created_asc" value="created_asc">오래된 업로드</MenuItem>
</Select>
</FormControl>
</Grid>
{/* 필터 초기화 */}
<Grid item xs={12}>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button
variant="outlined"
size="small"
startIcon={<Clear />}
onClick={clearFilters}
>
필터 초기화
</Button>
</Box>
</Grid>
</Grid>
</Box>
{/* 전역 Toast */}
<Toast
open={toast.open}
message={toast.message}
type={toast.type}
onClose={() => setToast({ open: false, message: '', type: 'info' })}
/>
{/* 리비전 비교 알림 */}
{revisionComparison && (
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
<strong>리비전 비교:</strong> {revisionComparison.summary}
</Typography>
</Alert>
)}
{/* 필터 상태 표시 */}
{(search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision || groupingType !== 'item' || sortBy) && (
<Box sx={{ mb: 2, p: 1, bgcolor: 'info.50', borderRadius: 1, border: '1px solid', borderColor: 'info.200' }}>
<Typography variant="body2" color="info.main">
필터 적용 :
{search && ` 검색 유형: ${search}`}
{searchValue && ` 검색어: "${searchValue}"`}
{selectedJobNo && ` Job No: ${selectedJobNo}`}
{selectedFilename && ` 파일: ${selectedFilename}`}
{selectedRevision && ` 리비전: ${selectedRevision}`}
{groupingType !== 'item' && ` 그룹핑: ${groupingType}`}
{itemType && ` 품목: ${itemType}`}
{materialGrade && ` 재질: ${materialGrade}`}
{sizeSpec && ` 사이즈: ${sizeSpec}`}
{fileFilter && ` 파일: ${fileFilter}`}
{sortBy && ` 정렬: ${sortBy}`}
</Typography>
</Box>
)}
{loading ? (
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<CircularProgress size={60} />
<Typography variant="h6" sx={{ mt: 2 }}>
자재 데이터 로딩 ...
</Typography>
</CardContent>
</Card>
) : materials.length === 0 ? (
<Card>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<Inventory sx={{ fontSize: 64, color: 'secondary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
{search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision ? '검색 결과가 없습니다' : '자재 데이터가 없습니다'}
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
{search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision
? '다른 검색 조건을 시도해보세요.'
: '파일 업로드 탭에서 BOM 파일을 업로드해주세요.'
}
</Typography>
{(search || searchValue || itemType || materialGrade || sizeSpec || fileFilter || selectedJobNo || selectedFilename || selectedRevision) && (
<Button variant="outlined" onClick={clearFilters}>
필터 초기화
</Button>
)}
</CardContent>
</Card>
) : (
<Card>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">
{totalCount.toLocaleString()} 자재 그룹
</Typography>
<Chip
label={`${materials.length}개 표시 중`}
color="primary"
variant="outlined"
/>
</Box>
<TableContainer component={Paper} variant="outlined">
<Table>
<TableHead>
<TableRow sx={{ bgcolor: 'grey.50' }}>
<TableCell><strong>번호</strong></TableCell>
<TableCell><strong>유형</strong></TableCell>
<TableCell><strong>자재명</strong></TableCell>
<TableCell align="center"><strong> 수량</strong></TableCell>
<TableCell align="center"><strong>단위</strong></TableCell>
<TableCell align="center"><strong>사이즈</strong></TableCell>
<TableCell><strong>재질</strong></TableCell>
<TableCell align="center"><strong>Job No.</strong></TableCell>
<TableCell align="center"><strong>리비전</strong></TableCell>
<TableCell align="center"><strong>변경</strong></TableCell>
<TableCell align="center"><strong>라인 </strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{materials.map((material, index) => (
<TableRow
key={`${material.id}-${index}`}
sx={{ '&:hover': { bgcolor: 'grey.50' } }}
>
<TableCell>
{page * rowsPerPage + index + 1}
</TableCell>
<TableCell>
<Chip
label={material.item_type || 'OTHER'}
size="small"
color={getItemTypeColor(material.item_type)}
/>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ maxWidth: 300 }}>
{material.original_description}
</Typography>
</TableCell>
<TableCell align="center">
<Typography variant="h6" color="primary">
{material.classified_category === 'PIPE' ? (() => {
const bomLength = material.pipe_details?.total_length_mm || 0;
const pipeCount = material.pipe_details?.pipe_count || 0;
const cuttingLoss = pipeCount * 2;
const requiredLength = bomLength + cuttingLoss;
const pipesNeeded = Math.ceil(requiredLength / 6000);
return pipesNeeded.toLocaleString();
})() : material.quantity.toLocaleString()}
</Typography>
</TableCell>
<TableCell align="center">
<Chip label={material.classified_category === 'PIPE' ? '본' : material.unit} size="small" />
</TableCell>
<TableCell align="center">
<Chip
label={material.size_spec || '-'}
size="small"
color="secondary"
/>
</TableCell>
<TableCell>
<Typography variant="body2" color="primary">
{material.material_grade || '-'}
</Typography>
</TableCell>
<TableCell align="center">
<Chip
label={material.job_number || '-'}
size="small"
color="info"
/>
</TableCell>
<TableCell align="center">
<Chip
label={material.revision || 'Rev.0'}
size="small"
color="warning"
/>
</TableCell>
<TableCell align="center">
{material.quantity_change && (
<Chip
label={`${material.quantity_change > 0 ? '+' : ''}${material.quantity_change}`}
size="small"
color={getRevisionChangeColor(material.quantity_change)}
icon={getRevisionChangeIcon(material.quantity_change)}
/>
)}
</TableCell>
<TableCell align="center">
<Chip
label={`${material.line_count || 1}개 라인`}
size="small"
variant="outlined"
title={material.line_numbers_str}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[10, 25, 50, 100]}
labelRowsPerPage="페이지당 행 수:"
labelDisplayedRows={({ from, to, count }) =>
`${from}-${to} / 총 ${count !== -1 ? count : to}`
}
/>
</CardContent>
</Card>
)}
</Box>
);
}
MaterialList.propTypes = {
selectedProject: PropTypes.shape({
id: PropTypes.string.isRequired,
project_name: PropTypes.string.isRequired,
official_project_code: PropTypes.string.isRequired,
}),
};
export default MaterialList;

View File

@@ -0,0 +1,559 @@
.navigation-bar {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 100;
}
.nav-container {
max-width: 1400px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 70px;
}
/* 브랜드 로고 */
.nav-brand {
display: flex;
align-items: center;
gap: 12px;
color: white;
text-decoration: none;
}
.brand-logo {
font-size: 32px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.brand-text h1 {
font-size: 20px;
font-weight: 700;
margin: 0;
line-height: 1.2;
}
.brand-text span {
font-size: 12px;
opacity: 0.9;
display: block;
line-height: 1;
}
/* 모바일 메뉴 토글 */
.mobile-menu-toggle {
display: none;
flex-direction: column;
background: none;
border: none;
cursor: pointer;
padding: 8px;
gap: 4px;
}
.mobile-menu-toggle span {
width: 24px;
height: 3px;
background: white;
border-radius: 2px;
transition: all 0.3s ease;
}
/* 메인 메뉴 */
.nav-menu {
display: flex;
align-items: center;
gap: 24px;
flex: 1;
justify-content: center;
}
.menu-items {
display: flex;
align-items: center;
gap: 8px;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: none;
border: none;
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s ease;
position: relative;
white-space: nowrap;
}
.menu-item:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateY(-1px);
}
.menu-item.active {
background: rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.menu-item.active::after {
content: '';
position: absolute;
bottom: -2px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 3px;
background: white;
border-radius: 2px;
}
.menu-icon {
font-size: 16px;
}
.menu-label {
font-weight: 600;
}
.admin-badge {
background: rgba(255, 255, 255, 0.2);
color: white;
font-size: 10px;
padding: 2px 6px;
border-radius: 10px;
font-weight: 600;
text-transform: uppercase;
}
/* 사용자 메뉴 */
.user-menu-container {
position: relative;
}
.user-menu-trigger {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 12px;
color: white;
cursor: pointer;
transition: all 0.2s ease;
}
.user-menu-trigger:hover {
background: rgba(255, 255, 255, 0.2);
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
}
.user-info {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
}
.user-name {
font-size: 14px;
font-weight: 600;
line-height: 1.2;
}
.user-role {
font-size: 11px;
opacity: 0.9;
line-height: 1;
}
.dropdown-arrow {
font-size: 10px;
transition: transform 0.2s ease;
}
.user-menu-trigger:hover .dropdown-arrow {
transform: rotate(180deg);
}
/* 사용자 드롭다운 */
.user-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 320px;
background: white;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
overflow: hidden;
animation: dropdownSlide 0.3s ease-out;
z-index: 1050;
}
@keyframes dropdownSlide {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.user-dropdown-header {
padding: 24px;
background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%);
display: flex;
gap: 16px;
align-items: flex-start;
}
.user-avatar-large {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 18px;
color: white;
flex-shrink: 0;
}
.user-details {
flex: 1;
min-width: 0;
}
.user-details .user-name {
font-size: 16px;
font-weight: 700;
color: #2d3748;
margin-bottom: 4px;
}
.user-username {
font-size: 14px;
color: #718096;
margin-bottom: 4px;
}
.user-email {
font-size: 13px;
color: #4a5568;
margin-bottom: 8px;
word-break: break-all;
}
.user-meta {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.role-badge,
.access-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.role-badge { background: #bee3f8; color: #2b6cb0; }
.access-badge { background: #c6f6d5; color: #2f855a; }
.user-department {
font-size: 12px;
color: #718096;
font-style: italic;
}
.user-dropdown-menu {
padding: 8px 0;
}
.dropdown-item {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 24px;
background: none;
border: none;
color: #4a5568;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
}
.dropdown-item:hover {
background: #f7fafc;
color: #2d3748;
}
.dropdown-item.logout-item {
color: #e53e3e;
}
.dropdown-item.logout-item:hover {
background: #fed7d7;
color: #c53030;
}
.item-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
.dropdown-divider {
height: 1px;
background: #e2e8f0;
margin: 8px 0;
}
.user-dropdown-footer {
padding: 16px 24px;
background: #f7fafc;
border-top: 1px solid #e2e8f0;
}
.permissions-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.permissions-label {
font-size: 12px;
font-weight: 600;
color: #718096;
text-transform: uppercase;
}
.permissions-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.permission-tag {
padding: 2px 6px;
background: #e2e8f0;
color: #4a5568;
font-size: 10px;
border-radius: 8px;
font-weight: 500;
}
.permission-more {
padding: 2px 6px;
background: #cbd5e0;
color: #2d3748;
font-size: 10px;
border-radius: 8px;
font-weight: 600;
}
/* 모바일 오버레이 */
.mobile-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 99;
}
/* 반응형 디자인 */
@media (max-width: 1024px) {
.menu-items {
gap: 4px;
}
.menu-item {
padding: 8px 12px;
font-size: 13px;
}
.menu-label {
display: none;
}
.menu-icon {
font-size: 18px;
}
}
@media (max-width: 768px) {
.nav-container {
padding: 0 16px;
height: 60px;
}
.brand-text h1 {
font-size: 18px;
}
.brand-text span {
font-size: 11px;
}
.mobile-menu-toggle {
display: flex;
}
.nav-menu {
position: fixed;
top: 60px;
left: 0;
right: 0;
background: white;
flex-direction: column;
align-items: stretch;
gap: 0;
padding: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-100%);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.nav-menu.mobile-open {
transform: translateY(0);
opacity: 1;
visibility: visible;
}
.mobile-overlay {
display: block;
}
.menu-items {
flex-direction: column;
gap: 8px;
width: 100%;
margin-bottom: 16px;
}
.menu-item {
width: 100%;
justify-content: flex-start;
padding: 16px;
color: #2d3748;
border-radius: 12px;
background: #f7fafc;
}
.menu-item:hover {
background: #edf2f7;
transform: none;
}
.menu-item.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.menu-label {
display: block;
}
.user-menu-container {
width: 100%;
}
.user-menu-trigger {
width: 100%;
justify-content: flex-start;
background: #f7fafc;
color: #2d3748;
border-radius: 12px;
padding: 16px;
}
.user-avatar {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.user-dropdown {
position: static;
width: 100%;
margin-top: 8px;
box-shadow: none;
border: 1px solid #e2e8f0;
}
}
@media (max-width: 480px) {
.nav-container {
padding: 0 12px;
}
.brand-text h1 {
font-size: 16px;
}
.user-dropdown {
width: calc(100vw - 24px);
left: 12px;
right: 12px;
}
}

View File

@@ -0,0 +1,292 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import './NavigationBar.css';
const NavigationBar = ({ currentPage, onNavigate }) => {
const { user, logout, hasPermission, isAdmin, isManager } = useAuth();
const [showUserMenu, setShowUserMenu] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// 메뉴 항목 정의 (권한별)
const menuItems = [
{
id: 'dashboard',
label: '대시보드',
icon: '📊',
path: '/dashboard',
permission: null, // 모든 사용자 접근 가능
description: '전체 현황 보기'
},
{
id: 'projects',
label: '프로젝트 관리',
icon: '📋',
path: '/projects',
permission: 'project.view',
description: '프로젝트 등록 및 관리'
},
{
id: 'bom',
label: 'BOM 관리',
icon: '📄',
path: '/bom',
permission: 'bom.view',
description: 'BOM 파일 업로드 및 분석'
},
{
id: 'materials',
label: '자재 관리',
icon: '🔧',
path: '/materials',
permission: 'bom.view',
description: '자재 목록 및 비교'
},
{
id: 'purchase',
label: '구매 관리',
icon: '💰',
path: '/purchase',
permission: 'project.view',
description: '구매 확인 및 관리'
},
{
id: 'files',
label: '파일 관리',
icon: '📁',
path: '/files',
permission: 'file.upload',
description: '파일 업로드 및 관리'
},
{
id: 'users',
label: '사용자 관리',
icon: '👥',
path: '/users',
permission: 'user.view',
description: '사용자 계정 관리',
adminOnly: true
},
{
id: 'system',
label: '시스템 설정',
icon: '⚙️',
path: '/system',
permission: 'system.admin',
description: '시스템 환경 설정',
adminOnly: true
}
];
// 사용자가 접근 가능한 메뉴만 필터링
const accessibleMenuItems = menuItems.filter(item => {
// 관리자 전용 메뉴 체크
if (item.adminOnly && !isAdmin() && !isManager()) {
return false;
}
// 권한 체크
if (item.permission && !hasPermission(item.permission)) {
return false;
}
return true;
});
const handleLogout = async () => {
try {
await logout();
setShowUserMenu(false);
} catch (error) {
console.error('Logout failed:', error);
}
};
const handleMenuClick = (item) => {
onNavigate(item.id);
setIsMobileMenuOpen(false);
};
const getRoleDisplayName = (role) => {
const roleMap = {
'admin': '관리자',
'system': '시스템',
'leader': '팀장',
'support': '지원',
'user': '사용자'
};
return roleMap[role] || role;
};
const getAccessLevelDisplayName = (level) => {
const levelMap = {
'manager': '관리자',
'leader': '팀장',
'worker': '작업자',
'viewer': '조회자'
};
return levelMap[level] || level;
};
return (
<nav className="navigation-bar">
<div className="nav-container">
{/* 로고 및 브랜드 */}
<div className="nav-brand">
<div className="brand-logo">🚀</div>
<div className="brand-text">
<h1>TK-MP System</h1>
<span>통합 프로젝트 관리</span>
</div>
</div>
{/* 모바일 메뉴 토글 */}
<button
className="mobile-menu-toggle"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
<span></span>
<span></span>
<span></span>
</button>
{/* 메인 메뉴 */}
<div className={`nav-menu ${isMobileMenuOpen ? 'mobile-open' : ''}`}>
<div className="menu-items">
{accessibleMenuItems.map(item => (
<button
key={item.id}
className={`menu-item ${currentPage === item.id ? 'active' : ''}`}
onClick={() => handleMenuClick(item)}
title={item.description}
>
<span className="menu-icon">{item.icon}</span>
<span className="menu-label">{item.label}</span>
{item.adminOnly && (
<span className="admin-badge">관리자</span>
)}
</button>
))}
</div>
{/* 사용자 메뉴 */}
<div className="user-menu-container">
<button
className="user-menu-trigger"
onClick={() => setShowUserMenu(!showUserMenu)}
>
<div className="user-avatar">
{user?.name?.charAt(0) || '👤'}
</div>
<div className="user-info">
<span className="user-name">{user?.name}</span>
<span className="user-role">
{getRoleDisplayName(user?.role)} · {getAccessLevelDisplayName(user?.access_level)}
</span>
</div>
<span className="dropdown-arrow"></span>
</button>
{showUserMenu && (
<div className="user-dropdown">
<div className="user-dropdown-header">
<div className="user-avatar-large">
{user?.name?.charAt(0) || '👤'}
</div>
<div className="user-details">
<div className="user-name">{user?.name}</div>
<div className="user-username">@{user?.username}</div>
<div className="user-email">{user?.email}</div>
<div className="user-meta">
<span className="role-badge role-{user?.role}">
{getRoleDisplayName(user?.role)}
</span>
<span className="access-badge access-{user?.access_level}">
{getAccessLevelDisplayName(user?.access_level)}
</span>
</div>
{user?.department && (
<div className="user-department">{user.department}</div>
)}
</div>
</div>
<div className="user-dropdown-menu">
<button className="dropdown-item">
<span className="item-icon">👤</span>
프로필 설정
</button>
<button className="dropdown-item">
<span className="item-icon">🔐</span>
비밀번호 변경
</button>
<button className="dropdown-item">
<span className="item-icon">🔔</span>
알림 설정
</button>
<div className="dropdown-divider"></div>
<button
className="dropdown-item logout-item"
onClick={handleLogout}
>
<span className="item-icon">🚪</span>
로그아웃
</button>
</div>
<div className="user-dropdown-footer">
<div className="permissions-info">
<span className="permissions-label">권한:</span>
<div className="permissions-list">
{user?.permissions?.slice(0, 3).map(permission => (
<span key={permission} className="permission-tag">
{permission}
</span>
))}
{user?.permissions?.length > 3 && (
<span className="permission-more">
+{user.permissions.length - 3}
</span>
)}
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* 모바일 오버레이 */}
{isMobileMenuOpen && (
<div
className="mobile-overlay"
onClick={() => setIsMobileMenuOpen(false)}
/>
)}
</nav>
);
};
export default NavigationBar;

Some files were not shown because too many files have changed in this diff Show More