feat: 자재 관리 페이지 대규모 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- 파이프 수량 계산 로직 수정 (단관 개수가 아닌 실제 길이 기반 계산)
- UI 전면 개편 (DevonThink 스타일의 간결하고 세련된 디자인)
- 자재별 그룹핑 로직 개선:
  * 플랜지: 동일 사양별 그룹핑, WN 스케줄 표시, ORIFICE 풀네임 표시
  * 피팅: 상세 타입 표시 (니플 길이, 엘보 각도/연결, 티 타입, 리듀서 타입 등)
  * 밸브: 동일 사양별 그룹핑, 타입/연결방식/압력 표시
  * 볼트: 크기/재질/길이별 그룹핑 (8SET → 개별 집계)
  * 가스켓: 동일 사양별 그룹핑, 재질/상세내역/두께 분리 표시
  * UNKNOWN: 원본 설명 전체 표시, 동일 항목 그룹핑
- 전체 카테고리 버튼 제거 (표시 복잡도 감소)
- 카테고리별 동적 컬럼 헤더 및 레이아웃 적용
This commit is contained in:
Hyungi Ahn
2025-09-09 09:24:45 +09:00
parent 4f8e395f87
commit 83b90ef05c
101 changed files with 10841 additions and 4813 deletions

View File

@@ -0,0 +1,362 @@
"""
사용자 활동 로그 서비스
모든 업무 활동을 추적하고 기록하는 서비스
"""
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import Optional, Dict, Any
from fastapi import Request
import json
from datetime import datetime
from ..utils.logger import get_logger
logger = get_logger(__name__)
class ActivityLogger:
"""사용자 활동 로그 관리 클래스"""
def __init__(self, db: Session):
self.db = db
def log_activity(
self,
username: str,
activity_type: str,
activity_description: str,
target_id: Optional[int] = None,
target_type: Optional[str] = None,
user_id: Optional[int] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
) -> int:
"""
사용자 활동 로그 기록
Args:
username: 사용자명 (필수)
activity_type: 활동 유형 (FILE_UPLOAD, PROJECT_CREATE 등)
activity_description: 활동 설명
target_id: 대상 ID (파일, 프로젝트 등)
target_type: 대상 유형 (FILE, PROJECT 등)
user_id: 사용자 ID
ip_address: IP 주소
user_agent: 브라우저 정보
metadata: 추가 메타데이터
Returns:
int: 생성된 로그 ID
"""
try:
insert_query = text("""
INSERT INTO user_activity_logs (
user_id, username, activity_type, activity_description,
target_id, target_type, ip_address, user_agent, metadata
) VALUES (
:user_id, :username, :activity_type, :activity_description,
:target_id, :target_type, :ip_address, :user_agent, :metadata
) RETURNING id
""")
result = self.db.execute(insert_query, {
'user_id': user_id,
'username': username,
'activity_type': activity_type,
'activity_description': activity_description,
'target_id': target_id,
'target_type': target_type,
'ip_address': ip_address,
'user_agent': user_agent,
'metadata': json.dumps(metadata) if metadata else None
})
log_id = result.fetchone()[0]
self.db.commit()
logger.info(f"Activity logged: {username} - {activity_type} - {activity_description}")
return log_id
except Exception as e:
logger.error(f"Failed to log activity: {str(e)}")
self.db.rollback()
raise
def log_file_upload(
self,
username: str,
file_id: int,
filename: str,
file_size: int,
job_no: str,
revision: str,
user_id: Optional[int] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None
) -> int:
"""파일 업로드 활동 로그"""
metadata = {
'filename': filename,
'file_size': file_size,
'job_no': job_no,
'revision': revision,
'upload_time': datetime.now().isoformat()
}
return self.log_activity(
username=username,
activity_type='FILE_UPLOAD',
activity_description=f'BOM 파일 업로드: {filename} (Job: {job_no}, Rev: {revision})',
target_id=file_id,
target_type='FILE',
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
metadata=metadata
)
def log_project_create(
self,
username: str,
project_id: int,
project_name: str,
job_no: str,
user_id: Optional[int] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None
) -> int:
"""프로젝트 생성 활동 로그"""
metadata = {
'project_name': project_name,
'job_no': job_no,
'create_time': datetime.now().isoformat()
}
return self.log_activity(
username=username,
activity_type='PROJECT_CREATE',
activity_description=f'프로젝트 생성: {project_name} ({job_no})',
target_id=project_id,
target_type='PROJECT',
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
metadata=metadata
)
def log_material_classify(
self,
username: str,
file_id: int,
classified_count: int,
job_no: str,
revision: str,
user_id: Optional[int] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None
) -> int:
"""자재 분류 활동 로그"""
metadata = {
'classified_count': classified_count,
'job_no': job_no,
'revision': revision,
'classify_time': datetime.now().isoformat()
}
return self.log_activity(
username=username,
activity_type='MATERIAL_CLASSIFY',
activity_description=f'자재 분류 완료: {classified_count}개 자재 (Job: {job_no}, Rev: {revision})',
target_id=file_id,
target_type='FILE',
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
metadata=metadata
)
def log_purchase_confirm(
self,
username: str,
job_no: str,
revision: str,
confirmed_count: int,
total_amount: Optional[float] = None,
user_id: Optional[int] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None
) -> int:
"""구매 확정 활동 로그"""
metadata = {
'job_no': job_no,
'revision': revision,
'confirmed_count': confirmed_count,
'total_amount': total_amount,
'confirm_time': datetime.now().isoformat()
}
return self.log_activity(
username=username,
activity_type='PURCHASE_CONFIRM',
activity_description=f'구매 확정: {confirmed_count}개 품목 (Job: {job_no}, Rev: {revision})',
target_id=None, # 구매는 특정 ID가 없음
target_type='PURCHASE',
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
metadata=metadata
)
def get_user_activities(
self,
username: str,
activity_type: Optional[str] = None,
limit: int = 50,
offset: int = 0
) -> list:
"""사용자 활동 이력 조회"""
try:
where_clause = "WHERE username = :username"
params = {'username': username}
if activity_type:
where_clause += " AND activity_type = :activity_type"
params['activity_type'] = activity_type
query = text(f"""
SELECT
id, activity_type, activity_description,
target_id, target_type, metadata, created_at
FROM user_activity_logs
{where_clause}
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset
""")
params.update({'limit': limit, 'offset': offset})
result = self.db.execute(query, params)
activities = []
for row in result.fetchall():
activity = {
'id': row[0],
'activity_type': row[1],
'activity_description': row[2],
'target_id': row[3],
'target_type': row[4],
'metadata': json.loads(row[5]) if row[5] else {},
'created_at': row[6].isoformat() if row[6] else None
}
activities.append(activity)
return activities
except Exception as e:
logger.error(f"Failed to get user activities: {str(e)}")
return []
def get_recent_activities(
self,
days: int = 7,
limit: int = 100
) -> list:
"""최근 활동 조회 (전체 사용자)"""
try:
query = text("""
SELECT
username, activity_type, activity_description,
target_id, target_type, created_at
FROM user_activity_logs
WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '%s days'
ORDER BY created_at DESC
LIMIT :limit
""" % days)
result = self.db.execute(query, {'limit': limit})
activities = []
for row in result.fetchall():
activity = {
'username': row[0],
'activity_type': row[1],
'activity_description': row[2],
'target_id': row[3],
'target_type': row[4],
'created_at': row[5].isoformat() if row[5] else None
}
activities.append(activity)
return activities
except Exception as e:
logger.error(f"Failed to get recent activities: {str(e)}")
return []
def get_client_info(request: Request) -> tuple:
"""
요청에서 클라이언트 정보 추출
Args:
request: FastAPI Request 객체
Returns:
tuple: (ip_address, user_agent)
"""
# IP 주소 추출 (프록시 고려)
ip_address = (
request.headers.get('x-forwarded-for', '').split(',')[0].strip() or
request.headers.get('x-real-ip', '') or
request.client.host if request.client else 'unknown'
)
# User-Agent 추출
user_agent = request.headers.get('user-agent', 'unknown')
return ip_address, user_agent
def log_activity_from_request(
db: Session,
request: Request,
username: str,
activity_type: str,
activity_description: str,
target_id: Optional[int] = None,
target_type: Optional[str] = None,
user_id: Optional[int] = None,
metadata: Optional[Dict[str, Any]] = None
) -> int:
"""
요청 정보를 포함한 활동 로그 기록 (편의 함수)
Args:
db: 데이터베이스 세션
request: FastAPI Request 객체
username: 사용자명
activity_type: 활동 유형
activity_description: 활동 설명
target_id: 대상 ID
target_type: 대상 유형
user_id: 사용자 ID
metadata: 추가 메타데이터
Returns:
int: 생성된 로그 ID
"""
ip_address, user_agent = get_client_info(request)
activity_logger = ActivityLogger(db)
return activity_logger.log_activity(
username=username,
activity_type=activity_type,
activity_description=activity_description,
target_id=target_id,
target_type=target_type,
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
metadata=metadata
)

