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

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

View File

View File

@@ -0,0 +1,610 @@
"""
대시보드 API
사용자별 맞춤형 대시보드 데이터 제공
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text, func
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
from ..database import get_db
from ..auth.middleware import get_current_user
from ..services.activity_logger import ActivityLogger
from ..utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get("/stats")
async def get_dashboard_stats(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
사용자별 맞춤형 대시보드 통계 데이터 조회
Returns:
dict: 사용자 역할에 맞는 통계 데이터
"""
try:
username = current_user.get('username')
user_role = current_user.get('role', 'user')
# 역할별 맞춤 통계 생성
if user_role == 'admin':
stats = await get_admin_stats(db)
elif user_role == 'manager':
stats = await get_manager_stats(db, username)
elif user_role == 'designer':
stats = await get_designer_stats(db, username)
elif user_role == 'purchaser':
stats = await get_purchaser_stats(db, username)
else:
stats = await get_user_stats(db, username)
return {
"success": True,
"user_role": user_role,
"stats": stats
}
except Exception as e:
logger.error(f"Dashboard stats error: {str(e)}")
raise HTTPException(status_code=500, detail=f"대시보드 통계 조회 실패: {str(e)}")
@router.get("/activities")
async def get_user_activities(
current_user: dict = Depends(get_current_user),
limit: int = Query(10, ge=1, le=50),
db: Session = Depends(get_db)
):
"""
사용자 활동 이력 조회
Args:
limit: 조회할 활동 수 (1-50)
Returns:
dict: 사용자 활동 이력
"""
try:
username = current_user.get('username')
activity_logger = ActivityLogger(db)
activities = activity_logger.get_user_activities(
username=username,
limit=limit
)
return {
"success": True,
"activities": activities,
"total": len(activities)
}
except Exception as e:
logger.error(f"User activities error: {str(e)}")
raise HTTPException(status_code=500, detail=f"활동 이력 조회 실패: {str(e)}")
@router.get("/recent-activities")
async def get_recent_activities(
current_user: dict = Depends(get_current_user),
days: int = Query(7, ge=1, le=30),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db)
):
"""
최근 전체 활동 조회 (관리자/매니저용)
Args:
days: 조회 기간 (일)
limit: 조회할 활동 수
Returns:
dict: 최근 활동 이력
"""
try:
user_role = current_user.get('role', 'user')
# 관리자와 매니저만 전체 활동 조회 가능
if user_role not in ['admin', 'manager']:
raise HTTPException(status_code=403, detail="권한이 없습니다")
activity_logger = ActivityLogger(db)
activities = activity_logger.get_recent_activities(
days=days,
limit=limit
)
return {
"success": True,
"activities": activities,
"period_days": days,
"total": len(activities)
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Recent activities error: {str(e)}")
raise HTTPException(status_code=500, detail=f"최근 활동 조회 실패: {str(e)}")
async def get_admin_stats(db: Session) -> Dict[str, Any]:
"""관리자용 통계"""
try:
# 전체 프로젝트 수
total_projects_query = text("SELECT COUNT(*) FROM jobs WHERE status != 'deleted'")
total_projects = db.execute(total_projects_query).scalar()
# 활성 사용자 수 (최근 30일 로그인)
active_users_query = text("""
SELECT COUNT(DISTINCT username)
FROM user_activity_logs
WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '30 days'
""")
active_users = db.execute(active_users_query).scalar() or 0
# 오늘 업로드된 파일 수
today_uploads_query = text("""
SELECT COUNT(*)
FROM files
WHERE DATE(upload_date) = CURRENT_DATE
""")
today_uploads = db.execute(today_uploads_query).scalar() or 0
# 전체 자재 수
total_materials_query = text("SELECT COUNT(*) FROM materials")
total_materials = db.execute(total_materials_query).scalar() or 0
return {
"title": "시스템 관리자",
"subtitle": "전체 시스템을 관리하고 모니터링합니다",
"metrics": [
{"label": "전체 프로젝트 수", "value": total_projects, "icon": "📋", "color": "#667eea"},
{"label": "활성 사용자 수", "value": active_users, "icon": "👥", "color": "#48bb78"},
{"label": "시스템 상태", "value": "정상", "icon": "🟢", "color": "#38b2ac"},
{"label": "오늘 업로드", "value": today_uploads, "icon": "📤", "color": "#ed8936"}
]
}
except Exception as e:
logger.error(f"Admin stats error: {str(e)}")
raise
async def get_manager_stats(db: Session, username: str) -> Dict[str, Any]:
"""매니저용 통계"""
try:
# 담당 프로젝트 수 (향후 assigned_to 필드 활용)
assigned_projects_query = text("""
SELECT COUNT(*)
FROM jobs
WHERE (assigned_to = :username OR created_by = :username)
AND status != 'deleted'
""")
assigned_projects = db.execute(assigned_projects_query, {"username": username}).scalar() or 0
# 이번 주 완료된 작업 (활동 로그 기반)
week_completed_query = text("""
SELECT COUNT(*)
FROM user_activity_logs
WHERE activity_type IN ('PROJECT_CREATE', 'PURCHASE_CONFIRM')
AND created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'
""")
week_completed = db.execute(week_completed_query).scalar() or 0
# 승인 대기 (구매 확정 대기 등)
pending_approvals_query = text("""
SELECT COUNT(*)
FROM material_purchase_tracking
WHERE purchase_status = 'PENDING'
OR purchase_status = 'REQUESTED'
""")
pending_approvals = db.execute(pending_approvals_query).scalar() or 0
return {
"title": "프로젝트 매니저",
"subtitle": "팀 프로젝트를 관리하고 진행상황을 모니터링합니다",
"metrics": [
{"label": "담당 프로젝트", "value": assigned_projects, "icon": "📋", "color": "#667eea"},
{"label": "팀 진행률", "value": "87%", "icon": "📈", "color": "#48bb78"},
{"label": "승인 대기", "value": pending_approvals, "icon": "", "color": "#ed8936"},
{"label": "이번 주 완료", "value": week_completed, "icon": "", "color": "#38b2ac"}
]
}
except Exception as e:
logger.error(f"Manager stats error: {str(e)}")
raise
async def get_designer_stats(db: Session, username: str) -> Dict[str, Any]:
"""설계자용 통계"""
try:
# 내가 업로드한 BOM 파일 수
my_files_query = text("""
SELECT COUNT(*)
FROM files
WHERE uploaded_by = :username
AND is_active = true
""")
my_files = db.execute(my_files_query, {"username": username}).scalar() or 0
# 분류된 자재 수
classified_materials_query = text("""
SELECT COUNT(*)
FROM materials m
JOIN files f ON m.file_id = f.id
WHERE f.uploaded_by = :username
AND m.classified_category IS NOT NULL
""")
classified_materials = db.execute(classified_materials_query, {"username": username}).scalar() or 0
# 검증 대기 자재 수
pending_verification_query = text("""
SELECT COUNT(*)
FROM materials m
JOIN files f ON m.file_id = f.id
WHERE f.uploaded_by = :username
AND m.is_verified = false
""")
pending_verification = db.execute(pending_verification_query, {"username": username}).scalar() or 0
# 이번 주 업로드 수
week_uploads_query = text("""
SELECT COUNT(*)
FROM files
WHERE uploaded_by = :username
AND upload_date >= CURRENT_TIMESTAMP - INTERVAL '7 days'
""")
week_uploads = db.execute(week_uploads_query, {"username": username}).scalar() or 0
# 분류 완료율 계산
total_materials_query = text("""
SELECT COUNT(*)
FROM materials m
JOIN files f ON m.file_id = f.id
WHERE f.uploaded_by = :username
""")
total_materials = db.execute(total_materials_query, {"username": username}).scalar() or 1
classification_rate = f"{(classified_materials / total_materials * 100):.0f}%" if total_materials > 0 else "0%"
return {
"title": "설계 담당자",
"subtitle": "BOM 파일을 관리하고 자재를 분류합니다",
"metrics": [
{"label": "내 BOM 파일", "value": my_files, "icon": "📄", "color": "#667eea"},
{"label": "분류 완료율", "value": classification_rate, "icon": "🎯", "color": "#48bb78"},
{"label": "검증 대기", "value": pending_verification, "icon": "", "color": "#ed8936"},
{"label": "이번 주 업로드", "value": week_uploads, "icon": "📤", "color": "#9f7aea"}
]
}
except Exception as e:
logger.error(f"Designer stats error: {str(e)}")
raise
async def get_purchaser_stats(db: Session, username: str) -> Dict[str, Any]:
"""구매자용 통계"""
try:
# 구매 요청 수
purchase_requests_query = text("""
SELECT COUNT(*)
FROM material_purchase_tracking
WHERE purchase_status IN ('PENDING', 'REQUESTED')
""")
purchase_requests = db.execute(purchase_requests_query).scalar() or 0
# 발주 완료 수
orders_completed_query = text("""
SELECT COUNT(*)
FROM material_purchase_tracking
WHERE purchase_status = 'CONFIRMED'
AND confirmed_by = :username
""")
orders_completed = db.execute(orders_completed_query, {"username": username}).scalar() or 0
# 입고 대기 수
receiving_pending_query = text("""
SELECT COUNT(*)
FROM material_purchase_tracking
WHERE purchase_status = 'ORDERED'
""")
receiving_pending = db.execute(receiving_pending_query).scalar() or 0
# 이번 달 구매 금액 (임시 데이터)
monthly_amount = "₩2.3M" # 실제로는 계산 필요
return {
"title": "구매 담당자",
"subtitle": "구매 요청을 처리하고 발주를 관리합니다",
"metrics": [
{"label": "구매 요청", "value": purchase_requests, "icon": "🛒", "color": "#667eea"},
{"label": "발주 완료", "value": orders_completed, "icon": "", "color": "#48bb78"},
{"label": "입고 대기", "value": receiving_pending, "icon": "📦", "color": "#ed8936"},
{"label": "이번 달 금액", "value": monthly_amount, "icon": "💰", "color": "#9f7aea"}
]
}
except Exception as e:
logger.error(f"Purchaser stats error: {str(e)}")
raise
async def get_user_stats(db: Session, username: str) -> Dict[str, Any]:
"""일반 사용자용 통계"""
try:
# 내 활동 수 (최근 7일)
my_activities_query = text("""
SELECT COUNT(*)
FROM user_activity_logs
WHERE username = :username
AND created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'
""")
my_activities = db.execute(my_activities_query, {"username": username}).scalar() or 0
# 접근 가능한 프로젝트 수 (임시)
accessible_projects = 5
return {
"title": "일반 사용자",
"subtitle": "할당된 업무를 수행하고 프로젝트에 참여합니다",
"metrics": [
{"label": "내 업무", "value": 6, "icon": "📋", "color": "#667eea"},
{"label": "완료율", "value": "75%", "icon": "📈", "color": "#48bb78"},
{"label": "대기 중", "value": 2, "icon": "", "color": "#ed8936"},
{"label": "이번 주 활동", "value": my_activities, "icon": "🎯", "color": "#9f7aea"}
]
}
except Exception as e:
logger.error(f"User stats error: {str(e)}")
raise
@router.get("/quick-actions")
async def get_quick_actions(
current_user: dict = Depends(get_current_user)
):
"""
사용자 역할별 빠른 작업 메뉴 조회
Returns:
dict: 역할별 빠른 작업 목록
"""
try:
user_role = current_user.get('role', 'user')
quick_actions = {
"admin": [
{"title": "사용자 관리", "icon": "👤", "path": "/admin/users", "color": "#667eea"},
{"title": "시스템 설정", "icon": "⚙️", "path": "/admin/settings", "color": "#48bb78"},
{"title": "백업 관리", "icon": "💾", "path": "/admin/backup", "color": "#ed8936"},
{"title": "활동 로그", "icon": "📊", "path": "/admin/logs", "color": "#9f7aea"}
],
"manager": [
{"title": "프로젝트 생성", "icon": "", "path": "/projects/new", "color": "#667eea"},
{"title": "팀 관리", "icon": "👥", "path": "/team", "color": "#48bb78"},
{"title": "진행 상황", "icon": "📊", "path": "/progress", "color": "#38b2ac"},
{"title": "승인 처리", "icon": "", "path": "/approvals", "color": "#ed8936"}
],
"designer": [
{"title": "BOM 업로드", "icon": "📤", "path": "/upload", "color": "#667eea"},
{"title": "자재 분류", "icon": "🔧", "path": "/materials", "color": "#48bb78"},
{"title": "리비전 관리", "icon": "🔄", "path": "/revisions", "color": "#38b2ac"},
{"title": "분류 검증", "icon": "", "path": "/verify", "color": "#ed8936"}
],
"purchaser": [
{"title": "구매 확정", "icon": "🛒", "path": "/purchase", "color": "#667eea"},
{"title": "발주 관리", "icon": "📋", "path": "/orders", "color": "#48bb78"},
{"title": "공급업체", "icon": "🏢", "path": "/suppliers", "color": "#38b2ac"},
{"title": "입고 처리", "icon": "📦", "path": "/receiving", "color": "#ed8936"}
],
"user": [
{"title": "내 업무", "icon": "📋", "path": "/my-tasks", "color": "#667eea"},
{"title": "프로젝트 보기", "icon": "👁️", "path": "/projects", "color": "#48bb78"},
{"title": "리포트 다운로드", "icon": "📊", "path": "/reports", "color": "#38b2ac"},
{"title": "도움말", "icon": "", "path": "/help", "color": "#9f7aea"}
]
}
return {
"success": True,
"user_role": user_role,
"quick_actions": quick_actions.get(user_role, quick_actions["user"])
}
except Exception as e:
logger.error(f"Quick actions error: {str(e)}")
raise HTTPException(status_code=500, detail=f"빠른 작업 조회 실패: {str(e)}")
@router.post("/projects")
async def create_project(
official_project_code: str = Query(..., description="프로젝트 코드"),
project_name: str = Query(..., description="프로젝트 이름"),
client_name: str = Query(None, description="고객사명"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
새 프로젝트 생성
Args:
official_project_code: 프로젝트 코드 (예: J24-001)
project_name: 프로젝트 이름
client_name: 고객사명 (선택)
Returns:
dict: 생성된 프로젝트 정보
"""
try:
# 중복 확인
check_query = text("SELECT id FROM projects WHERE official_project_code = :code")
existing = db.execute(check_query, {"code": official_project_code}).fetchone()
if existing:
raise HTTPException(status_code=400, detail="이미 존재하는 프로젝트 코드입니다")
# 프로젝트 생성
insert_query = text("""
INSERT INTO projects (official_project_code, project_name, client_name, status)
VALUES (:code, :name, :client, 'active')
RETURNING *
""")
new_project = db.execute(insert_query, {
"code": official_project_code,
"name": project_name,
"client": client_name
}).fetchone()
db.commit()
# TODO: 활동 로그 기록 (추후 구현)
# ActivityLogger 사용법 확인 필요
return {
"success": True,
"message": "프로젝트가 생성되었습니다",
"project": {
"id": new_project.id,
"official_project_code": new_project.official_project_code,
"project_name": new_project.project_name,
"client_name": new_project.client_name,
"status": new_project.status
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"프로젝트 생성 실패: {str(e)}")
raise HTTPException(status_code=500, detail=f"프로젝트 생성 실패: {str(e)}")
@router.get("/projects")
async def get_projects(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
프로젝트 목록 조회
Returns:
dict: 프로젝트 목록
"""
try:
query = text("""
SELECT
id,
official_project_code,
project_name,
client_name,
design_project_code,
design_project_name,
status,
created_at,
updated_at
FROM projects
ORDER BY created_at DESC
""")
results = db.execute(query).fetchall()
projects = []
for row in results:
projects.append({
"id": row.id,
"official_project_code": row.official_project_code,
"job_no": row.official_project_code, # job_no 필드 추가 (프론트엔드 호환성)
"project_name": row.project_name,
"job_name": row.project_name, # 호환성을 위해 추가
"client_name": row.client_name,
"design_project_code": row.design_project_code,
"design_project_name": row.design_project_name,
"status": row.status,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None
})
return {
"success": True,
"projects": projects,
"count": len(projects)
}
except Exception as e:
logger.error(f"프로젝트 목록 조회 실패: {str(e)}")
raise HTTPException(status_code=500, detail=f"프로젝트 목록 조회 실패: {str(e)}")
@router.patch("/projects/{project_id}")
async def update_project_name(
project_id: int,
job_name: str = Query(..., description="새 프로젝트 이름"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
프로젝트 이름 수정
Args:
project_id: 프로젝트 ID
job_name: 새 프로젝트 이름
Returns:
dict: 수정 결과
"""
try:
# 프로젝트 존재 확인
query = text("SELECT * FROM projects WHERE id = :project_id")
result = db.execute(query, {"project_id": project_id}).fetchone()
if not result:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
# 프로젝트 이름 업데이트
update_query = text("""
UPDATE projects
SET project_name = :project_name,
updated_at = CURRENT_TIMESTAMP
WHERE id = :project_id
RETURNING *
""")
updated = db.execute(update_query, {
"project_name": job_name,
"project_id": project_id
}).fetchone()
db.commit()
# TODO: 활동 로그 기록 (추후 구현)
return {
"success": True,
"message": "프로젝트 이름이 수정되었습니다",
"project": {
"id": updated.id,
"official_project_code": updated.official_project_code,
"project_name": updated.project_name,
"job_name": updated.project_name # 호환성
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"프로젝트 수정 실패: {str(e)}")
raise HTTPException(status_code=500, detail=f"프로젝트 수정 실패: {str(e)}")

View File

@@ -0,0 +1,593 @@
"""
엑셀 내보내기 및 구매 배치 관리 API
"""
from fastapi import APIRouter, Depends, HTTPException, status, Response
from fastapi.responses import FileResponse
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Optional, List, Dict, Any
from datetime import datetime
import json
import os
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
import uuid
from ..database import get_db
from ..auth.middleware import get_current_user
from ..utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/export", tags=["Export Management"])
# 엑셀 파일 저장 경로
EXPORT_DIR = "exports"
os.makedirs(EXPORT_DIR, exist_ok=True)
def create_excel_from_materials(materials: List[Dict], batch_info: Dict) -> str:
"""
자재 목록으로 엑셀 파일 생성
"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = batch_info.get("category", "자재목록")
# 헤더 스타일
header_fill = PatternFill(start_color="B8E6FF", end_color="B8E6FF", fill_type="solid")
header_font = Font(bold=True, size=11)
header_alignment = Alignment(horizontal="center", vertical="center")
thin_border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# 배치 정보 추가 (상단 3줄)
ws.merge_cells('A1:J1')
ws['A1'] = f"구매 배치: {batch_info.get('batch_no', 'N/A')}"
ws['A1'].font = Font(bold=True, size=14)
ws.merge_cells('A2:J2')
ws['A2'] = f"프로젝트: {batch_info.get('job_no', '')} - {batch_info.get('job_name', '')}"
ws.merge_cells('A3:J3')
ws['A3'] = f"내보낸 날짜: {batch_info.get('export_date', datetime.now().strftime('%Y-%m-%d %H:%M'))}"
# 빈 줄
ws.append([])
# 헤더 행
headers = [
"No.", "카테고리", "자재 설명", "크기", "스케줄/등급",
"재질", "수량", "단위", "추가요구", "사용자요구",
"구매상태", "PR번호", "PO번호", "공급업체", "예정일"
]
for col, header in enumerate(headers, 1):
cell = ws.cell(row=5, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = header_alignment
cell.border = thin_border
# 데이터 행
row_num = 6
for idx, material in enumerate(materials, 1):
row_data = [
idx,
material.get("category", ""),
material.get("description", ""),
material.get("size", ""),
material.get("schedule", ""),
material.get("material_grade", ""),
material.get("quantity", ""),
material.get("unit", ""),
material.get("additional_req", ""),
material.get("user_requirement", ""),
material.get("purchase_status", "pending"),
material.get("purchase_request_no", ""),
material.get("purchase_order_no", ""),
material.get("vendor_name", ""),
material.get("expected_date", "")
]
for col, value in enumerate(row_data, 1):
cell = ws.cell(row=row_num, column=col, value=value)
cell.border = thin_border
if col == 11: # 구매상태 컬럼
if value == "pending":
cell.fill = PatternFill(start_color="FFFFE0", end_color="FFFFE0", fill_type="solid")
elif value == "requested":
cell.fill = PatternFill(start_color="FFE4B5", end_color="FFE4B5", fill_type="solid")
elif value == "ordered":
cell.fill = PatternFill(start_color="ADD8E6", end_color="ADD8E6", fill_type="solid")
elif value == "received":
cell.fill = PatternFill(start_color="90EE90", end_color="90EE90", fill_type="solid")
row_num += 1
# 열 너비 자동 조정
for column in ws.columns:
max_length = 0
column_letter = get_column_letter(column[0].column)
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column_letter].width = adjusted_width
# 파일 저장
file_name = f"batch_{batch_info.get('batch_no', uuid.uuid4().hex[:8])}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
file_path = os.path.join(EXPORT_DIR, file_name)
wb.save(file_path)
return file_name
@router.post("/create-batch")
async def create_export_batch(
file_id: int,
job_no: Optional[str] = None,
category: Optional[str] = None,
materials: List[Dict] = [],
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
엑셀 내보내기 배치 생성 (자재 그룹화)
"""
try:
# 배치 번호 생성 (YYYYMMDD-XXX 형식)
batch_date = datetime.now().strftime('%Y%m%d')
# 오늘 생성된 배치 수 확인
count_query = text("""
SELECT COUNT(*) as count
FROM excel_export_history
WHERE DATE(export_date) = CURRENT_DATE
""")
count_result = db.execute(count_query).fetchone()
batch_seq = (count_result.count + 1) if count_result else 1
batch_no = f"{batch_date}-{str(batch_seq).zfill(3)}"
# Job 정보 조회
job_name = ""
if job_no:
job_query = text("SELECT job_name FROM jobs WHERE job_no = :job_no")
job_result = db.execute(job_query, {"job_no": job_no}).fetchone()
if job_result:
job_name = job_result.job_name
# 배치 정보
batch_info = {
"batch_no": batch_no,
"job_no": job_no,
"job_name": job_name,
"category": category,
"export_date": datetime.now().strftime('%Y-%m-%d %H:%M')
}
# 엑셀 파일 생성
excel_file_name = create_excel_from_materials(materials, batch_info)
# 내보내기 이력 저장
insert_history = text("""
INSERT INTO excel_export_history (
file_id, job_no, exported_by, export_type,
category, material_count, file_name, notes
) VALUES (
:file_id, :job_no, :exported_by, :export_type,
:category, :material_count, :file_name, :notes
) RETURNING export_id
""")
result = db.execute(insert_history, {
"file_id": file_id,
"job_no": job_no,
"exported_by": current_user.get("user_id"),
"export_type": "batch",
"category": category,
"material_count": len(materials),
"file_name": excel_file_name,
"notes": f"배치번호: {batch_no}"
})
export_id = result.fetchone().export_id
# 자재별 내보내기 기록
material_ids = []
for material in materials:
material_id = material.get("id")
if material_id:
material_ids.append(material_id)
insert_material = text("""
INSERT INTO exported_materials (
export_id, material_id, purchase_status,
quantity_exported
) VALUES (
:export_id, :material_id, 'pending',
:quantity
)
""")
db.execute(insert_material, {
"export_id": export_id,
"material_id": material_id,
"quantity": material.get("quantity", 0)
})
db.commit()
logger.info(f"Export batch created: {batch_no} with {len(materials)} materials")
return {
"success": True,
"batch_no": batch_no,
"export_id": export_id,
"file_name": excel_file_name,
"material_count": len(materials),
"message": f"배치 {batch_no}가 생성되었습니다"
}
except Exception as e:
db.rollback()
logger.error(f"Failed to create export batch: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"배치 생성 실패: {str(e)}"
)
@router.get("/batches")
async def get_export_batches(
file_id: Optional[int] = None,
job_no: Optional[str] = None,
status: Optional[str] = None,
limit: int = 50,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
내보내기 배치 목록 조회
"""
try:
query = text("""
SELECT
eeh.export_id,
eeh.file_id,
eeh.job_no,
eeh.export_date,
eeh.category,
eeh.material_count,
eeh.file_name,
eeh.notes,
u.name as exported_by,
j.job_name,
f.original_filename,
-- 상태별 집계
COUNT(DISTINCT em.material_id) as total_materials,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'pending' THEN em.material_id END) as pending_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) as requested_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) as ordered_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) as received_count,
-- 전체 상태 계산
CASE
WHEN COUNT(DISTINCT em.material_id) = COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END)
THEN 'completed'
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) > 0
THEN 'in_progress'
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) > 0
THEN 'requested'
ELSE 'pending'
END as batch_status
FROM excel_export_history eeh
LEFT JOIN users u ON eeh.exported_by = u.user_id
LEFT JOIN jobs j ON eeh.job_no = j.job_no
LEFT JOIN files f ON eeh.file_id = f.id
LEFT JOIN exported_materials em ON eeh.export_id = em.export_id
WHERE eeh.export_type = 'batch'
AND (:file_id IS NULL OR eeh.file_id = :file_id)
AND (:job_no IS NULL OR eeh.job_no = :job_no)
GROUP BY
eeh.export_id, eeh.file_id, eeh.job_no, eeh.export_date,
eeh.category, eeh.material_count, eeh.file_name, eeh.notes,
u.name, j.job_name, f.original_filename
HAVING (:status IS NULL OR
CASE
WHEN COUNT(DISTINCT em.material_id) = COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END)
THEN 'completed'
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) > 0
THEN 'in_progress'
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) > 0
THEN 'requested'
ELSE 'pending'
END = :status)
ORDER BY eeh.export_date DESC
LIMIT :limit
""")
results = db.execute(query, {
"file_id": file_id,
"job_no": job_no,
"status": status,
"limit": limit
}).fetchall()
batches = []
for row in results:
# 배치 번호 추출 (notes에서)
batch_no = ""
if row.notes and "배치번호:" in row.notes:
batch_no = row.notes.split("배치번호:")[1].strip()
batches.append({
"export_id": row.export_id,
"batch_no": batch_no,
"file_id": row.file_id,
"job_no": row.job_no,
"job_name": row.job_name,
"export_date": row.export_date.isoformat() if row.export_date else None,
"category": row.category,
"material_count": row.total_materials,
"file_name": row.file_name,
"exported_by": row.exported_by,
"source_file": row.original_filename,
"batch_status": row.batch_status,
"status_detail": {
"pending": row.pending_count,
"requested": row.requested_count,
"ordered": row.ordered_count,
"received": row.received_count,
"total": row.total_materials
}
})
return {
"success": True,
"batches": batches,
"count": len(batches)
}
except Exception as e:
logger.error(f"Failed to get export batches: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"배치 목록 조회 실패: {str(e)}"
)
@router.get("/batch/{export_id}/materials")
async def get_batch_materials(
export_id: int,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
배치에 포함된 자재 목록 조회
"""
try:
query = text("""
SELECT
em.id as exported_material_id,
em.material_id,
m.original_description,
m.classified_category,
m.size_inch,
m.schedule,
m.material_grade,
m.quantity,
m.unit,
em.purchase_status,
em.purchase_request_no,
em.purchase_order_no,
em.vendor_name,
em.expected_date,
em.quantity_ordered,
em.quantity_received,
em.unit_price,
em.total_price,
em.notes,
ur.requirement as user_requirement
FROM exported_materials em
JOIN materials m ON em.material_id = m.id
LEFT JOIN user_requirements ur ON m.id = ur.material_id
WHERE em.export_id = :export_id
ORDER BY m.classified_category, m.original_description
""")
results = db.execute(query, {"export_id": export_id}).fetchall()
materials = []
for row in results:
materials.append({
"exported_material_id": row.exported_material_id,
"material_id": row.material_id,
"description": row.original_description,
"category": row.classified_category,
"size": row.size_inch,
"schedule": row.schedule,
"material_grade": row.material_grade,
"quantity": row.quantity,
"unit": row.unit,
"user_requirement": row.user_requirement,
"purchase_status": row.purchase_status,
"purchase_request_no": row.purchase_request_no,
"purchase_order_no": row.purchase_order_no,
"vendor_name": row.vendor_name,
"expected_date": row.expected_date.isoformat() if row.expected_date else None,
"quantity_ordered": row.quantity_ordered,
"quantity_received": row.quantity_received,
"unit_price": float(row.unit_price) if row.unit_price else None,
"total_price": float(row.total_price) if row.total_price else None,
"notes": row.notes
})
return {
"success": True,
"materials": materials,
"count": len(materials)
}
except Exception as e:
logger.error(f"Failed to get batch materials: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"배치 자재 조회 실패: {str(e)}"
)
@router.get("/batch/{export_id}/download")
async def download_batch_excel(
export_id: int,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
저장된 배치 엑셀 파일 다운로드
"""
try:
# 배치 정보 조회
query = text("""
SELECT file_name, notes
FROM excel_export_history
WHERE export_id = :export_id
""")
result = db.execute(query, {"export_id": export_id}).fetchone()
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="배치를 찾을 수 없습니다"
)
file_path = os.path.join(EXPORT_DIR, result.file_name)
if not os.path.exists(file_path):
# 파일이 없으면 재생성
materials = await get_batch_materials(export_id, current_user, db)
batch_no = ""
if result.notes and "배치번호:" in result.notes:
batch_no = result.notes.split("배치번호:")[1].strip()
batch_info = {
"batch_no": batch_no,
"export_date": datetime.now().strftime('%Y-%m-%d %H:%M')
}
file_name = create_excel_from_materials(materials["materials"], batch_info)
file_path = os.path.join(EXPORT_DIR, file_name)
# DB 업데이트
update_query = text("""
UPDATE excel_export_history
SET file_name = :file_name
WHERE export_id = :export_id
""")
db.execute(update_query, {
"file_name": file_name,
"export_id": export_id
})
db.commit()
return FileResponse(
path=file_path,
filename=result.file_name,
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to download batch excel: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"엑셀 다운로드 실패: {str(e)}"
)
@router.patch("/batch/{export_id}/status")
async def update_batch_status(
export_id: int,
status: str,
purchase_request_no: Optional[str] = None,
purchase_order_no: Optional[str] = None,
vendor_name: Optional[str] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
배치 전체 상태 일괄 업데이트
"""
try:
# 배치의 모든 자재 상태 업데이트
update_query = text("""
UPDATE exported_materials
SET
purchase_status = :status,
purchase_request_no = COALESCE(:pr_no, purchase_request_no),
purchase_order_no = COALESCE(:po_no, purchase_order_no),
vendor_name = COALESCE(:vendor, vendor_name),
updated_by = :updated_by,
requested_date = CASE WHEN :status = 'requested' THEN CURRENT_TIMESTAMP ELSE requested_date END,
ordered_date = CASE WHEN :status = 'ordered' THEN CURRENT_TIMESTAMP ELSE ordered_date END,
received_date = CASE WHEN :status = 'received' THEN CURRENT_TIMESTAMP ELSE received_date END
WHERE export_id = :export_id
""")
result = db.execute(update_query, {
"export_id": export_id,
"status": status,
"pr_no": purchase_request_no,
"po_no": purchase_order_no,
"vendor": vendor_name,
"updated_by": current_user.get("user_id")
})
# 이력 기록
history_query = text("""
INSERT INTO purchase_status_history (
exported_material_id, material_id,
previous_status, new_status,
changed_by, reason
)
SELECT
em.id, em.material_id,
em.purchase_status, :new_status,
:changed_by, :reason
FROM exported_materials em
WHERE em.export_id = :export_id
""")
db.execute(history_query, {
"export_id": export_id,
"new_status": status,
"changed_by": current_user.get("user_id"),
"reason": f"배치 일괄 업데이트"
})
db.commit()
logger.info(f"Batch {export_id} status updated to {status}")
return {
"success": True,
"message": f"배치의 모든 자재가 {status} 상태로 변경되었습니다",
"updated_count": result.rowcount
}
except Exception as e:
db.rollback()
logger.error(f"Failed to update batch status: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"배치 상태 업데이트 실패: {str(e)}"
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
"""Job(프로젝트) 라우터 — tkuser API 프록시"""
from fastapi import APIRouter, Depends, HTTPException, Request
from ..auth.middleware import get_current_user
from ..utils.tkuser_client import get_token_from_request, get_projects, get_project
router = APIRouter()
@router.get("/")
async def get_jobs(request: Request, user: dict = Depends(get_current_user)):
"""프로젝트 목록 조회 (tkuser 프록시)"""
token = get_token_from_request(request)
projects = await get_projects(token, active_only=True)
return {
"success": True,
"total_count": len(projects),
"jobs": [
{
"job_no": p.get("job_no"),
"job_name": p.get("project_name"),
"client_name": p.get("client_name"),
"status": "진행중" if p.get("is_active") else "완료",
"created_at": p.get("created_at"),
"project_name": p.get("project_name"),
}
for p in projects
],
}
@router.get("/{job_no}")
async def get_job(job_no: str, request: Request, user: dict = Depends(get_current_user)):
"""프로젝트 상세 조회 — tkeg는 job_no로 참조하므로 목록에서 필터"""
token = get_token_from_request(request)
projects = await get_projects(token, active_only=False)
matched = next((p for p in projects if p.get("job_no") == job_no), None)
if not matched:
raise HTTPException(status_code=404, detail="Job을 찾을 수 없습니다")
return {
"success": True,
"job": {
"job_no": matched.get("job_no"),
"job_name": matched.get("project_name"),
"client_name": matched.get("client_name"),
"status": "진행중" if matched.get("is_active") else "완료",
"created_at": matched.get("created_at"),
},
}

View File

@@ -0,0 +1,657 @@
"""
자재 비교 및 발주 추적 API
- 리비전간 자재 비교
- 추가 발주 필요량 계산
- 발주 상태 관리
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text
import json
from typing import List, Optional, Dict
from datetime import datetime
from ..database import get_db
router = APIRouter(prefix="/materials", tags=["material-comparison"])
@router.post("/compare-revisions")
async def compare_material_revisions(
job_no: str,
current_revision: str,
previous_revision: Optional[str] = None,
save_result: bool = True,
db: Session = Depends(get_db)
):
"""
리비전간 자재 비교 및 추가 발주 필요량 계산
- 해시 기반 고성능 비교
- 누적 재고 고려한 실제 구매 필요량 계산
"""
try:
# 1. 파일 정보 조회
current_file = await get_file_by_revision(db, job_no, current_revision)
if not current_file:
raise HTTPException(status_code=404, detail=f"{job_no} {current_revision} 파일을 찾을 수 없습니다")
# 2. 이전 리비전 자동 탐지
if not previous_revision:
previous_revision = await get_previous_revision(db, job_no, current_revision)
previous_file = None
if previous_revision:
previous_file = await get_file_by_revision(db, job_no, previous_revision)
# 3. 자재 비교 실행
comparison_result = await perform_material_comparison(
db, current_file, previous_file, job_no
)
# 4. 결과 저장 (선택사항) - 임시로 비활성화
comparison_id = None
# TODO: 저장 기능 활성화
# if save_result and previous_file and previous_revision:
# comparison_id = await save_comparison_result(
# db, job_no, current_revision, previous_revision,
# current_file["id"], previous_file["id"], comparison_result
# )
return {
"success": True,
"job_no": job_no,
"current_revision": current_revision,
"previous_revision": previous_revision,
"comparison_id": comparison_id,
"summary": comparison_result["summary"],
"new_items": comparison_result["new_items"],
"modified_items": comparison_result["modified_items"],
"removed_items": comparison_result["removed_items"]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"자재 비교 실패: {str(e)}")
@router.get("/comparison-history")
async def get_comparison_history(
job_no: str = Query(..., description="Job 번호"),
limit: int = Query(10, ge=1, le=50),
db: Session = Depends(get_db)
):
"""
자재 비교 이력 조회
"""
try:
query = text("""
SELECT
id, current_revision, previous_revision,
new_items_count, modified_items_count, removed_items_count,
upload_date, created_by
FROM material_revisions_comparison
WHERE job_no = :job_no
ORDER BY upload_date DESC
LIMIT :limit
""")
result = db.execute(query, {"job_no": job_no, "limit": limit})
comparisons = result.fetchall()
return {
"success": True,
"job_no": job_no,
"comparisons": [
{
"id": comp[0],
"current_revision": comp[1],
"previous_revision": comp[2],
"new_items_count": comp[3],
"modified_items_count": comp[4],
"removed_items_count": comp[5],
"upload_date": comp[6],
"created_by": comp[7]
}
for comp in comparisons
]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"비교 이력 조회 실패: {str(e)}")
@router.get("/inventory-status")
async def get_material_inventory_status(
job_no: str = Query(..., description="Job 번호"),
material_hash: Optional[str] = Query(None, description="특정 자재 해시"),
db: Session = Depends(get_db)
):
"""
자재별 누적 재고 현황 조회
"""
try:
# 임시로 빈 결과 반환 (추후 개선)
return {
"success": True,
"job_no": job_no,
"inventory": []
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"재고 현황 조회 실패: {str(e)}")
@router.post("/confirm-purchase")
async def confirm_material_purchase(
job_no: str,
revision: str,
confirmations: List[Dict],
confirmed_by: str = "system",
db: Session = Depends(get_db)
):
"""
자재 발주 확정 처리
confirmations = [
{
"material_hash": "abc123",
"confirmed_quantity": 100,
"supplier_name": "ABC공급업체",
"unit_price": 1000
}
]
"""
try:
# 입력 데이터 검증
if not job_no or not revision:
raise HTTPException(status_code=400, detail="Job 번호와 리비전은 필수입니다")
if not confirmations:
raise HTTPException(status_code=400, detail="확정할 자재가 없습니다")
# 각 확정 항목 검증
for i, confirmation in enumerate(confirmations):
if not confirmation.get("material_hash"):
raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 material_hash가 없습니다")
confirmed_qty = confirmation.get("confirmed_quantity")
if confirmed_qty is None or confirmed_qty < 0:
raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 확정 수량이 유효하지 않습니다")
unit_price = confirmation.get("unit_price", 0)
if unit_price < 0:
raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 단가가 유효하지 않습니다")
confirmed_items = []
for confirmation in confirmations:
# 발주 추적 테이블에 저장/업데이트
upsert_query = text("""
INSERT INTO material_purchase_tracking (
job_no, material_hash, revision, description, size_spec, unit,
bom_quantity, calculated_quantity, confirmed_quantity,
purchase_status, supplier_name, unit_price, total_price,
confirmed_by, confirmed_at
)
SELECT
:job_no, m.material_hash, :revision, m.original_description,
m.size_spec, m.unit, m.quantity, :calculated_qty, :confirmed_qty,
'CONFIRMED', :supplier_name, :unit_price, :total_price,
:confirmed_by, CURRENT_TIMESTAMP
FROM materials m
WHERE m.material_hash = :material_hash
AND m.file_id = (
SELECT id FROM files
WHERE job_no = :job_no AND revision = :revision
ORDER BY upload_date DESC LIMIT 1
)
LIMIT 1
ON CONFLICT (job_no, material_hash, revision)
DO UPDATE SET
confirmed_quantity = :confirmed_qty,
purchase_status = 'CONFIRMED',
supplier_name = :supplier_name,
unit_price = :unit_price,
total_price = :total_price,
confirmed_by = :confirmed_by,
confirmed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING id, description, confirmed_quantity
""")
calculated_qty = confirmation.get("calculated_quantity", confirmation["confirmed_quantity"])
total_price = confirmation["confirmed_quantity"] * confirmation.get("unit_price", 0)
result = db.execute(upsert_query, {
"job_no": job_no,
"revision": revision,
"material_hash": confirmation["material_hash"],
"calculated_qty": calculated_qty,
"confirmed_qty": confirmation["confirmed_quantity"],
"supplier_name": confirmation.get("supplier_name", ""),
"unit_price": confirmation.get("unit_price", 0),
"total_price": total_price,
"confirmed_by": confirmed_by
})
confirmed_item = result.fetchone()
if confirmed_item:
confirmed_items.append({
"id": confirmed_item[0],
"material_hash": confirmed_item[1],
"confirmed_quantity": confirmed_item[2],
"supplier_name": confirmed_item[3],
"unit_price": confirmed_item[4],
"total_price": confirmed_item[5]
})
db.commit()
return {
"success": True,
"message": f"{len(confirmed_items)}개 자재 발주가 확정되었습니다",
"confirmed_items": confirmed_items,
"job_no": job_no,
"revision": revision
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"발주 확정 실패: {str(e)}")
@router.get("/purchase-status")
async def get_purchase_status(
job_no: str = Query(..., description="Job 번호"),
revision: Optional[str] = Query(None, description="리비전 (전체 조회시 생략)"),
status: Optional[str] = Query(None, description="발주 상태 필터"),
db: Session = Depends(get_db)
):
"""
발주 상태 조회
"""
try:
where_conditions = ["job_no = :job_no"]
params = {"job_no": job_no}
if revision:
where_conditions.append("revision = :revision")
params["revision"] = revision
if status:
where_conditions.append("purchase_status = :status")
params["status"] = status
query = text(f"""
SELECT
material_hash, revision, description, size_spec, unit,
bom_quantity, calculated_quantity, confirmed_quantity,
purchase_status, supplier_name, unit_price, total_price,
order_date, delivery_date, confirmed_by, confirmed_at
FROM material_purchase_tracking
WHERE {' AND '.join(where_conditions)}
ORDER BY revision DESC, description
""")
result = db.execute(query, params)
purchases = result.fetchall()
# 상태별 요약
status_summary = {}
total_amount = 0
for purchase in purchases:
status_key = purchase.purchase_status
if status_key not in status_summary:
status_summary[status_key] = {"count": 0, "total_amount": 0}
status_summary[status_key]["count"] += 1
status_summary[status_key]["total_amount"] += purchase.total_price or 0
total_amount += purchase.total_price or 0
return {
"success": True,
"job_no": job_no,
"revision": revision,
"purchases": [purchase._asdict() if hasattr(purchase, '_asdict') else dict(zip(purchase.keys(), purchase)) for purchase in purchases],
"summary": {
"total_items": len(purchases),
"total_amount": total_amount,
"status_breakdown": status_summary
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"발주 상태 조회 실패: {str(e)}")
# ========== 헬퍼 함수들 ==========
async def get_file_by_revision(db: Session, job_no: str, revision: str) -> Optional[Dict]:
"""리비전으로 파일 정보 조회"""
query = text("""
SELECT id, original_filename, revision, upload_date
FROM files
WHERE job_no = :job_no AND revision = :revision AND is_active = TRUE
ORDER BY upload_date DESC
LIMIT 1
""")
result = db.execute(query, {"job_no": job_no, "revision": revision})
file_row = result.fetchone()
if file_row:
return {
"id": file_row[0],
"original_filename": file_row[1],
"revision": file_row[2],
"upload_date": file_row[3]
}
return None
async def get_previous_revision(db: Session, job_no: str, current_revision: str) -> Optional[str]:
"""이전 리비전 자동 탐지 - 숫자 기반 비교"""
# 현재 리비전의 숫자 추출
try:
current_rev_num = int(current_revision.replace("Rev.", ""))
except (ValueError, AttributeError):
current_rev_num = 0
query = text("""
SELECT revision
FROM files
WHERE job_no = :job_no AND is_active = TRUE
ORDER BY revision DESC
""")
result = db.execute(query, {"job_no": job_no})
revisions = result.fetchall()
# 현재 리비전보다 낮은 리비전 중 가장 높은 것 찾기
previous_revision = None
highest_prev_num = -1
for row in revisions:
rev = row[0]
try:
rev_num = int(rev.replace("Rev.", ""))
if rev_num < current_rev_num and rev_num > highest_prev_num:
highest_prev_num = rev_num
previous_revision = rev
except (ValueError, AttributeError):
continue
return previous_revision
async def perform_material_comparison(
db: Session,
current_file: Dict,
previous_file: Optional[Dict],
job_no: str
) -> Dict:
"""
핵심 자재 비교 로직 - 간단한 버전
"""
# 1. 현재 리비전 자재 목록 (해시별로 그룹화)
current_materials = await get_materials_by_hash(db, current_file["id"])
# 2. 이전 리비전 자재 목록
previous_materials = {}
if previous_file:
previous_materials = await get_materials_by_hash(db, previous_file["id"])
# 3. 비교 실행
new_items = []
modified_items = []
removed_items = []
# 신규/변경 항목 찾기
for material_hash, current_item in current_materials.items():
current_qty = current_item["quantity"]
if material_hash not in previous_materials:
# 완전히 새로운 항목
new_item = {
"material_hash": material_hash,
"description": current_item["description"],
"size_spec": current_item["size_spec"],
"material_grade": current_item["material_grade"],
"quantity": current_qty,
"category": current_item["category"],
"unit": current_item["unit"]
}
# 파이프인 경우 pipe_details 정보 포함
if current_item.get("pipe_details"):
new_item["pipe_details"] = current_item["pipe_details"]
new_items.append(new_item)
else:
# 기존 항목 - 수량 변경 체크
previous_qty = previous_materials[material_hash]["quantity"]
qty_change = current_qty - previous_qty
if qty_change != 0:
modified_item = {
"material_hash": material_hash,
"description": current_item["description"],
"size_spec": current_item["size_spec"],
"material_grade": current_item["material_grade"],
"previous_quantity": previous_qty,
"current_quantity": current_qty,
"quantity_change": qty_change,
"category": current_item["category"],
"unit": current_item["unit"]
}
# 파이프인 경우 이전/현재 pipe_details 모두 포함
if current_item.get("pipe_details"):
modified_item["pipe_details"] = current_item["pipe_details"]
# 이전 리비전 pipe_details도 포함
previous_item = previous_materials[material_hash]
if previous_item.get("pipe_details"):
modified_item["previous_pipe_details"] = previous_item["pipe_details"]
# 실제 길이 변화 계산 (현재 총길이 - 이전 총길이)
if current_item.get("pipe_details"):
current_total = current_item["pipe_details"]["total_length_mm"]
previous_total = previous_item["pipe_details"]["total_length_mm"]
length_change = current_total - previous_total
modified_item["length_change"] = length_change
print(f"🔢 실제 길이 변화: {current_item['description'][:50]} - 이전:{previous_total:.0f}mm → 현재:{current_total:.0f}mm (변화:{length_change:+.0f}mm)")
modified_items.append(modified_item)
# 삭제된 항목 찾기
for material_hash, previous_item in previous_materials.items():
if material_hash not in current_materials:
removed_item = {
"material_hash": material_hash,
"description": previous_item["description"],
"size_spec": previous_item["size_spec"],
"material_grade": previous_item["material_grade"],
"quantity": previous_item["quantity"],
"category": previous_item["category"],
"unit": previous_item["unit"]
}
# 파이프인 경우 pipe_details 정보 포함
if previous_item.get("pipe_details"):
removed_item["pipe_details"] = previous_item["pipe_details"]
removed_items.append(removed_item)
return {
"summary": {
"total_current_items": len(current_materials),
"total_previous_items": len(previous_materials),
"new_items_count": len(new_items),
"modified_items_count": len(modified_items),
"removed_items_count": len(removed_items)
},
"new_items": new_items,
"modified_items": modified_items,
"removed_items": removed_items
}
async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
"""파일의 자재를 해시별로 그룹화하여 조회"""
import hashlib
# 로그 제거
query = text("""
SELECT
m.id,
m.original_description,
m.size_spec,
m.material_grade,
m.quantity,
m.classified_category,
m.unit,
pd.length_mm,
m.line_number
FROM materials m
LEFT JOIN pipe_details pd ON m.id = pd.material_id
WHERE m.file_id = :file_id
ORDER BY m.line_number
""")
result = db.execute(query, {"file_id": file_id})
materials = result.fetchall()
# 로그 제거
# 🔄 같은 파이프들을 Python에서 올바르게 그룹핑
materials_dict = {}
for mat in materials:
# 자재 해시 생성 (description + size_spec + material_grade)
hash_source = f"{mat[1] or ''}|{mat[2] or ''}|{mat[3] or ''}"
material_hash = hashlib.md5(hash_source.encode()).hexdigest()
# 개별 자재 로그 제거 (너무 많음)
if material_hash in materials_dict:
# 🔄 기존 항목에 수량 합계
existing = materials_dict[material_hash]
# 파이프가 아닌 경우만 quantity 합산 (파이프는 개별 길이가 다르므로 합산하지 않음)
if mat[5] != 'PIPE':
existing["quantity"] += float(mat[4]) if mat[4] else 0.0
existing["line_number"] += f", {mat[8]}" if mat[8] else ""
# 파이프인 경우 길이 정보 합산
if mat[5] == 'PIPE' and mat[7] is not None:
if "pipe_details" in existing:
# 총길이 합산: 기존 총길이 + 현재 파이프의 실제 길이 (DB에 저장된 개별 길이)
current_total = existing["pipe_details"]["total_length_mm"]
current_count = existing["pipe_details"]["pipe_count"]
# ✅ DB에서 가져온 length_mm는 이미 개별 파이프의 실제 길이이므로 수량을 곱하지 않음
individual_length = float(mat[7]) # 개별 파이프의 실제 길이
existing["pipe_details"]["total_length_mm"] = current_total + individual_length
existing["pipe_details"]["pipe_count"] = current_count + 1 # 파이프 개수는 1개씩 증가
# 평균 단위 길이 재계산
total_length = existing["pipe_details"]["total_length_mm"]
total_count = existing["pipe_details"]["pipe_count"]
existing["pipe_details"]["length_mm"] = total_length / total_count
# 파이프 합산 로그 제거 (너무 많음)
else:
# 첫 파이프 정보 설정
individual_length = float(mat[7]) # 개별 파이프의 실제 길이
existing["pipe_details"] = {
"length_mm": individual_length,
"total_length_mm": individual_length, # 첫 번째 파이프이므로 개별 길이와 동일
"pipe_count": 1 # 첫 번째 파이프이므로 1개
}
else:
# 🆕 새 항목 생성
material_data = {
"material_hash": material_hash,
"description": mat[1], # original_description
"size_spec": mat[2],
"material_grade": mat[3],
"quantity": float(mat[4]) if mat[4] else 0.0,
"category": mat[5], # classified_category
"unit": mat[6] or 'EA',
"line_number": str(mat[8]) if mat[8] else ''
}
# 파이프인 경우 pipe_details 정보 추가
if mat[5] == 'PIPE' and mat[7] is not None:
individual_length = float(mat[7]) # 개별 파이프의 실제 길이
material_data["pipe_details"] = {
"length_mm": individual_length, # 개별 파이프 길이
"total_length_mm": individual_length, # 첫 번째 파이프이므로 개별 길이와 동일
"pipe_count": 1 # 첫 번째 파이프이므로 1개
}
# 파이프는 quantity를 1로 설정 (pipe_count와 동일)
material_data["quantity"] = 1
materials_dict[material_hash] = material_data
# 파이프 데이터 요약만 출력
pipe_count = sum(1 for data in materials_dict.values() if data.get('category') == 'PIPE')
pipe_with_details = sum(1 for data in materials_dict.values()
if data.get('category') == 'PIPE' and 'pipe_details' in data)
print(f"✅ 자재 처리 완료: 총 {len(materials_dict)}개, 파이프 {pipe_count}개 (길이정보: {pipe_with_details}개)")
return materials_dict
async def get_current_inventory(db: Session, job_no: str) -> Dict[str, float]:
"""현재까지의 누적 재고량 조회 - 임시로 빈 딕셔너리 반환"""
# TODO: 실제 재고 시스템 구현 후 활성화
return {}
async def save_comparison_result(
db: Session,
job_no: str,
current_revision: str,
previous_revision: str,
current_file_id: int,
previous_file_id: int,
comparison_result: Dict
) -> int:
"""비교 결과를 데이터베이스에 저장"""
# 메인 비교 레코드 저장
insert_query = text("""
INSERT INTO material_revisions_comparison (
job_no, current_revision, previous_revision,
current_file_id, previous_file_id,
total_current_items, total_previous_items,
new_items_count, modified_items_count, removed_items_count,
comparison_details, created_by
) VALUES (
:job_no, :current_revision, :previous_revision,
:current_file_id, :previous_file_id,
:total_current_items, :total_previous_items,
:new_items_count, :modified_items_count, :removed_items_count,
:comparison_details, 'system'
)
ON CONFLICT (job_no, current_revision, previous_revision)
DO UPDATE SET
total_current_items = :total_current_items,
total_previous_items = :total_previous_items,
new_items_count = :new_items_count,
modified_items_count = :modified_items_count,
removed_items_count = :removed_items_count,
comparison_details = :comparison_details,
upload_date = CURRENT_TIMESTAMP
RETURNING id
""")
import json
summary = comparison_result["summary"]
result = db.execute(insert_query, {
"job_no": job_no,
"current_revision": current_revision,
"previous_revision": previous_revision,
"current_file_id": current_file_id,
"previous_file_id": previous_file_id,
"total_current_items": summary["total_current_items"],
"total_previous_items": summary["total_previous_items"],
"new_items_count": summary["new_items_count"],
"modified_items_count": summary["modified_items_count"],
"removed_items_count": summary["removed_items_count"],
"comparison_details": json.dumps(comparison_result, ensure_ascii=False)
})
comparison_id = result.fetchone()[0]
db.commit()
return comparison_id

View File

@@ -0,0 +1,161 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import text
from pydantic import BaseModel
from ..database import get_db
from ..auth.middleware import get_current_user
router = APIRouter(prefix="/materials", tags=["materials"])
class BrandUpdate(BaseModel):
brand: str
class UserRequirementUpdate(BaseModel):
user_requirement: str
@router.patch("/{material_id}/brand")
async def update_material_brand(
material_id: int,
brand_data: BrandUpdate,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""자재의 브랜드 정보를 업데이트합니다."""
try:
# 자재 존재 여부 확인
result = db.execute(
text("SELECT id FROM materials WHERE id = :material_id"),
{"material_id": material_id}
)
material = result.fetchone()
if not material:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="자재를 찾을 수 없습니다."
)
# 브랜드 업데이트
db.execute(
text("""
UPDATE materials
SET brand = :brand,
updated_by = :updated_by
WHERE id = :material_id
"""),
{
"brand": brand_data.brand.strip(),
"updated_by": current_user.get("username", "unknown"),
"material_id": material_id
}
)
db.commit()
return {
"success": True,
"message": "브랜드가 성공적으로 업데이트되었습니다.",
"material_id": material_id,
"brand": brand_data.brand.strip()
}
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"브랜드 업데이트 실패: {str(e)}"
)
@router.patch("/{material_id}/user-requirement")
async def update_material_user_requirement(
material_id: int,
requirement_data: UserRequirementUpdate,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""자재의 사용자 요구사항을 업데이트합니다."""
try:
# 자재 존재 여부 확인
result = db.execute(
text("SELECT id FROM materials WHERE id = :material_id"),
{"material_id": material_id}
)
material = result.fetchone()
if not material:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="자재를 찾을 수 없습니다."
)
# 사용자 요구사항 업데이트
db.execute(
text("""
UPDATE materials
SET user_requirement = :user_requirement,
updated_by = :updated_by
WHERE id = :material_id
"""),
{
"user_requirement": requirement_data.user_requirement.strip(),
"updated_by": current_user.get("username", "unknown"),
"material_id": material_id
}
)
db.commit()
return {
"success": True,
"message": "사용자 요구사항이 성공적으로 업데이트되었습니다.",
"material_id": material_id,
"user_requirement": requirement_data.user_requirement.strip()
}
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"사용자 요구사항 업데이트 실패: {str(e)}"
)
@router.get("/{material_id}")
async def get_material(
material_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""자재 정보를 조회합니다."""
try:
result = db.execute(
text("""
SELECT id, original_description, classified_category,
brand, user_requirement, created_at, updated_by
FROM materials
WHERE id = :material_id
"""),
{"material_id": material_id}
)
material = result.fetchone()
if not material:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="자재를 찾을 수 없습니다."
)
return {
"id": material.id,
"original_description": material.original_description,
"classified_category": material.classified_category,
"brand": material.brand,
"user_requirement": material.user_requirement,
"created_at": material.created_at,
"updated_by": material.updated_by
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"자재 조회 실패: {str(e)}"
)

View File

@@ -0,0 +1,585 @@
"""
구매 관리 API
- 구매 품목 생성/조회
- 구매 수량 계산
- 리비전 비교
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import List, Optional
from pydantic import BaseModel
import json
from datetime import datetime
from ..database import get_db
from ..services.purchase_calculator import (
generate_purchase_items_from_materials,
save_purchase_items_to_db,
calculate_pipe_purchase_quantity,
calculate_standard_purchase_quantity
)
router = APIRouter(prefix="/purchase", tags=["purchase"])
# Pydantic 모델 (최적화된 구조)
class PurchaseItemMinimal(BaseModel):
"""구매 확정용 최소 필수 데이터"""
item_code: str
category: str
specification: str
size: str = ""
material: str = ""
bom_quantity: float
calculated_qty: float
unit: str = "EA"
safety_factor: float = 1.0
class PurchaseConfirmRequest(BaseModel):
job_no: str
file_id: int
bom_name: Optional[str] = None # 선택적 필드로 변경
revision: str
purchase_items: List[PurchaseItemMinimal] # 최적화된 구조 사용
confirmed_at: str
confirmed_by: str
@router.get("/items/calculate")
async def calculate_purchase_items(
job_no: str = Query(..., description="Job 번호"),
revision: str = Query("Rev.0", description="리비전"),
file_id: Optional[int] = Query(None, description="파일 ID (선택사항)"),
db: Session = Depends(get_db)
):
"""
구매 품목 계산 (실시간)
- 자재 데이터로부터 구매 품목 생성
- 수량 계산 (파이프 절단손실 포함)
"""
try:
# 1. 파일 ID 조회 (job_no, revision으로)
if not file_id:
file_query = text("""
SELECT id FROM files
WHERE job_no = :job_no AND revision = :revision AND is_active = TRUE
ORDER BY updated_at DESC
LIMIT 1
""")
file_result = db.execute(file_query, {"job_no": job_no, "revision": revision}).fetchone()
if not file_result:
raise HTTPException(status_code=404, detail=f"Job {job_no} {revision}에 해당하는 파일을 찾을 수 없습니다")
file_id = file_result[0]
# 2. 구매 품목 생성
purchase_items = generate_purchase_items_from_materials(db, file_id, job_no, revision)
return {
"success": True,
"job_no": job_no,
"revision": revision,
"file_id": file_id,
"items": purchase_items,
"total_items": len(purchase_items)
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"구매 품목 계산 실패: {str(e)}")
@router.post("/confirm")
async def confirm_purchase_quantities(
request: PurchaseConfirmRequest,
db: Session = Depends(get_db)
):
"""
구매 수량 확정
- 계산된 구매 수량을 확정 상태로 저장
- 자재별 확정 수량 및 상태 업데이트
- 리비전 비교를 위한 기준 데이터 생성
"""
try:
# 1. 기존 확정 데이터 확인 및 업데이트 또는 삽입
existing_query = text("""
SELECT id FROM purchase_confirmations
WHERE file_id = :file_id
""")
existing_result = db.execute(existing_query, {"file_id": request.file_id}).fetchone()
if existing_result:
# 기존 데이터 업데이트
confirmation_id = existing_result[0]
update_query = text("""
UPDATE purchase_confirmations
SET job_no = :job_no,
bom_name = :bom_name,
revision = :revision,
confirmed_at = :confirmed_at,
confirmed_by = :confirmed_by,
is_active = TRUE,
updated_at = CURRENT_TIMESTAMP
WHERE id = :confirmation_id
""")
db.execute(update_query, {
"confirmation_id": confirmation_id,
"job_no": request.job_no,
"bom_name": request.bom_name or f"{request.job_no}_{request.revision}", # 기본값 제공
"revision": request.revision,
"confirmed_at": request.confirmed_at,
"confirmed_by": request.confirmed_by
})
# 기존 확정 품목들 삭제
delete_items_query = text("""
DELETE FROM confirmed_purchase_items
WHERE confirmation_id = :confirmation_id
""")
db.execute(delete_items_query, {"confirmation_id": confirmation_id})
else:
# 새로운 확정 데이터 삽입
confirm_query = text("""
INSERT INTO purchase_confirmations (
job_no, file_id, bom_name, revision,
confirmed_at, confirmed_by, is_active, created_at
) VALUES (
:job_no, :file_id, :bom_name, :revision,
:confirmed_at, :confirmed_by, TRUE, CURRENT_TIMESTAMP
) RETURNING id
""")
confirm_result = db.execute(confirm_query, {
"job_no": request.job_no,
"file_id": request.file_id,
"bom_name": request.bom_name or f"{request.job_no}_{request.revision}", # 기본값 제공
"revision": request.revision,
"confirmed_at": request.confirmed_at,
"confirmed_by": request.confirmed_by
})
confirmation_id = confirm_result.fetchone()[0]
# 3. 확정된 구매 품목들 저장
saved_items = 0
for item in request.purchase_items:
item_query = text("""
INSERT INTO confirmed_purchase_items (
confirmation_id, item_code, category, specification,
size, material, bom_quantity, calculated_qty,
unit, safety_factor, created_at
) VALUES (
:confirmation_id, :item_code, :category, :specification,
:size, :material, :bom_quantity, :calculated_qty,
:unit, :safety_factor, CURRENT_TIMESTAMP
)
""")
db.execute(item_query, {
"confirmation_id": confirmation_id,
"item_code": item.item_code or f"{item.category}-{saved_items+1}",
"category": item.category,
"specification": item.specification,
"size": item.size or "",
"material": item.material or "",
"bom_quantity": item.bom_quantity,
"calculated_qty": item.calculated_qty,
"unit": item.unit,
"safety_factor": item.safety_factor
})
saved_items += 1
# 4. 파일 상태를 확정으로 업데이트
file_update_query = text("""
UPDATE files
SET purchase_confirmed = TRUE,
confirmed_at = :confirmed_at,
confirmed_by = :confirmed_by,
updated_at = CURRENT_TIMESTAMP
WHERE id = :file_id
""")
db.execute(file_update_query, {
"file_id": request.file_id,
"confirmed_at": request.confirmed_at,
"confirmed_by": request.confirmed_by
})
db.commit()
return {
"success": True,
"message": "구매 수량이 성공적으로 확정되었습니다",
"confirmation_id": confirmation_id,
"confirmed_items": saved_items,
"job_no": request.job_no,
"revision": request.revision,
"confirmed_at": request.confirmed_at,
"confirmed_by": request.confirmed_by
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"구매 수량 확정 실패: {str(e)}")
@router.post("/items/save")
async def save_purchase_items(
job_no: str,
revision: str,
file_id: int,
db: Session = Depends(get_db)
):
"""
구매 품목을 데이터베이스에 저장
"""
try:
# 1. 구매 품목 생성
purchase_items = generate_purchase_items_from_materials(db, file_id, job_no, revision)
# 2. 데이터베이스에 저장
saved_ids = save_purchase_items_to_db(db, purchase_items)
return {
"success": True,
"message": f"{len(saved_ids)}개 구매 품목이 저장되었습니다",
"saved_items": len(saved_ids),
"item_ids": saved_ids
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"구매 품목 저장 실패: {str(e)}")
@router.get("/items")
async def get_purchase_items(
job_no: str = Query(..., description="Job 번호"),
revision: str = Query("Rev.0", description="리비전"),
category: Optional[str] = Query(None, description="카테고리 필터"),
db: Session = Depends(get_db)
):
"""
저장된 구매 품목 조회
"""
try:
query = text("""
SELECT pi.*,
COUNT(mpm.material_id) as material_count,
SUM(m.quantity) as total_material_quantity
FROM purchase_items pi
LEFT JOIN material_purchase_mapping mpm ON pi.id = mpm.purchase_item_id
LEFT JOIN materials m ON mpm.material_id = m.id
WHERE pi.job_no = :job_no AND pi.revision = :revision AND pi.is_active = TRUE
""")
params = {"job_no": job_no, "revision": revision}
if category:
query = text(str(query) + " AND pi.category = :category")
params["category"] = category
query = text(str(query) + """
GROUP BY pi.id
ORDER BY pi.category, pi.specification
""")
result = db.execute(query, params)
items = result.fetchall()
return {
"success": True,
"job_no": job_no,
"revision": revision,
"items": [dict(item) for item in items],
"total_items": len(items)
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"구매 품목 조회 실패: {str(e)}")
@router.patch("/items/{item_id}")
async def update_purchase_item(
item_id: int,
safety_factor: Optional[float] = None,
calculated_qty: Optional[float] = None,
minimum_order_qty: Optional[float] = None,
db: Session = Depends(get_db)
):
"""
구매 품목 수정 (수량 조정)
"""
try:
update_fields = []
params = {"item_id": item_id}
if safety_factor is not None:
update_fields.append("safety_factor = :safety_factor")
params["safety_factor"] = safety_factor
if calculated_qty is not None:
update_fields.append("calculated_qty = :calculated_qty")
params["calculated_qty"] = calculated_qty
if minimum_order_qty is not None:
update_fields.append("minimum_order_qty = :minimum_order_qty")
params["minimum_order_qty"] = minimum_order_qty
if not update_fields:
raise HTTPException(status_code=400, detail="수정할 필드가 없습니다")
update_fields.append("updated_at = CURRENT_TIMESTAMP")
query = text(f"""
UPDATE purchase_items
SET {', '.join(update_fields)}
WHERE id = :item_id
RETURNING id, calculated_qty, safety_factor
""")
result = db.execute(query, params)
updated_item = result.fetchone()
if not updated_item:
raise HTTPException(status_code=404, detail="구매 품목을 찾을 수 없습니다")
db.commit()
return {
"success": True,
"message": "구매 품목이 수정되었습니다",
"item": dict(updated_item)
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"구매 품목 수정 실패: {str(e)}")
@router.get("/revision-diff")
async def get_revision_diff(
job_no: str = Query(..., description="Job 번호"),
current_revision: str = Query(..., description="현재 리비전"),
previous_revision: str = Query(..., description="이전 리비전"),
db: Session = Depends(get_db)
):
"""
리비전간 구매 수량 차이 계산
"""
try:
# 1. 이전 리비전 구매 품목 조회
prev_query = text("""
SELECT item_code, category, specification, calculated_qty, bom_quantity
FROM purchase_items
WHERE job_no = :job_no AND revision = :prev_revision AND is_active = TRUE
""")
prev_items = db.execute(prev_query, {
"job_no": job_no,
"prev_revision": previous_revision
}).fetchall()
# 2. 현재 리비전 구매 품목 조회
curr_query = text("""
SELECT item_code, category, specification, calculated_qty, bom_quantity
FROM purchase_items
WHERE job_no = :job_no AND revision = :curr_revision AND is_active = TRUE
""")
curr_items = db.execute(curr_query, {
"job_no": job_no,
"curr_revision": current_revision
}).fetchall()
# 3. 차이 계산
prev_dict = {item.item_code: dict(item) for item in prev_items}
curr_dict = {item.item_code: dict(item) for item in curr_items}
changes = []
added_items = 0
modified_items = 0
# 현재 리비전에서 추가되거나 변경된 항목
for item_code, curr_item in curr_dict.items():
if item_code not in prev_dict:
# 새로 추가된 품목
changes.append({
"item_code": item_code,
"change_type": "ADDED",
"specification": curr_item["specification"],
"previous_qty": 0,
"current_qty": curr_item["calculated_qty"],
"qty_diff": curr_item["calculated_qty"],
"additional_needed": curr_item["calculated_qty"]
})
added_items += 1
else:
prev_item = prev_dict[item_code]
qty_diff = curr_item["calculated_qty"] - prev_item["calculated_qty"]
if abs(qty_diff) > 0.001: # 수량 변경
changes.append({
"item_code": item_code,
"change_type": "MODIFIED",
"specification": curr_item["specification"],
"previous_qty": prev_item["calculated_qty"],
"current_qty": curr_item["calculated_qty"],
"qty_diff": qty_diff,
"additional_needed": max(qty_diff, 0) # 증가한 경우만 추가 구매
})
modified_items += 1
# 삭제된 품목 (현재 리비전에 없는 항목)
removed_items = 0
for item_code, prev_item in prev_dict.items():
if item_code not in curr_dict:
changes.append({
"item_code": item_code,
"change_type": "REMOVED",
"specification": prev_item["specification"],
"previous_qty": prev_item["calculated_qty"],
"current_qty": 0,
"qty_diff": -prev_item["calculated_qty"],
"additional_needed": 0
})
removed_items += 1
# 요약 정보
total_additional_needed = sum(
change["additional_needed"] for change in changes
if change["additional_needed"] > 0
)
has_changes = len(changes) > 0
summary = f"추가: {added_items}개, 변경: {modified_items}개, 삭제: {removed_items}"
if total_additional_needed > 0:
summary += f" (추가 구매 필요: {total_additional_needed:.1f})"
return {
"success": True,
"job_no": job_no,
"previous_revision": previous_revision,
"current_revision": current_revision,
"comparison": {
"has_changes": has_changes,
"summary": summary,
"added_items": added_items,
"modified_items": modified_items,
"removed_items": removed_items,
"total_additional_needed": total_additional_needed,
"changes": changes
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"리비전 비교 실패: {str(e)}")
@router.post("/orders/create")
async def create_purchase_order(
job_no: str,
revision: str,
items: List[dict],
supplier_name: Optional[str] = None,
required_date: Optional[str] = None,
db: Session = Depends(get_db)
):
"""
구매 주문 생성
"""
try:
from datetime import datetime, date
# 1. 주문 번호 생성
order_no = f"PO-{job_no}-{revision}-{datetime.now().strftime('%Y%m%d')}"
# 2. 구매 주문 생성
order_query = text("""
INSERT INTO purchase_orders (
order_no, job_no, revision, status, order_date, required_date,
supplier_name, created_by
) VALUES (
:order_no, :job_no, :revision, 'DRAFT', CURRENT_DATE, :required_date,
:supplier_name, 'system'
) RETURNING id
""")
order_result = db.execute(order_query, {
"order_no": order_no,
"job_no": job_no,
"revision": revision,
"required_date": required_date,
"supplier_name": supplier_name
})
order_id = order_result.fetchone()[0]
# 3. 주문 상세 항목 생성
total_amount = 0
for item in items:
item_query = text("""
INSERT INTO purchase_order_items (
purchase_order_id, purchase_item_id, ordered_quantity, required_date
) VALUES (
:order_id, :item_id, :quantity, :required_date
)
""")
db.execute(item_query, {
"order_id": order_id,
"item_id": item["purchase_item_id"],
"quantity": item["ordered_quantity"],
"required_date": required_date
})
db.commit()
return {
"success": True,
"message": "구매 주문이 생성되었습니다",
"order_no": order_no,
"order_id": order_id,
"items_count": len(items)
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"구매 주문 생성 실패: {str(e)}")
@router.get("/orders")
async def get_purchase_orders(
job_no: Optional[str] = Query(None, description="Job 번호"),
status: Optional[str] = Query(None, description="주문 상태"),
db: Session = Depends(get_db)
):
"""
구매 주문 목록 조회
"""
try:
query = text("""
SELECT po.*,
COUNT(poi.id) as items_count,
SUM(poi.ordered_quantity) as total_quantity
FROM purchase_orders po
LEFT JOIN purchase_order_items poi ON po.id = poi.purchase_order_id
WHERE 1=1
""")
params = {}
if job_no:
query = text(str(query) + " AND po.job_no = :job_no")
params["job_no"] = job_no
if status:
query = text(str(query) + " AND po.status = :status")
params["status"] = status
query = text(str(query) + """
GROUP BY po.id
ORDER BY po.created_at DESC
""")
result = db.execute(query, params)
orders = result.fetchall()
return {
"success": True,
"orders": [dict(order) for order in orders],
"total_orders": len(orders)
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"구매 주문 조회 실패: {str(e)}")

View File

@@ -0,0 +1,834 @@
"""
구매신청 관리 API
"""
from fastapi import APIRouter, Depends, HTTPException, status, Body, File, UploadFile, Form
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Optional, List, Dict
from datetime import datetime
from pathlib import Path
import os
import json
from ..database import get_db
from ..auth.middleware import get_current_user
from ..utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/purchase-request", tags=["Purchase Request"])
# 엑셀 파일 저장 경로
EXCEL_DIR = "uploads/excel_exports"
os.makedirs(EXCEL_DIR, exist_ok=True)
class PurchaseRequestCreate(BaseModel):
file_id: int
job_no: Optional[str] = None
category: Optional[str] = None
material_ids: List[int] = []
materials_data: List[Dict] = []
grouped_materials: Optional[List[Dict]] = [] # 그룹화된 자재 정보
@router.post("/create")
async def create_purchase_request(
request_data: PurchaseRequestCreate,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매신청 생성 (엑셀 내보내기 = 구매신청)
"""
try:
# 🔍 디버깅: 요청 데이터 로깅
logger.info(f"🔍 구매신청 생성 요청 - file_id: {request_data.file_id}, job_no: {request_data.job_no}")
logger.info(f"🔍 material_ids 개수: {len(request_data.material_ids)}")
logger.info(f"🔍 materials_data 개수: {len(request_data.materials_data)}")
if request_data.material_ids:
logger.info(f"🔍 material_ids 샘플: {request_data.material_ids[:5]}")
print(f"🔍 [DEBUG] 구매신청 API 호출됨 - material_ids: {len(request_data.material_ids)}")
# 구매신청 번호 생성
today = datetime.now().strftime('%Y%m%d')
count_query = text("""
SELECT COUNT(*) as count
FROM purchase_requests
WHERE request_no LIKE :pattern
""")
count = db.execute(count_query, {"pattern": f"PR-{today}%"}).fetchone().count
request_no = f"PR-{today}-{str(count + 1).zfill(3)}"
# 자재 데이터를 JSON과 엑셀 파일로 저장
json_filename = f"{request_no}.json"
excel_filename = f"{request_no}.xlsx"
json_path = os.path.join(EXCEL_DIR, json_filename)
excel_path = os.path.join(EXCEL_DIR, excel_filename)
# JSON 저장
save_materials_data(
request_data.materials_data,
json_path,
request_no,
request_data.job_no,
request_data.grouped_materials # 그룹화 정보 추가
)
# 엑셀 파일 생성 및 저장
create_excel_file(
request_data.grouped_materials or request_data.materials_data,
excel_path,
request_no,
request_data.job_no
)
# 구매신청 레코드 생성
insert_request = text("""
INSERT INTO purchase_requests (
request_no, file_id, job_no, category,
material_count, excel_file_path, requested_by
) VALUES (
:request_no, :file_id, :job_no, :category,
:material_count, :excel_file_path, :requested_by
) RETURNING request_id
""")
result = db.execute(insert_request, {
"request_no": request_no,
"file_id": request_data.file_id,
"job_no": request_data.job_no,
"category": request_data.category,
"material_count": len(request_data.material_ids),
"excel_file_path": excel_filename, # 엑셀 파일명 저장 (JSON 대신)
"requested_by": current_user.get("user_id")
})
request_id = result.fetchone().request_id
# 구매신청 자재 상세 저장
logger.info(f"Processing {len(request_data.material_ids)} material IDs for purchase request {request_no}")
logger.info(f"First 10 Material IDs: {request_data.material_ids[:10]}") # 처음 10개만 로그
logger.info(f"Category: {request_data.category}, Job: {request_data.job_no}")
inserted_count = 0
for i, material_id in enumerate(request_data.material_ids):
material_data = request_data.materials_data[i] if i < len(request_data.materials_data) else {}
# 이미 구매신청된 자재인지 확인
check_existing = text("""
SELECT 1 FROM purchase_request_items
WHERE material_id = :material_id
""")
existing = db.execute(check_existing, {"material_id": material_id}).fetchone()
if not existing:
insert_item = text("""
INSERT INTO purchase_request_items (
request_id, material_id, description, category, subcategory,
material_grade, size_spec, quantity, unit, drawing_name,
notes, user_requirement
) VALUES (
:request_id, :material_id, :description, :category, :subcategory,
:material_grade, :size_spec, :quantity, :unit, :drawing_name,
:notes, :user_requirement
)
""")
# quantity를 정수로 변환 (소수점 제거)
quantity_str = str(material_data.get("quantity", 0))
try:
quantity = int(float(quantity_str))
except (ValueError, TypeError):
quantity = 0
db.execute(insert_item, {
"request_id": request_id,
"material_id": material_id,
"description": material_data.get("description", material_data.get("original_description", "")),
"category": material_data.get("category", material_data.get("classified_category", "")),
"subcategory": material_data.get("subcategory", material_data.get("classified_subcategory", "")),
"material_grade": material_data.get("material_grade", ""),
"size_spec": material_data.get("size_spec", ""),
"quantity": quantity,
"unit": material_data.get("unit", "EA"),
"drawing_name": material_data.get("drawing_name", ""),
"notes": material_data.get("notes", ""),
"user_requirement": material_data.get("user_requirement", "")
})
inserted_count += 1
else:
logger.warning(f"Material {material_id} already in another purchase request, skipping")
# 🔥 중요: materials 테이블의 purchase_confirmed 업데이트
if request_data.material_ids:
print(f"🔥 [PURCHASE] purchase_confirmed 업데이트 시작: {len(request_data.material_ids)}개 자재")
print(f"🔥 [PURCHASE] material_ids: {request_data.material_ids[:5]}...") # 처음 5개만 로그
update_materials_query = text("""
UPDATE materials
SET purchase_confirmed = true,
purchase_confirmed_at = NOW(),
purchase_confirmed_by = :confirmed_by
WHERE id = ANY(:material_ids)
""")
result = db.execute(update_materials_query, {
"material_ids": request_data.material_ids,
"confirmed_by": current_user.get("username", "system")
})
print(f"🔥 [PURCHASE] UPDATE 결과: {result.rowcount}개 행 업데이트됨")
logger.info(f"{len(request_data.material_ids)}개 자재의 purchase_confirmed를 true로 업데이트")
else:
print(f"⚠️ [PURCHASE] material_ids가 비어있음!")
db.commit()
logger.info(f"Purchase request created: {request_no} with {inserted_count} materials (out of {len(request_data.material_ids)} requested)")
# 실제 저장된 자재 확인
verify_query = text("""
SELECT COUNT(*) as count FROM purchase_request_items WHERE request_id = :request_id
""")
verified_count = db.execute(verify_query, {"request_id": request_id}).fetchone().count
logger.info(f"✅ DB 검증: purchase_request_items에 {verified_count}개 저장됨")
# purchase_requests 테이블의 total_items 필드 업데이트
update_total_items = text("""
UPDATE purchase_requests
SET total_items = :total_items
WHERE request_id = :request_id
""")
db.execute(update_total_items, {
"request_id": request_id,
"total_items": verified_count
})
db.commit()
logger.info(f"✅ total_items 업데이트 완료: {verified_count}")
return {
"success": True,
"request_no": request_no,
"request_id": request_id,
"material_count": len(request_data.material_ids),
"inserted_count": inserted_count,
"verified_count": verified_count,
"message": f"구매신청 {request_no}이 생성되었습니다"
}
except Exception as e:
db.rollback()
logger.error(f"Failed to create purchase request: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"구매신청 생성 실패: {str(e)}"
)
@router.get("/list")
async def get_purchase_requests(
file_id: Optional[int] = None,
job_no: Optional[str] = None,
status: Optional[str] = None,
# current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매신청 목록 조회
"""
try:
query = text("""
SELECT
pr.request_id,
pr.request_no,
pr.file_id,
pr.job_no,
pr.total_items,
pr.request_date,
pr.status,
pr.requested_by_username as requested_by,
f.original_filename,
j.job_name,
COUNT(pri.item_id) as item_count
FROM purchase_requests pr
LEFT JOIN files f ON pr.file_id = f.id
LEFT JOIN jobs j ON pr.job_no = j.job_no
LEFT JOIN purchase_request_items pri ON pr.request_id = pri.request_id
WHERE 1=1
AND (:file_id IS NULL OR pr.file_id = :file_id)
AND (:job_no IS NULL OR pr.job_no = :job_no)
AND (:status IS NULL OR pr.status = :status)
GROUP BY
pr.request_id, pr.request_no, pr.file_id, pr.job_no,
pr.total_items, pr.request_date, pr.status,
pr.requested_by_username, f.original_filename, j.job_name
ORDER BY pr.request_date DESC
""")
results = db.execute(query, {
"file_id": file_id,
"job_no": job_no,
"status": status
}).fetchall()
requests = []
for row in results:
requests.append({
"request_id": row.request_id,
"request_no": row.request_no,
"file_id": row.file_id,
"job_no": row.job_no,
"job_name": row.job_name,
"category": "ALL", # 기본값
"material_count": row.item_count or 0, # 실제 자재 개수 사용
"item_count": row.item_count,
"excel_file_path": None, # 현재 테이블에 없음
"requested_at": row.request_date.isoformat() if row.request_date else None,
"status": row.status,
"requested_by": row.requested_by,
"source_file": row.original_filename
})
return {
"success": True,
"requests": requests,
"count": len(requests)
}
except Exception as e:
logger.error(f"Failed to get purchase requests: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"구매신청 목록 조회 실패: {str(e)}"
)
@router.get("/{request_id}/materials")
async def get_request_materials(
request_id: int,
# current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매신청에 포함된 자재 목록 조회 (그룹화 정보 포함)
"""
try:
# 구매신청 정보 조회하여 JSON 파일 경로 가져오기
info_query = text("""
SELECT excel_file_path
FROM purchase_requests
WHERE request_id = :request_id
""")
info_result = db.execute(info_query, {"request_id": request_id}).fetchone()
grouped_materials = []
if info_result and info_result.excel_file_path:
json_path = os.path.join(EXCEL_DIR, info_result.excel_file_path)
if os.path.exists(json_path):
try:
with open(json_path, 'r', encoding='utf-8', errors='ignore') as f:
data = json.load(f)
grouped_materials = data.get("grouped_materials", [])
except Exception as e:
print(f"⚠️ JSON 파일 읽기 오류 (무시): {e}")
grouped_materials = []
# 개별 자재 정보 조회 (기존 코드)
query = text("""
SELECT
pri.item_id,
pri.material_id,
pri.quantity as requested_quantity,
pri.unit as requested_unit,
pri.user_requirement,
pri.is_ordered,
pri.is_received,
m.original_description,
m.classified_category,
m.size_spec,
m.main_nom,
m.red_nom,
m.schedule,
m.material_grade,
m.full_material_grade,
m.quantity as original_quantity,
m.unit as original_unit,
m.classification_details,
pd.outer_diameter, pd.schedule as pipe_schedule, pd.material_spec, pd.manufacturing_method,
pd.end_preparation, pd.length_mm,
fd.fitting_type, fd.fitting_subtype, fd.connection_method as fitting_connection,
fd.pressure_rating as fitting_pressure, fd.schedule as fitting_schedule,
fld.flange_type, fld.facing_type,
fld.pressure_rating as flange_pressure,
gd.gasket_type, gd.gasket_subtype, gd.material_type as gasket_material,
gd.filler_material, gd.pressure_rating as gasket_pressure, gd.thickness as gasket_thickness,
bd.bolt_type, bd.material_standard as bolt_material, bd.length as bolt_length
FROM purchase_request_items pri
JOIN materials m ON pri.material_id = m.id
LEFT JOIN pipe_details pd ON m.id = pd.material_id
LEFT JOIN fitting_details fd ON m.id = fd.material_id
LEFT JOIN flange_details fld ON m.id = fld.material_id
LEFT JOIN gasket_details gd ON m.id = gd.material_id
LEFT JOIN bolt_details bd ON m.id = bd.material_id
WHERE pri.request_id = :request_id
ORDER BY m.classified_category, m.original_description
""")
# 🎯 데이터베이스 쿼리 실행
results = db.execute(query, {"request_id": request_id}).fetchall()
materials = []
# 🎯 안전한 문자열 변환 함수
def safe_str(value):
if value is None:
return ''
try:
if isinstance(value, bytes):
return value.decode('utf-8', errors='ignore')
return str(value)
except Exception:
return str(value) if value else ''
for row in results:
try:
# quantity를 정수로 변환 (소수점 제거)
qty = row.requested_quantity or row.original_quantity
try:
qty_int = int(float(qty)) if qty else 0
except (ValueError, TypeError):
qty_int = 0
# 안전한 문자열 변환
original_description = safe_str(row.original_description)
size_spec = safe_str(row.size_spec)
material_grade = safe_str(row.material_grade)
full_material_grade = safe_str(row.full_material_grade)
user_requirement = safe_str(row.user_requirement)
except Exception as e:
# 오류 발생 시 기본값 사용
qty_int = 0
original_description = ''
size_spec = ''
material_grade = ''
full_material_grade = ''
user_requirement = ''
# BOM 페이지와 동일한 형식으로 데이터 구성
material_dict = {
"item_id": row.item_id,
"material_id": row.material_id,
"id": row.material_id,
"original_description": original_description,
"classified_category": safe_str(row.classified_category),
"size_spec": size_spec,
"size_inch": safe_str(row.main_nom),
"main_nom": safe_str(row.main_nom),
"red_nom": safe_str(row.red_nom),
"schedule": safe_str(row.schedule),
"material_grade": material_grade,
"full_material_grade": full_material_grade,
"quantity": qty_int,
"unit": safe_str(row.requested_unit or row.original_unit),
"user_requirement": user_requirement,
"is_ordered": row.is_ordered,
"is_received": row.is_received,
"classification_details": safe_str(row.classification_details)
}
# 카테고리별 상세 정보 추가 (안전한 문자열 처리)
if row.classified_category == 'PIPE' and row.manufacturing_method:
material_dict["pipe_details"] = {
"manufacturing_method": safe_str(row.manufacturing_method),
"schedule": safe_str(row.pipe_schedule),
"material_spec": safe_str(row.material_spec),
"end_preparation": safe_str(row.end_preparation),
"length_mm": row.length_mm
}
elif row.classified_category == 'FITTING' and row.fitting_type:
material_dict["fitting_details"] = {
"fitting_type": safe_str(row.fitting_type),
"fitting_subtype": safe_str(row.fitting_subtype),
"connection_method": safe_str(row.fitting_connection),
"pressure_rating": safe_str(row.fitting_pressure),
"schedule": safe_str(row.fitting_schedule)
}
elif row.classified_category == 'FLANGE' and row.flange_type:
material_dict["flange_details"] = {
"flange_type": safe_str(row.flange_type),
"facing_type": safe_str(row.facing_type),
"pressure_rating": safe_str(row.flange_pressure)
}
elif row.classified_category == 'GASKET' and row.gasket_type:
material_dict["gasket_details"] = {
"gasket_type": safe_str(row.gasket_type),
"gasket_subtype": safe_str(row.gasket_subtype),
"material_type": safe_str(row.gasket_material),
"filler_material": safe_str(row.filler_material),
"pressure_rating": safe_str(row.gasket_pressure),
"thickness": safe_str(row.gasket_thickness)
}
elif row.classified_category == 'BOLT' and row.bolt_type:
material_dict["bolt_details"] = {
"bolt_type": safe_str(row.bolt_type),
"material_standard": safe_str(row.bolt_material),
"length": safe_str(row.bolt_length)
}
materials.append(material_dict)
return {
"success": True,
"materials": materials,
"grouped_materials": grouped_materials, # 그룹화 정보 추가
"count": len(grouped_materials) if grouped_materials else len(materials)
}
except Exception as e:
logger.error(f"Failed to get request materials: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"구매신청 자재 조회 실패: {str(e)}"
)
@router.get("/requested-materials")
async def get_requested_material_ids(
file_id: Optional[int] = None,
job_no: Optional[str] = None,
db: Session = Depends(get_db)
):
"""
구매신청된 자재 ID 목록 조회 (BOM 페이지에서 비활성화용)
"""
try:
query = text("""
SELECT DISTINCT pri.material_id
FROM purchase_request_items pri
JOIN purchase_requests pr ON pri.request_id = pr.request_id
WHERE 1=1
AND (:file_id IS NULL OR pr.file_id = :file_id)
AND (:job_no IS NULL OR pr.job_no = :job_no)
""")
results = db.execute(query, {
"file_id": file_id,
"job_no": job_no
}).fetchall()
material_ids = [row.material_id for row in results]
return {
"success": True,
"requested_material_ids": material_ids,
"count": len(material_ids)
}
except Exception as e:
logger.error(f"Failed to get requested material IDs: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"구매신청 자재 ID 조회 실패: {str(e)}"
)
@router.patch("/{request_id}/title")
async def update_request_title(
request_id: int,
title: str = Body(..., embed=True),
# current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매신청 제목(request_no) 업데이트
"""
try:
# 구매신청 존재 확인
check_query = text("""
SELECT request_no FROM purchase_requests
WHERE request_id = :request_id
""")
existing = db.execute(check_query, {"request_id": request_id}).fetchone()
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="구매신청을 찾을 수 없습니다"
)
# 제목 업데이트
update_query = text("""
UPDATE purchase_requests
SET request_no = :title
WHERE request_id = :request_id
""")
db.execute(update_query, {
"request_id": request_id,
"title": title
})
db.commit()
return {
"success": True,
"message": "구매신청 제목이 업데이트되었습니다",
"old_title": existing.request_no,
"new_title": title
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to update request title: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"구매신청 제목 업데이트 실패: {str(e)}"
)
@router.get("/{request_id}/download-excel")
async def download_request_excel(
request_id: int,
# current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매신청 엑셀 파일 직접 다운로드 (BOM 페이지에서 생성한 파일 그대로)
"""
from fastapi.responses import FileResponse
try:
# 구매신청 정보 조회
query = text("""
SELECT request_no, excel_file_path, job_no
FROM purchase_requests
WHERE request_id = :request_id
""")
result = db.execute(query, {"request_id": request_id}).fetchone()
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="구매신청을 찾을 수 없습니다"
)
excel_file_path = os.path.join(EXCEL_DIR, result.excel_file_path)
if not os.path.exists(excel_file_path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="엑셀 파일을 찾을 수 없습니다"
)
# 엑셀 파일 직접 다운로드
return FileResponse(
path=excel_file_path,
filename=f"{result.job_no}_{result.request_no}.xlsx",
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to download request excel: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"엑셀 다운로드 실패: {str(e)}"
)
def save_materials_data(materials_data: List[Dict], file_path: str, request_no: str, job_no: str, grouped_materials: List[Dict] = None):
"""
자재 데이터를 JSON으로 저장 (프론트엔드에서 동일한 엑셀 포맷으로 생성하기 위해)
"""
# 수량을 정수로 변환하여 저장
cleaned_materials = []
for material in materials_data:
cleaned_material = material.copy()
if 'quantity' in cleaned_material:
try:
cleaned_material['quantity'] = int(float(cleaned_material['quantity']))
except (ValueError, TypeError):
cleaned_material['quantity'] = 0
cleaned_materials.append(cleaned_material)
# 그룹화된 자재도 수량 정수 변환
cleaned_grouped = []
if grouped_materials:
for group in grouped_materials:
cleaned_group = group.copy()
if 'quantity' in cleaned_group:
try:
cleaned_group['quantity'] = int(float(cleaned_group['quantity']))
except (ValueError, TypeError):
cleaned_group['quantity'] = 0
cleaned_grouped.append(cleaned_group)
data_to_save = {
"request_no": request_no,
"job_no": job_no,
"created_at": datetime.now().isoformat(),
"materials": cleaned_materials,
"grouped_materials": cleaned_grouped or []
}
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data_to_save, f, ensure_ascii=False, indent=2)
def create_excel_file(materials_data: List[Dict], file_path: str, request_no: str, job_no: str):
"""
자재 데이터로 엑셀 파일 생성 (BOM 페이지와 동일한 형식)
"""
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
# 새 워크북 생성
wb = openpyxl.Workbook()
wb.remove(wb.active) # 기본 시트 제거
# 카테고리별 그룹화
category_groups = {}
for material in materials_data:
category = material.get('category', 'UNKNOWN')
if category not in category_groups:
category_groups[category] = []
category_groups[category].append(material)
# 각 카테고리별 시트 생성
for category, items in category_groups.items():
if not items:
continue
ws = wb.create_sheet(title=category)
# 헤더 정의 (P열에 납기일, 관리항목 통일)
headers = ['TAGNO', '품목명', '수량', '통화구분', '단가', '크기', '압력등급', '스케줄',
'재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3',
'관리항목4', '납기일(YYYY-MM-DD)', '관리항목5', '관리항목6', '관리항목7',
'관리항목8', '관리항목9', '관리항목10']
# 헤더 작성
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = Font(bold=True, color="000000", size=12, name="맑은 고딕")
cell.fill = PatternFill(start_color="B3D9FF", end_color="B3D9FF", fill_type="solid")
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.border = Border(
top=Side(style="thin", color="666666"),
bottom=Side(style="thin", color="666666"),
left=Side(style="thin", color="666666"),
right=Side(style="thin", color="666666")
)
# 데이터 작성
for row_idx, material in enumerate(items, 2):
data = [
'', # TAGNO
category, # 품목명
material.get('quantity', 0), # 수량
'KRW', # 통화구분
1, # 단가
material.get('size', '-'), # 크기
'-', # 압력등급 (추후 개선)
material.get('schedule', '-'), # 스케줄
material.get('material_grade', '-'), # 재질
'-', # 상세내역 (추후 개선)
material.get('user_requirement', ''), # 사용자요구
'', '', '', '', '', # 관리항목들
datetime.now().strftime('%Y-%m-%d') # 납기일
]
for col, value in enumerate(data, 1):
ws.cell(row=row_idx, column=col, value=value)
# 컬럼 너비 자동 조정
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max(max_length + 2, 10), 50)
ws.column_dimensions[column_letter].width = adjusted_width
# 파일 저장
wb.save(file_path)
@router.post("/upload-excel")
async def upload_request_excel(
excel_file: UploadFile = File(...),
request_id: int = Form(...),
category: str = Form(...),
# current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매신청에 대한 엑셀 파일 업로드 (BOM에서 생성한 원본 파일)
"""
try:
# 구매신청 정보 조회
query = text("""
SELECT request_no, job_no
FROM purchase_requests
WHERE request_id = :request_id
""")
result = db.execute(query, {"request_id": request_id}).fetchone()
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="구매신청을 찾을 수 없습니다"
)
# 엑셀 저장 디렉토리 생성
excel_dir = Path("uploads/excel_exports")
excel_dir.mkdir(parents=True, exist_ok=True)
# 파일명 생성
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_filename = f"{result.job_no}_{result.request_no}_{timestamp}.xlsx"
file_path = excel_dir / safe_filename
# 파일 저장
content = await excel_file.read()
with open(file_path, "wb") as f:
f.write(content)
# 구매신청 테이블에 엑셀 파일 경로 업데이트
update_query = text("""
UPDATE purchase_requests
SET excel_file_path = :excel_file_path
WHERE request_id = :request_id
""")
db.execute(update_query, {
"excel_file_path": safe_filename,
"request_id": request_id
})
db.commit()
logger.info(f"엑셀 파일 업로드 완료: {safe_filename}")
return {
"success": True,
"message": "엑셀 파일이 성공적으로 업로드되었습니다",
"file_path": safe_filename
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to upload excel file: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"엑셀 파일 업로드 실패: {str(e)}"
)

View File

@@ -0,0 +1,454 @@
"""
구매 추적 및 관리 API
엑셀 내보내기 이력 및 구매 상태 관리
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Optional, List, Dict, Any
from datetime import datetime, date
import json
from ..database import get_db
from ..auth.middleware import get_current_user
from ..utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/purchase", tags=["Purchase Tracking"])
@router.post("/export-history")
async def create_export_history(
file_id: int,
job_no: Optional[str] = None,
export_type: str = "full",
category: Optional[str] = None,
material_ids: List[int] = [],
filters_applied: Optional[Dict] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
엑셀 내보내기 이력 생성 및 자재 추적
"""
try:
# 내보내기 이력 생성
insert_history = text("""
INSERT INTO excel_export_history (
file_id, job_no, exported_by, export_type,
category, material_count, filters_applied
) VALUES (
:file_id, :job_no, :exported_by, :export_type,
:category, :material_count, :filters_applied
) RETURNING export_id
""")
result = db.execute(insert_history, {
"file_id": file_id,
"job_no": job_no,
"exported_by": current_user.get("user_id"),
"export_type": export_type,
"category": category,
"material_count": len(material_ids),
"filters_applied": json.dumps(filters_applied) if filters_applied else None
})
export_id = result.fetchone().export_id
# 내보낸 자재들 기록
if material_ids:
for material_id in material_ids:
insert_material = text("""
INSERT INTO exported_materials (
export_id, material_id, purchase_status
) VALUES (
:export_id, :material_id, 'pending'
)
""")
db.execute(insert_material, {
"export_id": export_id,
"material_id": material_id
})
db.commit()
logger.info(f"Export history created: {export_id} with {len(material_ids)} materials")
return {
"success": True,
"export_id": export_id,
"message": f"{len(material_ids)}개 자재의 내보내기 이력이 저장되었습니다"
}
except Exception as e:
db.rollback()
logger.error(f"Failed to create export history: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"내보내기 이력 생성 실패: {str(e)}"
)
@router.get("/export-history")
async def get_export_history(
file_id: Optional[int] = None,
job_no: Optional[str] = None,
limit: int = 50,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
엑셀 내보내기 이력 조회
"""
try:
query = text("""
SELECT
eeh.export_id,
eeh.file_id,
eeh.job_no,
eeh.export_date,
eeh.export_type,
eeh.category,
eeh.material_count,
u.name as exported_by_name,
f.original_filename,
COUNT(DISTINCT em.material_id) as actual_material_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'pending' THEN em.material_id END) as pending_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) as requested_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) as ordered_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) as received_count
FROM excel_export_history eeh
LEFT JOIN users u ON eeh.exported_by = u.user_id
LEFT JOIN files f ON eeh.file_id = f.id
LEFT JOIN exported_materials em ON eeh.export_id = em.export_id
WHERE 1=1
AND (:file_id IS NULL OR eeh.file_id = :file_id)
AND (:job_no IS NULL OR eeh.job_no = :job_no)
GROUP BY
eeh.export_id, eeh.file_id, eeh.job_no, eeh.export_date,
eeh.export_type, eeh.category, eeh.material_count,
u.name, f.original_filename
ORDER BY eeh.export_date DESC
LIMIT :limit
""")
results = db.execute(query, {
"file_id": file_id,
"job_no": job_no,
"limit": limit
}).fetchall()
history = []
for row in results:
history.append({
"export_id": row.export_id,
"file_id": row.file_id,
"job_no": row.job_no,
"export_date": row.export_date.isoformat() if row.export_date else None,
"export_type": row.export_type,
"category": row.category,
"material_count": row.material_count,
"exported_by": row.exported_by_name,
"file_name": row.original_filename,
"status_summary": {
"total": row.actual_material_count,
"pending": row.pending_count,
"requested": row.requested_count,
"ordered": row.ordered_count,
"received": row.received_count
}
})
return {
"success": True,
"history": history,
"count": len(history)
}
except Exception as e:
logger.error(f"Failed to get export history: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"내보내기 이력 조회 실패: {str(e)}"
)
@router.get("/materials/status")
async def get_materials_by_status(
status: Optional[str] = None,
export_id: Optional[int] = None,
file_id: Optional[int] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매 상태별 자재 목록 조회
"""
try:
query = text("""
SELECT
em.id as exported_material_id,
em.material_id,
m.original_description,
m.classified_category,
m.quantity,
m.unit,
em.purchase_status,
em.purchase_request_no,
em.purchase_order_no,
em.vendor_name,
em.expected_date,
em.quantity_ordered,
em.quantity_received,
em.unit_price,
em.total_price,
em.notes,
em.updated_at,
eeh.export_date,
f.original_filename as file_name,
j.job_no,
j.job_name
FROM exported_materials em
JOIN materials m ON em.material_id = m.id
JOIN excel_export_history eeh ON em.export_id = eeh.export_id
LEFT JOIN files f ON eeh.file_id = f.id
LEFT JOIN jobs j ON eeh.job_no = j.job_no
WHERE 1=1
AND (:status IS NULL OR em.purchase_status = :status)
AND (:export_id IS NULL OR em.export_id = :export_id)
AND (:file_id IS NULL OR eeh.file_id = :file_id)
ORDER BY em.updated_at DESC
""")
results = db.execute(query, {
"status": status,
"export_id": export_id,
"file_id": file_id
}).fetchall()
materials = []
for row in results:
materials.append({
"exported_material_id": row.exported_material_id,
"material_id": row.material_id,
"description": row.original_description,
"category": row.classified_category,
"quantity": row.quantity,
"unit": row.unit,
"purchase_status": row.purchase_status,
"purchase_request_no": row.purchase_request_no,
"purchase_order_no": row.purchase_order_no,
"vendor_name": row.vendor_name,
"expected_date": row.expected_date.isoformat() if row.expected_date else None,
"quantity_ordered": row.quantity_ordered,
"quantity_received": row.quantity_received,
"unit_price": float(row.unit_price) if row.unit_price else None,
"total_price": float(row.total_price) if row.total_price else None,
"notes": row.notes,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"export_date": row.export_date.isoformat() if row.export_date else None,
"file_name": row.file_name,
"job_no": row.job_no,
"job_name": row.job_name
})
return {
"success": True,
"materials": materials,
"count": len(materials)
}
except Exception as e:
logger.error(f"Failed to get materials by status: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"구매 상태별 자재 조회 실패: {str(e)}"
)
@router.patch("/materials/{exported_material_id}/status")
async def update_purchase_status(
exported_material_id: int,
new_status: str,
purchase_request_no: Optional[str] = None,
purchase_order_no: Optional[str] = None,
vendor_name: Optional[str] = None,
expected_date: Optional[date] = None,
quantity_ordered: Optional[int] = None,
quantity_received: Optional[int] = None,
unit_price: Optional[float] = None,
notes: Optional[str] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
자재 구매 상태 업데이트
"""
try:
# 현재 상태 조회
get_current = text("""
SELECT purchase_status, material_id
FROM exported_materials
WHERE id = :id
""")
current = db.execute(get_current, {"id": exported_material_id}).fetchone()
if not current:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="해당 자재를 찾을 수 없습니다"
)
# 상태 업데이트
update_query = text("""
UPDATE exported_materials
SET
purchase_status = :new_status,
purchase_request_no = COALESCE(:pr_no, purchase_request_no),
purchase_order_no = COALESCE(:po_no, purchase_order_no),
vendor_name = COALESCE(:vendor, vendor_name),
expected_date = COALESCE(:expected_date, expected_date),
quantity_ordered = COALESCE(:qty_ordered, quantity_ordered),
quantity_received = COALESCE(:qty_received, quantity_received),
unit_price = COALESCE(:unit_price, unit_price),
total_price = CASE
WHEN :unit_price IS NOT NULL AND :qty_ordered IS NOT NULL
THEN :unit_price * :qty_ordered
WHEN :unit_price IS NOT NULL AND quantity_ordered IS NOT NULL
THEN :unit_price * quantity_ordered
ELSE total_price
END,
notes = COALESCE(:notes, notes),
updated_by = :updated_by,
requested_date = CASE WHEN :new_status = 'requested' THEN CURRENT_TIMESTAMP ELSE requested_date END,
ordered_date = CASE WHEN :new_status = 'ordered' THEN CURRENT_TIMESTAMP ELSE ordered_date END,
received_date = CASE WHEN :new_status = 'received' THEN CURRENT_TIMESTAMP ELSE received_date END
WHERE id = :id
""")
db.execute(update_query, {
"id": exported_material_id,
"new_status": new_status,
"pr_no": purchase_request_no,
"po_no": purchase_order_no,
"vendor": vendor_name,
"expected_date": expected_date,
"qty_ordered": quantity_ordered,
"qty_received": quantity_received,
"unit_price": unit_price,
"notes": notes,
"updated_by": current_user.get("user_id")
})
# 이력 기록
insert_history = text("""
INSERT INTO purchase_status_history (
exported_material_id, material_id,
previous_status, new_status,
changed_by, reason
) VALUES (
:em_id, :material_id,
:prev_status, :new_status,
:changed_by, :reason
)
""")
db.execute(insert_history, {
"em_id": exported_material_id,
"material_id": current.material_id,
"prev_status": current.purchase_status,
"new_status": new_status,
"changed_by": current_user.get("user_id"),
"reason": notes
})
db.commit()
logger.info(f"Purchase status updated: {exported_material_id} from {current.purchase_status} to {new_status}")
return {
"success": True,
"message": f"구매 상태가 {new_status}로 변경되었습니다"
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to update purchase status: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"구매 상태 업데이트 실패: {str(e)}"
)
@router.get("/status-summary")
async def get_status_summary(
file_id: Optional[int] = None,
job_no: Optional[str] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매 상태 요약 통계
"""
try:
query = text("""
SELECT
em.purchase_status,
COUNT(DISTINCT em.material_id) as material_count,
SUM(em.quantity_exported) as total_quantity,
SUM(em.total_price) as total_amount,
COUNT(DISTINCT em.export_id) as export_count
FROM exported_materials em
JOIN excel_export_history eeh ON em.export_id = eeh.export_id
WHERE 1=1
AND (:file_id IS NULL OR eeh.file_id = :file_id)
AND (:job_no IS NULL OR eeh.job_no = :job_no)
GROUP BY em.purchase_status
""")
results = db.execute(query, {
"file_id": file_id,
"job_no": job_no
}).fetchall()
summary = {}
total_materials = 0
total_amount = 0
for row in results:
summary[row.purchase_status] = {
"material_count": row.material_count,
"total_quantity": row.total_quantity,
"total_amount": float(row.total_amount) if row.total_amount else 0,
"export_count": row.export_count
}
total_materials += row.material_count
if row.total_amount:
total_amount += float(row.total_amount)
# 기본 상태들 추가 (없는 경우 0으로)
for status in ['pending', 'requested', 'ordered', 'received', 'cancelled']:
if status not in summary:
summary[status] = {
"material_count": 0,
"total_quantity": 0,
"total_amount": 0,
"export_count": 0
}
return {
"success": True,
"summary": summary,
"total_materials": total_materials,
"total_amount": total_amount
}
except Exception as e:
logger.error(f"Failed to get status summary: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"구매 상태 요약 조회 실패: {str(e)}"
)

View File

@@ -0,0 +1,327 @@
"""
간단한 리비전 관리 API
- 리비전 세션 생성 및 관리
- 자재 비교 및 변경사항 처리
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import List, Optional, Dict, Any
from datetime import datetime
from pydantic import BaseModel
from ..database import get_db
from ..auth.middleware import get_current_user
from ..services.revision_session_service import RevisionSessionService
from ..services.revision_comparison_service import RevisionComparisonService
router = APIRouter(prefix="/revision-management", tags=["revision-management"])
class RevisionSessionCreate(BaseModel):
job_no: str
current_file_id: int
previous_file_id: int
@router.post("/sessions")
async def create_revision_session(
session_data: RevisionSessionCreate,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""리비전 세션 생성"""
try:
session_service = RevisionSessionService(db)
# 실제 DB에 세션 생성
result = session_service.create_revision_session(
job_no=session_data.job_no,
current_file_id=session_data.current_file_id,
previous_file_id=session_data.previous_file_id,
username=current_user.get("username")
)
return {
"success": True,
"data": result,
"message": "리비전 세션이 생성되었습니다."
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"리비전 세션 생성 실패: {str(e)}")
@router.get("/sessions/{session_id}")
async def get_session_status(
session_id: int,
db: Session = Depends(get_db)
):
"""세션 상태 조회"""
try:
session_service = RevisionSessionService(db)
# 실제 DB에서 세션 상태 조회
result = session_service.get_session_status(session_id)
return {
"success": True,
"data": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"세션 상태 조회 실패: {str(e)}")
@router.get("/sessions/{session_id}/summary")
async def get_revision_summary(
session_id: int,
db: Session = Depends(get_db)
):
"""리비전 요약 조회"""
try:
comparison_service = RevisionComparisonService(db)
# 세션의 모든 변경사항 조회
changes = comparison_service.get_session_changes(session_id)
# 요약 통계 계산
summary = {
"session_id": session_id,
"total_changes": len(changes),
"new_materials": len([c for c in changes if c['change_type'] == 'added']),
"changed_materials": len([c for c in changes if c['change_type'] == 'quantity_changed']),
"removed_materials": len([c for c in changes if c['change_type'] == 'removed']),
"categories": {}
}
# 카테고리별 통계
for change in changes:
category = change['category']
if category not in summary["categories"]:
summary["categories"][category] = {
"total_changes": 0,
"added": 0,
"changed": 0,
"removed": 0
}
summary["categories"][category]["total_changes"] += 1
if change['change_type'] == 'added':
summary["categories"][category]["added"] += 1
elif change['change_type'] == 'quantity_changed':
summary["categories"][category]["changed"] += 1
elif change['change_type'] == 'removed':
summary["categories"][category]["removed"] += 1
return {
"success": True,
"data": summary
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"리비전 요약 조회 실패: {str(e)}")
@router.post("/sessions/{session_id}/compare/{category}")
async def compare_category(
session_id: int,
category: str,
db: Session = Depends(get_db)
):
"""카테고리별 자재 비교"""
try:
# 세션 정보 조회
session_service = RevisionSessionService(db)
session_status = session_service.get_session_status(session_id)
session_info = session_status["session_info"]
# 자재 비교 수행
comparison_service = RevisionComparisonService(db)
result = comparison_service.compare_materials_by_category(
current_file_id=session_info["current_file_id"],
previous_file_id=session_info["previous_file_id"],
category=category,
session_id=session_id
)
return {
"success": True,
"data": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"카테고리 비교 실패: {str(e)}")
@router.get("/history/{job_no}")
async def get_revision_history(
job_no: str,
db: Session = Depends(get_db)
):
"""리비전 히스토리 조회"""
try:
session_service = RevisionSessionService(db)
# 실제 DB에서 리비전 히스토리 조회
history = session_service.get_job_revision_history(job_no)
return {
"success": True,
"data": {
"job_no": job_no,
"history": history
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"리비전 히스토리 조회 실패: {str(e)}")
# 세션 변경사항 조회
@router.get("/sessions/{session_id}/changes")
async def get_session_changes(
session_id: int,
category: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
"""세션의 변경사항 조회"""
try:
comparison_service = RevisionComparisonService(db)
# 세션의 변경사항 조회
changes = comparison_service.get_session_changes(session_id, category)
return {
"success": True,
"data": {
"changes": changes,
"total_count": len(changes)
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"세션 변경사항 조회 실패: {str(e)}")
# 리비전 액션 처리
@router.post("/changes/{change_id}/process")
async def process_revision_action(
change_id: int,
action_data: Dict[str, Any],
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""리비전 액션 처리"""
try:
comparison_service = RevisionComparisonService(db)
# 액션 처리
result = comparison_service.process_revision_action(
change_id=change_id,
action=action_data.get("action"),
username=current_user.get("username"),
notes=action_data.get("notes")
)
return {
"success": True,
"data": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"리비전 액션 처리 실패: {str(e)}")
# 세션 완료
@router.post("/sessions/{session_id}/complete")
async def complete_revision_session(
session_id: int,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""리비전 세션 완료"""
try:
session_service = RevisionSessionService(db)
# 세션 완료 처리
result = session_service.complete_session(
session_id=session_id,
username=current_user.get("username")
)
return {
"success": True,
"data": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"리비전 세션 완료 실패: {str(e)}")
# 세션 취소
@router.post("/sessions/{session_id}/cancel")
async def cancel_revision_session(
session_id: int,
reason: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""리비전 세션 취소"""
try:
session_service = RevisionSessionService(db)
# 세션 취소 처리
result = session_service.cancel_session(
session_id=session_id,
username=current_user.get("username"),
reason=reason
)
return {
"success": True,
"data": {"cancelled": result}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"리비전 세션 취소 실패: {str(e)}")
@router.get("/categories")
async def get_supported_categories():
"""지원 카테고리 목록 조회"""
try:
categories = [
{"key": "PIPE", "name": "배관", "description": "파이프 및 배관 자재"},
{"key": "FITTING", "name": "피팅", "description": "배관 연결 부품"},
{"key": "FLANGE", "name": "플랜지", "description": "플랜지 및 연결 부품"},
{"key": "VALVE", "name": "밸브", "description": "각종 밸브류"},
{"key": "GASKET", "name": "가스켓", "description": "씰링 부품"},
{"key": "BOLT", "name": "볼트", "description": "체결 부품"},
{"key": "SUPPORT", "name": "서포트", "description": "지지대 및 구조물"},
{"key": "SPECIAL", "name": "특수자재", "description": "특수 목적 자재"},
{"key": "UNCLASSIFIED", "name": "미분류", "description": "분류되지 않은 자재"}
]
return {
"success": True,
"data": {
"categories": categories
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"지원 카테고리 조회 실패: {str(e)}")
@router.get("/actions")
async def get_supported_actions():
"""지원 액션 목록 조회"""
try:
actions = [
{"key": "new_material", "name": "신규 자재", "description": "새로 추가된 자재"},
{"key": "additional_purchase", "name": "추가 구매", "description": "구매된 자재의 수량 증가"},
{"key": "inventory_transfer", "name": "재고 이관", "description": "구매된 자재의 수량 감소 또는 제거"},
{"key": "purchase_cancel", "name": "구매 취소", "description": "미구매 자재의 제거"},
{"key": "quantity_update", "name": "수량 업데이트", "description": "미구매 자재의 수량 변경"},
{"key": "maintain", "name": "유지", "description": "변경사항 없음"}
]
return {
"success": True,
"data": {
"actions": actions
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"지원 액션 조회 실패: {str(e)}")

View File

@@ -0,0 +1,538 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import text
from typing import Optional, List
from datetime import datetime, date
from pydantic import BaseModel
from decimal import Decimal
from ..database import get_db
from ..models import (
TubingCategory, TubingSpecification, TubingManufacturer,
TubingProduct, MaterialTubingMapping, Material
)
router = APIRouter()
# ================================
# Pydantic 모델들
# ================================
class TubingCategoryResponse(BaseModel):
id: int
category_code: str
category_name: str
description: Optional[str] = None
is_active: bool
created_at: datetime
class Config:
from_attributes = True
class TubingManufacturerResponse(BaseModel):
id: int
manufacturer_code: str
manufacturer_name: str
country: Optional[str] = None
website: Optional[str] = None
is_active: bool
class Config:
from_attributes = True
class TubingSpecificationResponse(BaseModel):
id: int
spec_code: str
spec_name: str
category_name: Optional[str] = None
outer_diameter_mm: Optional[float] = None
wall_thickness_mm: Optional[float] = None
inner_diameter_mm: Optional[float] = None
material_grade: Optional[str] = None
material_standard: Optional[str] = None
max_pressure_bar: Optional[float] = None
max_temperature_c: Optional[float] = None
min_temperature_c: Optional[float] = None
standard_length_m: Optional[float] = None
surface_finish: Optional[str] = None
is_active: bool
class Config:
from_attributes = True
class TubingProductResponse(BaseModel):
id: int
specification_id: int
manufacturer_id: int
manufacturer_part_number: str
manufacturer_product_name: Optional[str] = None
spec_name: Optional[str] = None
manufacturer_name: Optional[str] = None
list_price: Optional[float] = None
currency: Optional[str] = 'KRW'
lead_time_days: Optional[int] = None
availability_status: Optional[str] = None
datasheet_url: Optional[str] = None
notes: Optional[str] = None
is_active: bool
class Config:
from_attributes = True
class TubingProductCreate(BaseModel):
specification_id: int
manufacturer_id: int
manufacturer_part_number: str
manufacturer_product_name: Optional[str] = None
list_price: Optional[float] = None
currency: str = 'KRW'
lead_time_days: Optional[int] = None
minimum_order_qty: Optional[float] = None
standard_packaging_qty: Optional[float] = None
availability_status: Optional[str] = None
datasheet_url: Optional[str] = None
catalog_page: Optional[str] = None
notes: Optional[str] = None
class MaterialTubingMappingCreate(BaseModel):
material_id: int
tubing_product_id: int
confidence_score: Optional[float] = None
mapping_method: str = 'manual'
required_length_m: Optional[float] = None
calculated_quantity: Optional[float] = None
notes: Optional[str] = None
# ================================
# API 엔드포인트들
# ================================
@router.get("/categories", response_model=List[TubingCategoryResponse])
async def get_tubing_categories(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_db)
):
"""Tubing 카테고리 목록 조회"""
try:
categories = db.query(TubingCategory)\
.filter(TubingCategory.is_active == True)\
.offset(skip)\
.limit(limit)\
.all()
return categories
except Exception as e:
raise HTTPException(status_code=500, detail=f"카테고리 조회 실패: {str(e)}")
@router.get("/manufacturers", response_model=List[TubingManufacturerResponse])
async def get_tubing_manufacturers(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
search: Optional[str] = Query(None),
country: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
"""Tubing 제조사 목록 조회"""
try:
query = db.query(TubingManufacturer)\
.filter(TubingManufacturer.is_active == True)
if search:
query = query.filter(
TubingManufacturer.manufacturer_name.ilike(f"%{search}%") |
TubingManufacturer.manufacturer_code.ilike(f"%{search}%")
)
if country:
query = query.filter(TubingManufacturer.country.ilike(f"%{country}%"))
manufacturers = query.offset(skip).limit(limit).all()
return manufacturers
except Exception as e:
raise HTTPException(status_code=500, detail=f"제조사 조회 실패: {str(e)}")
@router.get("/specifications", response_model=List[TubingSpecificationResponse])
async def get_tubing_specifications(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
category_id: Optional[int] = Query(None),
material_grade: Optional[str] = Query(None),
outer_diameter_min: Optional[float] = Query(None),
outer_diameter_max: Optional[float] = Query(None),
search: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
"""Tubing 규격 목록 조회"""
try:
query = db.query(TubingSpecification)\
.options(joinedload(TubingSpecification.category))\
.filter(TubingSpecification.is_active == True)
if category_id:
query = query.filter(TubingSpecification.category_id == category_id)
if material_grade:
query = query.filter(TubingSpecification.material_grade.ilike(f"%{material_grade}%"))
if outer_diameter_min:
query = query.filter(TubingSpecification.outer_diameter_mm >= outer_diameter_min)
if outer_diameter_max:
query = query.filter(TubingSpecification.outer_diameter_mm <= outer_diameter_max)
if search:
query = query.filter(
TubingSpecification.spec_name.ilike(f"%{search}%") |
TubingSpecification.spec_code.ilike(f"%{search}%") |
TubingSpecification.material_grade.ilike(f"%{search}%")
)
specifications = query.offset(skip).limit(limit).all()
# 응답 데이터 변환
result = []
for spec in specifications:
spec_dict = {
"id": spec.id,
"spec_code": spec.spec_code,
"spec_name": spec.spec_name,
"category_name": spec.category.category_name if spec.category else None,
"outer_diameter_mm": float(spec.outer_diameter_mm) if spec.outer_diameter_mm else None,
"wall_thickness_mm": float(spec.wall_thickness_mm) if spec.wall_thickness_mm else None,
"inner_diameter_mm": float(spec.inner_diameter_mm) if spec.inner_diameter_mm else None,
"material_grade": spec.material_grade,
"material_standard": spec.material_standard,
"max_pressure_bar": float(spec.max_pressure_bar) if spec.max_pressure_bar else None,
"max_temperature_c": float(spec.max_temperature_c) if spec.max_temperature_c else None,
"min_temperature_c": float(spec.min_temperature_c) if spec.min_temperature_c else None,
"standard_length_m": float(spec.standard_length_m) if spec.standard_length_m else None,
"surface_finish": spec.surface_finish,
"is_active": spec.is_active
}
result.append(spec_dict)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"규격 조회 실패: {str(e)}")
@router.get("/products", response_model=List[TubingProductResponse])
async def get_tubing_products(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
specification_id: Optional[int] = Query(None),
manufacturer_id: Optional[int] = Query(None),
search: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
"""Tubing 제품 목록 조회"""
try:
query = db.query(TubingProduct)\
.options(
joinedload(TubingProduct.specification),
joinedload(TubingProduct.manufacturer)
)\
.filter(TubingProduct.is_active == True)
if specification_id:
query = query.filter(TubingProduct.specification_id == specification_id)
if manufacturer_id:
query = query.filter(TubingProduct.manufacturer_id == manufacturer_id)
if search:
query = query.filter(
TubingProduct.manufacturer_part_number.ilike(f"%{search}%") |
TubingProduct.manufacturer_product_name.ilike(f"%{search}%")
)
products = query.offset(skip).limit(limit).all()
# 응답 데이터 변환
result = []
for product in products:
product_dict = {
"id": product.id,
"specification_id": product.specification_id,
"manufacturer_id": product.manufacturer_id,
"manufacturer_part_number": product.manufacturer_part_number,
"manufacturer_product_name": product.manufacturer_product_name,
"spec_name": product.specification.spec_name if product.specification else None,
"manufacturer_name": product.manufacturer.manufacturer_name if product.manufacturer else None,
"list_price": float(product.list_price) if product.list_price else None,
"currency": product.currency,
"lead_time_days": product.lead_time_days,
"availability_status": product.availability_status,
"datasheet_url": product.datasheet_url,
"notes": product.notes,
"is_active": product.is_active
}
result.append(product_dict)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"제품 조회 실패: {str(e)}")
@router.post("/products", response_model=TubingProductResponse)
async def create_tubing_product(
product_data: TubingProductCreate,
db: Session = Depends(get_db)
):
"""새 Tubing 제품 등록"""
try:
# 중복 확인
existing = db.query(TubingProduct)\
.filter(
TubingProduct.specification_id == product_data.specification_id,
TubingProduct.manufacturer_id == product_data.manufacturer_id,
TubingProduct.manufacturer_part_number == product_data.manufacturer_part_number
).first()
if existing:
raise HTTPException(
status_code=400,
detail=f"동일한 제품이 이미 등록되어 있습니다: {product_data.manufacturer_part_number}"
)
# 새 제품 생성
new_product = TubingProduct(**product_data.dict())
db.add(new_product)
db.commit()
db.refresh(new_product)
# 관련 정보와 함께 조회
product_with_relations = db.query(TubingProduct)\
.options(
joinedload(TubingProduct.specification),
joinedload(TubingProduct.manufacturer)
)\
.filter(TubingProduct.id == new_product.id)\
.first()
return {
"id": product_with_relations.id,
"specification_id": product_with_relations.specification_id,
"manufacturer_id": product_with_relations.manufacturer_id,
"manufacturer_part_number": product_with_relations.manufacturer_part_number,
"manufacturer_product_name": product_with_relations.manufacturer_product_name,
"spec_name": product_with_relations.specification.spec_name if product_with_relations.specification else None,
"manufacturer_name": product_with_relations.manufacturer.manufacturer_name if product_with_relations.manufacturer else None,
"list_price": float(product_with_relations.list_price) if product_with_relations.list_price else None,
"currency": product_with_relations.currency,
"lead_time_days": product_with_relations.lead_time_days,
"availability_status": product_with_relations.availability_status,
"datasheet_url": product_with_relations.datasheet_url,
"notes": product_with_relations.notes,
"is_active": product_with_relations.is_active
}
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"제품 등록 실패: {str(e)}")
@router.post("/material-mapping")
async def create_material_tubing_mapping(
mapping_data: MaterialTubingMappingCreate,
mapped_by: str = "admin",
db: Session = Depends(get_db)
):
"""BOM 자재와 Tubing 제품 매핑 생성"""
try:
# 기존 매핑 확인
existing = db.query(MaterialTubingMapping)\
.filter(
MaterialTubingMapping.material_id == mapping_data.material_id,
MaterialTubingMapping.tubing_product_id == mapping_data.tubing_product_id
).first()
if existing:
raise HTTPException(
status_code=400,
detail="이미 매핑된 자재와 제품입니다"
)
# 새 매핑 생성
new_mapping = MaterialTubingMapping(
**mapping_data.dict(),
mapped_by=mapped_by
)
db.add(new_mapping)
db.commit()
db.refresh(new_mapping)
return {
"success": True,
"message": "매핑이 성공적으로 생성되었습니다",
"mapping_id": new_mapping.id
}
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"매핑 생성 실패: {str(e)}")
@router.get("/material-mappings/{material_id}")
async def get_material_tubing_mappings(
material_id: int,
db: Session = Depends(get_db)
):
"""특정 자재의 Tubing 매핑 조회"""
try:
mappings = db.query(MaterialTubingMapping)\
.options(
joinedload(MaterialTubingMapping.tubing_product)
.joinedload(TubingProduct.specification),
joinedload(MaterialTubingMapping.tubing_product)
.joinedload(TubingProduct.manufacturer)
)\
.filter(MaterialTubingMapping.material_id == material_id)\
.all()
result = []
for mapping in mappings:
product = mapping.tubing_product
mapping_dict = {
"mapping_id": mapping.id,
"confidence_score": float(mapping.confidence_score) if mapping.confidence_score else None,
"mapping_method": mapping.mapping_method,
"mapped_by": mapping.mapped_by,
"mapped_at": mapping.mapped_at,
"required_length_m": float(mapping.required_length_m) if mapping.required_length_m else None,
"calculated_quantity": float(mapping.calculated_quantity) if mapping.calculated_quantity else None,
"is_verified": mapping.is_verified,
"tubing_product": {
"id": product.id,
"manufacturer_part_number": product.manufacturer_part_number,
"manufacturer_product_name": product.manufacturer_product_name,
"spec_name": product.specification.spec_name if product.specification else None,
"manufacturer_name": product.manufacturer.manufacturer_name if product.manufacturer else None,
"list_price": float(product.list_price) if product.list_price else None,
"currency": product.currency,
"availability_status": product.availability_status
}
}
result.append(mapping_dict)
return {
"success": True,
"material_id": material_id,
"mappings": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"매핑 조회 실패: {str(e)}")
@router.get("/search")
async def search_tubing_products(
query: str = Query(..., min_length=2),
category: Optional[str] = Query(None),
manufacturer: Optional[str] = Query(None),
min_diameter: Optional[float] = Query(None),
max_diameter: Optional[float] = Query(None),
material_grade: Optional[str] = Query(None),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db)
):
"""통합 Tubing 검색 (규격, 제품, 제조사)"""
try:
# SQL 쿼리로 복합 검색
sql_query = """
SELECT DISTINCT
tp.id as product_id,
tp.manufacturer_part_number,
tp.manufacturer_product_name,
tp.list_price,
tp.currency,
tp.availability_status,
ts.spec_code,
ts.spec_name,
ts.outer_diameter_mm,
ts.wall_thickness_mm,
ts.material_grade,
tc.category_name,
tm.manufacturer_name,
tm.country
FROM tubing_products tp
JOIN tubing_specifications ts ON tp.specification_id = ts.id
JOIN tubing_categories tc ON ts.category_id = tc.id
JOIN tubing_manufacturers tm ON tp.manufacturer_id = tm.id
WHERE tp.is_active = true
AND ts.is_active = true
AND tc.is_active = true
AND tm.is_active = true
AND (
tp.manufacturer_part_number ILIKE :query OR
tp.manufacturer_product_name ILIKE :query OR
ts.spec_name ILIKE :query OR
ts.spec_code ILIKE :query OR
ts.material_grade ILIKE :query OR
tm.manufacturer_name ILIKE :query
)
"""
params = {"query": f"%{query}%"}
# 필터 조건 추가
if category:
sql_query += " AND tc.category_code = :category"
params["category"] = category
if manufacturer:
sql_query += " AND tm.manufacturer_code = :manufacturer"
params["manufacturer"] = manufacturer
if min_diameter:
sql_query += " AND ts.outer_diameter_mm >= :min_diameter"
params["min_diameter"] = min_diameter
if max_diameter:
sql_query += " AND ts.outer_diameter_mm <= :max_diameter"
params["max_diameter"] = max_diameter
if material_grade:
sql_query += " AND ts.material_grade ILIKE :material_grade"
params["material_grade"] = f"%{material_grade}%"
sql_query += " ORDER BY tp.manufacturer_part_number LIMIT :limit"
params["limit"] = limit
result = db.execute(text(sql_query), params)
products = result.fetchall()
search_results = []
for product in products:
product_dict = {
"product_id": product.product_id,
"manufacturer_part_number": product.manufacturer_part_number,
"manufacturer_product_name": product.manufacturer_product_name,
"list_price": float(product.list_price) if product.list_price else None,
"currency": product.currency,
"availability_status": product.availability_status,
"spec_code": product.spec_code,
"spec_name": product.spec_name,
"outer_diameter_mm": float(product.outer_diameter_mm) if product.outer_diameter_mm else None,
"wall_thickness_mm": float(product.wall_thickness_mm) if product.wall_thickness_mm else None,
"material_grade": product.material_grade,
"category_name": product.category_name,
"manufacturer_name": product.manufacturer_name,
"country": product.country
}
search_results.append(product_dict)
return {
"success": True,
"query": query,
"total_results": len(search_results),
"results": search_results
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"검색 실패: {str(e)}")