feat(tkeg): tkeg BOM 자재관리 서비스 초기 세팅 (api + web + docker-compose)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
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)}")
|
||||
Reference in New Issue
Block a user