feat: 자재 관리 페이지 대규모 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 파이프 수량 계산 로직 수정 (단관 개수가 아닌 실제 길이 기반 계산) - UI 전면 개편 (DevonThink 스타일의 간결하고 세련된 디자인) - 자재별 그룹핑 로직 개선: * 플랜지: 동일 사양별 그룹핑, WN 스케줄 표시, ORIFICE 풀네임 표시 * 피팅: 상세 타입 표시 (니플 길이, 엘보 각도/연결, 티 타입, 리듀서 타입 등) * 밸브: 동일 사양별 그룹핑, 타입/연결방식/압력 표시 * 볼트: 크기/재질/길이별 그룹핑 (8SET → 개별 집계) * 가스켓: 동일 사양별 그룹핑, 재질/상세내역/두께 분리 표시 * UNKNOWN: 원본 설명 전체 표시, 동일 항목 그룹핑 - 전체 카테고리 버튼 제거 (표시 복잡도 감소) - 카테고리별 동적 컬럼 헤더 및 레이아웃 적용
This commit is contained in:
289
backend/app/services/revision_comparator.py
Normal file
289
backend/app/services/revision_comparator.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
리비전 비교 서비스
|
||||
- 기존 확정 자재와 신규 자재 비교
|
||||
- 변경된 자재만 분류 처리
|
||||
- 리비전 업로드 최적화
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RevisionComparator:
|
||||
"""리비전 비교 및 차이 분석 클래스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_previous_confirmed_materials(self, job_no: str, current_revision: str) -> Optional[Dict]:
|
||||
"""
|
||||
이전 확정된 자재 목록 조회
|
||||
|
||||
Args:
|
||||
job_no: 프로젝트 번호
|
||||
current_revision: 현재 리비전 (예: Rev.1)
|
||||
|
||||
Returns:
|
||||
확정된 자재 정보 딕셔너리 또는 None
|
||||
"""
|
||||
try:
|
||||
# 현재 리비전 번호 추출
|
||||
current_rev_num = self._extract_revision_number(current_revision)
|
||||
|
||||
# 이전 리비전들 중 확정된 것 찾기 (역순으로 검색)
|
||||
for prev_rev_num in range(current_rev_num - 1, -1, -1):
|
||||
prev_revision = f"Rev.{prev_rev_num}"
|
||||
|
||||
# 해당 리비전의 확정 데이터 조회
|
||||
query = text("""
|
||||
SELECT pc.id, pc.revision, pc.confirmed_at, pc.confirmed_by,
|
||||
COUNT(cpi.id) as confirmed_items_count
|
||||
FROM purchase_confirmations pc
|
||||
LEFT JOIN confirmed_purchase_items cpi ON pc.id = cpi.confirmation_id
|
||||
WHERE pc.job_no = :job_no
|
||||
AND pc.revision = :revision
|
||||
AND pc.is_active = TRUE
|
||||
GROUP BY pc.id, pc.revision, pc.confirmed_at, pc.confirmed_by
|
||||
ORDER BY pc.confirmed_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
result = self.db.execute(query, {
|
||||
"job_no": job_no,
|
||||
"revision": prev_revision
|
||||
}).fetchone()
|
||||
|
||||
if result and result.confirmed_items_count > 0:
|
||||
logger.info(f"이전 확정 자료 발견: {job_no} {prev_revision} ({result.confirmed_items_count}개 품목)")
|
||||
|
||||
# 확정된 품목들 상세 조회
|
||||
items_query = text("""
|
||||
SELECT cpi.item_code, cpi.category, cpi.specification,
|
||||
cpi.size, cpi.material, cpi.bom_quantity,
|
||||
cpi.calculated_qty, cpi.unit, cpi.safety_factor
|
||||
FROM confirmed_purchase_items cpi
|
||||
WHERE cpi.confirmation_id = :confirmation_id
|
||||
ORDER BY cpi.category, cpi.specification
|
||||
""")
|
||||
|
||||
items_result = self.db.execute(items_query, {
|
||||
"confirmation_id": result.id
|
||||
}).fetchall()
|
||||
|
||||
return {
|
||||
"confirmation_id": result.id,
|
||||
"revision": result.revision,
|
||||
"confirmed_at": result.confirmed_at,
|
||||
"confirmed_by": result.confirmed_by,
|
||||
"items": [dict(item) for item in items_result],
|
||||
"items_count": len(items_result)
|
||||
}
|
||||
|
||||
logger.info(f"이전 확정 자료 없음: {job_no} (현재: {current_revision})")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"이전 확정 자료 조회 실패: {str(e)}")
|
||||
return None
|
||||
|
||||
def compare_materials(self, previous_confirmed: Dict, new_materials: List[Dict]) -> Dict:
|
||||
"""
|
||||
기존 확정 자재와 신규 자재 비교
|
||||
|
||||
Args:
|
||||
previous_confirmed: 이전 확정 자재 정보
|
||||
new_materials: 신규 업로드된 자재 목록
|
||||
|
||||
Returns:
|
||||
비교 결과 딕셔너리
|
||||
"""
|
||||
try:
|
||||
# 이전 확정 자재를 해시맵으로 변환 (빠른 검색을 위해)
|
||||
confirmed_materials = {}
|
||||
for item in previous_confirmed["items"]:
|
||||
material_hash = self._generate_material_hash(
|
||||
item["specification"],
|
||||
item["size"],
|
||||
item["material"]
|
||||
)
|
||||
confirmed_materials[material_hash] = item
|
||||
|
||||
# 신규 자재 분석
|
||||
unchanged_materials = [] # 변경 없음 (분류 불필요)
|
||||
changed_materials = [] # 변경됨 (재분류 필요)
|
||||
new_materials_list = [] # 신규 추가 (분류 필요)
|
||||
|
||||
for new_material in new_materials:
|
||||
# 자재 해시 생성 (description 기반)
|
||||
description = new_material.get("description", "")
|
||||
size = self._extract_size_from_description(description)
|
||||
material = self._extract_material_from_description(description)
|
||||
|
||||
material_hash = self._generate_material_hash(description, size, material)
|
||||
|
||||
if material_hash in confirmed_materials:
|
||||
confirmed_item = confirmed_materials[material_hash]
|
||||
|
||||
# 수량 비교
|
||||
new_qty = float(new_material.get("quantity", 0))
|
||||
confirmed_qty = float(confirmed_item["bom_quantity"])
|
||||
|
||||
if abs(new_qty - confirmed_qty) > 0.001: # 수량 변경
|
||||
changed_materials.append({
|
||||
**new_material,
|
||||
"change_type": "QUANTITY_CHANGED",
|
||||
"previous_quantity": confirmed_qty,
|
||||
"previous_item": confirmed_item
|
||||
})
|
||||
else:
|
||||
# 수량 동일 - 기존 분류 결과 재사용
|
||||
unchanged_materials.append({
|
||||
**new_material,
|
||||
"reuse_classification": True,
|
||||
"previous_item": confirmed_item
|
||||
})
|
||||
else:
|
||||
# 신규 자재
|
||||
new_materials_list.append({
|
||||
**new_material,
|
||||
"change_type": "NEW_MATERIAL"
|
||||
})
|
||||
|
||||
# 삭제된 자재 찾기 (이전에는 있었지만 현재는 없는 것)
|
||||
new_material_hashes = set()
|
||||
for material in new_materials:
|
||||
description = material.get("description", "")
|
||||
size = self._extract_size_from_description(description)
|
||||
material_grade = self._extract_material_from_description(description)
|
||||
hash_key = self._generate_material_hash(description, size, material_grade)
|
||||
new_material_hashes.add(hash_key)
|
||||
|
||||
removed_materials = []
|
||||
for hash_key, confirmed_item in confirmed_materials.items():
|
||||
if hash_key not in new_material_hashes:
|
||||
removed_materials.append({
|
||||
"change_type": "REMOVED",
|
||||
"previous_item": confirmed_item
|
||||
})
|
||||
|
||||
comparison_result = {
|
||||
"has_previous_confirmation": True,
|
||||
"previous_revision": previous_confirmed["revision"],
|
||||
"previous_confirmed_at": previous_confirmed["confirmed_at"],
|
||||
"unchanged_count": len(unchanged_materials),
|
||||
"changed_count": len(changed_materials),
|
||||
"new_count": len(new_materials_list),
|
||||
"removed_count": len(removed_materials),
|
||||
"total_materials": len(new_materials),
|
||||
"classification_needed": len(changed_materials) + len(new_materials_list),
|
||||
"unchanged_materials": unchanged_materials,
|
||||
"changed_materials": changed_materials,
|
||||
"new_materials": new_materials_list,
|
||||
"removed_materials": removed_materials
|
||||
}
|
||||
|
||||
logger.info(f"리비전 비교 완료: 변경없음 {len(unchanged_materials)}, "
|
||||
f"변경됨 {len(changed_materials)}, 신규 {len(new_materials_list)}, "
|
||||
f"삭제됨 {len(removed_materials)}")
|
||||
|
||||
return comparison_result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"자재 비교 실패: {str(e)}")
|
||||
raise
|
||||
|
||||
def _extract_revision_number(self, revision: str) -> int:
|
||||
"""리비전 문자열에서 숫자 추출 (Rev.1 → 1)"""
|
||||
try:
|
||||
if revision.startswith("Rev."):
|
||||
return int(revision.replace("Rev.", ""))
|
||||
return 0
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
def _generate_material_hash(self, description: str, size: str, material: str) -> str:
|
||||
"""자재 고유성 판단을 위한 해시 생성"""
|
||||
# RULES.md의 코딩 컨벤션 준수
|
||||
hash_input = f"{description}|{size}|{material}".lower().strip()
|
||||
return hashlib.md5(hash_input.encode()).hexdigest()
|
||||
|
||||
def _extract_size_from_description(self, description: str) -> str:
|
||||
"""자재 설명에서 사이즈 정보 추출"""
|
||||
# 간단한 사이즈 패턴 추출 (실제로는 더 정교한 로직 필요)
|
||||
import re
|
||||
size_patterns = [
|
||||
r'(\d+(?:\.\d+)?)\s*(?:mm|MM|인치|inch|")',
|
||||
r'(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)',
|
||||
r'DN\s*(\d+)',
|
||||
r'(\d+)\s*A'
|
||||
]
|
||||
|
||||
for pattern in size_patterns:
|
||||
match = re.search(pattern, description, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(0)
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_material_from_description(self, description: str) -> str:
|
||||
"""자재 설명에서 재질 정보 추출"""
|
||||
# 일반적인 재질 패턴
|
||||
materials = ["SS304", "SS316", "SS316L", "A105", "WCB", "CF8M", "CF8", "CS"]
|
||||
|
||||
description_upper = description.upper()
|
||||
for material in materials:
|
||||
if material in description_upper:
|
||||
return material
|
||||
|
||||
return ""
|
||||
|
||||
def get_revision_comparison(db: Session, job_no: str, current_revision: str,
|
||||
new_materials: List[Dict]) -> Dict:
|
||||
"""
|
||||
리비전 비교 수행 (편의 함수)
|
||||
|
||||
Args:
|
||||
db: 데이터베이스 세션
|
||||
job_no: 프로젝트 번호
|
||||
current_revision: 현재 리비전
|
||||
new_materials: 신규 자재 목록
|
||||
|
||||
Returns:
|
||||
비교 결과 또는 전체 분류 필요 정보
|
||||
"""
|
||||
comparator = RevisionComparator(db)
|
||||
|
||||
# 이전 확정 자료 조회
|
||||
previous_confirmed = comparator.get_previous_confirmed_materials(job_no, current_revision)
|
||||
|
||||
if previous_confirmed is None:
|
||||
# 이전 확정 자료가 없으면 전체 분류 필요
|
||||
return {
|
||||
"has_previous_confirmation": False,
|
||||
"classification_needed": len(new_materials),
|
||||
"all_materials_need_classification": True,
|
||||
"materials_to_classify": new_materials,
|
||||
"message": "이전 확정 자료가 없어 전체 자재를 분류합니다."
|
||||
}
|
||||
|
||||
# 이전 확정 자료가 있으면 비교 수행
|
||||
return comparator.compare_materials(previous_confirmed, new_materials)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user