From a6868b129e29691c84bf5e24286fa0b4e5c0b813 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Sat, 6 Dec 2025 09:05:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A6=AC=EB=B9=84=EC=A0=84=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주요 개선사항: 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 --- .env.example | 26 ++++ backend/app/routers/files.py | 255 +++++++++++++++++++++-------------- docker-compose.override.yml | 6 +- 3 files changed, 186 insertions(+), 101 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..16c550e --- /dev/null +++ b/.env.example @@ -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 diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index d43f6d3..85dd770 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -1730,11 +1730,25 @@ async def upload_file( "uploaded_by": username, "parsed_count": parsed_count } - + # 누락된 도면 정보 추가 if 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 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: """ - 간단한 리비전 비교 로직 (purchase_confirmed 기반) - + 개선된 리비전 비교 로직 + + 요구사항: + 1. 구매확정된 자재는 완전히 제외 (수량 변경 여부와 무관) + 2. 삭제된 항목은 별도 리스트로 반환 + 3. 추가된 항목만 분류 대상 + Args: db: 데이터베이스 세션 job_no: 프로젝트 번호 parent_file_id: 이전 파일 ID new_materials: 신규 자재 목록 - + 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: - # 1. 이전 파일의 구매확정된 자재 조회 + # 1. 이전 파일의 모든 자재 조회 previous_materials_query = text(""" 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, purchase_confirmed - FROM materials + 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: + print("📝 이전 자료가 없음 - 전체 자재 분류") return { "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 = {} - purchased_count = 0 - unpurchased_count = 0 - + purchased_dict = {} # 구매확정된 자재 별도 관리 + unpurchased_dict = {} # 미구매 자재 별도 관리 + for material in previous_materials: - # 자재 식별 키 생성 (description + size만 사용 - 도면 변경 시에도 매칭 가능) + # 자재 식별 키 생성 (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] = { + + material_data = { "original_description": material.original_description, "classified_category": material.classified_category, "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, "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: 미구매 동일 자재는 분류 불필요 (기존 분류 유지) + # 동일 자재가 있으면 수량 합산 + previous_dict[key]["quantity"] += float(material.quantity or 0) 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]}...") 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()) - + + # 4-2. 삭제된 자재 확인 (미구매 자재 중에서만) + for key, previous_material in unpurchased_dict.items(): + 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 { - "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"]]), + "has_purchased_materials": len(purchased_dict) > 0, + "purchased_count": len(purchased_dict), + "unpurchased_count": len(unpurchased_dict), "new_count": new_count, - "changed_count": changed_count, - "materials_to_classify": materials_to_classify, + "removed_count": len(removed_materials), + "excluded_purchased_count": excluded_purchased_count, + "materials_to_classify": materials_to_classify, # 신규 자재만 + "removed_materials": removed_materials, # 삭제된 자재 "total_previous": len(previous_dict), "total_new": len(new_dict) } - + except Exception as e: - print(f"❌ 간단한 리비전 비교 실패: {str(e)}") + print(f"❌ 리비전 비교 실패: {str(e)}") import traceback traceback.print_exc() return { "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 } \ No newline at end of file diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 46f2d5e..fd617b5 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -26,13 +26,13 @@ services: # 개발 환경에서는 모든 포트를 외부에 노출 postgres: ports: - - "5432:5432" + - "${POSTGRES_PORT:-15432}:5432" redis: ports: - - "6379:6379" + - "${REDIS_PORT:-16379}:6379" pgadmin: ports: - - "5050:80" + - "${PGADMIN_PORT:-15050}:80"