feat: 리비전 관리 시스템 개선

주요 개선사항:
1. 구매확정된 자재 완전 제외 - 리비전 비교 시 구매확정된 자재는 수량 변경 여부와 무관하게 완전히 제외
2. 삭제된 항목 추적 - 이전 리비전에는 있었지만 신규 리비전에는 없는 자재를 removed_materials로 반환
3. PIPE 특별 처리 - 6,000mm(1본) 단위로 필요 본수를 계산하여 비교   - 4,500mm → 5,000mm: 둘 다 1본 필요 → 변경 없음   - 4,500mm → 7,000mm: 1본 → 2본 필요 → 분류 필요
4. 리비전 비교 결과 상세 정보 반환   - has_purchased_materials, purchased_count, unpurchased_count   - new_count, removed_count, excluded_purchased_count   - removed_materials 리스트

기술적 변경:
- perform_simple_revision_comparison 함수 완전 재작성
- 구매확정/미구매 자재 별도 관리 (purchased_dict, unpurchased_dict)
- PIPE 카테고리 자재는 math.ceil(수량/6000)로 필요 본수 계산
- 업로드 응답에 revision_comparison 필드 추가

설정 변경:
- docker-compose.override.yml: 포트를 환경 변수로 관리
- .env.example 추가: 환경 변수 템플릿 제공

🤖 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 09:05:48 +09:00
parent 17843e285f
commit a6868b129e
3 changed files with 186 additions and 101 deletions

26
.env.example Normal file
View File

@@ -0,0 +1,26 @@
# TK-MP-Project 환경 변수 설정 예시
# 사용법: 이 파일을 .env로 복사한 후 필요한 값을 수정하세요
# cp .env.example .env
# PostgreSQL 설정
POSTGRES_DB=tk_mp_bom
POSTGRES_USER=tkmp_user
POSTGRES_PASSWORD=your_password_here
POSTGRES_PORT=15432
# Redis 설정
REDIS_PORT=16379
# 백엔드 설정
BACKEND_PORT=18000
ENVIRONMENT=development
DEBUG=true
# 프론트엔드 설정
FRONTEND_PORT=13000
VITE_API_URL=http://localhost:18000
# pgAdmin 설정
PGADMIN_EMAIL=admin@example.com
PGADMIN_PASSWORD=admin_password_here
PGADMIN_PORT=15050

View File

