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:
@@ -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)}"
|
||||
}
|
||||
Reference in New Issue
Block a user