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