View File

@@ -29,13 +29,13 @@ PIPE_MANUFACTURING = {
# ========== PIPE 끝 가공별 분류 ==========
PIPE_END_PREP = {
"BOTH_ENDS_BEVELED": {
"codes": ["BOE", "BOTH END", "BOTH BEVELED", "양쪽개선"],
"codes": ["BBE", "BOE", "BOTH END", "BOTH BEVELED", "양쪽개선"],
"cutting_note": "양쪽 개선",
"machining_required": True,
"confidence": 0.95
},
"ONE_END_BEVELED": {
"codes": ["BE", "BEV", "PBE", "PIPE BEVELED END"],
"codes": ["BE", "BEV", "PBE", "PIPE BEVELED END", "POE"],
"cutting_note": "한쪽 개선",
"machining_required": True,
"confidence": 0.95
@@ -45,9 +45,85 @@ PIPE_END_PREP = {
"cutting_note": "무 개선",
"machining_required": False,
"confidence": 0.95
},
"THREADED": {
"codes": ["TOE", "THE", "THREADED", "나사", "스레드"],
"cutting_note": "나사 가공",
"machining_required": True,
"confidence": 0.90
}
}
# ========== 구매용 파이프 분류 (끝단 가공 제외) ==========
def get_purchase_pipe_description(description: str) -> str:
"""구매용 파이프 설명 - 끝단 가공 정보 제거"""
# 모든 끝단 가공 코드들을 수집
end_prep_codes = []
for prep_data in PIPE_END_PREP.values():
end_prep_codes.extend(prep_data["codes"])
# 설명에서 끝단 가공 코드 제거
clean_description = description.upper()
# 끝단 가공 코드들을 길이 순으로 정렬 (긴 것부터 처리)
end_prep_codes.sort(key=len, reverse=True)
for code in end_prep_codes:
# 단어 경계를 고려하여 제거 (부분 매칭 방지)
pattern = r'\b' + re.escape(code) + r'\b'
clean_description = re.sub(pattern, '', clean_description, flags=re.IGNORECASE)
# 끝단 가공 관련 패턴들 추가 제거
# BOE-POE, POE-TOE 같은 조합 패턴들
end_prep_patterns = [
r'\b[A-Z]{2,3}E-[A-Z]{2,3}E\b', # BOE-POE, POE-TOE 등
r'\b[A-Z]{2,3}E-[A-Z]{2,3}\b', # BOE-TO, POE-TO 등
r'\b[A-Z]{2,3}-[A-Z]{2,3}E\b', # BO-POE, PO-TOE 등
r'\b[A-Z]{2,3}-[A-Z]{2,3}\b', # BO-PO, PO-TO 등
]
for pattern in end_prep_patterns:
clean_description = re.sub(pattern, '', clean_description, flags=re.IGNORECASE)
# 남은 하이픈과 공백 정리
clean_description = re.sub(r'\s*-\s*', ' ', clean_description) # 하이픈 제거
clean_description = re.sub(r'\s+', ' ', clean_description).strip() # 연속 공백 정리
return clean_description
def extract_end_preparation_info(description: str) -> Dict:
"""파이프 설명에서 끝단 가공 정보 추출"""
desc_upper = description.upper()
# 끝단 가공 코드 찾기
for prep_type, prep_data in PIPE_END_PREP.items():
for code in prep_data["codes"]:
if code in desc_upper:
return {
"end_preparation_type": prep_type,
"end_preparation_code": code,
"machining_required": prep_data["machining_required"],
"cutting_note": prep_data["cutting_note"],
"confidence": prep_data["confidence"],
"matched_pattern": code,
"original_description": description,
"clean_description": get_purchase_pipe_description(description)
}
# 기본값: PBE (양쪽 무개선)
return {
"end_preparation_type": "NO_BEVEL", # PBE로 매핑될 예정
"end_preparation_code": "PBE",
"machining_required": False,
"cutting_note": "양쪽 무개선 (기본값)",
"confidence": 0.5,
"matched_pattern": "DEFAULT",
"original_description": description,
"clean_description": get_purchase_pipe_description(description)
}
# ========== PIPE 스케줄별 분류 ==========
PIPE_SCHEDULE = {
"patterns": [
@@ -62,6 +138,23 @@ PIPE_SCHEDULE = {
]
}
def classify_pipe_for_purchase(dat_file: str, description: str, main_nom: str,
length: Optional[float] = None) -> Dict:
"""구매용 파이프 분류 - 끝단 가공 정보 제외"""
# 끝단 가공 정보 제거한 설명으로 분류
clean_description = get_purchase_pipe_description(description)
# 기본 파이프 분류 수행
result = classify_pipe(dat_file, clean_description, main_nom, length)
# 구매용임을 표시
result["purchase_classification"] = True
result["original_description"] = description
result["clean_description"] = clean_description
return result
def classify_pipe(dat_file: str, description: str, main_nom: str,
length: Optional[float] = None) -> Dict:
"""

View 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)