feat(tkeg): tkeg BOM 자재관리 서비스 초기 세팅 (api + web + docker-compose)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
18
tkeg/api/Dockerfile
Normal 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
0
tkeg/api/app/__init__.py
Normal file
4
tkeg/api/app/api/__init__.py
Normal file
4
tkeg/api/app/api/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
API 모듈
|
||||
분리된 API 엔드포인트들
|
||||
"""
|
||||
231
tkeg/api/app/api/spools.py
Normal file
231
tkeg/api/app/api/spools.py
Normal 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
|
||||
]
|
||||
}
|
||||
9
tkeg/api/app/auth/__init__.py
Normal file
9
tkeg/api/app/auth/__init__.py
Normal 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",
|
||||
]
|
||||
17
tkeg/api/app/auth/jwt_service.py
Normal file
17
tkeg/api/app/auth/jwt_service.py
Normal 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
|
||||
74
tkeg/api/app/auth/middleware.py
Normal file
74
tkeg/api/app/auth/middleware.py
Normal 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
290
tkeg/api/app/config.py
Normal 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
35
tkeg/api/app/database.py
Normal 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
265
tkeg/api/app/main.py
Normal 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
471
tkeg/api/app/models.py
Normal 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")
|
||||
0
tkeg/api/app/routers/__init__.py
Normal file
0
tkeg/api/app/routers/__init__.py
Normal file
610
tkeg/api/app/routers/dashboard.py
Normal file
610
tkeg/api/app/routers/dashboard.py
Normal 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)}")
|
||||
593
tkeg/api/app/routers/export_manager.py
Normal file
593
tkeg/api/app/routers/export_manager.py
Normal 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)}"
|
||||
)
|
||||
3074
tkeg/api/app/routers/files.py
Normal file
3074
tkeg/api/app/routers/files.py
Normal file
File diff suppressed because it is too large
Load Diff
48
tkeg/api/app/routers/jobs.py
Normal file
48
tkeg/api/app/routers/jobs.py
Normal 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"),
|
||||
},
|
||||
}
|
||||
657
tkeg/api/app/routers/material_comparison.py
Normal file
657
tkeg/api/app/routers/material_comparison.py
Normal 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
|
||||
161
tkeg/api/app/routers/materials.py
Normal file
161
tkeg/api/app/routers/materials.py
Normal 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)}"
|
||||
)
|
||||
585
tkeg/api/app/routers/purchase.py
Normal file
585
tkeg/api/app/routers/purchase.py
Normal 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)}")
|
||||
834
tkeg/api/app/routers/purchase_request.py
Normal file
834
tkeg/api/app/routers/purchase_request.py
Normal 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)}"
|
||||
)
|
||||
454
tkeg/api/app/routers/purchase_tracking.py
Normal file
454
tkeg/api/app/routers/purchase_tracking.py
Normal 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)}"
|
||||
)
|
||||
327
tkeg/api/app/routers/revision_management.py
Normal file
327
tkeg/api/app/routers/revision_management.py
Normal 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)}")
|
||||
538
tkeg/api/app/routers/tubing.py
Normal file
538
tkeg/api/app/routers/tubing.py
Normal 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
47
tkeg/api/app/schemas.py
Normal 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
|
||||
69
tkeg/api/app/schemas/__init__.py
Normal file
69
tkeg/api/app/schemas/__init__.py
Normal 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"
|
||||
]
|
||||
354
tkeg/api/app/schemas/response_models.py
Normal file
354
tkeg/api/app/schemas/response_models.py
Normal 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
|
||||
]
|
||||
13
tkeg/api/app/services/__init__.py
Normal file
13
tkeg/api/app/services/__init__.py
Normal 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'
|
||||
]
|
||||
362
tkeg/api/app/services/activity_logger.py
Normal file
362
tkeg/api/app/services/activity_logger.py
Normal 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
|
||||
)
|
||||
1251
tkeg/api/app/services/bolt_classifier.py
Normal file
1251
tkeg/api/app/services/bolt_classifier.py
Normal file
File diff suppressed because it is too large
Load Diff
157
tkeg/api/app/services/classifier_constants.py
Normal file
157
tkeg/api/app/services/classifier_constants.py
Normal 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", "스프링"]
|
||||
}
|
||||
}
|
||||
300
tkeg/api/app/services/excel_parser.py
Normal file
300
tkeg/api/app/services/excel_parser.py
Normal 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
|
||||
80
tkeg/api/app/services/exclude_classifier.py
Normal file
80
tkeg/api/app/services/exclude_classifier.py
Normal 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)
|
||||
333
tkeg/api/app/services/file_service.py
Normal file
333
tkeg/api/app/services/file_service.py
Normal 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)
|
||||
886
tkeg/api/app/services/fitting_classifier.py
Normal file
886
tkeg/api/app/services/fitting_classifier.py
Normal 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"
|
||||
}
|
||||
595
tkeg/api/app/services/flange_classifier.py
Normal file
595
tkeg/api/app/services/flange_classifier.py
Normal 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"]
|
||||
}
|
||||
616
tkeg/api/app/services/gasket_classifier.py
Normal file
616
tkeg/api/app/services/gasket_classifier.py
Normal 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)
|
||||
233
tkeg/api/app/services/instrument_classifier.py
Normal file
233
tkeg/api/app/services/instrument_classifier.py
Normal 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
|
||||
301
tkeg/api/app/services/integrated_classifier.py
Normal file
301
tkeg/api/app/services/integrated_classifier.py
Normal 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)
|
||||
338
tkeg/api/app/services/material_classifier.py
Normal file
338
tkeg/api/app/services/material_classifier.py
Normal 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
|
||||
263
tkeg/api/app/services/material_grade_extractor.py
Normal file
263
tkeg/api/app/services/material_grade_extractor.py
Normal 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)
|
||||
}
|
||||
592
tkeg/api/app/services/material_service.py
Normal file
592
tkeg/api/app/services/material_service.py
Normal 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", "")
|
||||
})
|
||||
590
tkeg/api/app/services/materials_schema.py
Normal file
590
tkeg/api/app/services/materials_schema.py
Normal 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", "구상흑연주철", "덕타일"
|
||||
]
|
||||
}
|
||||
573
tkeg/api/app/services/pipe_classifier.py
Normal file
573
tkeg/api/app/services/pipe_classifier.py
Normal 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
|
||||
50
tkeg/api/app/services/plate_classifier.py
Normal file
50
tkeg/api/app/services/plate_classifier.py
Normal 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
|
||||
}
|
||||
}
|
||||
754
tkeg/api/app/services/purchase_calculator.py
Normal file
754
tkeg/api/app/services/purchase_calculator.py
Normal 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
|
||||
417
tkeg/api/app/services/revision_comparator.py
Normal file
417
tkeg/api/app/services/revision_comparator.py
Normal 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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
457
tkeg/api/app/services/revision_comparison_service.py
Normal file
457
tkeg/api/app/services/revision_comparison_service.py
Normal 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}개"}
|
||||
289
tkeg/api/app/services/revision_session_service.py
Normal file
289
tkeg/api/app/services/revision_session_service.py
Normal 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
|
||||
256
tkeg/api/app/services/spool_manager.py
Normal file
256
tkeg/api/app/services/spool_manager.py
Normal 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
|
||||
229
tkeg/api/app/services/spool_manager_v2.py
Normal file
229
tkeg/api/app/services/spool_manager_v2.py
Normal 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
|
||||
34
tkeg/api/app/services/structural_classifier.py
Normal file
34
tkeg/api/app/services/structural_classifier.py
Normal 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
|
||||
}
|
||||
}
|
||||
329
tkeg/api/app/services/support_classifier.py
Normal file
329
tkeg/api/app/services/support_classifier.py
Normal 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)
|
||||
143
tkeg/api/app/services/test_bolt_classifier.py
Normal file
143
tkeg/api/app/services/test_bolt_classifier.py
Normal 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()
|
||||
184
tkeg/api/app/services/test_fitting_classifier.py
Normal file
184
tkeg/api/app/services/test_fitting_classifier.py
Normal 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()
|
||||
126
tkeg/api/app/services/test_flange_classifier.py
Normal file
126
tkeg/api/app/services/test_flange_classifier.py
Normal 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()
|
||||
127
tkeg/api/app/services/test_gasket_classifier.py
Normal file
127
tkeg/api/app/services/test_gasket_classifier.py
Normal 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()
|
||||
48
tkeg/api/app/services/test_instrument_classifier.py
Normal file
48
tkeg/api/app/services/test_instrument_classifier.py
Normal 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()
|
||||
53
tkeg/api/app/services/test_material_classifier.py
Normal file
53
tkeg/api/app/services/test_material_classifier.py
Normal 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()
|
||||
55
tkeg/api/app/services/test_pipe_classifier.py
Normal file
55
tkeg/api/app/services/test_pipe_classifier.py
Normal 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()
|
||||
130
tkeg/api/app/services/test_valve_classifier.py
Normal file
130
tkeg/api/app/services/test_valve_classifier.py
Normal 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()
|
||||
759
tkeg/api/app/services/valve_classifier.py
Normal file
759
tkeg/api/app/services/valve_classifier.py
Normal 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"]
|
||||
}
|
||||
12
tkeg/api/app/utils/__init__.py
Normal file
12
tkeg/api/app/utils/__init__.py
Normal 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"
|
||||
]
|
||||
266
tkeg/api/app/utils/cache_manager.py
Normal file
266
tkeg/api/app/utils/cache_manager.py
Normal 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()
|
||||
139
tkeg/api/app/utils/error_handlers.py
Normal file
139
tkeg/api/app/utils/error_handlers.py
Normal 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("에러 핸들러 등록 완료")
|
||||
335
tkeg/api/app/utils/file_processor.py
Normal file
335
tkeg/api/app/utils/file_processor.py
Normal 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()
|
||||
169
tkeg/api/app/utils/file_validator.py
Normal file
169
tkeg/api/app/utils/file_validator.py
Normal 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)
|
||||
87
tkeg/api/app/utils/logger.py
Normal file
87
tkeg/api/app/utils/logger.py
Normal 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)
|
||||
56
tkeg/api/app/utils/tkuser_client.py
Normal file
56
tkeg/api/app/utils/tkuser_client.py
Normal 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"))
|
||||
355
tkeg/api/app/utils/transaction_manager.py
Normal file
355
tkeg/api/app/utils/transaction_manager.py
Normal 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()
|
||||
131
tkeg/api/database/init/01_schema.sql
Normal file
131
tkeg/api/database/init/01_schema.sql
Normal 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;
|
||||
8
tkeg/api/database/init/02_add_classification_details.sql
Normal file
8
tkeg/api/database/init/02_add_classification_details.sql
Normal 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);
|
||||
0
tkeg/api/database/init/02_seed_data.sql
Normal file
0
tkeg/api/database/init/02_seed_data.sql
Normal file
78
tkeg/api/database/init/20_purchase_confirmations.sql
Normal file
78
tkeg/api/database/init/20_purchase_confirmations.sql
Normal 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 '구매 수량 확정자';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1419
tkeg/api/database/init/99_complete_schema.sql
Normal file
1419
tkeg/api/database/init/99_complete_schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
62
tkeg/api/requirements.txt
Normal file
62
tkeg/api/requirements.txt
Normal 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
24
tkeg/web/.gitignore
vendored
Normal 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
17
tkeg/web/Dockerfile
Normal 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
348
tkeg/web/PAGES_GUIDE.md
Normal 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
161
tkeg/web/README.md
Normal 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
29
tkeg/web/eslint.config.js
Normal 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
13
tkeg/web/index.html
Normal 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
27
tkeg/web/nginx.conf
Normal 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
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
38
tkeg/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
tkeg/web/public/img/login-bg.jpeg
Normal file
BIN
tkeg/web/public/img/login-bg.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
BIN
tkeg/web/public/img/logo.png
Normal file
BIN
tkeg/web/public/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
1
tkeg/web/public/vite.svg
Normal file
1
tkeg/web/public/vite.svg
Normal 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
260
tkeg/web/src/App.css
Normal 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
84
tkeg/web/src/api.js
Normal 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;
|
||||
1
tkeg/web/src/assets/react.svg
Normal file
1
tkeg/web/src/assets/react.svg
Normal 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 |
136
tkeg/web/src/components/BOMFileUpload.jsx
Normal file
136
tkeg/web/src/components/BOMFileUpload.jsx
Normal 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;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
440
tkeg/web/src/components/Dashboard.jsx
Normal file
440
tkeg/web/src/components/Dashboard.jsx
Normal 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;
|
||||
590
tkeg/web/src/components/FileManager.jsx
Normal file
590
tkeg/web/src/components/FileManager.jsx
Normal 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;
|
||||
579
tkeg/web/src/components/FileUpload.jsx
Normal file
579
tkeg/web/src/components/FileUpload.jsx
Normal 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;
|
||||
99
tkeg/web/src/components/FittingDetailsCard.jsx
Normal file
99
tkeg/web/src/components/FittingDetailsCard.jsx
Normal 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;
|
||||
516
tkeg/web/src/components/MaterialComparisonResult.jsx
Normal file
516
tkeg/web/src/components/MaterialComparisonResult.jsx
Normal 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;
|
||||
855
tkeg/web/src/components/MaterialList.jsx
Normal file
855
tkeg/web/src/components/MaterialList.jsx
Normal 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;
|
||||
559
tkeg/web/src/components/NavigationBar.css
Normal file
559
tkeg/web/src/components/NavigationBar.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
292
tkeg/web/src/components/NavigationBar.jsx
Normal file
292
tkeg/web/src/components/NavigationBar.jsx
Normal 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
Reference in New Issue
Block a user