feat: 리비전 관리 시스템 및 구매확정 기능 구현

- 리비전 관리 라우터 및 서비스 추가 (revision_management.py, revision_comparison_service.py, revision_session_service.py)
- 구매확정 기능 구현: materials 테이블에 purchase_confirmed 필드 추가 및 업데이트 로직
- 리비전 비교 로직 구현: 구매확정된 자재 기반으로 신규/변경 자재 자동 분류
- 데이터베이스 스키마 확장: revision_sessions, revision_material_changes, inventory_transfers 테이블 추가
- 구매신청 생성 시 자재 상세 정보 저장 및 purchase_confirmed 자동 업데이트
- 프론트엔드: 리비전 관리 컴포넌트 및 hooks 추가
- 파일 목록 조회 API 추가 (/files/list)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2025-12-06 07:36:44 +09:00
parent c258303bb7
commit 17843e285f
12 changed files with 2759 additions and 83 deletions

View File

@@ -339,6 +339,12 @@ async def upload_file(
current_user: dict = Depends(get_current_user)
):
# 🎯 트랜잭션 오류 방지: 완전한 트랜잭션 초기화
# 🔍 디버깅: 업로드 파라미터 로깅
print(f"🔍 [UPLOAD] job_no: {job_no}, revision: {revision}, parent_file_id: {parent_file_id}")
print(f"🔍 [UPLOAD] bom_name: {bom_name}, filename: {file.filename}")
print(f"🔍 [UPLOAD] revision != 'Rev.0': {revision != 'Rev.0'}")
try:
# 1. 현재 트랜잭션 완전 롤백
db.rollback()
@@ -502,6 +508,7 @@ async def upload_file(
})
file_id = file_result.fetchone()[0]
db.commit() # 파일 레코드 즉시 커밋
print(f"파일 저장 완료: file_id = {file_id}, uploaded_by = {username}")
# 🔄 리비전 비교 수행 (RULES.md 코딩 컨벤션 준수)
@@ -509,29 +516,30 @@ async def upload_file(
materials_to_classify = materials_data
if revision != "Rev.0": # 리비전 업로드인 경우만 비교
# 로그 제거
print(f"🔍 [DEBUG] 리비전 비교 시작 - revision: {revision}, parent_file_id: {parent_file_id}")
try:
revision_comparison = get_revision_comparison(db, job_no, revision, materials_data)
# 간단한 리비전 비교 로직 (purchase_confirmed 기반)
print(f"🔍 [DEBUG] perform_simple_revision_comparison 호출 중...")
revision_comparison = perform_simple_revision_comparison(db, job_no, parent_file_id, materials_data)
print(f"🔍 [DEBUG] 리비전 비교 완료: {revision_comparison.keys() if revision_comparison else 'None'}")
if revision_comparison.get("has_previous_confirmation", False):
print(f"📊 리비전 비교 결과:")
print(f" - 변경없음: {revision_comparison.get('unchanged_count', 0)}")
print(f" - 변경됨: {revision_comparison.get('changed_count', 0)}")
print(f" - 신규: {revision_comparison.get('new_count', 0)}")
print(f" - 삭제됨: {revision_comparison.get('removed_count', 0)}")
print(f" - 분류 필요: {revision_comparison.get('classification_needed', 0)}")
if revision_comparison.get("has_purchased_materials", False):
print(f"📊 간단한 리비전 비교 결과:")
print(f" - 구매확정된 자재: {revision_comparison.get('purchased_count', 0)}")
print(f" - 미구매 자재: {revision_comparison.get('unpurchased_count', 0)}")
print(f" - 신규 자재: {revision_comparison.get('new_count', 0)}")
print(f" - 변경된 자재: {revision_comparison.get('changed_count', 0)}")
# 분류가 필요한 자재만 추출 (변경됨 + 신규)
materials_to_classify = (
revision_comparison.get("changed_materials", []) +
revision_comparison.get("new_materials", [])
)
# 신규 및 변경된 자재만 분류
materials_to_classify = revision_comparison.get("materials_to_classify", [])
print(f" - 분류 필요: {len(materials_to_classify)}")
else:
print("📝 이전 확정 자료 없음 - 전체 자재 분류")
print("📝 이전 구매확정 자료 없음 - 전체 자재 분류")
except Exception as e:
logger.error(f"리비전 비교 실패: {str(e)}")
print(f"⚠️ 리비전 비교 실패, 전체 자재 분류로 진행: {str(e)}")
import traceback
traceback.print_exc()
print(f"🔧 자재 분류 시작: {len(materials_to_classify)}개 자재")
@@ -1730,6 +1738,11 @@ async def upload_file(
return response_data
except Exception as e:
import traceback
error_details = traceback.format_exc()
print(f"❌ 파일 업로드 실패 - 상세 에러:")
print(error_details)
db.rollback()
if os.path.exists(file_path):
os.remove(file_path)
@@ -1779,6 +1792,56 @@ async def get_files(
except Exception as e:
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}")
@router.get("/list")
async def get_files_list(
job_no: Optional[str] = None,
db: Session = Depends(get_db)
):
"""파일 목록 조회 (리비전 모드 확인용)"""
try:
query = """
SELECT id, filename, original_filename, bom_name, job_no, revision,
description, file_size, parsed_count, upload_date, is_active
FROM files
WHERE is_active = TRUE
"""
params = {}
if job_no:
query += " AND job_no = :job_no"
params["job_no"] = job_no
query += " ORDER BY upload_date ASC" # 업로드 순서대로 정렬
result = db.execute(text(query), params)
files = result.fetchall()
files_list = [
{
"id": file.id,
"filename": file.filename,
"original_filename": file.original_filename,
"bom_name": file.bom_name,
"job_no": file.job_no,
"revision": file.revision,
"description": file.description,
"file_size": file.file_size,
"parsed_count": file.parsed_count,
"upload_date": file.upload_date,
"is_active": file.is_active
}
for file in files
]
return {
"success": True,
"files": files_list,
"total_count": len(files_list)
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}")
@router.get("/project/{project_code}")
async def get_files_by_project(
project_code: str,
@@ -3957,4 +4020,156 @@ async def get_excel_exports(
"success": False,
"exports": [],
"message": "엑셀 내보내기 목록을 조회할 수 없습니다."
}
def perform_simple_revision_comparison(db: Session, job_no: str, parent_file_id: int, new_materials: List[Dict]) -> Dict:
"""
간단한 리비전 비교 로직 (purchase_confirmed 기반)
Args:
db: 데이터베이스 세션
job_no: 프로젝트 번호
parent_file_id: 이전 파일 ID
new_materials: 신규 자재 목록
Returns:
비교 결과
"""
try:
# 1. 이전 파일의 구매확정된 자재 조회
previous_materials_query = text("""
SELECT original_description, classified_category, size_spec, material_grade,
main_nom, red_nom, drawing_name, line_no,
COALESCE(total_quantity, quantity, 0) as quantity, unit,
purchase_confirmed
FROM materials
WHERE file_id = :parent_file_id
ORDER BY id
""")
previous_result = db.execute(previous_materials_query, {"parent_file_id": parent_file_id})
previous_materials = previous_result.fetchall()
if not previous_materials:
return {
"has_purchased_materials": False,
"message": "이전 자료가 없습니다."
}
# 2. 이전 자재를 키별로 그룹화 (구매확정 상태 포함)
previous_dict = {}
purchased_count = 0
unpurchased_count = 0
for material in previous_materials:
# 자재 식별 키 생성 (description + size만 사용 - 도면 변경 시에도 매칭 가능)
key = f"{material.original_description.strip().upper()}|{material.size_spec or ''}"
if key in previous_dict:
# 동일 자재가 있으면 수량 합산
previous_dict[key]["quantity"] += float(material.quantity or 0)
else:
previous_dict[key] = {
"original_description": material.original_description,
"classified_category": material.classified_category,
"size_spec": material.size_spec,
"material_grade": material.material_grade,
"main_nom": material.main_nom,
"red_nom": material.red_nom,
"drawing_name": material.drawing_name,
"line_no": material.line_no,
"quantity": float(material.quantity or 0),
"unit": material.unit,
"purchase_confirmed": material.purchase_confirmed
}
if material.purchase_confirmed:
purchased_count += 1
else:
unpurchased_count += 1
# 3. 신규 자재를 키별로 그룹화
new_dict = {}
for material in new_materials:
key = f"{material.get('original_description', '').strip().upper()}|{material.get('size_spec', '') or ''}"
# total_quantity 우선 사용
quantity = float(material.get("total_quantity") or material.get("quantity", 0))
if key in new_dict:
new_dict[key]["quantity"] += quantity
else:
new_dict[key] = material.copy()
new_dict[key]["quantity"] = quantity
# 4. 비교 수행
materials_to_classify = []
new_count = 0
changed_count = 0
matched_count = 0
print(f"🔍 [DEBUG] 이전 자재 키 개수: {len(previous_dict)}")
print(f"🔍 [DEBUG] 신규 자재 키 개수: {len(new_dict)}")
# 구매확정된 자재 키들 확인
purchased_keys = [key for key, mat in previous_dict.items() if mat["purchase_confirmed"]]
print(f"🔍 [DEBUG] 구매확정된 자재 키 개수: {len(purchased_keys)}")
if purchased_keys:
print(f"🔍 [DEBUG] 구매확정 자재 샘플: {purchased_keys[:3]}")
for key, new_material in new_dict.items():
if key in previous_dict:
previous_material = previous_dict[key]
matched_count += 1
# 구매확정된 자재 매칭 로그
if previous_material["purchase_confirmed"]:
print(f"✅ 구매확정 자재 매칭: {key[:50]}...")
# 수량 비교
if abs(new_material["quantity"] - previous_material["quantity"]) > 0.001:
# 수량이 변경된 경우
print(f"🔄 수량 변경: {new_material.get('original_description', '')[:50]}... "
f"{previous_material['quantity']}{new_material['quantity']}")
# 구매확정된 자재의 수량 변경은 특별 처리 필요
if previous_material["purchase_confirmed"]:
print(f"⚠️ 구매확정된 자재 수량 변경 감지!")
materials_to_classify.append(new_material)
changed_count += 1
else:
# 수량이 동일하고 구매확정된 자재는 분류에서 제외
if previous_material["purchase_confirmed"]:
print(f"🚫 구매확정된 동일 자재 제외: {key[:50]}...")
# else: 미구매 동일 자재는 분류 불필요 (기존 분류 유지)
else:
# 완전히 새로운 자재
print(f" 신규 자재: {new_material.get('original_description', '')[:50]}...")
materials_to_classify.append(new_material)
new_count += 1
print(f"🔍 [DEBUG] 매칭된 자재: {matched_count}개, 신규: {new_count}개, 변경: {changed_count}")
has_purchased = any(mat["purchase_confirmed"] for mat in previous_dict.values())
return {
"has_purchased_materials": has_purchased,
"purchased_count": len([m for m in previous_dict.values() if m["purchase_confirmed"]]),
"unpurchased_count": len([m for m in previous_dict.values() if not m["purchase_confirmed"]]),
"new_count": new_count,
"changed_count": changed_count,
"materials_to_classify": materials_to_classify,
"total_previous": len(previous_dict),
"total_new": len(new_dict)
}
except Exception as e:
print(f"❌ 간단한 리비전 비교 실패: {str(e)}")
import traceback
traceback.print_exc()
return {
"has_purchased_materials": False,
"message": f"비교 실패: {str(e)}"
}

View File

@@ -42,6 +42,14 @@ async def create_purchase_request(
구매신청 생성 (엑셀 내보내기 = 구매신청)
"""
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("""
@@ -116,9 +124,13 @@ async def create_purchase_request(
if not existing:
insert_item = text("""
INSERT INTO purchase_request_items (
request_id, material_id, quantity, unit, user_requirement
request_id, material_id, description, category, subcategory,
material_grade, size_spec, quantity, unit, drawing_name,
notes, user_requirement
) VALUES (
:request_id, :material_id, :quantity, :unit, :user_requirement
:request_id, :material_id, :description, :category, :subcategory,
:material_grade, :size_spec, :quantity, :unit, :drawing_name,
:notes, :user_requirement
)
""")
# quantity를 정수로 변환 (소수점 제거)
@@ -131,14 +143,44 @@ async def create_purchase_request(
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", ""),
"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)")
@@ -150,6 +192,20 @@ async def create_purchase_request(
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,
@@ -224,7 +280,7 @@ async def get_purchase_requests(
"job_no": row.job_no,
"job_name": row.job_name,
"category": "ALL", # 기본값
"material_count": row.total_items or 0,
"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,

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