@@ -1730,11 +1730,25 @@ async def upload_file(
"uploaded_by": username, "uploaded_by": username,
"parsed_count": parsed_count "parsed_count": parsed_count
} }
# 누락된 도면 정보 추가 # 누락된 도면 정보 추가
if missing_drawings_info: if missing_drawings_info:
response_data["missing_drawings"] = missing_drawings_info response_data["missing_drawings"] = missing_drawings_info
# 🔄 리비전 비교 결과 추가
if revision_comparison:
response_data["revision_comparison"] = {
"has_purchased_materials": revision_comparison.get("has_purchased_materials", False),
"purchased_count": revision_comparison.get("purchased_count", 0),
"unpurchased_count": revision_comparison.get("unpurchased_count", 0),
"new_count": revision_comparison.get("new_count", 0),
"removed_count": revision_comparison.get("removed_count", 0),
"excluded_purchased_count": revision_comparison.get("excluded_purchased_count", 0),
"removed_materials": revision_comparison.get("removed_materials", []),
"total_previous": revision_comparison.get("total_previous", 0),
"total_new": revision_comparison.get("total_new", 0)
}
return response_data return response_data
except Exception as e: except Exception as e:
@@ -4025,52 +4039,68 @@ async def get_excel_exports(
def perform_simple_revision_comparison(db: Session, job_no: str, parent_file_id: int, new_materials: List[Dict]) -> Dict: def perform_simple_revision_comparison(db: Session, job_no: str, parent_file_id: int, new_materials: List[Dict]) -> Dict:
""" """
간단한 리비전 비교 로직 (purchase_confirmed 기반) 개선된 리비전 비교 로직
요구사항:
1. 구매확정된 자재는 완전히 제외 (수량 변경 여부와 무관)
2. 삭제된 항목은 별도 리스트로 반환
3. 추가된 항목만 분류 대상
Args: Args:
db: 데이터베이스 세션 db: 데이터베이스 세션
job_no: 프로젝트 번호 job_no: 프로젝트 번호
parent_file_id: 이전 파일 ID parent_file_id: 이전 파일 ID
new_materials: 신규 자재 목록 new_materials: 신규 자재 목록
Returns: Returns:
비교 결과 비교 결과 {
has_purchased_materials: bool,
purchased_count: int,
unpurchased_count: int,
new_count: int,
removed_count: int,
materials_to_classify: List[Dict], # 신규 자재만
removed_materials: List[Dict], # 삭제된 자재
total_previous: int,
total_new: int
}
""" """
try: try:
# 1. 이전 파일의 구매확정된 자재 조회 # 1. 이전 파일의 모든 자재 조회
previous_materials_query = text(""" previous_materials_query = text("""
SELECT original_description, classified_category, size_spec, material_grade, SELECT original_description, classified_category, size_spec, material_grade,
main_nom, red_nom, drawing_name, line_no, main_nom, red_nom, drawing_name, line_no,
COALESCE(total_quantity, quantity, 0) as quantity, unit, COALESCE(total_quantity, quantity, 0) as quantity, unit,
purchase_confirmed purchase_confirmed
FROM materials FROM materials
WHERE file_id = :parent_file_id WHERE file_id = :parent_file_id
ORDER BY id ORDER BY id
""") """)
previous_result = db.execute(previous_materials_query, {"parent_file_id": parent_file_id}) previous_result = db.execute(previous_materials_query, {"parent_file_id": parent_file_id})
previous_materials = previous_result.fetchall() previous_materials = previous_result.fetchall()
if not previous_materials: if not previous_materials:
print("📝 이전 자료가 없음 - 전체 자재 분류")
return { return {
"has_purchased_materials": False, "has_purchased_materials": False,
"message": "이전 자료가 없습니다." "message": "이전 자료가 없습니다.",
"materials_to_classify": new_materials,
"new_count": len(new_materials),
"removed_materials": [],
"removed_count": 0
} }
# 2. 이전 자재를 키별로 그룹화 (구매확정 상태 포함) # 2. 이전 자재를 키별로 그룹화
previous_dict = {} previous_dict = {}
purchased_count = 0 purchased_dict = {} # 구매확정된 자재 별도 관리
unpurchased_count = 0 unpurchased_dict = {} # 미구매 자재 별도 관리
for material in previous_materials: for material in previous_materials:
# 자재 식별 키 생성 (description + size만 사용 - 도면 변경 시에도 매칭 가능) # 자재 식별 키 생성 (description + size)
key = f"{material.original_description.strip().upper()}|{material.size_spec or ''}" key = f"{material.original_description.strip().upper()}|{material.size_spec or ''}"
if key in previous_dict: material_data = {
# 동일 자재가 있으면 수량 합산
previous_dict[key]["quantity"] += float(material.quantity or 0)
else:
previous_dict[key] = {
"original_description": material.original_description, "original_description": material.original_description,
"classified_category": material.classified_category, "classified_category": material.classified_category,
"size_spec": material.size_spec, "size_spec": material.size_spec,
@@ -4083,93 +4113,122 @@ def perform_simple_revision_comparison(db: Session, job_no: str, parent_file_id:
"unit": material.unit, "unit": material.unit,
"purchase_confirmed": material.purchase_confirmed "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: if key in previous_dict:
previous_material = previous_dict[key] # 동일 자재가 있으면 수량 합산
matched_count += 1 previous_dict[key]["quantity"] += float(material.quantity or 0)
# 구매확정된 자재 매칭 로그
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: else:
# 완전히 새로운 자재 previous_dict[key] = material_data
# 구매확정 여부에 따라 분류
if material.purchase_confirmed:
purchased_dict[key] = previous_dict[key]
else:
unpurchased_dict[key] = previous_dict[key]
# 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 = [] # 분류가 필요한 신규 자재만
removed_materials = [] # 삭제된 자재 (이전에는 있었지만 신규에는 없는)
new_count = 0
excluded_purchased_count = 0
print(f"\n{'='*60}")
print(f"🔍 리비전 비교 시작")
print(f"{'='*60}")
print(f"📊 이전 자재: {len(previous_dict)}개 (구매확정: {len(purchased_dict)}개, 미구매: {len(unpurchased_dict)}개)")
print(f"📊 신규 자재: {len(new_dict)}")
# 4-1. 신규 자재 확인 (구매확정된 자재는 제외)
for key, new_material in new_dict.items():
if key in purchased_dict:
# ✅ 구매확정된 자재는 완전히 제외
excluded_purchased_count += 1
print(f"🚫 구매확정 자재 제외: {new_material.get('original_description', '')[:50]}...")
elif key in unpurchased_dict:
# 미구매 자재인데 새 리비전에도 있음
previous_material = unpurchased_dict[key]
# 🔧 PIPE 특별 처리: 6,000mm(1본) 단위로 비교
if previous_material.get("classified_category") == "PIPE":
import math
# 이전 수량으로 필요한 본수 계산
prev_qty = previous_material.get("quantity", 0)
new_qty = new_material.get("quantity", 0)
prev_pipes_needed = math.ceil(prev_qty / 6000)
new_pipes_needed = math.ceil(new_qty / 6000)
if prev_pipes_needed != new_pipes_needed:
# 필요한 본수가 변경됨 - 분류 필요
print(f"🔧 PIPE 본수 변경: {new_material.get('original_description', '')[:40]}... "
f"{prev_qty}mm({prev_pipes_needed}본) → {new_qty}mm({new_pipes_needed}본)")
materials_to_classify.append(new_material)
new_count += 1
else:
# 필요한 본수 동일 - 기존 분류 유지
print(f"♻️ PIPE 본수 유지: {new_material.get('original_description', '')[:40]}... "
f"{prev_qty}mm → {new_qty}mm ({new_pipes_needed}본)")
else:
# 기타 자재: 기존 분류 유지
print(f"♻️ 기존 미구매 자재 유지: {new_material.get('original_description', '')[:50]}...")
else:
# ✅ 완전히 새로운 자재 - 분류 필요
print(f" 신규 자재: {new_material.get('original_description', '')[:50]}...") print(f" 신규 자재: {new_material.get('original_description', '')[:50]}...")
materials_to_classify.append(new_material) materials_to_classify.append(new_material)
new_count += 1 new_count += 1
print(f"🔍 [DEBUG] 매칭된 자재: {matched_count}개, 신규: {new_count}개, 변경: {changed_count}") # 4-2. 삭제된 자재 확인 (미구매 자재 중에서만)
for key, previous_material in unpurchased_dict.items():
has_purchased = any(mat["purchase_confirmed"] for mat in previous_dict.values()) if key not in new_dict:
# ✅ 이전에는 있었지만 새 리비전에는 없음
removed_materials.append(previous_material)
print(f" 삭제된 자재: {previous_material.get('original_description', '')[:50]}...")
print(f"\n{'='*60}")
print(f"📊 비교 결과")
print(f"{'='*60}")
print(f"✅ 신규 자재: {new_count}개 (분류 필요)")
print(f" 삭제 자재: {len(removed_materials)}")
print(f"🚫 구매확정 제외: {excluded_purchased_count}")
print(f"{'='*60}\n")
return { return {
"has_purchased_materials": has_purchased, "has_purchased_materials": len(purchased_dict) > 0,
"purchased_count": len([m for m in previous_dict.values() if m["purchase_confirmed"]]), "purchased_count": len(purchased_dict),
"unpurchased_count": len([m for m in previous_dict.values() if not m["purchase_confirmed"]]), "unpurchased_count": len(unpurchased_dict),
"new_count": new_count, "new_count": new_count,
"changed_count": changed_count, "removed_count": len(removed_materials),
"materials_to_classify": materials_to_classify, "excluded_purchased_count": excluded_purchased_count,
"materials_to_classify": materials_to_classify, # 신규 자재만
"removed_materials": removed_materials, # 삭제된 자재
"total_previous": len(previous_dict), "total_previous": len(previous_dict),
"total_new": len(new_dict) "total_new": len(new_dict)
} }
except Exception as e: except Exception as e:
print(f" 간단한 리비전 비교 실패: {str(e)}") print(f"❌ 리비전 비교 실패: {str(e)}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return { return {
"has_purchased_materials": False, "has_purchased_materials": False,
"message": f"비교 실패: {str(e)}" "message": f"비교 실패: {str(e)}",
"materials_to_classify": new_materials,
"removed_materials": [],
"new_count": len(new_materials),
"removed_count": 0
} }

View File

@@ -26,13 +26,13 @@ services:
# 개발 환경에서는 모든 포트를 외부에 노출 # 개발 환경에서는 모든 포트를 외부에 노출
postgres: postgres:
ports: ports:
- "5432:5432" - "${POSTGRES_PORT:-15432}:5432"
redis: redis:
ports: ports:
- "6379:6379" - "${REDIS_PORT:-16379}:6379"
pgadmin: pgadmin:
ports: ports:
- "5050:80" - "${PGADMIN_PORT:-15050}:80"