feat: 자재 관리 페이지 대규모 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- 파이프 수량 계산 로직 수정 (단관 개수가 아닌 실제 길이 기반 계산)
- UI 전면 개편 (DevonThink 스타일의 간결하고 세련된 디자인)
- 자재별 그룹핑 로직 개선:
  * 플랜지: 동일 사양별 그룹핑, WN 스케줄 표시, ORIFICE 풀네임 표시
  * 피팅: 상세 타입 표시 (니플 길이, 엘보 각도/연결, 티 타입, 리듀서 타입 등)
  * 밸브: 동일 사양별 그룹핑, 타입/연결방식/압력 표시
  * 볼트: 크기/재질/길이별 그룹핑 (8SET → 개별 집계)
  * 가스켓: 동일 사양별 그룹핑, 재질/상세내역/두께 분리 표시
  * UNKNOWN: 원본 설명 전체 표시, 동일 항목 그룹핑
- 전체 카테고리 버튼 제거 (표시 복잡도 감소)
- 카테고리별 동적 컬럼 헤더 및 레이아웃 적용
This commit is contained in:
Hyungi Ahn
2025-09-09 09:24:45 +09:00
parent 4f8e395f87
commit 83b90ef05c
101 changed files with 10841 additions and 4813 deletions

View File

@@ -0,0 +1,427 @@
"""
대시보드 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)}")

File diff suppressed because it is too large Load Diff

View File

@@ -1,399 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import List, Optional
import os
import shutil
from datetime import datetime
import uuid
import pandas as pd
import re
from pathlib import Path
from ..database import get_db
router = APIRouter()
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"}
@router.get("/")
async def get_files_info():
return {
"message": "파일 관리 API",
"allowed_extensions": list(ALLOWED_EXTENSIONS),
"upload_directory": str(UPLOAD_DIR)
}
@router.get("/test")
async def test_endpoint():
return {"status": "파일 API가 정상 작동합니다!"}
@router.post("/add-missing-columns")
async def add_missing_columns(db: Session = Depends(get_db)):
"""누락된 컬럼들 추가"""
try:
db.execute(text("ALTER TABLE files ADD COLUMN IF NOT EXISTS parsed_count INTEGER DEFAULT 0"))
db.execute(text("ALTER TABLE materials ADD COLUMN IF NOT EXISTS row_number INTEGER"))
db.commit()
return {
"success": True,
"message": "누락된 컬럼들이 추가되었습니다",
"added_columns": ["files.parsed_count", "materials.row_number"]
}
except Exception as e:
db.rollback()
return {"success": False, "error": f"컬럼 추가 실패: {str(e)}"}
def validate_file_extension(filename: str) -> bool:
return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
def generate_unique_filename(original_filename: str) -> str:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
unique_id = str(uuid.uuid4())[:8]
stem = Path(original_filename).stem
suffix = Path(original_filename).suffix
return f"{stem}_{timestamp}_{unique_id}{suffix}"
def parse_dataframe(df):
df = df.dropna(how='all')
df.columns = df.columns.str.strip().str.lower()
column_mapping = {
'description': ['description', 'item', 'material', '품명', '자재명'],
'quantity': ['qty', 'quantity', 'ea', '수량'],
'main_size': ['main_nom', 'nominal_diameter', 'nd', '주배관'],
'red_size': ['red_nom', 'reduced_diameter', '축소배관'],
'length': ['length', 'len', '길이'],
'weight': ['weight', 'wt', '중량'],
'dwg_name': ['dwg_name', 'drawing', '도면명'],
'line_num': ['line_num', 'line_number', '라인번호']
}
mapped_columns = {}
for standard_col, possible_names in column_mapping.items():
for possible_name in possible_names:
if possible_name in df.columns:
mapped_columns[standard_col] = possible_name
break
materials = []
for index, row in df.iterrows():
description = str(row.get(mapped_columns.get('description', ''), ''))
quantity_raw = row.get(mapped_columns.get('quantity', ''), 0)
try:
quantity = float(quantity_raw) if pd.notna(quantity_raw) else 0
except:
quantity = 0
material_grade = ""
if "ASTM" in description.upper():
astm_match = re.search(r'ASTM\s+([A-Z0-9\s]+)', description.upper())
if astm_match:
material_grade = astm_match.group(0).strip()
main_size = str(row.get(mapped_columns.get('main_size', ''), ''))
red_size = str(row.get(mapped_columns.get('red_size', ''), ''))
if main_size != 'nan' and red_size != 'nan' and red_size != '':
size_spec = f"{main_size} x {red_size}"
elif main_size != 'nan' and main_size != '':
size_spec = main_size
else:
size_spec = ""
if description and description not in ['nan', 'None', '']:
materials.append({
'original_description': description,
'quantity': quantity,
'unit': "EA",
'size_spec': size_spec,
'material_grade': material_grade,
'line_number': index + 1,
'row_number': index + 1
})
return materials
def parse_file_data(file_path):
file_extension = Path(file_path).suffix.lower()
try:
if file_extension == ".csv":
df = pd.read_csv(file_path, encoding='utf-8')
elif file_extension in [".xlsx", ".xls"]:
df = pd.read_excel(file_path, sheet_name=0)
else:
raise HTTPException(status_code=400, detail="지원하지 않는 파일 형식")
return parse_dataframe(df)
except Exception as e:
raise HTTPException(status_code=400, detail=f"파일 파싱 실패: {str(e)}")
@router.post("/upload")
async def upload_file(
file: UploadFile = File(...),
job_no: str = Form(...),
revision: str = Form("Rev.0"),
db: Session = Depends(get_db)
):
if not validate_file_extension(file.filename):
raise HTTPException(
status_code=400,
detail=f"지원하지 않는 파일 형식입니다. 허용된 확장자: {', '.join(ALLOWED_EXTENSIONS)}"
)
if file.size and file.size > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="파일 크기는 10MB를 초과할 수 없습니다")
unique_filename = generate_unique_filename(file.filename)
file_path = UPLOAD_DIR / unique_filename
try:
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
except Exception as e:
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
try:
materials_data = parse_file_data(str(file_path))
parsed_count = len(materials_data)
# 파일 정보 저장
file_insert_query = text("""
INSERT INTO files (filename, original_filename, file_path, job_no, revision, description, file_size, parsed_count, is_active)
VALUES (:filename, :original_filename, :file_path, :job_no, :revision, :description, :file_size, :parsed_count, :is_active)
RETURNING id
""")
file_result = db.execute(file_insert_query, {
"filename": unique_filename,
"original_filename": file.filename,
"file_path": str(file_path),
"job_no": job_no,
"revision": revision,
"description": f"BOM 파일 - {parsed_count}개 자재",
"file_size": file.size,
"parsed_count": parsed_count,
"is_active": True
})
file_id = file_result.fetchone()[0]
# 자재 데이터 저장
materials_inserted = 0
for material_data in materials_data:
material_insert_query = text("""
INSERT INTO materials (
file_id, original_description, quantity, unit, size_spec,
material_grade, line_number, row_number, classified_category,
classification_confidence, is_verified, created_at
)
VALUES (
:file_id, :original_description, :quantity, :unit, :size_spec,
:material_grade, :line_number, :row_number, :classified_category,
:classification_confidence, :is_verified, :created_at
)
""")
db.execute(material_insert_query, {
"file_id": file_id,
"original_description": material_data["original_description"],
"quantity": material_data["quantity"],
"unit": material_data["unit"],
"size_spec": material_data["size_spec"],
"material_grade": material_data["material_grade"],
"line_number": material_data["line_number"],
"row_number": material_data["row_number"],
"classified_category": None,
"classification_confidence": None,
"is_verified": False,
"created_at": datetime.now()
})
materials_inserted += 1
db.commit()
return {
"success": True,
"message": f"완전한 DB 저장 성공! {materials_inserted}개 자재 저장됨",
"original_filename": file.filename,
"file_id": file_id,
"parsed_materials_count": parsed_count,
"saved_materials_count": materials_inserted,
"sample_materials": materials_data[:3] if materials_data else []
}
except Exception as e:
db.rollback()
if os.path.exists(file_path):
os.remove(file_path)
raise HTTPException(status_code=500, detail=f"파일 처리 실패: {str(e)}")
@router.get("/materials")
async def get_materials(
job_no: Optional[str] = None,
file_id: Optional[str] = None,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db)
):
"""저장된 자재 목록 조회"""
try:
query = """
SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit,
m.size_spec, m.material_grade, m.line_number, m.row_number,
m.created_at,
f.original_filename, f.job_no,
j.job_no, j.job_name
FROM materials m
LEFT JOIN files f ON m.file_id = f.id
LEFT JOIN jobs j ON f.job_no = j.job_no
WHERE 1=1
"""
params = {}
if job_no:
query += " AND f.job_no = :job_no"
params["job_no"] = job_no
if file_id:
query += " AND m.file_id = :file_id"
params["file_id"] = file_id
query += " ORDER BY m.line_number ASC LIMIT :limit OFFSET :skip"
params["limit"] = limit
params["skip"] = skip
result = db.execute(text(query), params)
materials = result.fetchall()
# 전체 개수 조회
count_query = """
SELECT COUNT(*) as total
FROM materials m
LEFT JOIN files f ON m.file_id = f.id
WHERE 1=1
"""
count_params = {}
if job_no:
count_query += " AND f.job_no = :job_no"
count_params["job_no"] = job_no
if file_id:
count_query += " AND m.file_id = :file_id"
count_params["file_id"] = file_id
count_result = db.execute(text(count_query), count_params)
total_count = count_result.fetchone()[0]
return {
"success": True,
"total_count": total_count,
"returned_count": len(materials),
"skip": skip,
"limit": limit,
"materials": [
{
"id": m.id,
"file_id": m.file_id,
"filename": m.original_filename,
"job_no": m.job_no,
"project_code": m.official_project_code,
"project_name": m.project_name,
"original_description": m.original_description,
"quantity": float(m.quantity) if m.quantity else 0,
"unit": m.unit,
"size_spec": m.size_spec,
"material_grade": m.material_grade,
"line_number": m.line_number,
"row_number": m.row_number,
"created_at": m.created_at
}
for m in materials
]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"자재 조회 실패: {str(e)}")
@router.get("/materials/summary")
async def get_materials_summary(
job_no: Optional[str] = None,
file_id: Optional[str] = None,
db: Session = Depends(get_db)
):
"""자재 요약 통계"""
try:
query = """
SELECT
COUNT(*) as total_items,
COUNT(DISTINCT m.original_description) as unique_descriptions,
COUNT(DISTINCT m.size_spec) as unique_sizes,
COUNT(DISTINCT m.material_grade) as unique_materials,
SUM(m.quantity) as total_quantity,
AVG(m.quantity) as avg_quantity,
MIN(m.created_at) as earliest_upload,
MAX(m.created_at) as latest_upload
FROM materials m
LEFT JOIN files f ON m.file_id = f.id
WHERE 1=1
"""
params = {}
if job_no:
query += " AND f.job_no = :job_no"
params["job_no"] = job_no
if file_id:
query += " AND m.file_id = :file_id"
params["file_id"] = file_id
result = db.execute(text(query), params)
summary = result.fetchone()
return {
"success": True,
"summary": {
"total_items": summary.total_items,
"unique_descriptions": summary.unique_descriptions,
"unique_sizes": summary.unique_sizes,
"unique_materials": summary.unique_materials,
"total_quantity": float(summary.total_quantity) if summary.total_quantity else 0,
"avg_quantity": round(float(summary.avg_quantity), 2) if summary.avg_quantity else 0,
"earliest_upload": summary.earliest_upload,
"latest_upload": summary.latest_upload
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"요약 조회 실패: {str(e)}")
# Job 검증 함수 (파일 끝에 추가할 예정)
async def validate_job_exists(job_no: str, db: Session):
"""Job 존재 여부 및 활성 상태 확인"""
try:
query = text("SELECT job_no, job_name, status FROM jobs WHERE job_no = :job_no AND is_active = true")
job = db.execute(query, {"job_no": job_no}).fetchone()
if not job:
return {"valid": False, "error": f"Job No. '{job_no}'를 찾을 수 없습니다"}
if job.status == '완료':
return {"valid": False, "error": f"완료된 Job '{job.job_name}'에는 파일을 업로드할 수 없습니다"}
return {
"valid": True,
"job": {
"job_no": job.job_no,
"job_name": job.job_name,
"status": job.status
}
}
except Exception as e:
return {"valid": False, "error": f"Job 검증 실패: {str(e)}"}

View File

@@ -157,6 +157,26 @@ async def confirm_material_purchase(
]
"""
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:
@@ -470,7 +490,7 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
"""파일의 자재를 해시별로 그룹화하여 조회"""
import hashlib
print(f"🚨🚨🚨 get_materials_by_hash 호출됨! file_id={file_id} 🚨🚨🚨")
# 로그 제거
query = text("""
SELECT
@@ -492,11 +512,7 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
result = db.execute(query, {"file_id": file_id})
materials = result.fetchall()
print(f"🔍 쿼리 결과 개수: {len(materials)}")
if len(materials) > 0:
print(f"🔍 첫 번째 자료 샘플: {materials[0]}")
else:
print(f"❌ 자료가 없음! file_id={file_id}")
# 로그 제거
# 🔄 같은 파이프들을 Python에서 올바르게 그룹핑
materials_dict = {}
@@ -505,38 +521,41 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
hash_source = f"{mat[1] or ''}|{mat[2] or ''}|{mat[3] or ''}"
material_hash = hashlib.md5(hash_source.encode()).hexdigest()
print(f"📝 개별 자재: {mat[1][:50]}... ({mat[2]}) - 수량: {mat[4]}, 길이: {mat[7]}mm")
# 개별 자재 로그 제거 (너무 많음)
if material_hash in materials_dict:
# 🔄 기존 항목에 수량 합계
existing = materials_dict[material_hash]
existing["quantity"] += float(mat[4]) if mat[4] else 0.0
# 파이프가 아닌 경우만 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"]
new_length = float(mat[4]) * float(mat[7]) # 수량 × 단위길이
existing["pipe_details"]["total_length_mm"] = current_total + new_length
existing["pipe_details"]["pipe_count"] = current_count + float(mat[4])
# ✅ 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
print(f"🔄 파이프 합산: {mat[1]} ({mat[2]}) - 총길이: {total_length}mm, 총개수: {total_count}개, 평균: {total_length/total_count:.1f}mm")
# 파이프 합산 로그 제거 (너무 많음)
else:
# 첫 파이프 정보 설정
pipe_length = float(mat[4]) * float(mat[7])
individual_length = float(mat[7]) # 개별 파이프의 실제 길이
existing["pipe_details"] = {
"length_mm": float(mat[7]),
"total_length_mm": pipe_length,
"pipe_count": float(mat[4])
"length_mm": individual_length,
"total_length_mm": individual_length, # 첫 번째 파이프이므로 개별 길이와 동일
"pipe_count": 1 # 첫 번째 파이프이므로 1개
}
else:
# 🆕 새 항목 생성
@@ -553,27 +572,22 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
# 파이프인 경우 pipe_details 정보 추가
if mat[5] == 'PIPE' and mat[7] is not None:
pipe_length = float(mat[4]) * float(mat[7]) # 수량 × 단위길이
individual_length = float(mat[7]) # 개별 파이프의 실제 길이
material_data["pipe_details"] = {
"length_mm": float(mat[7]), # 단위 길이
"total_length_mm": pipe_length, # 총 길이
"pipe_count": float(mat[4]) # 파이프 개수
"length_mm": individual_length, # 개별 파이프 길이
"total_length_mm": individual_length, # 첫 번째 파이프이므로 개별 길이와 동일
"pipe_count": 1 # 첫 번째 파이프이므로 1개
}
print(f"🆕 파이프 신규: {mat[1]} ({mat[2]}) - 단위: {mat[7]}mm, 총길이: {pipe_length}mm")
# 파이프는 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_details 있는 파이프 {pipe_with_details}")
# 첫 번째 파이프 데이터 샘플 출력
for hash_key, data in materials_dict.items():
if data.get('category') == 'PIPE':
print(f"🔍 파이프 샘플: {data}")
break
print(f"✅ 자재 처리 완료: 총 {len(materials_dict)}개, 파이프 {pipe_count} (길이정보: {pipe_with_details})")
return materials_dict

View File

@@ -5,11 +5,13 @@
- 리비전 비교
"""
from fastapi import APIRouter, Depends, HTTPException, Query
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 (
@@ -21,6 +23,28 @@ from ..services.purchase_calculator import (
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 번호"),
@@ -39,7 +63,7 @@ async def calculate_purchase_items(
file_query = text("""
SELECT id FROM files
WHERE job_no = :job_no AND revision = :revision AND is_active = TRUE
ORDER BY created_at DESC
ORDER BY updated_at DESC
LIMIT 1
""")
file_result = db.execute(file_query, {"job_no": job_no, "revision": revision}).fetchone()
@@ -62,6 +86,139 @@ async def calculate_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,