Compare commits

..

88 Commits

Author SHA1 Message Date
Hyungi Ahn
ee99586a2f refactor: centralize classifier constants and simplify logic 2026-01-09 14:07:45 +09:00
Hyungi Ahn
f16bc662ad feat: enhance revision logic with fuzzy matching, dynamic material loading, and schema automation
- Improved RevisionComparator with fuzzy matching (RapidFuzz) and dynamic DB material loading
- Enhanced regex patterns for better size/material extraction
- Initialized Alembic for schema migrations and created baseline migration
- Added entrypoint.sh for automated migrations in Docker
- Fixed SyntaxError in fitting_classifier.py
- Updated test suite with new functionality tests
2026-01-09 09:36:40 +09:00
Hyungi Ahn
afea8428b2 엑셀 파싱 이원화(표준/인벤터) 및 자재 분류기(Plate, H-Beam, Swagelok) 개선 2026-01-08 11:14:25 +09:00
Hyungi Ahn
6ad1ef7aad fix: 리비전 업로드 시 구매확정 상태 상속 문제 해결
문제점:
- 리비전 파일 업로드 시 모든 자재가 purchase_confirmed=false로 새로 저장됨
- 이전 리비전에서 구매확정한 자재가 다시 미구매 상태로 돌아가는 버그

해결방법:
1. perform_simple_revision_comparison 함수에서 구매확정 정보 반환
   - purchase_confirmed, purchase_confirmed_at, purchase_confirmed_by
   - purchased_materials_map 딕셔너리로 반환

2. materials 테이블 insert 시 구매확정 상태 상속
   - 자재 식별 키로 purchased_materials_map 확인
   - 매칭되면 구매확정 상태와 메타데이터 상속
   - 로그 출력: "🔥 구매확정 상태 상속: ..."

3. 디버깅 정보 개선
   - 리비전 비교 결과에 excluded_purchased_count 추가
   - 첫 번째 자재 저장 시 purchase_confirmed 상태 출력

동작 방식:
1. Rev.1에서 자재 A를 구매확정 → purchase_confirmed=true
2. Rev.2 업로드 시 자재 A가 포함되어 있으면
3. 리비전 비교에서 자재 A를 purchased_materials_map에 저장
4. 새 파일의 자재 A 저장 시 구매확정 상태 상속
5. Rev.2의 자재 A도 purchase_confirmed=true 유지

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 09:13:08 +09:00
Hyungi Ahn
a6868b129e 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>
2025-12-06 09:05:48 +09:00
Hyungi Ahn
17843e285f 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>
2025-12-06 07:36:44 +09:00
Hyungi Ahn
c258303bb7 🎯 UI/UX 개선 및 안정성 강화
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
 주요 개선사항:
- Rev.0일 때 Revisions 카운트 0으로 정확히 표시
- 업로드 후 파일 목록 자동 새로고침
- 대시보드 계정 메뉴 zIndex 문제 해결
- 구매관리 페이지 500 오류 해결 및 대시보드 리다이렉트
- 구매신청 관리 페이지 버튼 텍스트 개선

🔧 기술적 수정:
- purchase_requests API SQL 쿼리 테이블 구조에 맞게 수정
- UserMenu 드롭다운 zIndex 1050으로 상향 조정
- 프론트엔드 완전 재빌드로 최신 변경사항 반영
- 완전한 자동 마이그레이션 시스템 구축 (43개 테이블 스키마 동기화)

🚀 다음 단계: 리비전 기능 재도입 준비 완료
2025-10-21 15:44:43 +09:00
hyungi
edfe1bdf78 feat: 완전한 데이터베이스 스키마 정리 및 테스트 서버 안정화
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 43개 테이블로 구성된 완전한 BOM 관리 시스템
- 카테고리별 상세 테이블 (9개): pipe, fitting, flange, valve, gasket, bolt, support, special, instrument
- 사용자 관리 시스템: users, login_logs, user_sessions, user_activity_logs
- 구매 관리 시스템: purchase_requests, purchase_request_items, purchase_items
- 리비전 관리 시스템: material_revisions_comparison, material_comparison_details
- 튜빙 시스템: tubing_categories, tubing_manufacturers, tubing_products, tubing_specifications
- 표준화/분류 시스템: material_standards, material_categories, material_patterns 등
- 권한 관리 시스템: permissions, role_permissions
- 고급 기능: user_requirements, pipe_end_preparations, material_tubing_mapping
- 성능 최적화: materials 테이블 17개 인덱스, GIN/복합/조건부 인덱스
- 데이터 무결성: 외래키 제약으로 일관성 보장
- 확장성: JSONB 활용한 유연한 메타데이터 저장
2025-10-21 12:33:05 +09:00
hyungi
ab607dfa9a feat: 통합 BOM 관리 시스템 구현
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
🎯 주요 변경사항:
- 통합 BOM 페이지 (UnifiedBOMPage) 신규 개발
- 탭 구조로 업로드 → 파일 관리 → 자재 관리 워크플로우 개선
- 컴포넌트 분리로 스파게티 코드 방지

📤 업로드 탭 (BOMUploadTab):
- 드래그 앤 드롭 파일 업로드
- 파일 검증 및 진행률 표시
- 업로드 완료 후 자동 Files 탭 이동

📊 파일 관리 탭 (BOMFilesTab):
- 프로젝트별 BOM 파일 목록 조회
- 리비전 히스토리 표시
- BOM 선택 후 자동 Materials 탭 이동

📋 자재 관리 탭 (BOMMaterialsTab):
- 기존 BOMManagementPage 래핑
- 선택된 BOM의 자재 분류 및 관리

🔧 백엔드 API 개선:
- /files/project/{project_code} 엔드포인트 추가
- 한글 프로젝트 코드 URL 인코딩 지원
- 프로젝트별 파일 조회 기능 구현

🎨 대시보드 개선:
- 3개 BOM 카드를 1개 통합 카드로 변경
- 기능 미리보기 태그 추가
- 더 직관적인 네비게이션

📁 코드 구조 개선:
- 기존 페이지들을 _deprecated 폴더로 이동
- 탭별 컴포넌트 분리 (components/bom/tabs/)
- PAGES_GUIDE.md 업데이트

 사용자 경험 개선:
- 자연스러운 워크플로우 (업로드 → 선택 → 관리)
- 탭 간 상태 공유 및 자동 네비게이션
- 통합된 인터페이스에서 모든 BOM 작업 처리
2025-10-17 14:44:17 +09:00
hyungi
e0ad21bfad feat: SPECIAL/UNCLASSIFIED 카테고리 추가 및 WELD GAP 자동 제외
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
주요 변경사항:
- SPECIAL 카테고리 추가: 특수 제작 품목 관리 (Type, Drawing, Detail1-4)
- UNCLASSIFIED 카테고리 추가: 미분류 자재 원본 그대로 표시
- UNKNOWN → UNCLASSIFIED 통합: 기존 UNKNOWN 카테고리 제거
- WELD GAP 자동 제외: BOM 업로드 시 WELD GAP 항목 자동 필터링

백엔드:
- integrated_classifier.py: UNKNOWN → UNCLASSIFIED 변경, SPECIAL 우선순위 분류
- files.py: parse_dataframe에서 WELD GAP 필터링, UNKNOWN 참조 제거
- exclude_classifier.py: WELD GAP 제외 로직 유지

프론트엔드:
- SpecialMaterialsView.jsx: 특수 제작 품목 관리 컴포넌트
- UnclassifiedMaterialsView.jsx: 미분류 자재 관리 컴포넌트
- BOMManagementPage.jsx: 새 카테고리 추가 및 라우팅
- excelExport.js: SPECIAL/UNCLASSIFIED 엑셀 내보내기 지원
- 모든 UNKNOWN 참조를 UNCLASSIFIED로 변경

기능 개선:
- 저장 기능: 모든 카테고리에 추가요청사항 저장/편집 기능
- P열 납기일 규칙: 모든 카테고리 엑셀 내보내기 통일
- UI 개선: Detail1-4 컬럼명으로 혼동 방지
- 데이터 정리: 모든 프로젝트 및 BOM 데이터 초기화
2025-10-17 13:48:48 +09:00
hyungi
f336b5a4a8 feat: 모든 카테고리에 추가요청사항 저장 기능 및 엑셀 내보내기 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 모든 BOM 카테고리(Pipe, Fitting, Flange, Gasket, Bolt, Support)에 추가요청사항 저장/편집 기능 추가
- 저장된 데이터의 카테고리 변경 및 페이지 새로고침 시 지속성 보장
- 백엔드 materials 테이블에 brand, user_requirement 컬럼 추가
- 새로운 /materials/{id}/brand, /materials/{id}/user-requirement PATCH API 엔드포인트 추가
- 모든 카테고리에서 Additional Request 컬럼 너비 확장 (UI 겹침 방지)
- GASKET 카테고리 엑셀 내보내기에 누락된 '추가요청사항' 컬럼 추가
- 엑셀 내보내기 시 저장된 추가요청사항이 우선 반영되도록 개선
- P열 납기일 규칙 유지하며 관리항목 개수 조정
2025-10-17 12:54:17 +09:00
hyungi
6b6360ecd5 feat: 서포트 카테고리 전면 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 서포트 카테고리 UI 개선: 좌우 스크롤, 헤더/본문 동기화, 가운데 정렬
- 동일 항목 합산 기능 구현 (Type + Size + Grade 기준)
- 헤더 구조 변경: 압력/스케줄 제거, 구매수량 단일화, User Requirements 추가
- 우레탄 블럭슈 두께 정보(40t, 27t) Material Grade에 포함
- 서포트 수량 계산 수정: 취합된 숫자 그대로 표시 (4의 배수 계산 제거)
- 서포트 분류 로직 개선: CLAMP, U-BOLT, URETHANE BLOCK SHOE 등 정확한 분류
- 백엔드 서포트 분류기에 User Requirements 추출 기능 추가
- 엑셀 내보내기에 서포트 카테고리 처리 로직 추가
2025-10-17 07:59:35 +09:00
hyungi
a27213e0e5 feat: 가스켓 카테고리 개선 및 엑셀 내보내기 최적화
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 가스켓 카테고리 정렬 오류 수정 (FilterableHeader props 추가)
- 가스켓 엑셀 내보내기 개선:
  * 품목명을 BOM 페이지 타입과 동일하게 표시 (SPIRAL WOUND GASKET 등)
  * 재질을 재질1/재질2로 분리 (SS304/GRAPHITE → 재질1: SS304/GRAPHITE, 재질2: /SS304/SS304)
  * originalDescription에서 4개 재질 패턴 우선 추출
  * P열 납기일 규칙 준수
- 프로젝트 비활성화 기능 수정 (localStorage 영구 저장)
- 모든 카테고리 정렬 함수 안전성 강화
2025-10-16 15:51:24 +09:00
hyungi
379af6b1e3 fix: 엑셀 내보내기 구조 개선 - 파이프 카테고리 우선 적용
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 모든 카테고리에서 '단위' 컬럼 제거 (수량만 사용)
- 파이프 카테고리: 납기일이 정확히 P열에 위치하도록 수정
- 파이프 전용 컬럼 구조: 크기, 스케줄, 재질, 제조방법, 사용자요구, 추가요청사항, 관리항목1~4
- createExcelBlob 함수에서 ExcelJS → XLSX 변경으로 오류 해결
- 백엔드 EXCEL_DIR 경로 수정 (exports → uploads/excel_exports)
- BOM에서 생성한 엑셀을 구매관리에서 동일하게 다운로드 가능

배포 버전: index-c08dc565.js
다음 단계: 피팅, 플랜지 카테고리 엑셀 구조 개선
2025-10-16 15:13:42 +09:00
hyungi
a5bfeec9aa feat: 엑셀 다운로드 방식 개선 - BOM에서 생성한 엑셀을 구매관리에서 다운로드
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 파이프, 피팅, 플랜지, 밸브 카테고리에 새로운 엑셀 업로드 로직 적용
- createExcelBlob 함수로 클라이언트에서 엑셀 생성 후 서버 업로드
- /purchase-request/upload-excel API로 엑셀 파일 서버 저장
- 구매관리 페이지에서 원본 엑셀 파일 다운로드 가능
- 가스켓, 볼트, 서포트는 추후 개선 시 적용 예정

배포 버전: index-5e5aa4a4.js
2025-10-16 14:53:22 +09:00
hyungi
c7297c6fb7 feat: 피팅류 엑셀 내보내기 개선 및 프로젝트 비활성화 버그 수정
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
 피팅류 엑셀 내보내기 개선:
- 품목명에 상세 피팅 타입 표시 (SOCK-O-LET, ELBOW 90° LR 등)
- G열부터 압력등급/스케줄/재질/사용자요구/추가요청사항 체계적 배치
- 분류기 추출 요구사항(J열)과 사용자 입력 요구사항(K열) 분리
- P열 납기일 고정 규칙 유지, 관리항목 자동 채움

🐛 프로젝트 비활성화 버그 수정:
- 백엔드: job_no 필드 추가로 프론트엔드 호환성 확보
- 프론트엔드: 안전한 프로젝트 식별자 처리 로직 구현
- 개별 프로젝트 비활성화 시 전체 프로젝트 영향 문제 해결
- 디버깅 로그 추가로 상태 변경 추적 가능

🔧 기타 개선사항:
- BOM 페이지 이모지 제거
- 구매신청 후 자재 비활성화 기능 구현
- 모든 카테고리 뷰에 onPurchasedMaterialsUpdate 콜백 추가
2025-10-16 14:00:44 +09:00
hyungi
22baea38e1 feat: BOM 페이지 개선 및 구매관리 기능 향상
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- BOM 페이지에서 모든 카테고리 이모지 제거 (깔끔한 UI)
- 구매신청된 자재 비활성화 기능 개선
- 구매관리 페이지에서 선택된 프로젝트만 표시하도록 수정
- 파이프 카테고리 Excel 내보내기 개선:
  * 끝단처리, 압력등급 컬럼 제거
  * 사용자요구(분류기 추출) 및 추가요청사항 컬럼 추가
  * 품목명에서 끝단처리 정보 제거
  * 납기일 P열 고정 및 관리항목 자동 채움
- 파이프 분류기에서 사용자 요구사항 추출 기능 추가
- 재질별 스케줄 표시 개선 (SUS: SCH 40S, Carbon: SCH 40)
2025-10-16 13:27:14 +09:00
hyungi
64fd9ad3d2 feat: BOM 관리 시스템 대폭 개선 및 Docker 배포 가이드 추가
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 🎨 UI/UX 개선: 데본씽크 스타일 모던 디자인 적용
- 📁 컴포넌트 구조 개선: 폴더별 체계적 관리 (common/, bom/, materials/)
- 🔧 BOM 관리 페이지 리팩토링: NewMaterialsPage → BOMManagementPage + 카테고리별 컴포넌트 분리
- 💾 구매신청 기능 개선: 선택된 자재 비활성화, 제목 편집, 엑셀 다운로드
- 📊 자재 표시 개선: 타입/서브타입 컬럼 정리, 상세 정보 복원
- 🐛 CSS 빌드 오류 수정: NewMaterialsPage.css 문법 오류 해결
- 📚 문서화: PAGES_GUIDE.md 추가, README에 Docker 캐시 문제 해결 가이드 추가
- 🔄 API 개선: 구매신청 자재 조회, 제목 수정 엔드포인트 추가
2025-10-16 12:45:23 +09:00
hyungi
5aef867110 🔧 구매신청 관리 페이지 수정
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 업로드 당시 분류된 정보를 그대로 표시하도록 수정
- 복잡한 BOM 페이지 스타일 분류 로직 제거
- 간단하고 안정적인 테이블 형태로 자재 목록 표시
- 카테고리별 그룹화 유지하되 에러 방지를 위해 단순화

 해결된 문제:
- 구매신청 페이지에서 몇몇 항목이 깨지던 문제 해결
- 업로드 당시 정보를 그대로 보여주도록 개선
2025-10-16 07:11:50 +09:00
hyungi
ee13e92b61 📊 엑셀 내보내기 개선 완료
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
 개선 사항:
- 품목명에 상세 타입 정보 포함 (빠짐없이)
- 타입 컬럼 제거 (중복 방지)
- 납기일을 항상 P열에 고정
- 남는 공간을 관리항목1부터 채우기

🔧 상세 개선:
- ELBOW: 각도(90°/45°), 반경(LR/SR), 연결방식(SW/BW) 표시
- FLANGE: 풀네임 + 끝단처리 정보 포함
- 모든 카테고리에서 타입 컬럼 제거하고 품목명에 통합
- P열 납기일 고정, 관리항목으로 빈 공간 채우기
2025-10-16 07:08:19 +09:00
hyungi
5e995d1208 🔧 REDUCING FLANGE 분류 로직 강화
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 다양한 REDUCING FLANGE 패턴 대응 (REDUCING FLANGE, RED FLANGE, REDUCER FLANGE 등)
- FLANGE + REDUCING 조합 키워드 감지 로직 추가
- 우선순위 검사 강화로 FITTING 분류 오류 방지
- 원인: REDUCER 키워드로 인한 FITTING 분류 문제 해결
2025-10-16 07:02:14 +09:00
hyungi
f1e1fb6475 🔧 피팅 분류 및 표시 개선 완료
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
 주요 개선사항:
• 엘보 90도/45도 각도 표시 개선 (ELBOW 90° LR BW 형태)
• RL/SL (Long/Short Radius) 표시 추가
• 엘보 서브타입 분류 로직 강화 (90DEG_LONG_RADIUS, 90DEG_SHORT_RADIUS)
• REDUCING FLANGE 분류 개선 (RED 키워드 제거로 피팅 오분류 방지)
• 구매신청 엑셀 중복 생성 문제 해결

🎯 분류기 개선:
• fitting_classifier.py: 엘보 조합 키워드 우선 확인 로직 추가
• integrated_classifier.py: FITTING 키워드에서 RED 제거
• NewMaterialsPage.jsx: 엘보 상세 표시 로직 개선

📊 테스트 완료:
• 엘보 각도 및 반경 정보 정확 표시
• REDUCING FLANGE → FLANGE 분류 확인
• 구매신청 엑셀 단일 생성 확인
2025-10-16 06:52:38 +09:00
hyungi
c3ebb38669 Merge commit 'b944292'
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
2025-10-15 17:43:25 +09:00
hyungi
b9442928da 🔧 자재 분류기 개선 및 ELL 키워드 분류 문제 해결 (테스트 필요 - 안되는거 같음)
분류기 개선사항:
1. ELL-O-LET vs 일반 엘보 분류 개선
   - OLET 우선순위 확인 로직 추가
   - ELL 키워드 충돌 문제 해결

2. 엘보 서브타입 강화
   - 90DEG_LONG_RADIUS, 90DEG_SHORT_RADIUS 등 조합형 추가
   - 더 구체적인 키워드 패턴 지원

3. 레듀스 플랜지 분류 개선
   - REDUCING FLANGE가 FITTING이 아닌 FLANGE로 분류되도록 수정
   - 특별 우선순위 로직 추가

4. 90 ELL SW 분류 문제 해결
   - fitting_keywords에 ELL 키워드 추가
   - ELBOW description_keywords에 ELL, 90 ELL, 45 ELL 추가

기술적 개선:
- 키워드 우선순위 체계 강화
- 구체적인 패턴 매칭 개선
- 분류 신뢰도 향상

플랜지 카테고리 개선:
- 타입 풀네임 표시 (WN → WELD NECK FLANGE)
- 끝단처리 별도 컬럼 추가 (RF → RAISED FACE)
- 엑셀 내보내기 구조 개선 (P열 납기일, 관리항목 4개)
2025-10-15 17:43:10 +09:00
hyungi
e799aae71b feat: 오늘 6시간 작업 내용 복구 완료
- 엑셀 내보내기 개선: 납기일 P열 이동, 관리항목 4개로 축소
- J24-001 더미 프로젝트 옵션 제거
- CORS 오류 해결: API URL /api로 통일
- BOM 페이지 수정사항 포함
- 트랜잭션 오류 해결 시도
2025-10-15 13:55:25 +09:00
hyungi
b10bd8d01c temp: 현재 변경사항 임시 저장 2025-10-15 13:54:46 +09:00
hyungi
9725331af0 fix: 트랜잭션 오류 해결 및 CORS 오류 수정
- 리비전 업로드 시 InFailedSqlTransaction 오류 해결
- 트랜잭션 롤백 및 새 세션 생성 로직 추가
- 특정 쿼리 실행 전 트랜잭션 롤백 처리
- API URL을 /api로 통일하여 CORS 오류 해결
- J24-001 더미 프로젝트 옵션 완전 제거
2025-10-15 13:45:20 +09:00
Hyungi Ahn
8f5330a008 feat: BOM과 구매관리 페이지 엑셀 통합 및 완전 동일화
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
엑셀 내보내기 통합:
- BOM 페이지: 구매신청 시 백엔드에서 엑셀 파일 생성 및 저장
- 구매관리 페이지: 저장된 엑셀 파일 직접 다운로드 (재생성 안 함)
- 두 페이지에서 완전히 동일한 엑셀 파일 제공

백엔드 엑셀 생성:
- openpyxl 사용하여 서버에서 엑셀 생성
- 카테고리별 시트 구성
- 헤더 스타일링 (연파랑 배경)
- 컬럼 너비 자동 조정

FLANGE 품목명 개선:
- 품목명: FLANGE (간단)
- 상세내역: WELD NECK RF, SLIP-ON RF 등 (전체 이름)
- 특수 플랜지: ORIFICE FLANGE, SPECTACLE BLIND 등 구분

구매신청 관리 API 개선:
- 상세 정보 포함 (pipe_details, fitting_details, flange_details 등)
- BOM 형식과 동일한 데이터 구조
- 수량 정수 변환 (3.000 → 3)

에러 수정:
- fileName 중복 선언 해결
- flange_details.connection_method 컬럼 제거 (존재하지 않음)
- Python 문법 오류 수정 (new Date() → datetime.now())

DB 스키마 개선:
- revision_status 컬럼 추가 및 크기 조정 (VARCHAR(30))
- 리비전 변경사항 추적 지원
2025-10-14 15:59:33 +09:00
Hyungi Ahn
72126ef78d fix: 구매신청 엑셀 수량 표시 개선 및 FLANGE 품목명 개선
- 구매신청 관리 페이지 수량을 정수로 표시 (3.000 EA → 3 EA)
- JSON 저장 시 수량 정수 변환
- FLANGE 품목명 세분화: WN RF, SO RF, ORIFICE FLANGE, SPECTACLE BLIND 등
- 구매관리 페이지 엑셀 다운로드 데이터 구조 개선
- 디버그 로그 추가
2025-10-14 15:29:01 +09:00
Hyungi Ahn
5a21ef8f6c feat: 리비전 관리 시스템 완전 개편
변동이력 관리로 전환:
- 도면번호 기준 변경 추적
- 리비전 업로드 시 전체 자재 저장 (차이분만 저장 방식 폐지)
- 구매신청 정보 수량 기반 상속

리비전 변경 감지:
- 수량/재질/크기/카테고리 변경 감지
- 변경 유형: specification_changed, quantity_changed, added, removed
- 도면별 변경사항 추적

누락 도면 처리:
- 리비전 업로드 시 누락된 도면 자동 감지
- 3가지 선택 옵션: 일부 업로드 / 도면 삭제 / 취소
- 구매신청 여부에 따라 다른 처리 (재고품 vs 숨김)

자재 상태 관리:
- revision_status 컬럼 추가 (active/inventory/deleted_not_purchased/changed)
- 재고품: 연노랑색 배경, '재고품' 배지
- 변경됨: 파란색 테두리, '변경됨' 배지
- 삭제됨: 자동 숨김

구매신청 정보 상속:
- 수량 기반 상속 (그룹별 개수만큼만)
- Rev.0에서 3개 구매 → Rev.1에서 처음 3개만 상속, 추가분은 미구매
- 도면번호 정확히 일치하는 경우에만 상속

기타 개선:
- 구매신청 관리 페이지 수량 표시 개선 (3 EA, 소수점 제거)
- 도면번호/라인번호 파싱 및 저장 (DWG_NAME, LINE_NUM 컬럼)
- SPECIAL 카테고리 도면번호 표시
- 마이그레이션 스크립트 추가 (29_add_revision_status.sql)
2025-10-14 14:30:34 +09:00
Hyungi Ahn
e27020ae9b feat: 구매신청 기능 완성 및 SUPPORT/SPECIAL 카테고리 개선
- 모든 카테고리 구매신청 기능 완성 (PIPE, FITTING, VALVE, FLANGE, GASKET, BOLT, SUPPORT, SPECIAL, UNKNOWN)
- 구매신청 완료 항목: 회색 배경, 체크박스 비활성화, '구매신청완료' 배지 표시
- 전체 선택/구매신청 시 이미 구매신청된 항목 자동 제외
- 구매신청 quantity 타입 에러 수정 (문자열 -> 정수 변환)

SUPPORT 카테고리 (구 U-BOLT):
- U-BOLT -> SUPPORT로 카테고리명 변경
- 클램프, 유볼트, 우레탄블럭슈 분류 개선
- 테이블 헤더: 선택-종류-타입-크기-디스크립션-추가요구-사용자요구-수량
- 크기 정보 main_nom 필드에서 가져오기 (배관 인치)
- 엑셀 내보내기 형식 조정

SPECIAL 카테고리:
- SPECIAL 키워드 자재 자동 분류 (SPECIFICATION 제외)
- 파일 업로드 시 SPECIAL 카테고리 처리 로직 추가
- 도면번호 필드 추가 (drawing_name, line_no)
- 타입 필드: 크기/스케줄/재질 제외한 핵심 정보 표시
- 엑셀 DWG_NAME, LINE_NUM 컬럼 파싱 및 저장

FITTING 카테고리:
- 테이블 컬럼 너비 조정 (선택 2%, 종류 8.5%, 수량 12%)

구매신청 관리:
- 엑셀 재다운로드 형식 개선 (BOM 페이지와 동일한 형식)
- 그룹화된 자재 정보 포함하여 저장 및 다운로드
2025-10-14 12:39:25 +09:00
Hyungi Ahn
e468663386 🔧 User 모델 ID 필드명 수정
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- id → user_id (실제 User 모델의 primary key)
- 모든 참조를 user_id로 통일
- 회원가입 완전 수정 완료
2025-10-14 07:43:56 +09:00
Hyungi Ahn
745ecaf3a3 🔧 거부 쿼리 user_id 컬럼명 수정
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- id → user_id
- access_level='pending' → is_active=FALSE
- 모든 쿼리가 실제 DB 스키마에 맞춤
2025-10-14 07:42:51 +09:00
Hyungi Ahn
5a3ee33e9b 🔧 승인 대기 로직 수정 - is_active=False 사용
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- access_level='pending' 대신 is_active=False 사용
- access_level='worker'로 설정 (승인 시 변경 가능)
- 승인 대기 쿼리: is_active=FALSE로 조회
- 승인 쿼리: user_id 컬럼명 수정
- 거부 쿼리도 user_id 기준으로 수정 필요

로그인 제한:
- 로그인 API에서 이미 is_active 체크 중
- 비활성 계정은 로그인 불가
- 관리자 승인 후에만 로그인 가능
2025-10-14 07:42:14 +09:00
Hyungi Ahn
b1bfd1a4c0 🔧 User 모델 필드명 수정
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- hashed_password → password
- User 모델의 실제 필드명에 맞춤
- 회원가입 기능 최종 수정
2025-10-14 07:38:58 +09:00
Hyungi Ahn
39917be585 🔧 create_user 파라미터를 딕셔너리로 변경
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- create_user(user_data: Dict) 형식에 맞춤
- 키워드 인자 → 딕셔너리로 변경
- 회원가입 기능 정상 작동
2025-10-14 07:37:02 +09:00
Hyungi Ahn
50eab5ac5f 🔧 비밀번호 해싱을 bcrypt로 직접 처리
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- AuthService.hash_password() 메서드가 없어서 직접 bcrypt 사용
- bcrypt.hashpw()로 비밀번호 해싱
- 회원가입 기능 정상 작동
2025-10-14 07:35:15 +09:00
Hyungi Ahn
fb46902b85 🔧 AuthService db 파라미터 추가
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- AuthService() → AuthService(db)
- 회원가입 기능 정상 작동
2025-10-14 07:33:20 +09:00
Hyungi Ahn
e50f0887ad 🔧 UserRepository 메서드명 수정
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- get_user_by_username → find_by_username
- 실제 models.py의 메서드명에 맞춤
- 회원가입 기능 정상 작동
2025-10-14 07:30:56 +09:00
Hyungi Ahn
e14f8b69c7 회원가입 신청 기능 완성 (간소화)
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
백엔드:
- signup_routes.py 신규 생성
- POST /auth/signup-request: 회원가입 신청
- GET /auth/signup-requests: 승인 대기 목록 (관리자)
- POST /auth/approve-signup/{id}: 승인 (관리자)
- DELETE /auth/reject-signup/{id}: 거부 (관리자)
- main.py에 signup_router 등록

프론트엔드:
- SimpleLogin에 회원가입 폼 추가
- 필수 항목만: 사용자명, 비밀번호, 비밀번호 확인, 이름
- 간단하고 깔끔한 UI
- 비밀번호 일치 검사 및 최소 길이 검사
- 제출 후 승인 대기 안내 메시지
2025-10-14 07:28:06 +09:00
Hyungi Ahn
dfb6c7e8a4 🔧 ActivityLogger 호출 제거
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- log_activity() 파라미터 오류로 임시 제거
- TODO로 표시하여 추후 올바른 방식으로 구현
- 프로젝트 생성/수정 기능은 정상 작동
2025-10-14 07:17:51 +09:00
Hyungi Ahn
6d8bb468c3 프로젝트 생성 및 관리 기능 완성
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
백엔드:
- POST /dashboard/projects 엔드포인트 추가
- 프로젝트 생성 기능 구현
- 중복 코드 검사
- GET /dashboard/projects 컬럼명 수정 (실제 DB 스키마에 맞춤)
- PATCH /dashboard/projects/{id} 컬럼명 수정

프론트엔드:
- 메인 대시보드에 프로젝트 관리 섹션 추가
- ' 새 프로젝트' 버튼으로 생성 폼 표시/숨김
- '✏️ 이름 수정' 버튼으로 프로젝트 이름 수정
- 프로젝트 생성 폼:
  - 프로젝트 코드 (필수)
  - 프로젝트 이름 (필수)
  - 고객사명 (선택)
- 실시간 프로젝트 목록 갱신
- API 연동 완료
2025-10-14 07:14:55 +09:00
Hyungi Ahn
003983872c 성능 대폭 개선 - parseMaterialInfo 캐싱
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
백엔드:
- GET /dashboard/projects 엔드포인트 추가
- 프로젝트 목록 조회 API 구현

프론트엔드:
- parseMaterialInfo 결과를 useMemo로 캐싱
- parsedMaterialsMap으로 중복 계산 방지
- getParsedInfo() 함수로 캐시된 값 사용
- 성능 개선: 1315개 자재 × 6번 계산 → 1315개 자재 × 1번 계산
- 약 80% 계산 감소 (8000번 → 1500번)

효과:
- 페이지 로딩 속도 대폭 향상
- 메모리 사용량 감소
- 필터/정렬 기능 유지하면서 가벼워짐
2025-10-14 07:06:24 +09:00
Hyungi Ahn
a55e2e1c37 🚨 긴급: 에러 로거 무한 루프 수정
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- errorLogger.js에서 무한 루프 발생
- 원인: API 에러 → localStorage 저장 실패 → 서버 전송 실패 → 또 에러 발생
- 해결: errorLogger 완전 비활성화
- setupGlobalErrorHandlers() 비활성화

사용자 조치 필요:
브라우저 개발자 도구(F12) > Application > Local Storage > localhost:13000 > Clear All
2025-10-14 07:00:13 +09:00
Hyungi Ahn
433d894175 ✏️ 메인 대시보드에 프로젝트 이름 수정 기능 추가
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- App.jsx dashboard에 프로젝트 이름 수정 기능 구현
- 프로젝트 선택 시 '✏️ 이름 수정' 버튼 표시
- 실제 API에서 프로젝트 목록 로드 (더미 데이터는 fallback)
- 편집 폼: 파란색 박스로 명확하게 구분
- Enter 키로 빠른 저장
- 💾 저장 / ✕ 취소 버튼
- 저장 후 드롭다운 자동 갱신
2025-10-14 06:58:14 +09:00
Hyungi Ahn
aca00cf3cb ✏️ JobSelectionPage에 프로젝트 이름 수정 기능 추가
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 선택된 프로젝트 옆에 '✏️ 수정' 버튼 추가
- 수정 버튼 클릭 시 편집 폼 표시
- 입력 필드 + 저장/취소 버튼
- Enter 키로 저장 가능
- API 연동: PATCH /dashboard/projects/{id}
- 실시간 업데이트: 드롭다운 목록 및 선택된 이름 자동 갱신
- 간단하고 직관적인 UX
2025-10-14 06:51:45 +09:00
Hyungi Ahn
ca0336d627 ✏️ 프로젝트 이름 인라인 편집 기능 추가
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
백엔드:
- dashboard.py에 PATCH /dashboard/projects/{id} 엔드포인트 추가
- 프로젝트 이름 업데이트 기능 구현
- 활동 로그 기록

프론트엔드:
- ProjectsPage에 인라인 편집 기능 추가
- 더블클릭으로 편집 모드 진입
- Enter 키로 저장, Escape로 취소
- 저장/취소 버튼 (✓/✕) 제공
- 간단하고 직관적인 UX
2025-10-14 06:47:52 +09:00
Hyungi Ahn
9325d36031 메모리 성능 최적화 - 필터 계산 제한
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- FilterableHeader의 uniqueValues 계산을 최대 200개로 제한
- 메모리 누수 방지: 대량 자재(1000개+)에서 무거운 계산 반복 방지
- parseMaterialInfo 호출 횟수 대폭 감소
- 페이지 리로드 문제 해결
2025-10-14 06:44:18 +09:00
Hyungi Ahn
07ca79f376 🔄 SUPPORT 카테고리를 U-BOLT로 통합
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- DB: SUPPORT 카테고리 76개 자재를 U_BOLT로 변경 (총 211개)
- 프론트엔드: SUPPORT 표시명을 'U-BOLT'로 변경
- SUPPORT 전용 헤더 및 본문 렌더링 제거
- U_BOLT 렌더링 개선:
  - U-BOLT: 기본 표시
  - URETHANE: 우레탄 블록 슈
  - CLAMP: 클램프 (신규 추가)
- CLAMP 배지 스타일 추가 (청록색)
2025-10-14 06:37:00 +09:00
Hyungi Ahn
e8fb531fdb 🎯 선택 컬럼 대폭 축소 - 경계선 위치 수정
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 선택: 3.5% → 2% (체크박스에 딱 맞게)
- 종류: 6.5% → 8% (배지 표시 공간 확보)
- 본문의 border-right가 선택 컬럼 끝에 위치하도록 조정
- 모든 카테고리에 일괄 적용
2025-10-13 15:58:02 +09:00
Hyungi Ahn
e45bcf12c0 🔧 선택/종류 컬럼 비율 재조정
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 선택: 2.5% → 3.5% (체크박스 공간 확보)
- 종류: 7.5% → 6.5% (배지 크기에 맞게 축소)
- 모든 카테고리에 일괄 적용
2025-10-13 15:55:00 +09:00
Hyungi Ahn
48b100d0d4 🔧 PIPE 수량 셀 단순화 - wrapper div 제거
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- quantity-info, quantity-details wrapper 제거
- 단순 span으로 변경하여 grid 구조 정상화
- 추가 정보(단관 개수, 길이)는 제거
- 이제 헤더와 본문이 완벽히 정렬됨
2025-10-13 15:52:35 +09:00
Hyungi Ahn
f1fe614977 테이블 정렬 완료 - 백분율 기반 컬럼 너비
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 모든 카테고리의 컬럼을 백분율(%)로 통일
- 선택: 2.5%, 종류: 7.5%로 최적화
- 화면 너비에 맞게 꽉 차도록 표시
- 헤더와 본문이 완벽하게 정렬됨
- 엑셀과 유사한 깔끔한 테이블 완성
2025-10-13 15:50:55 +09:00
Hyungi Ahn
e3d0c4d9a0 💯 컬럼 너비를 백분율로 변경 - 화면 꽉 차게 표시
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 모든 고정 픽셀 너비를 백분율(%)로 변경
- 화면 너비에 맞게 테이블이 꽉 차도록 설정
- overflow-x: hidden으로 가로 스크롤 제거
- 헤더와 본문이 정확히 동일한 백분율 사용
- 예: PIPE 9개 컬럼 = 5% 8% 12% 8% 10% 20% 12% 15% 10%
- 이제 어떤 화면 크기에서도 헤더와 본문이 완벽히 정렬됨
2025-10-13 15:44:51 +09:00
Hyungi Ahn
5a9274d42b 🔧 테이블 border 구조 수정 - 양쪽 테두리 통일
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 헤더 행의 border-left, border-right 제거
- 본문 행의 border-left, border-right 제거
- 대신 첫/마지막 셀에 border 적용으로 통일
- materials-grid의 외곽 border가 전체를 감쌈
- 불필요한 이중 border 제거
2025-10-13 15:41:58 +09:00
Hyungi Ahn
1d2ab35d18 🎯 컬럼 고정 너비 적용 - minmax 제거
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 문제 원인: minmax(최소값, auto)로 인해 내용에 따라 컬럼 크기 변동
- 모든 minmax()를 고정 너비로 변경
- 헤더와 본문이 항상 정확히 동일한 너비 유지
- 예: minmax(130px, auto) → 130px
- 이제 모든 카테고리에서 완벽한 정렬 보장
2025-10-13 15:40:15 +09:00
Hyungi Ahn
805d164124 🔒 반응형 제거 및 고정 너비 적용
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 문제 원인: 반응형 구조로 인해 브라우저 크기에 따라 컬럼 너비가 변경됨
- materials-page: min-width: 1400px 적용
- materials-grid: min-width: 1200px, width: fit-content 적용
- 이제 테이블이 고정 너비를 유지하고 스크롤로 확인 가능
- 헤더와 본문이 항상 동일한 너비 유지
2025-10-13 15:38:48 +09:00
Hyungi Ahn
f09e494bd4 🗑️ 전체(ALL) 카테고리 제거 - 헤더/본문 정렬 문제 해결
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 전체 카테고리 버튼 제거
- 기본 선택 카테고리를 PIPE로 변경
- 문제 원인: 전체 카테고리에서 서로 다른 컬럼 수를 가진 자재들이 섞여서 표시됨
  (PIPE 9개, FLANGE 10개, GASKET 11개 등)
- 이제 각 카테고리별로만 표시되어 헤더와 본문이 완벽히 정렬됨
- quantity-info wrapper 제거로 셀 구조 단순화
2025-10-13 15:30:27 +09:00
Hyungi Ahn
a0d22508be 엑셀 스타일 테이블 완성 - 헤더/본문 완벽 정렬
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 헤더와 본문의 border 색상 통일 (#d1d5db)
- 테이블 전체에 일관된 border 적용
- 헤더 상단 border 추가로 완전한 박스 형태
- 모든 카테고리에서 헤더와 본문이 정확히 일치
- 엑셀과 동일한 깔끔한 그리드 스타일
2025-10-13 15:27:11 +09:00
Hyungi Ahn
fa032e95c6 📊 엑셀 스타일 테이블 완성 - 동적 컬럼 너비
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 모든 카테고리에 minmax() 적용으로 내용에 맞게 컬럼 자동 확장
- 헤더와 본문에 동일한 padding: 6px 4px 적용
- 모든 margin 제거하고 순수 grid로 구성
- materials-grid에 외곽 border 추가
- 엑셀처럼 명확한 셀 구분선
- 텍스트가 길어도 잘리지 않고 컬럼이 자동 확장됨
2025-10-13 15:25:11 +09:00
Hyungi Ahn
0ed1047839 📊 테이블을 엑셀 스타일로 완전 재구성
- 모든 margin, padding 제거하고 순수 grid로 재구성
- 헤더 셀: padding: 6px 4px (엑셀처럼 내부 여백만)
- 본문 셀: padding: 6px 4px (헤더와 동일)
- 모든 셀에 명확한 border (엑셀 그리드 스타일)
- materials-grid에 외곽 border와 border-radius 추가
- 헤더 배경: #f3f4f6 (엑셀 회색 헤더)
- 이제 헤더와 본문이 완벽히 정렬됨
2025-10-13 15:21:09 +09:00
Hyungi Ahn
511f5c4f19 🔧 헤더 패딩 제거로 그리드 정렬 완전 해결
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 근본 원인: 헤더 셀의 padding: 0 8px가 그리드 정렬을 방해
- 모든 헤더 div와 filterable-header의 padding 제거
- grid-template-columns 너비가 정확히 적용되도록 수정
- 이제 헤더와 본문의 컬럼이 완벽히 정렬됨
2025-10-13 15:09:38 +09:00
Hyungi Ahn
2ea7f2879f 🔄 테이블 헤더 고정 해제 및 가로 스크롤 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 모든 카테고리 헤더의 position: sticky 제거
- 헤더와 본문이 함께 좌우로 스크롤되도록 변경
- 더 나은 UX: 전체 테이블을 좌우로 자유롭게 이동 가능
- 컬럼 너비 최적화 (타입, 재질 등 컬럼 크기 조정)
2025-10-13 15:04:43 +09:00
Hyungi Ahn
573f145f50 🎨 모든 카테고리 테이블 그리드 정렬 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 모든 카테고리의 헤더와 본문 grid-template-columns를 통일
- FLANGE, FITTING, VALVE, GASKET, UNKNOWN 컬럼 너비 재조정
- 선택 컬럼: 40px → 60px (체크박스 공간 확보)
- 각 카테고리별 최적 컬럼 너비 적용으로 내용 잘림 방지
- 사용자요구 컬럼: 150px → 200px (입력 공간 확대)
2025-10-13 14:59:33 +09:00
Hyungi Ahn
cde930c263 SUPPORT 카테고리 전용 테이블 레이아웃 추가
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- NewMaterialsPage.jsx: SUPPORT 전용 헤더 및 본문 행 추가 (9개 컬럼)
- NewMaterialsPage.css: SUPPORT 전용 그리드 레이아웃 및 스타일 추가
- 헤더와 본문의 컬럼이 정확히 매칭되도록 수정
- SUPPORT 전용 배지 스타일 추가 (청록색)
2025-10-13 14:56:03 +09:00
Hyungi Ahn
2e0d91cf59 🔧 볼트 재질 정보 개선 및 A320/A194M 패턴 지원
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- bolt_classifier.py: A320/A194M 조합 패턴 처리 로직 추가
- material_grade_extractor.py: A320/A194M 패턴 추출 개선
- integrated_classifier.py: SPECIAL, U_BOLT 카테고리 우선 분류
- 데이터베이스: 492개 볼트의 material_grade를 완전한 형태로 업데이트
  - A320/A194M GR B8/8: 78개
  - A193/A194 GR B7/2H: 414개
- 프론트엔드: BOLT 카테고리 전용 UI (길이 표시)
- Excel 내보내기: BOLT용 컬럼 순서 및 재질 정보 개선
- SPECIAL, U_BOLT 카테고리 지원 추가
2025-10-01 08:18:25 +09:00
Hyungi Ahn
50570e4624 feat: 사용자 요구사항 기능 완전 구현 및 전체 카테고리 추가
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 사용자 요구사항 저장/로드/엑셀 내보내기 기능 완전 구현
- 백엔드 API 수정: Request Body 방식으로 변경
- 데이터베이스 스키마: material_id 컬럼 추가
- 프론트엔드 상태 관리 개선: 저장 후 자동 리로드
- 입력 필드 연결 문제 해결: 누락된 onChange 핸들러 추가
- NewMaterialsPage에 '전체' 카테고리 버튼 추가 (기본 선택)
- Docker 환경 개선: 프론트엔드 볼륨 마운트 및 포트 수정
- UI 개선: 벌레 이모지 제거, 디버그 코드 정리
2025-09-30 08:55:20 +09:00
Hyungi Ahn
0f9a5ad2ea 🔧 재질 정보 표시 개선 및 UI 확장
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
 주요 수정사항:
- 재질 GRADE 전체 표기: ASTM A106 B 완전 표시 (A10 잘림 현상 해결)
- material_grade_extractor.py 정규표현식 패턴 개선
- files.py 파일 업로드 시 재질 추출 로직 수정
- CSS 그리드 너비 확장으로 텍스트 잘림 현상 해결
- 사용자 요구사항 엑셀 다운로드 기능 완료

🎯 해결된 문제:
1. ASTM A106 B → ASTM A10 잘림 문제
2. 재질 컬럼 너비 부족으로 인한 표시 문제
3. 사용자 요구사항이 엑셀에 반영되지 않는 문제

📋 다음 단계 준비:
- 파이프 끝단 정보 제외 취합 로직 개선
- 플랜지 타입 정보 확장
- 자재 분류 필터 기능 추가
2025-09-25 08:32:17 +09:00
hyungi
af4ad25a54 fix: 리비전 업로드 시 누적 자재 조회 및 차이분 계산 로직 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 리비전 업로드 시 모든 이전 리비전의 누적 자재를 조회하도록 수정
- 기존 단일 부모 파일 조회 → job_no 기준 누적 조회로 변경
- 차이분 계산 시 디버깅 로그 추가로 매칭 상태 확인 가능
- 자재 그룹핑과 라인 아이템 구분을 명확히 하는 로그 개선
- 기존 자재가 없는 경우 경고 메시지 추가
2025-09-16 09:07:06 +09:00
hyungi
04299542b5 [TEST] Cloudflare Tunnel 대응 및 리비전 증분 계산 수정
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
🌐 Nginx 프록시 설정 (테스트용):
- nginx-proxy.conf: /api 요청을 백엔드로 프록시
- docker-compose.proxy.yml: 프록시 서버 설정
- VITE_API_URL=/api 환경변수 설정으로 단일 도메인 접속

🎨 UI 텍스트 변경 (테스트용):
- LoginPage: TK-MP System → BOM 테스트 서버
- LoginPage: 통합 프로젝트 관리 시스템 → BOM 분류 시스템 v1.0
- LogMonitoringPage: 탭 네비게이션 추가 (로그인/활동/시스템 로그)
- SystemSettingsPage: 활동 로그 모니터링 기능 개선

🔧 백엔드 수정 (테스트용):
- files.py: 리비전 증분 계산 로직 수정 (전체 재분류 → 차이분만 분류)
- create_system_admin.py: database_url → get_database_url() 수정

⚠️ 주의: 이 커밋은 테스트 환경에서의 변경사항입니다.
2025-09-10 08:49:19 +09:00
Hyungi Ahn
fe3fd76112 feat: 최종 완전 통합 DB 스키마 - 사용자 요구사항 시스템 추가
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
🔬 추가된 마지막 테이블들:
- requirement_types (요구사항 타입 마스터)
- user_requirements (사용자 추가 요구사항)
- 임팩테스트, 열처리, 인증서, 비파괴검사 등 10종 기본 타입

📊 최종 통계 (정확한 수치):
- 총 테이블: 38개 (모든 backend/scripts 파일 통합 완료)
- 총 인덱스: 107개 (복합, GIN, 조건부 인덱스 포함)
- 총 뷰: 5개 (통계 및 성능 모니터링)
- 총 함수: 15개 이상 (트리거, 해시 생성 등)

 완전성 검증:
- 21개 SQL 파일 모두 통합 완료
- 성능 최적화 인덱스 전체 적용
- 자동화 트리거 및 함수 모두 포함
- 기본 데이터 자동 생성 완료

🚀 배포 준비 완료:
- 다른 환경에서 한 번에 모든 테이블 생성 가능
- 기본 계정, 프로젝트, 권한, 요구사항 타입 자동 설정
2025-09-10 07:48:45 +09:00
Hyungi Ahn
389a4c2026 fix: 완전한 통합 DB 스키마 완성 - 모든 누락 테이블 추가
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
🗄️ 추가된 주요 테이블:
- Jobs 테이블 (프로젝트 관리, project_type 포함)
- 자재 규격/재질 기준표 (8개 테이블)
- 자재 비교 시스템 (리비전 비교, 해시 기반)
- Tubing 시스템 (5개 테이블 + 제조사 데이터)

📊 성능 최적화:
- 복합 인덱스, GIN 인덱스, 조건부 인덱스
- 총 50+ 인덱스로 검색/정렬 성능 극대화

🔧 자동화 기능:
- 해시 자동 생성 함수 및 트리거
- updated_at 자동 갱신 트리거
- 정규화된 description 자동 생성

📈 통계 및 뷰:
- classification_summary (분류 통계)
- classification_performance (분류 성능)
- material_inventory_status (재고 현황)

📝 완전성:
- 총 30+ 테이블, 50+ 인덱스, 6개 뷰, 10+ 함수
- 모든 backend/scripts SQL 파일 통합 완료
- 다른 환경 배포 시 한 번에 모든 테이블 생성 가능
2025-09-10 07:44:01 +09:00
Hyungi Ahn
f674f3b350 feat: 완전한 자재 그룹핑 시스템 및 통합 DB 스키마 구현
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
🎯 주요 기능:
- 모든 카테고리 자재 그룹핑 (파이프, 피팅, 플랜지, 밸브, 볼트, 가스켓, UNKNOWN)
- 같은 재질/사이즈 자재 자동 통합 표시
- 리비전 업로드 시 차이분만 처리하는 스마트 시스템

🎨 UI/UX 개선:
- NewMaterialsPage: DevonThink 스타일 깔끔한 자재 목록
- SystemSettingsPage: 사용자 관리 기능 완성
- 과도한 디버그 로그 제거로 성능 향상

🗄️ 데이터베이스:
- 통합 초기화 스키마 (99_complete_schema.sql)
- 다른 환경 배포 시 모든 테이블 자동 생성
- 기본 계정/프로젝트/권한 자동 설정

🚀 배포 개선:
- docker-run.sh 스크립트 개선
- 환경 변수 설정 가이드 업데이트
2025-09-10 07:32:58 +09:00
Hyungi Ahn
529777aa14 feat: 완전한 사용자 관리 및 로그 모니터링 시스템 구현
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 시스템 관리자/관리자 권한별 대시보드 기능 추가
- 사용자 관리 페이지: 계정 생성, 역할 변경, 사용자 삭제
- 시스템 로그 페이지: 로그인 로그, 시스템 오류 로그 조회
- 로그 모니터링 대시보드: 실시간 통계, 최근 활동, 오류 모니터링
- 프론트엔드 ErrorBoundary 및 오류 로깅 시스템 통합
- 계정 설정 페이지: 프로필 업데이트, 비밀번호 변경
- 3단계 권한 시스템 (system/admin/user) 완전 구현
- 시스템 관리자 계정 생성 기능 (hyungi/000000)
- 로그인 페이지 테스트 계정 안내 제거
- API 오류 수정: CORS, 이메일 검증, User 모델 import 등
2025-09-09 12:58:14 +09:00
Hyungi Ahn
881fc13580 리비전 업로드 시 정확한 수량 차이분 계산 로직 구현
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 기존 자재와 새 자재의 수량을 비교하여 증가분만 저장
- Rev.0: 엘보 10개, Rev.1: 엘보 12개 → Rev.1에는 2개만 저장
- 완전 신규 자재는 전체 수량 저장
- 수량 감소/동일한 자재는 저장하지 않음
- 리비전별 정확한 차이분 관리 구현
2025-09-09 12:03:47 +09:00
Hyungi Ahn
83b90ef05c feat: 자재 관리 페이지 대규모 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 파이프 수량 계산 로직 수정 (단관 개수가 아닌 실제 길이 기반 계산)
- UI 전면 개편 (DevonThink 스타일의 간결하고 세련된 디자인)
- 자재별 그룹핑 로직 개선:
  * 플랜지: 동일 사양별 그룹핑, WN 스케줄 표시, ORIFICE 풀네임 표시
  * 피팅: 상세 타입 표시 (니플 길이, 엘보 각도/연결, 티 타입, 리듀서 타입 등)
  * 밸브: 동일 사양별 그룹핑, 타입/연결방식/압력 표시
  * 볼트: 크기/재질/길이별 그룹핑 (8SET → 개별 집계)
  * 가스켓: 동일 사양별 그룹핑, 재질/상세내역/두께 분리 표시
  * UNKNOWN: 원본 설명 전체 표시, 동일 항목 그룹핑
- 전체 카테고리 버튼 제거 (표시 복잡도 감소)
- 카테고리별 동적 컬럼 헤더 및 레이아웃 적용
2025-09-09 09:24:45 +09:00
Hyungi Ahn
4f8e395f87 feat: SWG 가스켓 전체 구성 정보 표시 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- H/F/I/O SS304/GRAPHITE/CS/CS 패턴에서 4개 구성요소 모두 표시
- 기존 SS304 + GRAPHITE → SS304/GRAPHITE/CS/CS로 완전한 구성 표시
- 외부링/필러/내부링/추가구성 모든 정보 포함
- 구매수량 계산 모달에서 정확한 재질 정보 확인 가능
2025-08-30 14:23:01 +09:00
Hyungi Ahn
78d90c7a8f Fix BOM API endpoints: Add trailing slash to /jobs/ endpoints
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- Fixed fetchJobs() and createJob() to use /jobs/ instead of /jobs
- Resolves 307 Temporary Redirect issue from FastAPI
- BOM feature now works properly without connection errors
2025-08-28 10:21:12 +09:00
Hyungi Ahn
28a1302cae 📝 RULES.md 업데이트: Docker 환경 구성 및 트러블슈팅 가이드 추가
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- Docker 컨테이너 구성 및 실행 방법 명시
- 해결된 주요 문제들 문서화:
  * 프론트엔드 API 연결 오류 (10080 포트 문제)
  * 백엔드 데이터베이스 연결 실패 (localhost vs postgres)
  * 환경별 설정 관리 개선
- 실행 전 체크리스트 제공으로 향후 헷갈림 방지
2025-08-01 13:46:37 +09:00
Hyungi Ahn
7dbb742981 🐳 Docker 환경별 설정 분리 및 실행 스크립트 추가
- docker-compose.dev.yml: 개발환경 전용 설정
- docker-compose.prod.yml: 프로덕션환경 전용 설정
- scripts/dev.sh, scripts/prod.sh: 환경별 실행 스크립트
- Dockerfile 및 nginx.conf 추가로 완전한 컨테이너화 구현
- 환경변수를 통한 유연한 API URL 설정
2025-08-01 13:46:22 +09:00
Hyungi Ahn
a7e4c0158e 🔧 프론트엔드 API URL 설정 개선
- 프로덕션에서 nginx 프록시를 통한 /api 경로 사용
- 환경변수 VITE_API_URL을 통한 유연한 설정 지원
- Docker Compose에서 환경변수 주입 설정 추가
2025-08-01 13:46:07 +09:00
Hyungi Ahn
f34eb0e210 🔧 백엔드 데이터베이스 연결 설정 수정
- localhost에서 postgres 컨테이너명으로 변경
- Docker 환경에서 컨테이너 간 통신 가능하도록 개선
- 환경변수를 통한 DATABASE_URL 오버라이드 지원
2025-08-01 13:45:54 +09:00
Hyungi Ahn
48f8f634d1 볼트 분류 기능 대폭 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Failing after 2m11s
- 실제 볼트 사이즈 추출: 설명의 첫 번째 숫자를 실제 볼트 직경으로 사용
- 분수 표기 변환: 0.625 → 5/8, 0.75 → 3/4 등 현장 친화적 표기
- 특수 용도 볼트 분류: PSV(압력안전밸브), LT(저온용), CK(체크밸브), ORI(오리피스)
- 표면처리 정보 추출: ELEC.GALV, HOT DIP GALV 등 코팅 정보
- 복합 재질 규격 파싱: ASTM A193/A194 GR B7/2H 정확 분류
- 특수 용도별 색상 구분: PSV 빨강, LT 주황, CK 파랑, ORI 보라
- 프론트엔드 표시 개선: 분수 사이즈, 특수 용도 현황 별도 섹션
- inch 기호 제거: 깔끔한 분수 표시로 현장 가독성 향상
2025-07-29 14:34:33 +09:00
Hyungi Ahn
fc925974bb fix: Use actual container IPs for network communication
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Failing after 2m13s
2025-07-24 12:29:50 +09:00
Hyungi Ahn
501daf7360 test: retry after network connection
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Failing after 34s
2025-07-24 12:24:20 +09:00
Hyungi Ahn
4439c88d00 fix: Update checkout configuration for Docker network
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Failing after 33s
2025-07-24 12:19:33 +09:00
Hyungi Ahn
407d1cede6 ci: Add SonarQube integration
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Failing after 1m37s
2025-07-24 08:46:13 +09:00
Hyungi Ahn
9e5250a8f9 자재 분류 시스템 개선 및 통합 분류기 구현
- 통합 분류기 구현으로 키워드 우선순위 체계 적용
- HEX.PLUG → FITTING 분류 수정 (기존 VALVE 오분류 해결)
- 플랜지/밸브가 볼트로 오분류되는 문제 해결 (A193, A194 재질 키워드 우선순위 적용)
- 피팅 재질(A234, A403, A420) 기반 분류 추가
- 니플 길이 정보 보존 로직 개선
- 파이프 끝단 가공 정보를 구매 단계에서 제외
- PostgreSQL 사용으로 RULES.md 업데이트
- 상호 배타적 키워드 시스템 구현 (Level 1 키워드 우선)
2025-07-23 14:38:49 +09:00
253 changed files with 69433 additions and 6399 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

@@ -0,0 +1,35 @@
name: SonarQube Analysis
on:
push:
branches: [ main, master, develop ]
pull_request:
branches: [ main, master ]
jobs:
sonarqube:
name: SonarQube Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
run: |
git clone http://172.17.0.2:3000/${{ gitea.repository }}.git .
- name: Test SonarQube connection
run: |
echo "Testing connection to SonarQube..."
curl -f http://172.17.0.3:9000/api/system/ping || echo "Connection failed"
- name: Run SonarQube scan
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |
docker run \
--rm \
--network bridge \
-e SONAR_HOST_URL="http://172.17.0.3:9000" \
-e SONAR_SCANNER_OPTS="-Dsonar.projectKey=my-project" \
-e SONAR_TOKEN="${SONAR_TOKEN}" \
-v "$(pwd):/usr/src" \
sonarsource/sonar-scanner-cli

193
DOCKER-GUIDE.md Normal file
View File

@@ -0,0 +1,193 @@
# TK-MP-Project Docker 가이드
## 🚀 빠른 시작
### 1. 개발 환경 실행
```bash
./docker-run.sh dev up
```
### 2. 프로덕션 환경 실행
```bash
./docker-run.sh prod up
```
### 3. 시놀로지 NAS 환경 실행
```bash
./docker-run.sh synology up
```
## 📋 사용 가능한 명령어
| 명령어 | 설명 |
|--------|------|
| `up` | 컨테이너 시작 (기본값) |
| `down` | 컨테이너 중지 |
| `build` | 이미지 빌드 |
| `rebuild` | 이미지 재빌드 (캐시 무시) |
| `logs` | 로그 실시간 확인 |
| `ps` 또는 `status` | 서비스 상태 확인 |
| `restart` | 컨테이너 재시작 |
## 🌍 환경별 설정
### 개발 환경 (dev)
- **포트**: 모든 서비스 외부 노출
- Frontend: http://localhost:13000
- Backend API: http://localhost:18000
- PostgreSQL: localhost:5432
- Redis: localhost:6379
- pgAdmin: http://localhost:5050
- **특징**:
- 코드 실시간 반영 (Hot Reload)
- 디버그 모드 활성화
- 모든 로그 레벨 출력
### 프로덕션 환경 (prod)
- **포트**: Nginx를 통한 리버스 프록시
- Web: http://localhost (Nginx)
- HTTPS: https://localhost (SSL 설정 필요)
- **특징**:
- 내부 서비스 포트 비노출
- 최적화된 빌드
- 로그 레벨 INFO
- pgAdmin 비활성화
### 시놀로지 NAS 환경 (synology)
- **포트**: 포트 충돌 방지를 위한 커스텀 포트
- Frontend: http://localhost:10173
- Backend API: http://localhost:10080
- PostgreSQL: localhost:15432
- Redis: localhost:16379
- pgAdmin: http://localhost:15050
- **특징**:
- 명명된 볼륨 사용
- 시놀로지 Container Manager 호환
## 🔧 환경 설정 파일
각 환경별 설정은 다음 파일에서 관리됩니다:
- `env.development` - 개발 환경 설정
- `env.production` - 프로덕션 환경 설정
- `env.synology` - 시놀로지 환경 설정
### 주요 환경 변수
```bash
# 배포 환경
DEPLOY_ENV=development|production|synology
# 포트 설정
FRONTEND_EXTERNAL_PORT=13000
BACKEND_EXTERNAL_PORT=18000
POSTGRES_EXTERNAL_PORT=5432
# 데이터베이스 설정
POSTGRES_DB=tk_mp_bom
POSTGRES_USER=tkmp_user
POSTGRES_PASSWORD=tkmp_password_2025
# 디버그 설정
DEBUG=true|false
LOG_LEVEL=DEBUG|INFO|WARNING|ERROR
```
## 🛠️ 사용 예시
### 개발 시작
```bash
# 개발 환경 시작
./docker-run.sh dev up
# 로그 확인
./docker-run.sh dev logs
# 상태 확인
./docker-run.sh dev ps
```
### 프로덕션 배포
```bash
# 이미지 빌드
./docker-run.sh prod build
# 프로덕션 시작
./docker-run.sh prod up
# 상태 확인
./docker-run.sh prod ps
```
### 시놀로지 NAS 배포
```bash
# 시놀로지 환경 시작
./docker-run.sh synology up
# 로그 확인
./docker-run.sh synology logs
```
### 컨테이너 관리
```bash
# 컨테이너 중지
./docker-run.sh dev down
# 컨테이너 재시작
./docker-run.sh dev restart
# 이미지 재빌드 (캐시 무시)
./docker-run.sh dev rebuild
```
## 🔍 트러블슈팅
### 포트 충돌 해결
환경 설정 파일에서 `*_EXTERNAL_PORT` 변수를 수정하세요.
### 볼륨 권한 문제
```bash
# 볼륨 삭제 후 재생성
docker volume prune
./docker-run.sh dev up
```
### 이미지 빌드 문제
```bash
# 캐시 없이 재빌드
./docker-run.sh dev rebuild
```
## 📁 파일 구조
```
TK-MP-Project/
├── docker-compose.yml # 통합 Docker Compose 파일
├── docker-run.sh # 실행 스크립트
├── env.development # 개발 환경 설정
├── env.production # 프로덕션 환경 설정
├── env.synology # 시놀로지 환경 설정
├── docker-backup/ # 기존 파일 백업
│ ├── docker-compose.yml
│ ├── docker-compose.prod.yml
│ ├── docker-compose.synology.yml
│ └── docker-compose.override.yml
└── DOCKER-GUIDE.md # 이 가이드 파일
```
## 🎯 마이그레이션 가이드
기존 Docker Compose 파일을 사용하던 경우:
1. **기존 컨테이너 중지**
```bash
docker-compose down
```
2. **새로운 방식으로 시작**
```bash
./docker-run.sh dev up
```
3. **기존 파일은 `docker-backup/` 폴더에 보관됨**

View File

@@ -1,28 +0,0 @@
# TK-MP-Project Backend 개선/확장/운영 권장사항
## 1. 코드 구조/품질
- ResponseModel(Pydantic) 적용: API 반환값의 타입 안정성 및 문서화 강화
- 로깅/에러 처리: print → logging 모듈, 운영 환경에 맞는 에러/이벤트 기록
- 환경변수/설정 분리: CORS, DB, 포트 등 환경별 관리 용이하게 분리
- 라우터 자동 등록/동적 관리: 라우터가 많아질 경우 코드 중복 최소화
## 2. 보안/운영
- CORS 제한: 운영 환경에서는 허용 origin을 제한
- 업로드 파일 검증 강화: 경로, 파일명, 크기 등 보안 검증 추가
## 3. 성능/확장성
- 대용량 파일/데이터 처리: 비동기/청크 처리, 인덱스 튜닝 등
- DB 트랜잭션 명확화: 파일/자재 저장 등에서 트랜잭션 관리 강화
## 4. 테스트/CI
- 자동화 테스트(assert 기반): print 위주 → assert 기반 자동화로 CI/CD 연동
- 테스트 커버리지 확대: 다양한 예외/경계 케이스 추가
## 5. 기타
- 코드/유틸 함수 분리: 중복 유틸 함수는 별도 모듈로 분리
- 상태/활성화 관리 enum화: status 등은 enum으로 관리
- 삭제/수정 API 추가: Job 등 주요 엔티티의 논리적 삭제/수정 지원
---
*2024-07-15 기준, backend 코드 리뷰 기반 개선/확장/운영 권장사항 정리*

123
README.md
View File

@@ -1,123 +0,0 @@
아! 이해했습니다! 😅
cat > README.md << 'EOF' 명령어에서 EOF까지의 모든 내용을 한 번에 입력하라는 뜻이에요.
즉, 이 전체 부분을 한 번에 복사해서 터미널에 붙여넣기하면 됩니다:
bashcat > README.md << 'EOF'
# 🚀 TK-MP-Project: BOM 시스템 개발 프로젝트
## 📋 프로젝트 개요
BOM (Bill of Materials) 시스템의 기능 이상을 해결하고, 도면 완성 후 자재 관리의 모든 프로세스를 자동화하는 종합 시스템 개발 프로젝트입니다.
## 🎯 프로젝트 목표
### 핵심 미션
**"도면 완성 후 자재 관리의 모든 번거로움을 해결"**
### 주요 해결 과제
- 📄 **파일 분석 자동화**: 엑셀/CSV 자재 목록의 자동 분류 및 정제
- 🔍 **정확한 분류 체계**: 파이프/피팅/볼트/밸브/계기류의 4단계 자동 분류
- 💾 **체계적 데이터 관리**: 프로젝트별 버전 관리 및 이력 추적
- 📊 **업무별 맞춤 출력**: 구매/생산/품질 각 팀의 필요에 맞는 자료 생성
- 🔄 **리비전 변화 추적**: 도면 변경 시 자재 변경사항 자동 비교
## 💻 기술 스택
### Backend
- **Language**: Python 3.9+
- **Framework**: FastAPI (고성능 API 서버)
- **Database**: PostgreSQL 15 (복잡한 관계형 데이터 처리)
- **ORM**: SQLAlchemy (데이터베이스 모델링)
- **Data Processing**: Pandas, openpyxl (파일 처리)
### DevOps & Tools
- **Containerization**: Docker & Docker Compose
- **Version Control**: Git (Gitea 호스팅)
- **Development**: VS Code + Python 확장
## 🌐 개발 환경 설정
### Git 저장소 접속
```bash
# VPN 연결 필요: vpn.hyungi.net:21194
git clone http://192.168.1.227:10300/hyungi/TK-MP-Project.git
cd TK-MP-Project
데이터베이스 실행
bash# PostgreSQL 및 pgAdmin 실행
docker-compose up -d postgres pgadmin redis
# 접속 확인
# pgAdmin: http://localhost:5050 (admin@tkmp.local / admin2025)
Python 개발 환경
bash# 가상환경 생성
python -m venv venv
source venv/bin/activate # macOS/Linux
# 의존성 설치
pip install -r backend/requirements.txt
# 개발 서버 실행
cd backend
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
📁 프로젝트 구조
TK-MP-Project/
├── README.md
├── docker-compose.yml
├── backend/ # FastAPI 백엔드
│ ├── app/
│ │ ├── models/ # SQLAlchemy 모델
│ │ ├── schemas/ # Pydantic 스키마
│ │ ├── api/ # API 라우터
│ │ ├── core/ # 설정 및 유틸리티
│ │ ├── services/ # 비즈니스 로직
│ │ └── database/ # DB 연결 설정
│ └── requirements.txt
├── database/ # DB 스키마 및 초기 데이터
│ └── init/
│ └── 01_schema.sql
└── docs/ # 프로젝트 문서
🚀 개발 로드맵
Phase 1: 기반 시스템 구축 (진행중)
Git 환경 구축 ✅
데이터베이스 스키마 설계 ✅
Docker 개발 환경 설정 ✅
FastAPI 기본 구조 구현
파일 업로드 및 파싱 기능
Phase 2: 핵심 기능 개발
자재 분류 알고리즘 구현
웹 인터페이스 구축
구매 BOM 생성 기능
Phase 3: 고도화
리비전 비교 기능
파이프 cutting 자료 생성
사용자 테스트 및 최적화
🗄️ 데이터베이스 스키마
핵심 테이블
projects: 프로젝트 관리 (코드 매칭, 버전 관리)
files: 업로드된 자재 목록 파일들
materials: 개별 자재 상세 정보 (분류 결과 포함)
주요 기능
프로젝트별 파일 버전 관리 (Rev.0, Rev.1, Rev.2)
자재 자동 분류 시스템 (카테고리, 재질, 사이즈)
분류 신뢰도 및 사용자 검증 시스템
📞 개발팀
Lead Developer: hyungi
Gitea Repository: http://192.168.1.227:10300/hyungi/TK-MP-Project
🎯 다음 단계
데이터베이스 실행: docker-compose up -d postgres pgadmin
Python 환경 구축: 가상환경 생성 및 패키지 설치
FastAPI 구조 구현: 기본 API 서버 및 모델 생성
Last Updated: 2025.07.14

View File

@@ -1,33 +0,0 @@
# TK-MP-Project Backend 코드 리뷰 요약
## 1. 전체 구조
- FastAPI + SQLAlchemy 기반 백엔드
- models, schemas, routers, services, api, uploads 등 역할별 디렉토리 분리
- 자재/BOM/스풀/계장 등 플랜트/조선/기계 실무에 특화된 구조
## 2. 주요 코드 검토
- **main.py**: 앱 진입점, CORS, 라우터 등록, 헬스체크 등
- **routers/**: 파일, 작업(Job) 등 API 엔드포인트 구현
- **services/**: 품목별 분류기(볼트, 밸브, 플랜지, 피팅, 가스켓, 파이프, 계장 등), 스풀 관리, 테스트 코드
- **material_classifier.py**: 재질 분류 공통 모듈, 규격/패턴/키워드 기반 robust 분류
- **spool_manager.py/v2**: 도면-에어리어-스풀 넘버링, 유효성 검증, 자동 추천 등
- **api/**: 과거 버전/백업/보조 코드(실제 서비스는 routers/가 메인)
- **테스트 코드**: 다양한 실무 케이스를 print 기반으로 커버(자동화는 미흡)
- **materials_schema.py**: 분류기에서 사용하는 규격/패턴/키워드/등급 등 데이터 정의
## 3. 품목별 분류기 구조
- 볼트/밸브/플랜지/피팅/가스켓/파이프/계장 등 각 품목별로 dict 기반 패턴/키워드/규격 관리
- material_classifier와 연동, 신뢰도/구매정보 등 실무적 정보 제공
- 구조/로직은 유사하나, 각 품목별 실무 특성에 맞는 분류 포인트 반영
## 4. 테스트 코드
- 다양한 실무 케이스를 print 기반으로 커버
- 자동화(assert) 기반 테스트는 미흡(추후 개선 필요)
## 5. materials_schema.py
- 분류기에서 사용하는 규격/패턴/키워드/등급 등 실무적 데이터가 체계적으로 구조화
- 신규 규격/등급/패턴 추가/수정이 용이
---
*2024-07-15 기준, 전체 backend 코드 리뷰 및 구조 요약*

2271
RULES.md

File diff suppressed because it is too large Load Diff

25
backend/.dockerignore Normal file
View File

@@ -0,0 +1,25 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env
venv
.venv
pip-log.txt
pip-delete-this-directory.txt
.tox
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.log
.git
.mypy_cache
.pytest_cache
.hypothesis
.DS_Store
uploads/*
!uploads/.gitkeep

31
backend/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# Python 3.9 베이스 이미지 사용
FROM python:3.9-slim
# 작업 디렉토리 설정
WORKDIR /app
# 시스템 패키지 업데이트 및 필요한 패키지 설치
RUN apt-get update && apt-get install -y \
gcc \
g++ \
libpq-dev \
libmagic1 \
libmagic-dev \
netcat-openbsd \
&& rm -rf /var/lib/apt/lists/*
# requirements.txt 복사 및 의존성 설치
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 애플리케이션 코드 복사
COPY . .
# 포트 8000 노출
EXPOSE 8000
# 환경변수 설정
ENV PYTHONPATH=/app
# 서버 실행
CMD ["bash", "start.sh"]

116
backend/alembic.ini Normal file
View File

@@ -0,0 +1,116 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
backend/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

116
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,116 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
import os
import sys
from pathlib import Path
# Backend root directory adding to path to allow imports
backend_path = Path(__file__).parent.parent
sys.path.append(str(backend_path))
from app.models import Base
from app.config import get_settings
target_metadata = Base.metadata
# Update config with app settings
# Update config with app settings
settings = get_settings()
# 우선적으로 환경변수에서 DB URL을 확인하여 설정 (로컬 마이그레이션용)
env_db_url = os.getenv("DATABASE_URL")
if env_db_url:
config.set_main_option("sqlalchemy.url", env_db_url)
else:
config.set_main_option("sqlalchemy.url", settings.get_database_url())
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
# Debug: Check what URL is actually being used
# 우선적으로 환경변수에서 DB URL을 확인하여 설정 (로컬 마이그레이션용)
env_db_url = os.getenv("DATABASE_URL")
if env_db_url:
print(f"DEBUG: Using DATABASE_URL from environment: {env_db_url}")
url = env_db_url
else:
url = config.get_main_option("sqlalchemy.url")
print(f"DEBUG: Using default configuration URL: {url}")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# Debug: Check what URL is actually being used
env_db_url = os.getenv("DATABASE_URL")
if env_db_url:
print(f"DEBUG: Using DATABASE_URL from environment: {env_db_url}")
config.set_main_option("sqlalchemy.url", env_db_url)
else:
url = config.get_main_option("sqlalchemy.url")
print(f"DEBUG: Using default configuration URL: {url}")
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,872 @@
"""Initial baseline
Revision ID: 8905071fdd15
Revises:
Create Date: 2026-01-09 09:29:05.123731
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '8905071fdd15'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('role_permissions')
op.drop_table('user_activity_logs')
op.drop_index('idx_support_details_file_id', table_name='support_details')
op.drop_index('idx_support_details_material_id', table_name='support_details')
op.drop_table('support_details')
op.drop_table('material_purchase_tracking')
op.drop_index('idx_purchase_confirmations_job_revision', table_name='purchase_confirmations')
op.drop_table('purchase_confirmations')
op.drop_table('valve_details')
op.drop_table('purchase_items')
op.drop_index('idx_revision_sessions_job_no', table_name='revision_sessions')
op.drop_index('idx_revision_sessions_status', table_name='revision_sessions')
op.drop_table('revision_sessions')
op.drop_table('instrument_details')
op.drop_table('fitting_details')
op.drop_table('flange_details')
op.drop_index('idx_special_material_details_file_id', table_name='special_material_details')
op.drop_index('idx_special_material_details_material_id', table_name='special_material_details')
op.drop_table('special_material_details')
op.drop_table('pipe_end_preparations')
op.drop_table('user_sessions')
op.drop_table('login_logs')
op.drop_table('material_revisions_comparison')
op.drop_index('idx_purchase_request_items_category', table_name='purchase_request_items')
op.drop_index('idx_purchase_request_items_material_id', table_name='purchase_request_items')
op.drop_index('idx_purchase_request_items_request_id', table_name='purchase_request_items')
op.drop_table('purchase_request_items')
op.drop_table('material_comparison_details')
op.drop_index('idx_confirmed_purchase_items_category', table_name='confirmed_purchase_items')
op.drop_index('idx_confirmed_purchase_items_confirmation', table_name='confirmed_purchase_items')
op.drop_table('confirmed_purchase_items')
op.drop_table('material_purchase_mapping')
op.drop_table('bolt_details')
op.drop_table('permissions')
op.drop_index('idx_purchase_requests_job_no', table_name='purchase_requests')
op.drop_index('idx_purchase_requests_requested_by', table_name='purchase_requests')
op.drop_index('idx_purchase_requests_status', table_name='purchase_requests')
op.drop_table('purchase_requests')
op.drop_table('gasket_details')
op.drop_index('idx_revision_changes_action', table_name='revision_material_changes')
op.drop_index('idx_revision_changes_session', table_name='revision_material_changes')
op.drop_index('idx_revision_changes_status', table_name='revision_material_changes')
op.drop_table('revision_material_changes')
op.drop_index('idx_revision_logs_date', table_name='revision_action_logs')
op.drop_index('idx_revision_logs_session', table_name='revision_action_logs')
op.drop_index('idx_revision_logs_type', table_name='revision_action_logs')
op.drop_table('revision_action_logs')
op.drop_index('idx_inventory_transfers_date', table_name='inventory_transfers')
op.drop_index('idx_inventory_transfers_material', table_name='inventory_transfers')
op.drop_table('inventory_transfers')
op.drop_table('users')
op.drop_table('jobs')
op.drop_index('idx_files_active', table_name='files')
op.drop_index('idx_files_project', table_name='files')
op.drop_index('idx_files_purchase_confirmed', table_name='files')
op.drop_index('idx_files_uploaded_by', table_name='files')
op.create_index(op.f('ix_files_id'), 'files', ['id'], unique=False)
op.drop_constraint('files_project_id_fkey', 'files', type_='foreignkey')
op.create_foreign_key(None, 'files', 'projects', ['project_id'], ['id'])
op.drop_column('files', 'description')
op.drop_column('files', 'classification_completed')
op.drop_column('files', 'purchase_confirmed')
op.drop_column('files', 'bom_name')
op.drop_column('files', 'confirmed_at')
op.drop_column('files', 'confirmed_by')
op.drop_column('files', 'job_no')
op.drop_column('files', 'parsed_count')
op.create_index(op.f('ix_material_categories_id'), 'material_categories', ['id'], unique=False)
op.create_index(op.f('ix_material_grades_id'), 'material_grades', ['id'], unique=False)
op.create_index(op.f('ix_material_patterns_id'), 'material_patterns', ['id'], unique=False)
op.create_index(op.f('ix_material_specifications_id'), 'material_specifications', ['id'], unique=False)
op.drop_constraint('material_standards_standard_code_key', 'material_standards', type_='unique')
op.create_index(op.f('ix_material_standards_id'), 'material_standards', ['id'], unique=False)
op.create_index(op.f('ix_material_standards_standard_code'), 'material_standards', ['standard_code'], unique=True)
op.create_index(op.f('ix_material_tubing_mapping_id'), 'material_tubing_mapping', ['id'], unique=False)
op.alter_column('materials', 'verified_by',
existing_type=sa.VARCHAR(length=100),
type_=sa.String(length=50),
existing_nullable=True)
op.alter_column('materials', 'material_hash',
existing_type=sa.VARCHAR(length=64),
type_=sa.String(length=100),
existing_nullable=True)
op.alter_column('materials', 'full_material_grade',
existing_type=sa.TEXT(),
type_=sa.String(length=100),
existing_nullable=True)
op.drop_index('idx_materials_category', table_name='materials')
op.drop_index('idx_materials_classification_details', table_name='materials', postgresql_using='gin')
op.drop_index('idx_materials_file', table_name='materials')
op.drop_index('idx_materials_material_size', table_name='materials')
op.create_index(op.f('ix_materials_id'), 'materials', ['id'], unique=False)
op.drop_constraint('materials_file_id_fkey', 'materials', type_='foreignkey')
op.create_foreign_key(None, 'materials', 'files', ['file_id'], ['id'])
op.drop_column('materials', 'classification_details')
op.add_column('pipe_details', sa.Column('material_standard', sa.String(length=50), nullable=True))
op.add_column('pipe_details', sa.Column('material_grade', sa.String(length=50), nullable=True))
op.add_column('pipe_details', sa.Column('material_type', sa.String(length=50), nullable=True))
op.add_column('pipe_details', sa.Column('wall_thickness', sa.String(length=50), nullable=True))
op.add_column('pipe_details', sa.Column('nominal_size', sa.String(length=50), nullable=True))
op.add_column('pipe_details', sa.Column('material_confidence', sa.Numeric(precision=3, scale=2), nullable=True))
op.add_column('pipe_details', sa.Column('manufacturing_confidence', sa.Numeric(precision=3, scale=2), nullable=True))
op.add_column('pipe_details', sa.Column('end_prep_confidence', sa.Numeric(precision=3, scale=2), nullable=True))
op.add_column('pipe_details', sa.Column('schedule_confidence', sa.Numeric(precision=3, scale=2), nullable=True))
op.alter_column('pipe_details', 'file_id',
existing_type=sa.INTEGER(),
nullable=False)
op.create_index(op.f('ix_pipe_details_id'), 'pipe_details', ['id'], unique=False)
op.drop_constraint('pipe_details_material_id_fkey', 'pipe_details', type_='foreignkey')
op.drop_constraint('pipe_details_file_id_fkey', 'pipe_details', type_='foreignkey')
op.create_foreign_key(None, 'pipe_details', 'files', ['file_id'], ['id'])
op.drop_column('pipe_details', 'outer_diameter')
op.drop_column('pipe_details', 'additional_info')
op.drop_column('pipe_details', 'classification_confidence')
op.drop_column('pipe_details', 'material_id')
op.drop_column('pipe_details', 'material_spec')
op.drop_index('idx_projects_design_code', table_name='projects')
op.drop_index('idx_projects_official_code', table_name='projects')
op.drop_constraint('projects_official_project_code_key', 'projects', type_='unique')
op.create_index(op.f('ix_projects_id'), 'projects', ['id'], unique=False)
op.create_index(op.f('ix_projects_official_project_code'), 'projects', ['official_project_code'], unique=True)
op.create_index(op.f('ix_requirement_types_id'), 'requirement_types', ['id'], unique=False)
op.create_index(op.f('ix_special_material_grades_id'), 'special_material_grades', ['id'], unique=False)
op.create_index(op.f('ix_special_material_patterns_id'), 'special_material_patterns', ['id'], unique=False)
op.create_index(op.f('ix_special_materials_id'), 'special_materials', ['id'], unique=False)
op.create_index(op.f('ix_tubing_categories_id'), 'tubing_categories', ['id'], unique=False)
op.alter_column('tubing_manufacturers', 'contact_info',
existing_type=postgresql.JSONB(astext_type=sa.Text()),
type_=sa.JSON(),
existing_nullable=True)
op.alter_column('tubing_manufacturers', 'quality_certs',
existing_type=postgresql.JSONB(astext_type=sa.Text()),
type_=sa.JSON(),
existing_nullable=True)
op.create_index(op.f('ix_tubing_manufacturers_id'), 'tubing_manufacturers', ['id'], unique=False)
op.alter_column('tubing_products', 'last_price_update',
existing_type=sa.DATE(),
type_=sa.DateTime(),
existing_nullable=True)
op.drop_constraint('tubing_products_specification_id_manufacturer_id_manufactur_key', 'tubing_products', type_='unique')
op.create_index(op.f('ix_tubing_products_id'), 'tubing_products', ['id'], unique=False)
op.create_index(op.f('ix_tubing_specifications_id'), 'tubing_specifications', ['id'], unique=False)
op.alter_column('user_requirements', 'due_date',
existing_type=sa.DATE(),
type_=sa.DateTime(),
existing_nullable=True)
op.create_index(op.f('ix_user_requirements_id'), 'user_requirements', ['id'], unique=False)
op.drop_constraint('user_requirements_material_id_fkey', 'user_requirements', type_='foreignkey')
op.drop_constraint('user_requirements_file_id_fkey', 'user_requirements', type_='foreignkey')
op.create_foreign_key(None, 'user_requirements', 'materials', ['material_id'], ['id'])
op.create_foreign_key(None, 'user_requirements', 'files', ['file_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'user_requirements', type_='foreignkey')
op.drop_constraint(None, 'user_requirements', type_='foreignkey')
op.create_foreign_key('user_requirements_file_id_fkey', 'user_requirements', 'files', ['file_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('user_requirements_material_id_fkey', 'user_requirements', 'materials', ['material_id'], ['id'], ondelete='CASCADE')
op.drop_index(op.f('ix_user_requirements_id'), table_name='user_requirements')
op.alter_column('user_requirements', 'due_date',
existing_type=sa.DateTime(),
type_=sa.DATE(),
existing_nullable=True)
op.drop_index(op.f('ix_tubing_specifications_id'), table_name='tubing_specifications')
op.drop_index(op.f('ix_tubing_products_id'), table_name='tubing_products')
op.create_unique_constraint('tubing_products_specification_id_manufacturer_id_manufactur_key', 'tubing_products', ['specification_id', 'manufacturer_id', 'manufacturer_part_number'])
op.alter_column('tubing_products', 'last_price_update',
existing_type=sa.DateTime(),
type_=sa.DATE(),
existing_nullable=True)
op.drop_index(op.f('ix_tubing_manufacturers_id'), table_name='tubing_manufacturers')
op.alter_column('tubing_manufacturers', 'quality_certs',
existing_type=sa.JSON(),
type_=postgresql.JSONB(astext_type=sa.Text()),
existing_nullable=True)
op.alter_column('tubing_manufacturers', 'contact_info',
existing_type=sa.JSON(),
type_=postgresql.JSONB(astext_type=sa.Text()),
existing_nullable=True)
op.drop_index(op.f('ix_tubing_categories_id'), table_name='tubing_categories')
op.drop_index(op.f('ix_special_materials_id'), table_name='special_materials')
op.drop_index(op.f('ix_special_material_patterns_id'), table_name='special_material_patterns')
op.drop_index(op.f('ix_special_material_grades_id'), table_name='special_material_grades')
op.drop_index(op.f('ix_requirement_types_id'), table_name='requirement_types')
op.drop_index(op.f('ix_projects_official_project_code'), table_name='projects')
op.drop_index(op.f('ix_projects_id'), table_name='projects')
op.create_unique_constraint('projects_official_project_code_key', 'projects', ['official_project_code'])
op.create_index('idx_projects_official_code', 'projects', ['official_project_code'], unique=False)
op.create_index('idx_projects_design_code', 'projects', ['design_project_code'], unique=False)
op.add_column('pipe_details', sa.Column('material_spec', sa.VARCHAR(length=100), autoincrement=False, nullable=True))
op.add_column('pipe_details', sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True))
op.add_column('pipe_details', sa.Column('classification_confidence', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True))
op.add_column('pipe_details', sa.Column('additional_info', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True))
op.add_column('pipe_details', sa.Column('outer_diameter', sa.VARCHAR(length=50), autoincrement=False, nullable=True))
op.drop_constraint(None, 'pipe_details', type_='foreignkey')
op.create_foreign_key('pipe_details_file_id_fkey', 'pipe_details', 'files', ['file_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('pipe_details_material_id_fkey', 'pipe_details', 'materials', ['material_id'], ['id'], ondelete='CASCADE')
op.drop_index(op.f('ix_pipe_details_id'), table_name='pipe_details')
op.alter_column('pipe_details', 'file_id',
existing_type=sa.INTEGER(),
nullable=True)
op.drop_column('pipe_details', 'schedule_confidence')
op.drop_column('pipe_details', 'end_prep_confidence')
op.drop_column('pipe_details', 'manufacturing_confidence')
op.drop_column('pipe_details', 'material_confidence')
op.drop_column('pipe_details', 'nominal_size')
op.drop_column('pipe_details', 'wall_thickness')
op.drop_column('pipe_details', 'material_type')
op.drop_column('pipe_details', 'material_grade')
op.drop_column('pipe_details', 'material_standard')
op.add_column('materials', sa.Column('classification_details', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True))
op.drop_constraint(None, 'materials', type_='foreignkey')
op.create_foreign_key('materials_file_id_fkey', 'materials', 'files', ['file_id'], ['id'], ondelete='CASCADE')
op.drop_index(op.f('ix_materials_id'), table_name='materials')
op.create_index('idx_materials_material_size', 'materials', ['material_grade', 'size_spec'], unique=False)
op.create_index('idx_materials_file', 'materials', ['file_id'], unique=False)
op.create_index('idx_materials_classification_details', 'materials', ['classification_details'], unique=False, postgresql_using='gin')
op.create_index('idx_materials_category', 'materials', ['classified_category', 'classified_subcategory'], unique=False)
op.alter_column('materials', 'full_material_grade',
existing_type=sa.String(length=100),
type_=sa.TEXT(),
existing_nullable=True)
op.alter_column('materials', 'material_hash',
existing_type=sa.String(length=100),
type_=sa.VARCHAR(length=64),
existing_nullable=True)
op.alter_column('materials', 'verified_by',
existing_type=sa.String(length=50),
type_=sa.VARCHAR(length=100),
existing_nullable=True)
op.drop_index(op.f('ix_material_tubing_mapping_id'), table_name='material_tubing_mapping')
op.drop_index(op.f('ix_material_standards_standard_code'), table_name='material_standards')
op.drop_index(op.f('ix_material_standards_id'), table_name='material_standards')
op.create_unique_constraint('material_standards_standard_code_key', 'material_standards', ['standard_code'])
op.drop_index(op.f('ix_material_specifications_id'), table_name='material_specifications')
op.drop_index(op.f('ix_material_patterns_id'), table_name='material_patterns')
op.drop_index(op.f('ix_material_grades_id'), table_name='material_grades')
op.drop_index(op.f('ix_material_categories_id'), table_name='material_categories')
op.add_column('files', sa.Column('parsed_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True))
op.add_column('files', sa.Column('job_no', sa.VARCHAR(length=50), autoincrement=False, nullable=True))
op.add_column('files', sa.Column('confirmed_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True, comment='구매 수량 확정자'))
op.add_column('files', sa.Column('confirmed_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True, comment='구매 수량 확정 시간'))
op.add_column('files', sa.Column('bom_name', sa.VARCHAR(length=255), autoincrement=False, nullable=True))
op.add_column('files', sa.Column('purchase_confirmed', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True, comment='구매 수량 확정 여부'))
op.add_column('files', sa.Column('classification_completed', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True))
op.add_column('files', sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True))
op.drop_constraint(None, 'files', type_='foreignkey')
op.create_foreign_key('files_project_id_fkey', 'files', 'projects', ['project_id'], ['id'], ondelete='CASCADE')
op.drop_index(op.f('ix_files_id'), table_name='files')
op.create_index('idx_files_uploaded_by', 'files', ['uploaded_by'], unique=False)
op.create_index('idx_files_purchase_confirmed', 'files', ['purchase_confirmed'], unique=False)
op.create_index('idx_files_project', 'files', ['project_id'], unique=False)
op.create_index('idx_files_active', 'files', ['is_active'], unique=False)
op.create_table('jobs',
sa.Column('job_no', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('job_name', sa.VARCHAR(length=200), autoincrement=False, nullable=False),
sa.Column('client_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('end_user', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('epc_company', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('project_site', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
sa.Column('contract_date', sa.DATE(), autoincrement=False, nullable=True),
sa.Column('delivery_date', sa.DATE(), autoincrement=False, nullable=True),
sa.Column('delivery_terms', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'진행중'::character varying"), autoincrement=False, nullable=True),
sa.Column('delivery_completed_date', sa.DATE(), autoincrement=False, nullable=True),
sa.Column('project_closed_date', sa.DATE(), autoincrement=False, nullable=True),
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('created_by', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
sa.Column('updated_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('assigned_to', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('project_type', sa.VARCHAR(length=50), server_default=sa.text("'냉동기'::character varying"), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('job_no', name='jobs_pkey')
)
op.create_table('users',
sa.Column('user_id', sa.INTEGER(), server_default=sa.text("nextval('users_user_id_seq'::regclass)"), autoincrement=True, nullable=False),
sa.Column('username', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('password', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('email', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('role', sa.VARCHAR(length=20), server_default=sa.text("'user'::character varying"), autoincrement=False, nullable=True),
sa.Column('access_level', sa.VARCHAR(length=20), server_default=sa.text("'worker'::character varying"), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
sa.Column('failed_login_attempts', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('locked_until', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('department', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('position', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('phone', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('last_login_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'active'::character varying"), autoincrement=False, nullable=True),
sa.CheckConstraint("access_level::text = ANY (ARRAY['admin'::character varying, 'system'::character varying, 'group_leader'::character varying, 'support_team'::character varying, 'worker'::character varying]::text[])", name='users_access_level_check'),
sa.CheckConstraint("role::text = ANY (ARRAY['admin'::character varying, 'system'::character varying, 'leader'::character varying, 'support'::character varying, 'user'::character varying]::text[])", name='users_role_check'),
sa.PrimaryKeyConstraint('user_id', name='users_pkey'),
sa.UniqueConstraint('username', name='users_username_key'),
postgresql_ignore_search_path=False
)
op.create_table('inventory_transfers',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('revision_change_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('material_description', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('category', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('quantity', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=False),
sa.Column('unit', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
sa.Column('inventory_location', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('storage_notes', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('transferred_by', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('transferred_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'transferred'::character varying"), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['revision_change_id'], ['revision_material_changes.id'], name='inventory_transfers_revision_change_id_fkey'),
sa.PrimaryKeyConstraint('id', name='inventory_transfers_pkey')
)
op.create_index('idx_inventory_transfers_material', 'inventory_transfers', ['material_description'], unique=False)
op.create_index('idx_inventory_transfers_date', 'inventory_transfers', ['transferred_at'], unique=False)
op.create_table('revision_action_logs',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('session_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('revision_change_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('action_type', sa.VARCHAR(length=30), autoincrement=False, nullable=False),
sa.Column('action_description', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('executed_by', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('executed_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('result', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
sa.Column('result_message', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('result_data', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['revision_change_id'], ['revision_material_changes.id'], name='revision_action_logs_revision_change_id_fkey'),
sa.ForeignKeyConstraint(['session_id'], ['revision_sessions.id'], name='revision_action_logs_session_id_fkey'),
sa.PrimaryKeyConstraint('id', name='revision_action_logs_pkey')
)
op.create_index('idx_revision_logs_type', 'revision_action_logs', ['action_type'], unique=False)
op.create_index('idx_revision_logs_session', 'revision_action_logs', ['session_id'], unique=False)
op.create_index('idx_revision_logs_date', 'revision_action_logs', ['executed_at'], unique=False)
op.create_table('revision_material_changes',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('session_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('previous_material_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('material_description', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('category', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('change_type', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
sa.Column('previous_quantity', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
sa.Column('current_quantity', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
sa.Column('quantity_difference', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
sa.Column('purchase_status', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
sa.Column('purchase_confirmed_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('revision_action', sa.VARCHAR(length=30), autoincrement=False, nullable=True),
sa.Column('action_status', sa.VARCHAR(length=20), server_default=sa.text("'pending'::character varying"), autoincrement=False, nullable=True),
sa.Column('processed_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('processed_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('processing_notes', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='revision_material_changes_material_id_fkey'),
sa.ForeignKeyConstraint(['session_id'], ['revision_sessions.id'], name='revision_material_changes_session_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='revision_material_changes_pkey')
)
op.create_index('idx_revision_changes_status', 'revision_material_changes', ['action_status'], unique=False)
op.create_index('idx_revision_changes_session', 'revision_material_changes', ['session_id'], unique=False)
op.create_index('idx_revision_changes_action', 'revision_material_changes', ['revision_action'], unique=False)
op.create_table('gasket_details',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('gasket_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('gasket_subtype', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('material_type', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('filler_material', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('size_inches', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('pressure_rating', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('thickness', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('temperature_range', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('fire_safe', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('classification_confidence', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
sa.Column('additional_info', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='gasket_details_file_id_fkey', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='gasket_details_material_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='gasket_details_pkey')
)
op.create_table('purchase_requests',
sa.Column('request_id', sa.INTEGER(), server_default=sa.text("nextval('purchase_requests_request_id_seq'::regclass)"), autoincrement=True, nullable=False),
sa.Column('request_no', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('job_no', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('category', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('material_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('excel_file_path', sa.VARCHAR(length=500), autoincrement=False, nullable=True),
sa.Column('project_name', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
sa.Column('requested_by', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('requested_by_username', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('request_date', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'pending'::character varying"), autoincrement=False, nullable=True),
sa.Column('total_items', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('notes', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('approved_by', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('approved_by_username', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('approved_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['approved_by'], ['users.user_id'], name='purchase_requests_approved_by_fkey'),
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='purchase_requests_file_id_fkey'),
sa.ForeignKeyConstraint(['requested_by'], ['users.user_id'], name='purchase_requests_requested_by_fkey'),
sa.PrimaryKeyConstraint('request_id', name='purchase_requests_pkey'),
sa.UniqueConstraint('request_no', name='purchase_requests_request_no_key'),
postgresql_ignore_search_path=False
)
op.create_index('idx_purchase_requests_status', 'purchase_requests', ['status'], unique=False)
op.create_index('idx_purchase_requests_requested_by', 'purchase_requests', ['requested_by'], unique=False)
op.create_index('idx_purchase_requests_job_no', 'purchase_requests', ['job_no'], unique=False)
op.create_table('permissions',
sa.Column('permission_id', sa.INTEGER(), server_default=sa.text("nextval('permissions_permission_id_seq'::regclass)"), autoincrement=True, nullable=False),
sa.Column('permission_name', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('module', sa.VARCHAR(length=30), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('permission_id', name='permissions_pkey'),
sa.UniqueConstraint('permission_name', name='permissions_permission_name_key'),
postgresql_ignore_search_path=False
)
op.create_table('bolt_details',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('bolt_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('thread_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('diameter', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('length', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('material_standard', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('material_grade', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('coating_type', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('pressure_rating', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('includes_nut', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('includes_washer', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('nut_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('washer_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('classification_confidence', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
sa.Column('additional_info', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='bolt_details_file_id_fkey', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='bolt_details_material_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='bolt_details_pkey')
)
op.create_table('material_purchase_mapping',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('purchase_item_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('quantity_ratio', sa.NUMERIC(precision=5, scale=2), server_default=sa.text('1.0'), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='material_purchase_mapping_material_id_fkey', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['purchase_item_id'], ['purchase_items.id'], name='material_purchase_mapping_purchase_item_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='material_purchase_mapping_pkey'),
sa.UniqueConstraint('material_id', 'purchase_item_id', name='material_purchase_mapping_material_id_purchase_item_id_key')
)
op.create_table('confirmed_purchase_items',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('confirmation_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('item_code', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('category', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('specification', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('size', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('material', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('bom_quantity', sa.NUMERIC(precision=15, scale=3), server_default=sa.text('0'), autoincrement=False, nullable=False),
sa.Column('calculated_qty', sa.NUMERIC(precision=15, scale=3), server_default=sa.text('0'), autoincrement=False, nullable=False),
sa.Column('unit', sa.VARCHAR(length=20), server_default=sa.text("'EA'::character varying"), autoincrement=False, nullable=False),
sa.Column('safety_factor', sa.NUMERIC(precision=5, scale=3), server_default=sa.text('1.0'), autoincrement=False, nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['confirmation_id'], ['purchase_confirmations.id'], name='confirmed_purchase_items_confirmation_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='confirmed_purchase_items_pkey'),
comment='확정된 구매 품목 상세 테이블'
)
op.create_index('idx_confirmed_purchase_items_confirmation', 'confirmed_purchase_items', ['confirmation_id'], unique=False)
op.create_index('idx_confirmed_purchase_items_category', 'confirmed_purchase_items', ['category'], unique=False)
op.create_table('material_comparison_details',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('comparison_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('material_hash', sa.VARCHAR(length=32), autoincrement=False, nullable=False),
sa.Column('change_type', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('size_spec', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('material_grade', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('previous_quantity', sa.NUMERIC(precision=10, scale=3), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('current_quantity', sa.NUMERIC(precision=10, scale=3), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('quantity_diff', sa.NUMERIC(precision=10, scale=3), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('additional_purchase_needed', sa.NUMERIC(precision=10, scale=3), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('classified_category', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('classification_confidence', sa.NUMERIC(precision=3, scale=2), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['comparison_id'], ['material_revisions_comparison.id'], name='material_comparison_details_comparison_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='material_comparison_details_pkey')
)
op.create_table('purchase_request_items',
sa.Column('item_id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('request_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('category', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('subcategory', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('material_grade', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('size_spec', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('quantity', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=False),
sa.Column('unit', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
sa.Column('drawing_name', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('notes', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('user_requirement', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('is_ordered', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True),
sa.Column('is_received', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='purchase_request_items_material_id_fkey', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['request_id'], ['purchase_requests.request_id'], name='purchase_request_items_request_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('item_id', name='purchase_request_items_pkey')
)
op.create_index('idx_purchase_request_items_request_id', 'purchase_request_items', ['request_id'], unique=False)
op.create_index('idx_purchase_request_items_material_id', 'purchase_request_items', ['material_id'], unique=False)
op.create_index('idx_purchase_request_items_category', 'purchase_request_items', ['category'], unique=False)
op.create_table('material_revisions_comparison',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('job_no', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('current_revision', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
sa.Column('previous_revision', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
sa.Column('current_file_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('previous_file_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('total_current_items', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('total_previous_items', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('new_items_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('modified_items_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('removed_items_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('unchanged_items_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('comparison_details', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('created_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['current_file_id'], ['files.id'], name='material_revisions_comparison_current_file_id_fkey'),
sa.ForeignKeyConstraint(['previous_file_id'], ['files.id'], name='material_revisions_comparison_previous_file_id_fkey'),
sa.PrimaryKeyConstraint('id', name='material_revisions_comparison_pkey'),
sa.UniqueConstraint('job_no', 'current_revision', 'previous_revision', name='material_revisions_comparison_job_no_current_revision_previ_key')
)
op.create_table('login_logs',
sa.Column('log_id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('login_time', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('ip_address', sa.VARCHAR(length=45), autoincrement=False, nullable=True),
sa.Column('user_agent', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('login_status', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
sa.Column('failure_reason', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('session_duration', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.CheckConstraint("login_status::text = ANY (ARRAY['success'::character varying, 'failed'::character varying]::text[])", name='login_logs_login_status_check'),
sa.ForeignKeyConstraint(['user_id'], ['users.user_id'], name='login_logs_user_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('log_id', name='login_logs_pkey')
)
op.create_table('user_sessions',
sa.Column('session_id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('refresh_token', sa.VARCHAR(length=500), autoincrement=False, nullable=False),
sa.Column('expires_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.Column('ip_address', sa.VARCHAR(length=45), autoincrement=False, nullable=True),
sa.Column('user_agent', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.user_id'], name='user_sessions_user_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('session_id', name='user_sessions_pkey')
)
op.create_table('pipe_end_preparations',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('end_preparation_type', sa.VARCHAR(length=50), server_default=sa.text("'PBE'::character varying"), autoincrement=False, nullable=True),
sa.Column('end_preparation_code', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
sa.Column('machining_required', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True),
sa.Column('cutting_note', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('original_description', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('clean_description', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('confidence', sa.DOUBLE_PRECISION(precision=53), server_default=sa.text('0.0'), autoincrement=False, nullable=True),
sa.Column('matched_pattern', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='pipe_end_preparations_file_id_fkey', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='pipe_end_preparations_material_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='pipe_end_preparations_pkey')
)
op.create_table('special_material_details',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('special_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('special_subtype', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('material_standard', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('material_grade', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('specifications', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('dimensions', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('weight_kg', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
sa.Column('classification_confidence', sa.NUMERIC(precision=3, scale=2), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='special_material_details_file_id_fkey', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='special_material_details_material_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='special_material_details_pkey')
)
op.create_index('idx_special_material_details_material_id', 'special_material_details', ['material_id'], unique=False)
op.create_index('idx_special_material_details_file_id', 'special_material_details', ['file_id'], unique=False)
op.create_table('flange_details',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('flange_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('facing_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('pressure_rating', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('material_standard', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('material_grade', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('size_inches', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('bolt_hole_count', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('bolt_hole_size', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('classification_confidence', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
sa.Column('additional_info', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='flange_details_file_id_fkey', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='flange_details_material_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='flange_details_pkey')
)
op.create_table('fitting_details',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('fitting_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('fitting_subtype', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('connection_method', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('connection_code', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('pressure_rating', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('max_pressure', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('manufacturing_method', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('material_standard', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('material_grade', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('material_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('main_size', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('reduced_size', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('length_mm', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
sa.Column('schedule', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('classification_confidence', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
sa.Column('additional_info', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='fitting_details_file_id_fkey', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='fitting_details_material_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='fitting_details_pkey')
)
op.create_table('instrument_details',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('instrument_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('instrument_subtype', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('measurement_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('measurement_range', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('accuracy', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('connection_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('connection_size', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('body_material', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('wetted_parts_material', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('electrical_rating', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('output_signal', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('classification_confidence', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
sa.Column('additional_info', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='instrument_details_file_id_fkey', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='instrument_details_material_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='instrument_details_pkey')
)
op.create_table('revision_sessions',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('job_no', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('current_file_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('previous_file_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('current_revision', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
sa.Column('previous_revision', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'processing'::character varying"), autoincrement=False, nullable=True),
sa.Column('total_materials', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('processed_materials', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('added_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('removed_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('changed_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('unchanged_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('purchase_cancel_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('inventory_transfer_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('additional_purchase_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('created_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('completed_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['current_file_id'], ['files.id'], name='revision_sessions_current_file_id_fkey'),
sa.ForeignKeyConstraint(['previous_file_id'], ['files.id'], name='revision_sessions_previous_file_id_fkey'),
sa.PrimaryKeyConstraint('id', name='revision_sessions_pkey')
)
op.create_index('idx_revision_sessions_status', 'revision_sessions', ['status'], unique=False)
op.create_index('idx_revision_sessions_job_no', 'revision_sessions', ['job_no'], unique=False)
op.create_table('purchase_items',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('item_code', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('category', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('specification', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('material_spec', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
sa.Column('size_spec', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('unit', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
sa.Column('bom_quantity', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=False),
sa.Column('safety_factor', sa.NUMERIC(precision=3, scale=2), server_default=sa.text('1.10'), autoincrement=False, nullable=True),
sa.Column('minimum_order_qty', sa.NUMERIC(precision=10, scale=3), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('order_unit_qty', sa.NUMERIC(precision=10, scale=3), server_default=sa.text('1'), autoincrement=False, nullable=True),
sa.Column('calculated_qty', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
sa.Column('cutting_loss', sa.NUMERIC(precision=10, scale=3), server_default=sa.text('0'), autoincrement=False, nullable=True),
sa.Column('standard_length', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
sa.Column('pipes_count', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('waste_length', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
sa.Column('detailed_spec', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('preferred_supplier', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
sa.Column('last_unit_price', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=True),
sa.Column('currency', sa.VARCHAR(length=10), server_default=sa.text("'KRW'::character varying"), autoincrement=False, nullable=True),
sa.Column('lead_time_days', sa.INTEGER(), server_default=sa.text('30'), autoincrement=False, nullable=True),
sa.Column('job_no', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('revision', sa.VARCHAR(length=20), server_default=sa.text("'Rev.0'::character varying"), autoincrement=False, nullable=True),
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('created_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('updated_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('approved_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('approved_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='purchase_items_file_id_fkey', ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name='purchase_items_pkey'),
sa.UniqueConstraint('item_code', name='purchase_items_item_code_key')
)
op.create_table('valve_details',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('valve_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('valve_subtype', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('actuator_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('connection_method', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('pressure_rating', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('pressure_class', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('body_material', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('trim_material', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('size_inches', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('fire_safe', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('low_temp_service', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('special_features', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('classification_confidence', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
sa.Column('additional_info', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='valve_details_file_id_fkey', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='valve_details_material_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='valve_details_pkey')
)
op.create_table('purchase_confirmations',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('job_no', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('bom_name', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
sa.Column('revision', sa.VARCHAR(length=50), server_default=sa.text("'Rev.0'::character varying"), autoincrement=False, nullable=False),
sa.Column('confirmed_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.Column('confirmed_by', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='purchase_confirmations_file_id_fkey'),
sa.PrimaryKeyConstraint('id', name='purchase_confirmations_pkey'),
comment='구매 수량 확정 마스터 테이블'
)
op.create_index('idx_purchase_confirmations_job_revision', 'purchase_confirmations', ['job_no', 'revision', 'is_active'], unique=False)
op.create_table('material_purchase_tracking',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('material_hash', sa.VARCHAR(length=64), autoincrement=False, nullable=False),
sa.Column('original_description', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('size_spec', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('material_grade', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('bom_quantity', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=False),
sa.Column('confirmed_quantity', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
sa.Column('purchase_quantity', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'pending'::character varying"), autoincrement=False, nullable=True),
sa.Column('confirmed_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('confirmed_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('ordered_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('ordered_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('approved_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('approved_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('job_no', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('revision', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('purchase_status', sa.VARCHAR(length=20), server_default=sa.text("'pending'::character varying"), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='material_purchase_tracking_file_id_fkey', ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name='material_purchase_tracking_pkey')
)
op.create_table('support_details',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('support_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('support_subtype', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('load_rating', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('load_capacity', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('material_standard', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('material_grade', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('pipe_size', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
sa.Column('length_mm', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=True),
sa.Column('width_mm', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=True),
sa.Column('height_mm', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=True),
sa.Column('classification_confidence', sa.NUMERIC(precision=3, scale=2), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='support_details_file_id_fkey', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='support_details_material_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='support_details_pkey')
)
op.create_index('idx_support_details_material_id', 'support_details', ['material_id'], unique=False)
op.create_index('idx_support_details_file_id', 'support_details', ['file_id'], unique=False)
op.create_table('user_activity_logs',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('activity_type', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('activity_description', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('target_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('target_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('ip_address', sa.VARCHAR(length=45), autoincrement=False, nullable=True),
sa.Column('user_agent', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id', name='user_activity_logs_pkey')
)
op.create_table('role_permissions',
sa.Column('role_permission_id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('role', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
sa.Column('permission_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['permission_id'], ['permissions.permission_id'], name='role_permissions_permission_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('role_permission_id', name='role_permissions_pkey'),
sa.UniqueConstraint('role', 'permission_id', name='role_permissions_role_permission_id_key')
)
# ### end Alembic commands ###

View File

@@ -1,166 +0,0 @@
from flask import Flask, request, jsonify
import psycopg2
from contextlib import contextmanager
app = Flask(__name__)
@contextmanager
def get_db_connection():
conn = psycopg2.connect(
host="localhost",
database="tkmp_db",
user="tkmp_user",
password="tkmp2024!",
port="5432"
)
try:
yield conn
finally:
conn.close()
@app.route('/')
def home():
return {"message": "API 작동 중"}
@app.route('/api/materials')
def get_materials():
job_number = request.args.get('job_number')
if not job_number:
return {"error": "job_number 필요"}, 400
try:
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute("""
SELECT id, job_number, item_number, description,
category, quantity, unit, created_at
FROM materials
WHERE job_number = %s
ORDER BY item_number
""", (job_number,))
rows = cur.fetchall()
materials = []
for r in rows:
item = {
'id': r[0],
'job_number': r[1],
'item_number': r[2],
'description': r[3],
'category': r[4],
'quantity': r[5],
'unit': r[6],
'created_at': str(r[7]) if r[7] else None
}
materials.append(item)
return {
'success': True,
'data': materials,
'count': len(materials)
}
except Exception as e:
return {"error": f"DB 오류: {str(e)}"}, 500
if __name__ == '__main__':
print("🚀 서버 시작: http://localhost:5000")
app.run(debug=True, port=5000)
# 수정된 get_materials API (올바른 컬럼명 사용)
@app.route('/api/materials-fixed', methods=['GET'])
def get_materials_fixed():
"""올바른 컬럼명을 사용한 자재 조회 API"""
try:
file_id = request.args.get('file_id')
if not file_id:
return jsonify({
'success': False,
'error': 'file_id parameter is required'
}), 400
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute("""
SELECT
id, file_id, line_number, original_description,
classified_category, classified_subcategory,
quantity, unit, created_at
FROM materials
WHERE file_id = %s
ORDER BY line_number
""", (file_id,))
materials = []
for item in cur.fetchall():
material = {
'id': item[0],
'file_id': item[1],
'line_number': item[2],
'original_description': item[3],
'classified_category': item[4],
'classified_subcategory': item[5],
'quantity': float(item[6]) if item[6] else 0,
'unit': item[7],
'created_at': item[8].isoformat() if item[8] else None
}
materials.append(material)
return jsonify({
'success': True,
'data': materials,
'count': len(materials),
'file_id': file_id
})
except Exception as e:
print(f"Error in get_materials_fixed: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.get("/api/materials-test")
def get_materials_test(file_id: int):
"""테스트용 자재 조회 API"""
try:
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute("""
SELECT
id, file_id, line_number, original_description,
classified_category, quantity, unit
FROM materials
WHERE file_id = %s
ORDER BY line_number
LIMIT 5
""", (file_id,))
rows = cur.fetchall()
materials = []
for r in rows:
materials.append({
'id': r[0],
'file_id': r[1],
'line_number': r[2],
'description': r[3],
'category': r[4],
'quantity': float(r[5]) if r[5] else 0,
'unit': r[6]
})
return {
'success': True,
'data': materials,
'count': len(materials)
}
except Exception as e:
return {'error': str(e)}

View File

@@ -1 +1,4 @@
# API 라우터 패키지
"""
API 모듈
분리된 API 엔드포인트들
"""

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
"""
인증 모듈 초기화
TK-MP-Project 인증 시스템의 모든 컴포넌트를 노출
"""
from .jwt_service import jwt_service, JWTService
from .auth_service import AuthService, get_auth_service
from .auth_controller import router as auth_router
from .setup_controller import router as setup_router
from .middleware import (
auth_middleware,
get_current_user,
get_current_active_user,
require_admin,
require_leader_or_admin,
require_roles,
require_permissions,
get_user_from_token,
check_user_permission,
get_user_permissions_by_role,
get_current_user_optional
)
from .models import (
User,
LoginLog,
UserSession,
Permission,
RolePermission,
UserRepository
)
__all__ = [
# JWT 서비스
'jwt_service',
'JWTService',
# 인증 서비스
'AuthService',
'get_auth_service',
# 라우터
'auth_router',
'setup_router',
# 미들웨어 및 의존성
'auth_middleware',
'get_current_user',
'get_current_active_user',
'require_admin',
'require_leader_or_admin',
'require_roles',
'require_permissions',
'get_user_from_token',
'check_user_permission',
'get_user_permissions_by_role',
'get_current_user_optional',
# 모델
'User',
'LoginLog',
'UserSession',
'Permission',
'RolePermission',
'UserRepository'
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,396 @@
"""
인증 서비스
TK-FB-Project의 auth.service.js를 참고하여 FastAPI용으로 구현
"""
from typing import Dict, Any, Optional, Tuple
from datetime import datetime, timedelta
from fastapi import HTTPException, status, Request
from sqlalchemy.orm import Session
from .models import User, UserRepository
from .jwt_service import jwt_service
from ..utils.logger import get_logger
from ..utils.error_handlers import TKMPException
logger = get_logger(__name__)
class AuthService:
"""인증 서비스 클래스"""
def __init__(self, db: Session):
self.db = db
self.user_repo = UserRepository(db)
async def login(self, username: str, password: str, request: Request) -> Dict[str, Any]:
"""
사용자 로그인
Args:
username: 사용자명
password: 비밀번호
request: FastAPI Request 객체
Returns:
Dict[str, Any]: 로그인 결과 (토큰, 사용자 정보 등)
Raises:
TKMPException: 로그인 실패 시
"""
try:
# 클라이언트 정보 추출
ip_address = self._get_client_ip(request)
user_agent = request.headers.get('user-agent', 'unknown')
logger.info(f"Login attempt for username: {username} from IP: {ip_address}")
# 입력 검증
if not username or not password:
await self._record_login_failure(None, ip_address, user_agent, 'missing_credentials')
raise TKMPException(
message="사용자명과 비밀번호를 입력해주세요",
status_code=status.HTTP_400_BAD_REQUEST
)
# 사용자 조회
user = self.user_repo.find_by_username(username)
if not user:
await self._record_login_failure(None, ip_address, user_agent, 'user_not_found')
logger.warning(f"Login failed - user not found: {username}")
raise TKMPException(
status_code=status.HTTP_401_UNAUTHORIZED,
message="아이디 또는 비밀번호가 올바르지 않습니다"
)
# 계정 상태 확인 (새로운 status 체계)
if hasattr(user, 'status'):
if user.status == 'pending':
await self._record_login_failure(user.user_id, ip_address, user_agent, 'pending_account')
logger.warning(f"Login failed - pending account: {username}")
raise TKMPException(
status_code=status.HTTP_403_FORBIDDEN,
message="계정 승인 대기 중입니다. 관리자 승인 후 이용 가능합니다"
)
elif user.status == 'suspended':
await self._record_login_failure(user.user_id, ip_address, user_agent, 'suspended_account')
logger.warning(f"Login failed - suspended account: {username}")
raise TKMPException(
status_code=status.HTTP_403_FORBIDDEN,
message="계정이 정지되었습니다. 관리자에게 문의하세요"
)
elif user.status == 'deleted':
await self._record_login_failure(user.user_id, ip_address, user_agent, 'deleted_account')
logger.warning(f"Login failed - deleted account: {username}")
raise TKMPException(
status_code=status.HTTP_403_FORBIDDEN,
message="삭제된 계정입니다"
)
else:
# 하위 호환성: status 필드가 없으면 is_active 사용
if not user.is_active:
await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_disabled')
logger.warning(f"Login failed - account disabled: {username}")
raise TKMPException(
status_code=status.HTTP_403_FORBIDDEN,
message="비활성화된 계정입니다. 관리자에게 문의하세요"
)
# 계정 잠금 상태 확인
if user.is_locked():
remaining_time = int((user.locked_until - datetime.utcnow()).total_seconds() / 60)
await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_locked')
logger.warning(f"Login failed - account locked: {username}")
raise TKMPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
message=f"계정이 잠겨있습니다. {remaining_time}분 후에 다시 시도하세요"
)
# 비밀번호 확인
if not user.check_password(password):
# 로그인 실패 처리
user.increment_failed_attempts()
self.user_repo.update_user(user)
await self._record_login_failure(user.user_id, ip_address, user_agent, 'invalid_password')
logger.warning(f"Login failed - invalid password: {username}")
# 계정 잠금 확인
if user.failed_login_attempts >= 5:
logger.warning(f"Account locked due to failed attempts: {username}")
raise TKMPException(
message="아이디 또는 비밀번호가 올바르지 않습니다",
status_code=status.HTTP_401_UNAUTHORIZED
)
# 로그인 성공 처리
user.reset_failed_attempts()
user.update_last_login()
self.user_repo.update_user(user)
# 토큰 생성
user_data = user.to_dict()
access_token = jwt_service.create_access_token(user_data)
refresh_token = jwt_service.create_refresh_token(user.user_id)
# 세션 생성
expires_at = datetime.utcnow() + timedelta(days=7)
session = self.user_repo.create_session(
user_id=user.user_id,
refresh_token=refresh_token,
expires_at=expires_at,
ip_address=ip_address,
user_agent=user_agent
)
# 로그인 성공 기록
self.user_repo.record_login_log(
user_id=user.user_id,
ip_address=ip_address,
user_agent=user_agent,
status='success'
)
# 리디렉션 URL 결정
redirect_url = self._get_redirect_url(user.role)
logger.info(f"Login successful for user: {username} (role: {user.role})")
return {
'success': True,
'access_token': access_token,
'refresh_token': refresh_token,
'token_type': 'bearer',
'expires_in': 24 * 3600, # 24시간 (초)
'user': user_data,
'redirect_url': redirect_url,
'permissions': self.user_repo.get_user_permissions(user.role)
}
except TKMPException:
raise
except Exception as e:
logger.error(f"Login service error for {username}: {str(e)}")
raise TKMPException(
message="로그인 처리 중 서버 오류가 발생했습니다",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
async def refresh_token(self, refresh_token: str, request: Request) -> Dict[str, Any]:
"""
토큰 갱신
Args:
refresh_token: 리프레시 토큰
request: FastAPI Request 객체
Returns:
Dict[str, Any]: 새로운 토큰 정보
"""
try:
# 리프레시 토큰 검증
payload = jwt_service.verify_refresh_token(refresh_token)
user_id = payload['user_id']
# 세션 확인
session = self.user_repo.find_session_by_token(refresh_token)
if not session or session.is_expired() or not session.is_active:
logger.warning(f"Invalid or expired refresh token for user_id: {user_id}")
raise TKMPException(
status_code=status.HTTP_401_UNAUTHORIZED,
message="유효하지 않거나 만료된 리프레시 토큰입니다"
)
# 사용자 조회
user = self.user_repo.find_by_id(user_id)
if not user or not user.is_active:
logger.warning(f"User not found or inactive for token refresh: {user_id}")
raise TKMPException(
status_code=status.HTTP_401_UNAUTHORIZED,
message="사용자를 찾을 수 없거나 비활성화된 계정입니다"
)
# 새 액세스 토큰 생성
user_data = user.to_dict()
new_access_token = jwt_service.create_access_token(user_data)
logger.info(f"Token refreshed for user: {user.username}")
return {
'success': True,
'access_token': new_access_token,
'token_type': 'bearer',
'expires_in': 24 * 3600,
'user': user_data
}
except TKMPException:
raise
except Exception as e:
logger.error(f"Token refresh error: {str(e)}")
raise TKMPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
message="토큰 갱신 중 서버 오류가 발생했습니다"
)
async def logout(self, refresh_token: str) -> Dict[str, Any]:
"""
로그아웃
Args:
refresh_token: 리프레시 토큰
Returns:
Dict[str, Any]: 로그아웃 결과
"""
try:
# 세션 찾기 및 비활성화
session = self.user_repo.find_session_by_token(refresh_token)
if session:
session.deactivate()
self.user_repo.update_user(session.user)
logger.info(f"User logged out: user_id {session.user_id}")
return {
'success': True,
'message': '로그아웃되었습니다'
}
except Exception as e:
logger.error(f"Logout error: {str(e)}")
raise TKMPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
message="로그아웃 처리 중 오류가 발생했습니다"
)
async def register(self, user_data: Dict[str, Any]) -> Dict[str, Any]:
"""
사용자 등록
Args:
user_data: 사용자 정보
Returns:
Dict[str, Any]: 등록 결과
"""
try:
# 필수 필드 검증
required_fields = ['username', 'password', 'name']
for field in required_fields:
if not user_data.get(field):
raise TKMPException(
status_code=status.HTTP_400_BAD_REQUEST,
message=f"{field}는 필수 입력 항목입니다"
)
# 중복 사용자명 확인
existing_user = self.user_repo.find_by_username(user_data['username'])
if existing_user:
raise TKMPException(
status_code=status.HTTP_409_CONFLICT,
message="이미 존재하는 사용자명입니다"
)
# 이메일 중복 확인 (이메일이 제공된 경우)
if user_data.get('email'):
existing_email = self.user_repo.find_by_email(user_data['email'])
if existing_email:
raise TKMPException(
status_code=status.HTTP_409_CONFLICT,
message="이미 사용 중인 이메일입니다"
)
# 역할 매핑
role_map = {
'admin': 'admin',
'system': 'system',
'group_leader': 'leader',
'support_team': 'support',
'worker': 'user'
}
access_level = user_data.get('access_level', 'worker')
role = role_map.get(access_level, 'user')
# 사용자 생성
new_user_data = {
'username': user_data['username'],
'name': user_data['name'],
'email': user_data.get('email'),
'role': role,
'access_level': access_level,
'department': user_data.get('department'),
'position': user_data.get('position'),
'phone': user_data.get('phone')
}
user = User(**new_user_data)
user.set_password(user_data['password'])
self.db.add(user)
self.db.commit()
self.db.refresh(user)
logger.info(f"User registered successfully: {user.username}")
return {
'success': True,
'message': '사용자 등록이 완료되었습니다',
'user_id': user.user_id,
'username': user.username
}
except TKMPException:
raise
except Exception as e:
self.db.rollback()
logger.error(f"User registration error: {str(e)}")
raise TKMPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
message="사용자 등록 중 서버 오류가 발생했습니다"
)
def _get_client_ip(self, request: Request) -> str:
"""클라이언트 IP 주소 추출"""
# X-Forwarded-For 헤더 확인 (프록시 환경)
forwarded_for = request.headers.get('x-forwarded-for')
if forwarded_for:
return forwarded_for.split(',')[0].strip()
# X-Real-IP 헤더 확인
real_ip = request.headers.get('x-real-ip')
if real_ip:
return real_ip
# 직접 연결된 클라이언트 IP
return request.client.host if request.client else 'unknown'
def _get_redirect_url(self, role: str) -> str:
"""역할에 따른 리디렉션 URL 결정"""
redirect_urls = {
'system': '/admin/system',
'admin': '/admin/dashboard',
'leader': '/dashboard/leader',
'support': '/dashboard/support',
'user': '/dashboard'
}
return redirect_urls.get(role, '/dashboard')
async def _record_login_failure(self, user_id: Optional[int], ip_address: str,
user_agent: str, failure_reason: str):
"""로그인 실패 기록"""
try:
if user_id:
self.user_repo.record_login_log(
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
status='failed',
failure_reason=failure_reason
)
except Exception as e:
logger.error(f"Failed to record login failure: {str(e)}")
def get_auth_service(db: Session) -> AuthService:
"""인증 서비스 팩토리 함수"""
return AuthService(db)

View File

@@ -0,0 +1,273 @@
"""
JWT 토큰 관리 서비스
TK-FB-Project의 JWT 구현을 참고하여 FastAPI용으로 구현
"""
import jwt
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from fastapi import HTTPException, status
import os
from ..config import get_settings
from ..utils.logger import get_logger
settings = get_settings()
logger = get_logger(__name__)
# JWT 설정
JWT_SECRET = os.getenv('JWT_SECRET', 'tkmp-secret-key-2025')
JWT_REFRESH_SECRET = os.getenv('JWT_REFRESH_SECRET', 'tkmp-refresh-secret-2025')
JWT_ALGORITHM = 'HS256'
ACCESS_TOKEN_EXPIRE_HOURS = int(os.getenv('JWT_EXPIRES_IN_HOURS', '24'))
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv('JWT_REFRESH_EXPIRES_IN_DAYS', '7'))
class JWTService:
"""JWT 토큰 관리 서비스"""
@staticmethod
def create_access_token(user_data: Dict[str, Any]) -> str:
"""
Access Token 생성
Args:
user_data: 사용자 정보 딕셔너리
Returns:
str: JWT Access Token
"""
try:
# 토큰 만료 시간 설정
expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
# 토큰 페이로드 구성
payload = {
'user_id': user_data['user_id'],
'username': user_data['username'],
'name': user_data['name'],
'role': user_data['role'],
'access_level': user_data['access_level'],
'exp': expire,
'iat': datetime.utcnow(),
'type': 'access'
}
# JWT 토큰 생성
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
logger.debug(f"Access token created for user: {user_data['username']}")
return token
except Exception as e:
logger.error(f"Access token creation failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="토큰 생성에 실패했습니다"
)
@staticmethod
def create_refresh_token(user_id: int) -> str:
"""
Refresh Token 생성
Args:
user_id: 사용자 ID
Returns:
str: JWT Refresh Token
"""
try:
# 토큰 만료 시간 설정
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
# 토큰 페이로드 구성
payload = {
'user_id': user_id,
'exp': expire,
'iat': datetime.utcnow(),
'type': 'refresh'
}
# JWT 토큰 생성
token = jwt.encode(payload, JWT_REFRESH_SECRET, algorithm=JWT_ALGORITHM)
logger.debug(f"Refresh token created for user_id: {user_id}")
return token
except Exception as e:
logger.error(f"Refresh token creation failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="리프레시 토큰 생성에 실패했습니다"
)
@staticmethod
def verify_access_token(token: str) -> Dict[str, Any]:
"""
Access Token 검증
Args:
token: JWT Access Token
Returns:
Dict[str, Any]: 토큰 페이로드
Raises:
HTTPException: 토큰 검증 실패 시
"""
try:
# JWT 토큰 디코딩
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
# 토큰 타입 확인
if payload.get('type') != 'access':
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="잘못된 토큰 타입입니다"
)
# 필수 필드 확인
required_fields = ['user_id', 'username', 'role']
for field in required_fields:
if field not in payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"토큰에 {field} 정보가 없습니다"
)
logger.debug(f"Access token verified for user: {payload['username']}")
return payload
except jwt.ExpiredSignatureError:
logger.warning("Access token expired")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="토큰이 만료되었습니다"
)
except jwt.InvalidTokenError as e:
logger.warning(f"Invalid access token: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 토큰입니다"
)
except Exception as e:
logger.error(f"Access token verification failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="토큰 검증에 실패했습니다"
)
@staticmethod
def verify_refresh_token(token: str) -> Dict[str, Any]:
"""
Refresh Token 검증
Args:
token: JWT Refresh Token
Returns:
Dict[str, Any]: 토큰 페이로드
Raises:
HTTPException: 토큰 검증 실패 시
"""
try:
# JWT 토큰 디코딩
payload = jwt.decode(token, JWT_REFRESH_SECRET, algorithms=[JWT_ALGORITHM])
# 토큰 타입 확인
if payload.get('type') != 'refresh':
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="잘못된 리프레시 토큰 타입입니다"
)
# 필수 필드 확인
if 'user_id' not in payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="토큰에 사용자 정보가 없습니다"
)
logger.debug(f"Refresh token verified for user_id: {payload['user_id']}")
return payload
except jwt.ExpiredSignatureError:
logger.warning("Refresh token expired")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="리프레시 토큰이 만료되었습니다"
)
except jwt.InvalidTokenError as e:
logger.warning(f"Invalid refresh token: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 리프레시 토큰입니다"
)
except Exception as e:
logger.error(f"Refresh token verification failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="리프레시 토큰 검증에 실패했습니다"
)
@staticmethod
def get_token_expiry_info(token: str, token_type: str = 'access') -> Dict[str, Any]:
"""
토큰 만료 정보 조회
Args:
token: JWT Token
token_type: 토큰 타입 ('access' 또는 'refresh')
Returns:
Dict[str, Any]: 토큰 만료 정보
"""
try:
secret = JWT_SECRET if token_type == 'access' else JWT_REFRESH_SECRET
payload = jwt.decode(token, secret, algorithms=[JWT_ALGORITHM])
exp_timestamp = payload.get('exp')
iat_timestamp = payload.get('iat')
if exp_timestamp:
exp_datetime = datetime.fromtimestamp(exp_timestamp)
remaining_time = exp_datetime - datetime.utcnow()
return {
'expires_at': exp_datetime,
'issued_at': datetime.fromtimestamp(iat_timestamp) if iat_timestamp else None,
'remaining_seconds': int(remaining_time.total_seconds()),
'is_expired': remaining_time.total_seconds() <= 0
}
return {'error': '토큰에 만료 시간 정보가 없습니다'}
except Exception as e:
logger.error(f"Token expiry info retrieval failed: {str(e)}")
return {'error': str(e)}
# JWT 서비스 인스턴스
jwt_service = JWTService()

View File

@@ -0,0 +1,327 @@
"""
인증 및 권한 미들웨어
JWT 토큰 기반 인증과 역할 기반 접근 제어(RBAC) 구현
"""
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from typing import List, Optional, Callable, Any
from functools import wraps
import inspect
from ..database import get_db
from .jwt_service import jwt_service
from .models import UserRepository
from ..utils.logger import get_logger
logger = get_logger(__name__)
security = HTTPBearer()
class AuthMiddleware:
"""인증 미들웨어 클래스"""
def __init__(self):
self.security = HTTPBearer()
async def get_current_user(
self,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> dict:
"""
현재 사용자 정보 조회
Args:
credentials: JWT 토큰
db: 데이터베이스 세션
Returns:
dict: 사용자 정보
Raises:
HTTPException: 인증 실패 시
"""
try:
# 토큰 검증
payload = jwt_service.verify_access_token(credentials.credentials)
user_id = payload['user_id']
# 사용자 정보 조회
user_repo = UserRepository(db)
user = user_repo.find_by_id(user_id)
if not user:
logger.warning(f"User not found for token: user_id {user_id}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="사용자를 찾을 수 없습니다"
)
if not user.is_active:
logger.warning(f"Inactive user attempted access: {user.username}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="비활성화된 계정입니다"
)
if user.is_locked():
logger.warning(f"Locked user attempted access: {user.username}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="잠긴 계정입니다"
)
# 사용자 정보와 토큰 페이로드 결합
user_info = user.to_dict()
user_info.update({
'token_user_id': payload['user_id'],
'token_username': payload['username'],
'token_role': payload['role']
})
return user_info
except HTTPException:
raise
except Exception as e:
logger.error(f"Get current user error: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="인증 처리 중 오류가 발생했습니다"
)
async def get_current_active_user(
self,
current_user: dict = Depends(get_current_user)
) -> dict:
"""
현재 활성 사용자 정보 조회 (별칭)
Args:
current_user: 현재 사용자 정보
Returns:
dict: 사용자 정보
"""
return current_user
def require_roles(self, allowed_roles: List[str]):
"""
특정 역할을 요구하는 의존성 함수 생성
Args:
allowed_roles: 허용된 역할 목록
Returns:
Callable: 의존성 함수
"""
async def role_checker(
current_user: dict = Depends(self.get_current_user)
) -> dict:
user_role = current_user.get('role')
if user_role not in allowed_roles:
logger.warning(
f"Access denied for user {current_user.get('username')} "
f"with role {user_role}. Required roles: {allowed_roles}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"이 기능을 사용하려면 다음 권한이 필요합니다: {', '.join(allowed_roles)}"
)
return current_user
return role_checker
def require_permissions(self, required_permissions: List[str]):
"""
특정 권한을 요구하는 의존성 함수 생성
Args:
required_permissions: 필요한 권한 목록
Returns:
Callable: 의존성 함수
"""
async def permission_checker(
current_user: dict = Depends(self.get_current_user),
db: Session = Depends(get_db)
) -> dict:
user_role = current_user.get('role')
# 사용자 권한 조회
user_repo = UserRepository(db)
user_permissions = user_repo.get_user_permissions(user_role)
# 필요한 권한 확인
missing_permissions = []
for permission in required_permissions:
if permission not in user_permissions:
missing_permissions.append(permission)
if missing_permissions:
logger.warning(
f"Permission denied for user {current_user.get('username')} "
f"with role {user_role}. Missing permissions: {missing_permissions}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"이 기능을 사용하려면 다음 권한이 필요합니다: {', '.join(missing_permissions)}"
)
# 사용자 정보에 권한 정보 추가
current_user['permissions'] = user_permissions
return current_user
return permission_checker
def require_admin(self):
"""관리자 권한을 요구하는 의존성 함수"""
return self.require_roles(['admin', 'system'])
def require_leader_or_admin(self):
"""팀장 이상 권한을 요구하는 의존성 함수"""
return self.require_roles(['admin', 'system', 'leader'])
# 전역 인증 미들웨어 인스턴스
auth_middleware = AuthMiddleware()
# 편의를 위한 의존성 함수들
get_current_user = auth_middleware.get_current_user
get_current_active_user = auth_middleware.get_current_active_user
require_admin = auth_middleware.require_admin
require_leader_or_admin = auth_middleware.require_leader_or_admin
def require_roles(allowed_roles: List[str]):
"""역할 기반 접근 제어 데코레이터"""
return auth_middleware.require_roles(allowed_roles)
def require_permissions(required_permissions: List[str]):
"""권한 기반 접근 제어 데코레이터"""
return auth_middleware.require_permissions(required_permissions)
# 추가 유틸리티 함수들
async def get_user_from_token(token: str, db: Session) -> Optional[dict]:
"""
토큰에서 사용자 정보 추출 (미들웨어 없이 직접 사용)
Args:
token: JWT 토큰
db: 데이터베이스 세션
Returns:
Optional[dict]: 사용자 정보 또는 None
"""
try:
payload = jwt_service.verify_access_token(token)
user_id = payload['user_id']
user_repo = UserRepository(db)
user = user_repo.find_by_id(user_id)
if user and user.is_active and not user.is_locked():
return user.to_dict()
return None
except Exception as e:
logger.error(f"Get user from token error: {str(e)}")
return None
def check_user_permission(user_role: str, required_permission: str, db: Session) -> bool:
"""
사용자 권한 확인
Args:
user_role: 사용자 역할
required_permission: 필요한 권한
db: 데이터베이스 세션
Returns:
bool: 권한 보유 여부
"""
try:
user_repo = UserRepository(db)
user_permissions = user_repo.get_user_permissions(user_role)
return required_permission in user_permissions
except Exception as e:
logger.error(f"Check user permission error: {str(e)}")
return False
def get_user_permissions_by_role(role: str, db: Session) -> List[str]:
"""
역할별 권한 목록 조회
Args:
role: 사용자 역할
db: 데이터베이스 세션
Returns:
List[str]: 권한 목록
"""
try:
user_repo = UserRepository(db)
return user_repo.get_user_permissions(role)
except Exception as e:
logger.error(f"Get user permissions by role error: {str(e)}")
return []
# 선택적 인증 (토큰이 있으면 검증, 없으면 None 반환)
async def get_current_user_optional(
request: Request,
db: Session = Depends(get_db)
) -> Optional[dict]:
"""
선택적 사용자 인증 (토큰이 있으면 검증, 없으면 None)
Args:
request: FastAPI Request 객체
db: 데이터베이스 세션
Returns:
Optional[dict]: 사용자 정보 또는 None
"""
try:
# Authorization 헤더 확인
authorization = request.headers.get('authorization')
if not authorization or not authorization.startswith('Bearer '):
return None
token = authorization.split(' ')[1]
return await get_user_from_token(token, db)
except Exception as e:
logger.debug(f"Optional auth failed: {str(e)}")
return None

411
backend/app/auth/models.py Normal file
View File

@@ -0,0 +1,411 @@
"""
인증 시스템 모델
TK-FB-Project의 사용자 모델을 참고하여 SQLAlchemy 기반으로 구현
"""
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import text
import bcrypt
from ..database import Base
from ..utils.logger import get_logger
logger = get_logger(__name__)
class User(Base):
"""사용자 모델"""
__tablename__ = "users"
user_id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True, nullable=False)
password = Column(String(255), nullable=False)
name = Column(String(100), nullable=False)
email = Column(String(100), index=True)
# 권한 관리 - 3단계 시스템: system(제작자) > admin(관리자) > user(사용자)
role = Column(String(20), default='user', nullable=False) # system, admin, user
access_level = Column(String(20), default='worker', nullable=False) # 호환성 유지
# 계정 상태 관리
is_active = Column(Boolean, default=True, nullable=False) # DEPRECATED: Use status instead
status = Column(String(20), default='active', nullable=False) # pending, active, suspended, deleted
failed_login_attempts = Column(Integer, default=0)
locked_until = Column(DateTime, nullable=True)
# 추가 정보
department = Column(String(50))
position = Column(String(50))
phone = Column(String(20))
# 타임스탬프
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
last_login_at = Column(DateTime, nullable=True)
# 관계 설정
login_logs = relationship("LoginLog", back_populates="user", cascade="all, delete-orphan")
sessions = relationship("UserSession", back_populates="user", cascade="all, delete-orphan")
def __repr__(self):
return f"<User(username='{self.username}', name='{self.name}', role='{self.role}')>"
def to_dict(self) -> Dict[str, Any]:
"""사용자 정보를 딕셔너리로 변환 (비밀번호 제외)"""
return {
'user_id': self.user_id,
'username': self.username,
'name': self.name,
'email': self.email,
'role': self.role,
'access_level': self.access_level,
'is_active': self.is_active,
'department': self.department,
'position': self.position,
'phone': self.phone,
'created_at': self.created_at,
'last_login_at': self.last_login_at
}
def check_password(self, password: str) -> bool:
"""비밀번호 확인"""
try:
return bcrypt.checkpw(password.encode('utf-8'), self.password.encode('utf-8'))
except Exception as e:
logger.error(f"Password check failed for user {self.username}: {str(e)}")
return False
def set_password(self, password: str):
"""비밀번호 설정 (해싱)"""
try:
salt = bcrypt.gensalt()
self.password = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
except Exception as e:
logger.error(f"Password hashing failed for user {self.username}: {str(e)}")
raise
def is_locked(self) -> bool:
"""계정 잠금 상태 확인"""
if self.locked_until is None:
return False
return datetime.utcnow() < self.locked_until
def lock_account(self, minutes: int = 15):
"""계정 잠금"""
self.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
logger.warning(f"User account locked: {self.username} for {minutes} minutes")
def unlock_account(self):
"""계정 잠금 해제"""
self.locked_until = None
self.failed_login_attempts = 0
logger.info(f"User account unlocked: {self.username}")
def increment_failed_attempts(self):
"""로그인 실패 횟수 증가"""
self.failed_login_attempts += 1
if self.failed_login_attempts >= 5:
self.lock_account()
def reset_failed_attempts(self):
"""로그인 실패 횟수 초기화"""
self.failed_login_attempts = 0
self.locked_until = None
def update_last_login(self):
"""마지막 로그인 시간 업데이트"""
self.last_login_at = datetime.utcnow()
# 권한 체크 메서드들
def is_system(self) -> bool:
"""시스템 관리자 권한 확인"""
return self.role == 'system'
def is_admin(self) -> bool:
"""관리자 권한 확인 (시스템 관리자 포함)"""
return self.role in ['system', 'admin']
def is_user(self) -> bool:
"""일반 사용자 권한 확인"""
return self.role == 'user'
def can_create_users(self) -> bool:
"""사용자 생성 권한 확인 (시스템 관리자만)"""
return self.is_system()
def can_view_logs(self) -> bool:
"""로그 조회 권한 확인 (관리자 이상)"""
return self.is_admin()
def can_manage_system(self) -> bool:
"""시스템 관리 권한 확인 (시스템 관리자만)"""
return self.is_system()
def get_role_display_name(self) -> str:
"""역할 표시명 반환"""
role_names = {
'system': '시스템 관리자',
'admin': '관리자',
'user': '사용자'
}
return role_names.get(self.role, '알 수 없음')
class LoginLog(Base):
"""로그인 이력 모델"""
__tablename__ = "login_logs"
log_id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False)
login_time = Column(DateTime, default=func.now())
ip_address = Column(String(45))
user_agent = Column(Text)
login_status = Column(String(20), nullable=False) # 'success' or 'failed'
failure_reason = Column(String(100))
session_duration = Column(Integer) # 세션 지속 시간 (초)
created_at = Column(DateTime, default=func.now())
# 관계 설정
user = relationship("User", back_populates="login_logs")
def __repr__(self):
return f"<LoginLog(user_id={self.user_id}, status='{self.login_status}', time='{self.login_time}')>"
class UserSession(Base):
"""사용자 세션 모델 (Refresh Token 관리)"""
__tablename__ = "user_sessions"
session_id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False)
refresh_token = Column(String(500), nullable=False, index=True)
expires_at = Column(DateTime, nullable=False)
ip_address = Column(String(45))
user_agent = Column(Text)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
# 관계 설정
user = relationship("User", back_populates="sessions")
def __repr__(self):
return f"<UserSession(user_id={self.user_id}, expires_at='{self.expires_at}')>"
def is_expired(self) -> bool:
"""세션 만료 여부 확인"""
return datetime.utcnow() > self.expires_at
def deactivate(self):
"""세션 비활성화"""
self.is_active = False
class Permission(Base):
"""권한 모델"""
__tablename__ = "permissions"
permission_id = Column(Integer, primary_key=True, index=True)
permission_name = Column(String(50), unique=True, nullable=False)
description = Column(Text)
module = Column(String(30), index=True) # 모듈별 권한 관리
created_at = Column(DateTime, default=func.now())
def __repr__(self):
return f"<Permission(name='{self.permission_name}', module='{self.module}')>"
class RolePermission(Base):
"""역할-권한 매핑 모델"""
__tablename__ = "role_permissions"
role_permission_id = Column(Integer, primary_key=True, index=True)
role = Column(String(20), nullable=False, index=True)
permission_id = Column(Integer, ForeignKey("permissions.permission_id", ondelete="CASCADE"))
created_at = Column(DateTime, default=func.now())
# 관계 설정
permission = relationship("Permission")
def __repr__(self):
return f"<RolePermission(role='{self.role}', permission_id={self.permission_id})>"
class UserRepository:
"""사용자 데이터 접근 계층"""
def __init__(self, db: Session):
self.db = db
def find_by_username(self, username: str) -> Optional[User]:
"""사용자명으로 사용자 조회"""
try:
return self.db.query(User).filter(User.username == username).first()
except Exception as e:
logger.error(f"Failed to find user by username {username}: {str(e)}")
return None
def find_by_id(self, user_id: int) -> Optional[User]:
"""사용자 ID로 사용자 조회"""
try:
return self.db.query(User).filter(User.user_id == user_id).first()
except Exception as e:
logger.error(f"Failed to find user by id {user_id}: {str(e)}")
return None
def find_by_email(self, email: str) -> Optional[User]:
"""이메일로 사용자 조회"""
try:
return self.db.query(User).filter(User.email == email).first()
except Exception as e:
logger.error(f"Failed to find user by email {email}: {str(e)}")
return None
def create_user(self, user_data: Dict[str, Any]) -> User:
"""새 사용자 생성"""
try:
user = User(**user_data)
self.db.add(user)
self.db.commit()
self.db.refresh(user)
logger.info(f"User created: {user.username}")
return user
except Exception as e:
self.db.rollback()
logger.error(f"Failed to create user: {str(e)}")
raise
def update_user(self, user: User) -> User:
"""사용자 정보 업데이트"""
try:
self.db.commit()
self.db.refresh(user)
logger.info(f"User updated: {user.username}")
return user
except Exception as e:
self.db.rollback()
logger.error(f"Failed to update user {user.username}: {str(e)}")
raise
def delete_user(self, user: User):
"""사용자 삭제"""
try:
self.db.delete(user)
self.db.commit()
logger.info(f"User deleted: {user.username}")
except Exception as e:
self.db.rollback()
logger.error(f"Failed to delete user {user.username}: {str(e)}")
raise
def get_all_users(self, skip: int = 0, limit: int = 100) -> List[User]:
"""활성 사용자만 조회 (status='active')"""
try:
# status 필드가 있으면 status='active', 없으면 is_active=True (하위 호환성)
users = self.db.query(User)
if hasattr(User, 'status'):
users = users.filter(User.status == 'active')
else:
users = users.filter(User.is_active == True)
return users.offset(skip).limit(limit).all()
except Exception as e:
logger.error(f"Failed to get all users: {str(e)}")
return []
def get_user_permissions(self, role: str) -> List[str]:
"""사용자 역할에 따른 권한 목록 조회"""
try:
query = text("""
SELECT p.permission_name
FROM permissions p
JOIN role_permissions rp ON p.permission_id = rp.permission_id
WHERE rp.role = :role
""")
result = self.db.execute(query, {"role": role})
return [row[0] for row in result.fetchall()]
except Exception as e:
logger.error(f"Failed to get permissions for role {role}: {str(e)}")
return []
def record_login_log(self, user_id: int, ip_address: str, user_agent: str,
status: str, failure_reason: str = None):
"""로그인 이력 기록"""
try:
log = LoginLog(
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
login_status=status,
failure_reason=failure_reason
)
self.db.add(log)
self.db.commit()
logger.debug(f"Login log recorded for user_id {user_id}: {status}")
except Exception as e:
self.db.rollback()
logger.error(f"Failed to record login log: {str(e)}")
def create_session(self, user_id: int, refresh_token: str, expires_at: datetime,
ip_address: str, user_agent: str) -> UserSession:
"""사용자 세션 생성"""
try:
session = UserSession(
user_id=user_id,
refresh_token=refresh_token,
expires_at=expires_at,
ip_address=ip_address,
user_agent=user_agent
)
self.db.add(session)
self.db.commit()
self.db.refresh(session)
logger.debug(f"Session created for user_id {user_id}")
return session
except Exception as e:
self.db.rollback()
logger.error(f"Failed to create session: {str(e)}")
raise
def find_session_by_token(self, refresh_token: str) -> Optional[UserSession]:
"""리프레시 토큰으로 세션 조회"""
try:
return self.db.query(UserSession).filter(
UserSession.refresh_token == refresh_token,
UserSession.is_active == True
).first()
except Exception as e:
logger.error(f"Failed to find session by token: {str(e)}")
return None
def deactivate_user_sessions(self, user_id: int):
"""사용자의 모든 세션 비활성화"""
try:
self.db.query(UserSession).filter(
UserSession.user_id == user_id
).update({"is_active": False})
self.db.commit()
logger.info(f"All sessions deactivated for user_id {user_id}")
except Exception as e:
self.db.rollback()
logger.error(f"Failed to deactivate sessions for user_id {user_id}: {str(e)}")
raise

View File

@@ -0,0 +1,198 @@
"""
초기 시스템 설정 컨트롤러
배포 후 첫 실행 시 시스템 관리자 계정 생성
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from typing import Optional
from ..database import get_db
from .models import User, UserRepository
from ..utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter()
class SystemSetupRequest(BaseModel):
username: str
password: str
name: str
email: Optional[EmailStr] = None
department: Optional[str] = None
position: Optional[str] = None
class SystemSetupResponse(BaseModel):
success: bool
message: str
user_id: Optional[int] = None
setup_completed: bool
@router.get("/setup/status")
async def get_setup_status(db: Session = Depends(get_db)):
"""
시스템 초기 설정 상태 확인
Returns:
Dict: 설정 완료 여부
"""
try:
user_repo = UserRepository(db)
# 시스템 관리자가 존재하는지 확인
system_admin = db.query(User).filter(User.role == 'system').first()
return {
'success': True,
'setup_completed': system_admin is not None,
'has_system_admin': system_admin is not None,
'total_users': db.query(User).count()
}
except Exception as e:
logger.error(f"Setup status check error: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="설정 상태 확인 중 오류가 발생했습니다"
)
@router.post("/setup/initialize", response_model=SystemSetupResponse)
async def initialize_system(
setup_data: SystemSetupRequest,
db: Session = Depends(get_db)
):
"""
시스템 초기화 및 첫 번째 시스템 관리자 생성
Args:
setup_data: 시스템 관리자 계정 정보
db: 데이터베이스 세션
Returns:
SystemSetupResponse: 설정 결과
"""
try:
user_repo = UserRepository(db)
# 이미 시스템 관리자가 존재하는지 확인
existing_admin = db.query(User).filter(User.role == 'system').first()
if existing_admin:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="시스템이 이미 초기화되었습니다"
)
# 사용자명 중복 확인
existing_user = user_repo.find_by_username(setup_data.username)
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="이미 존재하는 사용자명입니다"
)
# 이메일 중복 확인 (이메일이 제공된 경우)
if setup_data.email:
existing_email = user_repo.find_by_email(setup_data.email)
if existing_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="이미 존재하는 이메일입니다"
)
# 시스템 관리자 계정 생성
user = User(
username=setup_data.username,
name=setup_data.name,
email=setup_data.email,
role='system',
access_level='system',
department=setup_data.department,
position=setup_data.position,
is_active=True
)
# 비밀번호 설정
user.set_password(setup_data.password)
# 데이터베이스에 저장
db.add(user)
db.commit()
db.refresh(user)
logger.info(f"System initialized with admin user: {user.username}")
return SystemSetupResponse(
success=True,
message="시스템이 성공적으로 초기화되었습니다",
user_id=user.user_id,
setup_completed=True
)
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"System initialization error: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="시스템 초기화 중 오류가 발생했습니다"
)
@router.post("/setup/reset")
async def reset_system_setup(
confirm_reset: bool = False,
db: Session = Depends(get_db)
):
"""
시스템 설정 리셋 (개발/테스트 용도)
Args:
confirm_reset: 리셋 확인
db: 데이터베이스 세션
Returns:
Dict: 리셋 결과
"""
try:
if not confirm_reset:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="리셋을 확인해주세요 (confirm_reset=true)"
)
# 개발 환경에서만 허용
from ..config import get_settings
settings = get_settings()
if settings.environment != 'development':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="개발 환경에서만 시스템 리셋이 가능합니다"
)
# 모든 사용자 삭제
db.query(User).delete()
db.commit()
logger.warning("System setup has been reset (development only)")
return {
'success': True,
'message': '시스템 설정이 리셋되었습니다',
'setup_completed': False
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"System reset error: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="시스템 리셋 중 오류가 발생했습니다"
)

View File

@@ -0,0 +1,337 @@
"""
회원가입 요청 및 관리자 승인 API
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import text
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from ..database import get_db
from .auth_service import AuthService
from .models import UserRepository
from .middleware import get_current_user
from ..utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/auth", tags=["signup"])
class SignupRequest(BaseModel):
username: str
password: str
name: str
email: Optional[str] = None
department: Optional[str] = None
position: Optional[str] = None
phone: Optional[str] = None
reason: Optional[str] = None # 가입 사유
@router.post("/signup-request")
async def signup_request(
signup_data: SignupRequest,
db: Session = Depends(get_db)
):
"""
회원가입 요청 (관리자 승인 대기)
Args:
signup_data: 가입 신청 정보
Returns:
dict: 요청 결과
"""
try:
# 중복 사용자명 확인
user_repo = UserRepository(db)
existing_user = user_repo.find_by_username(signup_data.username)
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="이미 존재하는 사용자명입니다"
)
# 중복 이메일 확인
if signup_data.email:
check_email = text("SELECT id FROM users WHERE email = :email")
existing_email = db.execute(check_email, {"email": signup_data.email}).fetchone()
if existing_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="이미 등록된 이메일입니다"
)
# 비밀번호 해싱
import bcrypt
hashed_password = bcrypt.hashpw(
signup_data.password.encode('utf-8'),
bcrypt.gensalt()
).decode('utf-8')
# 승인 대기 상태로 사용자 생성
new_user = user_repo.create_user({
'username': signup_data.username,
'password': hashed_password, # 필드명은 'password'
'name': signup_data.name,
'email': signup_data.email,
'access_level': 'worker', # 기본 레벨 (승인 시 변경 가능)
'department': signup_data.department,
'position': signup_data.position,
'phone': signup_data.phone,
'role': 'user',
'is_active': False, # 하위 호환성
'status': 'pending' # 새로운 status 체계: 승인 대기
})
# 가입 사유 저장 (notes 컬럼 활용)
if signup_data.reason:
update_notes = text("UPDATE users SET notes = :reason WHERE user_id = :user_id")
db.execute(update_notes, {"reason": signup_data.reason, "user_id": new_user.user_id})
db.commit()
return {
"success": True,
"message": "회원가입 요청이 전송되었습니다. 관리자 승인 후 이용 가능합니다.",
"user_id": new_user.user_id,
"username": new_user.username,
"status": "pending_approval"
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"회원가입 요청 실패: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"회원가입 요청 처리 중 오류가 발생했습니다: {str(e)}"
)
@router.get("/signup-requests")
async def get_signup_requests(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
회원가입 요청 목록 조회 (관리자 전용)
Returns:
dict: 승인 대기 중인 사용자 목록
"""
try:
# 관리자 권한 확인
if current_user.get('role') not in ['admin', 'system']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자만 접근 가능합니다"
)
# 승인 대기 중인 사용자 조회 (status='pending'인 사용자)
query = text("""
SELECT
user_id, username, name, email, department, position,
phone, created_at, role, is_active, status
FROM users
WHERE status = 'pending'
ORDER BY created_at DESC
""")
results = db.execute(query).fetchall()
pending_users = []
for row in results:
pending_users.append({
"user_id": row.user_id,
"id": row.user_id, # 호환성을 위해 둘 다 제공
"username": row.username,
"name": row.name,
"email": row.email,
"department": row.department,
"position": row.position,
"phone": row.phone,
"role": row.role,
"created_at": row.created_at.isoformat() if row.created_at else None,
"requested_at": row.created_at.isoformat() if row.created_at else None,
"is_active": row.is_active
})
return {
"success": True,
"requests": pending_users,
"count": len(pending_users)
}
except HTTPException:
raise
except Exception as e:
logger.error(f"가입 요청 목록 조회 실패: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"가입 요청 목록 조회 실패: {str(e)}"
)
@router.get("/pending-signups/count")
async def get_pending_signups_count(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
승인 대기 중인 회원가입 수 조회 (관리자 전용)
Returns:
dict: 승인 대기 중인 사용자 수
"""
try:
# 관리자 권한 확인
if current_user.get('role') not in ['admin', 'system']:
return {"count": 0} # 관리자가 아니면 0 반환
# 승인 대기 중인 사용자 수 조회
query = text("""
SELECT COUNT(*) as count
FROM users
WHERE status = 'pending'
""")
result = db.execute(query).fetchone()
count = result.count if result else 0
return {"count": count}
except Exception as e:
logger.error(f"승인 대기 회원가입 수 조회 실패: {str(e)}")
return {"count": 0} # 오류 시 0 반환
@router.post("/approve-signup/{user_id}")
async def approve_signup(
user_id: int,
access_level: str = 'worker',
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
회원가입 승인 (관리자 전용)
Args:
user_id: 승인할 사용자 ID
access_level: 부여할 접근 레벨 (worker, manager, admin)
Returns:
dict: 승인 결과
"""
try:
# 관리자 권한 확인
if current_user.get('role') not in ['admin', 'system']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자만 접근 가능합니다"
)
# 사용자 활성화 및 접근 레벨 설정
update_query = text("""
UPDATE users
SET is_active = TRUE,
status = 'active',
access_level = :access_level,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = :user_id AND status = 'pending'
RETURNING user_id as id, username, name
""")
result = db.execute(update_query, {
"user_id": user_id,
"access_level": access_level
}).fetchone()
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="승인 대기 중인 사용자를 찾을 수 없습니다"
)
db.commit()
return {
"success": True,
"message": f"{result.name}님의 가입이 승인되었습니다",
"user": {
"id": result.id,
"username": result.username,
"name": result.name,
"access_level": access_level
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"가입 승인 실패: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"가입 승인 처리 중 오류가 발생했습니다: {str(e)}"
)
@router.delete("/reject-signup/{user_id}")
async def reject_signup(
user_id: int,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
회원가입 거부 (관리자 전용)
Args:
user_id: 거부할 사용자 ID
Returns:
dict: 거부 결과
"""
try:
# 관리자 권한 확인
if current_user.get('role') not in ['admin', 'system']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자만 접근 가능합니다"
)
# 승인 대기 사용자 삭제
delete_query = text("""
DELETE FROM users
WHERE user_id = :user_id AND is_active = FALSE
RETURNING username, name
""")
result = db.execute(delete_query, {"user_id": user_id}).fetchone()
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="승인 대기 중인 사용자를 찾을 수 없습니다"
)
db.commit()
return {
"success": True,
"message": f"{result.name}님의 가입 요청이 거부되었습니다"
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"가입 거부 실패: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"가입 거부 처리 중 오류가 발생했습니다: {str(e)}"
)

287
backend/app/config.py Normal file
View File

@@ -0,0 +1,287 @@
"""
TK-MP-Project 설정 관리
환경별 설정을 중앙화하여 관리
"""
import os
from typing import List, Optional, Dict, Any
from pathlib import Path
from pydantic_settings import BaseSettings
from pydantic import Field, validator
import json
class DatabaseSettings(BaseSettings):
"""데이터베이스 설정"""
url: str = Field(
default="postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom",
description="데이터베이스 연결 URL"
)
pool_size: int = Field(default=10, description="연결 풀 크기")
max_overflow: int = Field(default=20, description="최대 오버플로우")
pool_timeout: int = Field(default=30, description="연결 타임아웃 (초)")
pool_recycle: int = Field(default=3600, description="연결 재활용 시간 (초)")
echo: bool = Field(default=False, description="SQL 로그 출력 여부")
class Config:
env_prefix = "DB_"
class RedisSettings(BaseSettings):
"""Redis 설정"""
url: str = Field(default="redis://redis:6379", description="Redis 연결 URL")
max_connections: int = Field(default=20, description="최대 연결 수")
socket_timeout: int = Field(default=5, description="소켓 타임아웃 (초)")
socket_connect_timeout: int = Field(default=5, description="연결 타임아웃 (초)")
retry_on_timeout: bool = Field(default=True, description="타임아웃 시 재시도")
decode_responses: bool = Field(default=False, description="응답 디코딩 여부")
class Config:
env_prefix = "REDIS_"
class SecuritySettings(BaseSettings):
"""보안 설정"""
cors_origins: List[str] = Field(default=[], description="CORS 허용 도메인")
cors_methods: List[str] = Field(
default=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
description="CORS 허용 메서드"
)
cors_headers: List[str] = Field(
default=["*"],
description="CORS 허용 헤더"
)
cors_credentials: bool = Field(default=True, description="CORS 자격증명 허용")
# 파일 업로드 보안
max_file_size: int = Field(default=50 * 1024 * 1024, description="최대 파일 크기 (bytes)")
allowed_file_extensions: List[str] = Field(
default=['.xlsx', '.xls', '.csv'],
description="허용된 파일 확장자"
)
upload_path: str = Field(default="uploads", description="업로드 경로")
# API 보안
api_key_header: str = Field(default="X-API-Key", description="API 키 헤더명")
rate_limit_per_minute: int = Field(default=100, description="분당 요청 제한")
class Config:
env_prefix = "SECURITY_"
class LoggingSettings(BaseSettings):
"""로깅 설정"""
level: str = Field(default="INFO", description="로그 레벨")
file_path: str = Field(default="logs/app.log", description="로그 파일 경로")
max_file_size: int = Field(default=10 * 1024 * 1024, description="로그 파일 최대 크기")
backup_count: int = Field(default=5, description="백업 파일 수")
format: str = Field(
default="%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s",
description="로그 포맷"
)
date_format: str = Field(default="%Y-%m-%d %H:%M:%S", description="날짜 포맷")
# 환경별 로그 레벨
development_level: str = Field(default="DEBUG", description="개발 환경 로그 레벨")
production_level: str = Field(default="INFO", description="운영 환경 로그 레벨")
test_level: str = Field(default="WARNING", description="테스트 환경 로그 레벨")
class Config:
env_prefix = "LOG_"
class PerformanceSettings(BaseSettings):
"""성능 설정"""
# 캐시 설정
cache_ttl_default: int = Field(default=3600, description="기본 캐시 TTL (초)")
cache_ttl_files: int = Field(default=300, description="파일 목록 캐시 TTL")
cache_ttl_materials: int = Field(default=600, description="자재 목록 캐시 TTL")
cache_ttl_jobs: int = Field(default=1800, description="작업 목록 캐시 TTL")
cache_ttl_classification: int = Field(default=3600, description="분류 결과 캐시 TTL")
cache_ttl_statistics: int = Field(default=900, description="통계 데이터 캐시 TTL")
# 파일 처리 설정
chunk_size: int = Field(default=1000, description="파일 처리 청크 크기")
max_workers: int = Field(default=4, description="최대 워커 수")
memory_limit_mb: int = Field(default=512, description="메모리 제한 (MB)")
class Config:
env_prefix = "PERF_"
class Settings(BaseSettings):
"""메인 애플리케이션 설정"""
# 기본 설정
app_name: str = Field(default="TK-MP BOM Management API", description="애플리케이션 이름")
app_version: str = Field(default="1.0.0", description="애플리케이션 버전")
app_description: str = Field(
default="자재 분류 및 프로젝트 관리 시스템",
description="애플리케이션 설명"
)
debug: bool = Field(default=False, description="디버그 모드")
# 환경 설정
environment: str = Field(
default="development",
description="실행 환경 (development, production, test, synology)"
)
# 서버 설정
host: str = Field(default="0.0.0.0", description="서버 호스트")
port: int = Field(default=8000, description="서버 포트")
reload: bool = Field(default=False, description="자동 재로드")
workers: int = Field(default=1, description="워커 프로세스 수")
# 하위 설정들
database: DatabaseSettings = Field(default_factory=DatabaseSettings)
redis: RedisSettings = Field(default_factory=RedisSettings)
security: SecuritySettings = Field(default_factory=SecuritySettings)
logging: LoggingSettings = Field(default_factory=LoggingSettings)
performance: PerformanceSettings = Field(default_factory=PerformanceSettings)
# 추가 설정
timezone: str = Field(default="Asia/Seoul", description="시간대")
language: str = Field(default="ko", description="기본 언어")
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = False
extra = "ignore"
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._setup_environment_specific_settings()
self._setup_cors_origins()
self._validate_settings()
@validator('environment')
def validate_environment(cls, v):
"""환경 값 검증"""
allowed_environments = ['development', 'production', 'test', 'synology']
if v not in allowed_environments:
raise ValueError(f'Environment must be one of: {allowed_environments}')
return v
@validator('port')
def validate_port(cls, v):
"""포트 번호 검증"""
if not 1 <= v <= 65535:
raise ValueError('Port must be between 1 and 65535')
return v
def _setup_environment_specific_settings(self):
"""환경별 특정 설정 적용"""
if self.environment == "development":
self.debug = True
self.reload = True
self.database.echo = True
self.logging.level = self.logging.development_level
elif self.environment == "production":
self.debug = False
self.reload = False
self.database.echo = False
self.logging.level = self.logging.production_level
self.workers = max(2, os.cpu_count() or 1)
elif self.environment == "test":
self.debug = False
self.reload = False
self.database.echo = False
self.logging.level = self.logging.test_level
# 테스트용 인메모리 데이터베이스
self.database.url = "sqlite:///:memory:"
elif self.environment == "synology":
self.debug = False
self.reload = False
self.host = "0.0.0.0"
self.port = 10080
def _setup_cors_origins(self):
"""환경별 CORS origins 설정"""
if not self.security.cors_origins:
cors_config = {
"development": [
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:13000",
"http://127.0.0.1:3000",
"http://127.0.0.1:5173",
"http://127.0.0.1:13000"
],
"production": [
"https://your-domain.com",
"https://api.your-domain.com"
],
"synology": [
"http://192.168.0.3:10173",
"http://localhost:10173"
],
"test": [
"http://testserver"
]
}
self.security.cors_origins = cors_config.get(
self.environment,
cors_config["development"]
)
def _validate_settings(self):
"""설정 검증"""
# 로그 디렉토리 생성
log_dir = Path(self.logging.file_path).parent
log_dir.mkdir(parents=True, exist_ok=True)
# 업로드 디렉토리 생성
upload_dir = Path(self.security.upload_path)
upload_dir.mkdir(parents=True, exist_ok=True)
def get_database_url(self) -> str:
"""데이터베이스 URL 반환"""
return self.database.url
def get_redis_url(self) -> str:
"""Redis URL 반환"""
return self.redis.url
def is_development(self) -> bool:
"""개발 환경 여부"""
return self.environment == "development"
def is_production(self) -> bool:
"""운영 환경 여부"""
return self.environment == "production"
def is_test(self) -> bool:
"""테스트 환경 여부"""
return self.environment == "test"
def get_cors_config(self) -> Dict[str, Any]:
"""CORS 설정 반환"""
return {
"allow_origins": self.security.cors_origins,
"allow_methods": self.security.cors_methods,
"allow_headers": self.security.cors_headers,
"allow_credentials": self.security.cors_credentials
}
def to_dict(self) -> Dict[str, Any]:
"""설정을 딕셔너리로 변환"""
return self.dict()
def save_to_file(self, file_path: str):
"""설정을 파일로 저장"""
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(self.to_dict(), f, indent=2, ensure_ascii=False, default=str)
# 전역 설정 인스턴스
settings = Settings()
def get_settings() -> Settings:
"""설정 인스턴스 반환"""
return settings

View File

@@ -3,11 +3,22 @@ from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
# 데이터베이스 URL
DATABASE_URL = "postgresql://tkmp_user:tkmp_password_2025@localhost:5432/tk_mp_bom"
# 데이터베이스 URL (환경변수에서 읽거나 기본값 사용)
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom"
)
# SQLAlchemy 엔진 생성
engine = create_engine(DATABASE_URL)
# SQLAlchemy 엔진 생성 (UTF-8 인코딩 설정)
engine = create_engine(
DATABASE_URL,
connect_args={
"client_encoding": "utf8",
"options": "-c client_encoding=utf8 -c timezone=UTC"
},
pool_pre_ping=True,
echo=False
)
# 세션 팩토리 생성
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

View File

@@ -7,162 +7,154 @@ from fastapi import Depends
from typing import Optional, List, Dict
import os
import shutil
# 설정 및 로깅 import
from .config import get_settings
from .utils.logger import get_logger
from .utils.error_handlers import setup_error_handlers
# FastAPI 앱 생성
# 설정 로드
settings = get_settings()
# 로거 설정
logger = get_logger(__name__)
# FastAPI 앱 생성 (요청 크기 제한 증가)
app = FastAPI(
title="TK-MP BOM Management API",
title=settings.app_name,
description="자재 분류 및 프로젝트 관리 시스템",
version="1.0.0"
version=settings.app_version,
debug=settings.debug
)
# CORS 설정
# 요청 크기 제한 설정 (100MB로 증가)
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
def __init__(self, app, max_request_size: int = 100 * 1024 * 1024): # 100MB
super().__init__(app)
self.max_request_size = max_request_size
async def dispatch(self, request: Request, call_next):
if "content-length" in request.headers:
content_length = int(request.headers["content-length"])
if content_length > self.max_request_size:
return Response("Request Entity Too Large", status_code=413)
return await call_next(request)
# 요청 크기 제한 미들웨어 추가
app.add_middleware(RequestSizeLimitMiddleware, max_request_size=100 * 1024 * 1024)
# 에러 핸들러 설정
setup_error_handlers(app)
# CORS 설정 (환경별 분리)
cors_config = settings.get_cors_config()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
**cors_config
)
# 라우터들 import 및 등록
logger.info(f"CORS origins configured for {settings.environment}: {settings.security.cors_origins}")
# 라우터들 import 및 등록 - files 라우터를 최우선으로 등록
try:
from .routers import files
app.include_router(files.router, prefix="/files", tags=["files"])
logger.info("FILES 라우터 등록 완료 - 최우선")
except ImportError:
print("files 라우터를 찾을 수 없습니다")
logger.warning("files 라우터를 찾을 수 없습니다")
try:
from .routers import jobs
app.include_router(jobs.router, prefix="/jobs", tags=["jobs"])
except ImportError:
print("jobs 라우터를 찾을 수 없습니다")
logger.warning("jobs 라우터를 찾을 수 없습니다")
try:
from .routers import purchase
app.include_router(purchase.router, tags=["purchase"])
except ImportError:
print("purchase 라우터를 찾을 수 없습니다")
logger.warning("purchase 라우터를 찾을 수 없습니다")
try:
from .routers import material_comparison
app.include_router(material_comparison.router, tags=["material-comparison"])
except ImportError:
print("material_comparison 라우터를 찾을 수 없습니다")
logger.warning("material_comparison 라우터를 찾을 수 없습니다")
# 파일 목록 조회 API
@app.get("/files")
async def get_files(
job_no: Optional[str] = None, # project_id 대신 job_no 사용
show_history: bool = False, # 이력 표시 여부
db: Session = Depends(get_db)
):
"""파일 목록 조회 (BOM별 그룹화)"""
try:
if show_history:
# 전체 이력 표시
query = "SELECT * FROM files"
params = {}
if job_no:
query += " WHERE job_no = :job_no"
params["job_no"] = job_no
query += " ORDER BY original_filename, revision DESC"
else:
# 최신 리비전만 표시
if job_no:
query = """
SELECT f1.* FROM files f1
INNER JOIN (
SELECT original_filename, MAX(revision) as max_revision
FROM files
WHERE job_no = :job_no
GROUP BY original_filename
) f2 ON f1.original_filename = f2.original_filename
AND f1.revision = f2.max_revision
WHERE f1.job_no = :job_no
ORDER BY f1.upload_date DESC
"""
params = {"job_no": job_no}
else:
# job_no가 없으면 전체 파일 조회
query = "SELECT * FROM files ORDER BY upload_date DESC"
params = {}
result = db.execute(text(query), params)
files = result.fetchall()
return [
{
"id": f.id,
"filename": f.original_filename,
"original_filename": f.original_filename,
"name": f.original_filename,
"job_no": f.job_no, # job_no 사용
"bom_name": f.bom_name or f.original_filename, # 실제 bom_name 값 사용, 없으면 파일명
"revision": f.revision or "Rev.0", # 실제 리비전 또는 기본값
"parsed_count": f.parsed_count or 0, # 파싱된 자재 수
"bom_type": f.file_type or "unknown", # file_type을 BOM 종류로 사용
"status": "active" if f.is_active else "inactive", # is_active 상태
"file_size": f.file_size,
"created_at": f.upload_date,
"upload_date": f.upload_date,
"description": f"파일: {f.original_filename}"
}
for f in files
]
except Exception as e:
print(f"파일 목록 조회 에러: {str(e)}")
return {"error": f"파일 목록 조회 실패: {str(e)}"}
try:
from .routers import dashboard
app.include_router(dashboard.router, tags=["dashboard"])
except ImportError:
logger.warning("dashboard 라우터를 찾을 수 없습니다")
# 파일 삭제 API
@app.delete("/files/{file_id}")
async def delete_file(
file_id: int,
db: Session = Depends(get_db)
):
"""파일 삭제"""
try:
# 먼저 파일 정보 조회
file_query = text("SELECT * FROM files WHERE id = :file_id")
file_result = db.execute(file_query, {"file_id": file_id})
file = file_result.fetchone()
if not file:
return {"error": "파일을 찾을 수 없습니다"}
# 먼저 상세 테이블의 데이터 삭제 (외래 키 제약 조건 때문)
# 각 자재 타입별 상세 테이블 데이터 삭제
detail_tables = [
'pipe_details', 'fitting_details', 'valve_details',
'flange_details', 'bolt_details', 'gasket_details',
'instrument_details'
]
# 해당 파일의 materials ID 조회
material_ids_query = text("SELECT id FROM materials WHERE file_id = :file_id")
material_ids_result = db.execute(material_ids_query, {"file_id": file_id})
material_ids = [row[0] for row in material_ids_result]
if material_ids:
# 각 상세 테이블에서 관련 데이터 삭제
for table in detail_tables:
delete_detail_query = text(f"DELETE FROM {table} WHERE material_id = ANY(:material_ids)")
db.execute(delete_detail_query, {"material_ids": material_ids})
# materials 테이블 데이터 삭제
materials_query = text("DELETE FROM materials WHERE file_id = :file_id")
db.execute(materials_query, {"file_id": file_id})
# 파일 삭제
delete_query = text("DELETE FROM files WHERE id = :file_id")
db.execute(delete_query, {"file_id": file_id})
db.commit()
return {"success": True, "message": "파일과 관련 데이터가 삭제되었습니다"}
except Exception as e:
db.rollback()
return {"error": f"파일 삭제 실패: {str(e)}"}
# 리비전 관리 라우터 (임시 비활성화)
# try:
# from .routers import revision_management
# app.include_router(revision_management.router, tags=["revision-management"])
# except ImportError:
# logger.warning("revision_management 라우터를 찾을 수 없습니다")
try:
from .routers import tubing
app.include_router(tubing.router, prefix="/tubing", tags=["tubing"])
except ImportError:
logger.warning("tubing 라우터를 찾을 수 없습니다")
# 구매 추적 라우터
try:
from .routers import purchase_tracking
app.include_router(purchase_tracking.router)
except ImportError:
logger.warning("purchase_tracking 라우터를 찾을 수 없습니다")
# 엑셀 내보내기 관리 라우터
try:
from .routers import export_manager
app.include_router(export_manager.router)
except ImportError:
logger.warning("export_manager 라우터를 찾을 수 없습니다")
# 구매신청 관리 라우터
try:
from .routers import purchase_request
app.include_router(purchase_request.router)
logger.info("purchase_request 라우터 등록 완료")
except ImportError as e:
logger.warning(f"purchase_request 라우터를 찾을 수 없습니다: {e}")
# 자재 관리 라우터
try:
from .routers import materials
app.include_router(materials.router)
logger.info("materials 라우터 등록 완료")
except ImportError as e:
logger.warning(f"materials 라우터를 찾을 수 없습니다: {e}")
# 파일 관리 API 라우터 등록 (비활성화 - files 라우터와 충돌 방지)
# try:
# from .api import file_management
# app.include_router(file_management.router, tags=["file-management"])
# logger.info("파일 관리 API 라우터 등록 완료")
# except ImportError as e:
# logger.warning(f"파일 관리 라우터를 찾을 수 없습니다: {e}")
logger.info("파일 관리 API 라우터 비활성화됨 (files 라우터 사용)")
# 인증 API 라우터 등록
try:
from .auth import auth_router, setup_router
from .auth.signup_routes import router as signup_router
app.include_router(auth_router, prefix="/auth", tags=["authentication"])
app.include_router(setup_router, prefix="/setup", tags=["system-setup"])
app.include_router(signup_router, tags=["signup"])
logger.info("인증 API 라우터 등록 완료")
logger.info("시스템 설정 API 라우터 등록 완료")
logger.info("회원가입 API 라우터 등록 완료")
except ImportError as e:
logger.warning(f"인증 라우터를 찾을 수 없습니다: {e}")
# 프로젝트 관리 API (비활성화 - jobs 테이블 사용)
# projects 테이블은 더 이상 사용하지 않음
@@ -249,6 +241,14 @@ async def root():
# print(f"Jobs 조회 에러: {str(e)}")
# return {"error": f"Jobs 조회 실패: {str(e)}"}
# 리비전 관리 라우터
try:
from .routers import revision_management
app.include_router(revision_management.router)
logger.info("revision_management 라우터 등록 완료")
except ImportError as e:
logger.warning(f"revision_management 라우터를 찾을 수 없습니다: {e}")
# 파일 업로드는 /files/upload 엔드포인트를 사용하세요 (routers/files.py)
# parse_file과 classify_material_item 함수는 routers/files.py로 이동되었습니다

View File

@@ -70,6 +70,25 @@ class Material(Base):
drawing_reference = Column(String(100))
notes = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow)
is_active = Column(Boolean, default=True)
# 추가 필드들
main_nom = Column(String(50))
red_nom = Column(String(50))
purchase_confirmed = Column(Boolean, default=False)
purchase_confirmed_at = Column(DateTime)
purchase_status = Column(String(20), default='not_purchased')
purchase_confirmed_by = Column(String(100))
confirmed_quantity = Column(Numeric(10, 3))
revision_status = Column(String(20), default='active')
material_hash = Column(String(100))
normalized_description = Column(Text)
full_material_grade = Column(String(100))
row_number = Column(Integer)
length = Column(Numeric(10, 3))
brand = Column(String(100))
user_requirement = Column(Text)
total_length = Column(Numeric(10, 3))
# 관계 설정
file = relationship("File", back_populates="materials")
@@ -276,8 +295,7 @@ class RequirementType(Base):
created_at = Column(DateTime, default=datetime.utcnow)
# 관계 설정
requirements = relationship("UserRequirement", back_populates="requirement_type")
# 관계 설정은 문자열 기반이므로 제거
class UserRequirement(Base):
"""사용자 추가 요구사항"""
@@ -285,6 +303,7 @@ class UserRequirement(Base):
id = Column(Integer, primary_key=True, index=True)
file_id = Column(Integer, ForeignKey("files.id"), nullable=False)
material_id = Column(Integer, ForeignKey("materials.id"), nullable=True) # 자재 ID (개별 자재별 요구사항 연결)
# 요구사항 타입
requirement_type = Column(String(50), nullable=False) # 'IMPACT_TEST', 'HEAT_TREATMENT', 'CUSTOM_SPEC', 'CERTIFICATION' 등
@@ -308,4 +327,145 @@ class UserRequirement(Base):
# 관계 설정
file = relationship("File", backref="user_requirements")
requirement_type_rel = relationship("RequirementType", back_populates="requirements")
# ========== Tubing 시스템 모델들 ==========
class TubingCategory(Base):
"""Tubing 카테고리 (일반, VCR, 위생용 등)"""
__tablename__ = "tubing_categories"
id = Column(Integer, primary_key=True, index=True)
category_code = Column(String(20), unique=True, nullable=False)
category_name = Column(String(100), nullable=False)
description = Column(Text)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
# 관계 설정
specifications = relationship("TubingSpecification", back_populates="category")
class TubingSpecification(Base):
"""Tubing 규격 마스터"""
__tablename__ = "tubing_specifications"
id = Column(Integer, primary_key=True, index=True)
category_id = Column(Integer, ForeignKey("tubing_categories.id"))
spec_code = Column(String(50), unique=True, nullable=False)
spec_name = Column(String(200), nullable=False)
# 물리적 규격
outer_diameter_mm = Column(Numeric(8, 3))
wall_thickness_mm = Column(Numeric(6, 3))
inner_diameter_mm = Column(Numeric(8, 3))
# 재질 정보
material_grade = Column(String(100))
material_standard = Column(String(100))
# 압력/온도 등급
max_pressure_bar = Column(Numeric(8, 2))
max_temperature_c = Column(Numeric(6, 2))
min_temperature_c = Column(Numeric(6, 2))
# 표준 규격
standard_length_m = Column(Numeric(8, 3))
bend_radius_min_mm = Column(Numeric(8, 2))
# 기타 정보
surface_finish = Column(String(100))
hardness = Column(String(50))
notes = Column(Text)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
# 관계 설정
category = relationship("TubingCategory", back_populates="specifications")
products = relationship("TubingProduct", back_populates="specification")
class TubingManufacturer(Base):
"""Tubing 제조사"""
__tablename__ = "tubing_manufacturers"
id = Column(Integer, primary_key=True, index=True)
manufacturer_code = Column(String(20), unique=True, nullable=False)
manufacturer_name = Column(String(200), nullable=False)
country = Column(String(100))
website = Column(String(500))
contact_info = Column(JSON) # JSONB 타입
quality_certs = Column(JSON) # JSONB 타입
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
# 관계 설정
products = relationship("TubingProduct", back_populates="manufacturer")
class TubingProduct(Base):
"""제조사별 Tubing 제품 (품목번호 매핑)"""
__tablename__ = "tubing_products"
id = Column(Integer, primary_key=True, index=True)
specification_id = Column(Integer, ForeignKey("tubing_specifications.id"))
manufacturer_id = Column(Integer, ForeignKey("tubing_manufacturers.id"))
# 제조사 품목번호 정보
manufacturer_part_number = Column(String(200), nullable=False)
manufacturer_product_name = Column(String(300))
# 가격/공급 정보
list_price = Column(Numeric(12, 2))
currency = Column(String(10), default='KRW')
lead_time_days = Column(Integer)
minimum_order_qty = Column(Numeric(10, 3))
standard_packaging_qty = Column(Numeric(10, 3))
# 가용성 정보
availability_status = Column(String(50))
last_price_update = Column(DateTime)
# 추가 정보
datasheet_url = Column(String(500))
catalog_page = Column(String(100))
notes = Column(Text)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
# 관계 설정
specification = relationship("TubingSpecification", back_populates="products")
manufacturer = relationship("TubingManufacturer", back_populates="products")
material_mappings = relationship("MaterialTubingMapping", back_populates="tubing_product")
class MaterialTubingMapping(Base):
"""BOM 자재와 Tubing 제품 매핑"""
__tablename__ = "material_tubing_mapping"
id = Column(Integer, primary_key=True, index=True)
material_id = Column(Integer, ForeignKey("materials.id", ondelete="CASCADE"))
tubing_product_id = Column(Integer, ForeignKey("tubing_products.id"))
# 매핑 정보
confidence_score = Column(Numeric(3, 2))
mapping_method = Column(String(50))
mapped_by = Column(String(100))
mapped_at = Column(DateTime, default=datetime.utcnow)
# 수량 정보
required_length_m = Column(Numeric(10, 3))
calculated_quantity = Column(Numeric(10, 3))
# 검증 정보
is_verified = Column(Boolean, default=False)
verified_by = Column(String(100))
verified_at = Column(DateTime)
notes = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow)
# 관계 설정
material = relationship("Material", backref="tubing_mappings")
tubing_product = relationship("TubingProduct", back_populates="material_mappings")

View File

@@ -0,0 +1,610 @@
"""
대시보드 API
사용자별 맞춤형 대시보드 데이터 제공
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text, func
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
from ..database import get_db
from ..auth.middleware import get_current_user
from ..services.activity_logger import ActivityLogger
from ..utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get("/stats")
async def get_dashboard_stats(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
사용자별 맞춤형 대시보드 통계 데이터 조회
Returns:
dict: 사용자 역할에 맞는 통계 데이터
"""
try:
username = current_user.get('username')
user_role = current_user.get('role', 'user')
# 역할별 맞춤 통계 생성
if user_role == 'admin':
stats = await get_admin_stats(db)
elif user_role == 'manager':
stats = await get_manager_stats(db, username)
elif user_role == 'designer':
stats = await get_designer_stats(db, username)
elif user_role == 'purchaser':
stats = await get_purchaser_stats(db, username)
else:
stats = await get_user_stats(db, username)
return {
"success": True,
"user_role": user_role,
"stats": stats
}
except Exception as e:
logger.error(f"Dashboard stats error: {str(e)}")
raise HTTPException(status_code=500, detail=f"대시보드 통계 조회 실패: {str(e)}")
@router.get("/activities")
async def get_user_activities(
current_user: dict = Depends(get_current_user),
limit: int = Query(10, ge=1, le=50),
db: Session = Depends(get_db)
):
"""
사용자 활동 이력 조회
Args:
limit: 조회할 활동 수 (1-50)
Returns:
dict: 사용자 활동 이력
"""
try:
username = current_user.get('username')
activity_logger = ActivityLogger(db)
activities = activity_logger.get_user_activities(
username=username,
limit=limit
)
return {
"success": True,
"activities": activities,
"total": len(activities)
}
except Exception as e:
logger.error(f"User activities error: {str(e)}")
raise HTTPException(status_code=500, detail=f"활동 이력 조회 실패: {str(e)}")
@router.get("/recent-activities")
async def get_recent_activities(
current_user: dict = Depends(get_current_user),
days: int = Query(7, ge=1, le=30),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db)
):
"""
최근 전체 활동 조회 (관리자/매니저용)
Args:
days: 조회 기간 (일)
limit: 조회할 활동 수
Returns:
dict: 최근 활동 이력
"""
try:
user_role = current_user.get('role', 'user')
# 관리자와 매니저만 전체 활동 조회 가능
if user_role not in ['admin', 'manager']:
raise HTTPException(status_code=403, detail="권한이 없습니다")
activity_logger = ActivityLogger(db)
activities = activity_logger.get_recent_activities(
days=days,
limit=limit
)
return {
"success": True,
"activities": activities,
"period_days": days,
"total": len(activities)
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Recent activities error: {str(e)}")
raise HTTPException(status_code=500, detail=f"최근 활동 조회 실패: {str(e)}")
async def get_admin_stats(db: Session) -> Dict[str, Any]:
"""관리자용 통계"""
try:
# 전체 프로젝트 수
total_projects_query = text("SELECT COUNT(*) FROM jobs WHERE status != 'deleted'")
total_projects = db.execute(total_projects_query).scalar()
# 활성 사용자 수 (최근 30일 로그인)
active_users_query = text("""
SELECT COUNT(DISTINCT username)
FROM user_activity_logs
WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '30 days'
""")
active_users = db.execute(active_users_query).scalar() or 0
# 오늘 업로드된 파일 수
today_uploads_query = text("""
SELECT COUNT(*)
FROM files
WHERE DATE(upload_date) = CURRENT_DATE
""")
today_uploads = db.execute(today_uploads_query).scalar() or 0
# 전체 자재 수
total_materials_query = text("SELECT COUNT(*) FROM materials")
total_materials = db.execute(total_materials_query).scalar() or 0
return {
"title": "시스템 관리자",
"subtitle": "전체 시스템을 관리하고 모니터링합니다",
"metrics": [
{"label": "전체 프로젝트 수", "value": total_projects, "icon": "📋", "color": "#667eea"},
{"label": "활성 사용자 수", "value": active_users, "icon": "👥", "color": "#48bb78"},
{"label": "시스템 상태", "value": "정상", "icon": "🟢", "color": "#38b2ac"},
{"label": "오늘 업로드", "value": today_uploads, "icon": "📤", "color": "#ed8936"}
]
}
except Exception as e:
logger.error(f"Admin stats error: {str(e)}")
raise
async def get_manager_stats(db: Session, username: str) -> Dict[str, Any]:
"""매니저용 통계"""
try:
# 담당 프로젝트 수 (향후 assigned_to 필드 활용)
assigned_projects_query = text("""
SELECT COUNT(*)
FROM jobs
WHERE (assigned_to = :username OR created_by = :username)
AND status != 'deleted'
""")
assigned_projects = db.execute(assigned_projects_query, {"username": username}).scalar() or 0
# 이번 주 완료된 작업 (활동 로그 기반)
week_completed_query = text("""
SELECT COUNT(*)
FROM user_activity_logs
WHERE activity_type IN ('PROJECT_CREATE', 'PURCHASE_CONFIRM')
AND created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'
""")
week_completed = db.execute(week_completed_query).scalar() or 0
# 승인 대기 (구매 확정 대기 등)
pending_approvals_query = text("""
SELECT COUNT(*)
FROM material_purchase_tracking
WHERE purchase_status = 'PENDING'
OR purchase_status = 'REQUESTED'
""")
pending_approvals = db.execute(pending_approvals_query).scalar() or 0
return {
"title": "프로젝트 매니저",
"subtitle": "팀 프로젝트를 관리하고 진행상황을 모니터링합니다",
"metrics": [
{"label": "담당 프로젝트", "value": assigned_projects, "icon": "📋", "color": "#667eea"},
{"label": "팀 진행률", "value": "87%", "icon": "📈", "color": "#48bb78"},
{"label": "승인 대기", "value": pending_approvals, "icon": "", "color": "#ed8936"},
{"label": "이번 주 완료", "value": week_completed, "icon": "", "color": "#38b2ac"}
]
}
except Exception as e:
logger.error(f"Manager stats error: {str(e)}")
raise
async def get_designer_stats(db: Session, username: str) -> Dict[str, Any]:
"""설계자용 통계"""
try:
# 내가 업로드한 BOM 파일 수
my_files_query = text("""
SELECT COUNT(*)
FROM files
WHERE uploaded_by = :username
AND is_active = true
""")
my_files = db.execute(my_files_query, {"username": username}).scalar() or 0
# 분류된 자재 수
classified_materials_query = text("""
SELECT COUNT(*)
FROM materials m
JOIN files f ON m.file_id = f.id
WHERE f.uploaded_by = :username
AND m.classified_category IS NOT NULL
""")
classified_materials = db.execute(classified_materials_query, {"username": username}).scalar() or 0
# 검증 대기 자재 수
pending_verification_query = text("""
SELECT COUNT(*)
FROM materials m
JOIN files f ON m.file_id = f.id
WHERE f.uploaded_by = :username
AND m.is_verified = false
""")
pending_verification = db.execute(pending_verification_query, {"username": username}).scalar() or 0
# 이번 주 업로드 수
week_uploads_query = text("""
SELECT COUNT(*)
FROM files
WHERE uploaded_by = :username
AND upload_date >= CURRENT_TIMESTAMP - INTERVAL '7 days'
""")
week_uploads = db.execute(week_uploads_query, {"username": username}).scalar() or 0
# 분류 완료율 계산
total_materials_query = text("""
SELECT COUNT(*)
FROM materials m
JOIN files f ON m.file_id = f.id
WHERE f.uploaded_by = :username
""")
total_materials = db.execute(total_materials_query, {"username": username}).scalar() or 1
classification_rate = f"{(classified_materials / total_materials * 100):.0f}%" if total_materials > 0 else "0%"
return {
"title": "설계 담당자",
"subtitle": "BOM 파일을 관리하고 자재를 분류합니다",
"metrics": [
{"label": "내 BOM 파일", "value": my_files, "icon": "📄", "color": "#667eea"},
{"label": "분류 완료율", "value": classification_rate, "icon": "🎯", "color": "#48bb78"},
{"label": "검증 대기", "value": pending_verification, "icon": "", "color": "#ed8936"},
{"label": "이번 주 업로드", "value": week_uploads, "icon": "📤", "color": "#9f7aea"}
]
}
except Exception as e:
logger.error(f"Designer stats error: {str(e)}")
raise
async def get_purchaser_stats(db: Session, username: str) -> Dict[str, Any]:
"""구매자용 통계"""
try:
# 구매 요청 수
purchase_requests_query = text("""
SELECT COUNT(*)
FROM material_purchase_tracking
WHERE purchase_status IN ('PENDING', 'REQUESTED')
""")
purchase_requests = db.execute(purchase_requests_query).scalar() or 0
# 발주 완료 수
orders_completed_query = text("""
SELECT COUNT(*)
FROM material_purchase_tracking
WHERE purchase_status = 'CONFIRMED'
AND confirmed_by = :username
""")
orders_completed = db.execute(orders_completed_query, {"username": username}).scalar() or 0
# 입고 대기 수
receiving_pending_query = text("""
SELECT COUNT(*)
FROM material_purchase_tracking
WHERE purchase_status = 'ORDERED'
""")
receiving_pending = db.execute(receiving_pending_query).scalar() or 0
# 이번 달 구매 금액 (임시 데이터)
monthly_amount = "₩2.3M" # 실제로는 계산 필요
return {
"title": "구매 담당자",
"subtitle": "구매 요청을 처리하고 발주를 관리합니다",
"metrics": [
{"label": "구매 요청", "value": purchase_requests, "icon": "🛒", "color": "#667eea"},
{"label": "발주 완료", "value": orders_completed, "icon": "", "color": "#48bb78"},
{"label": "입고 대기", "value": receiving_pending, "icon": "📦", "color": "#ed8936"},
{"label": "이번 달 금액", "value": monthly_amount, "icon": "💰", "color": "#9f7aea"}
]
}
except Exception as e:
logger.error(f"Purchaser stats error: {str(e)}")
raise
async def get_user_stats(db: Session, username: str) -> Dict[str, Any]:
"""일반 사용자용 통계"""
try:
# 내 활동 수 (최근 7일)
my_activities_query = text("""
SELECT COUNT(*)
FROM user_activity_logs
WHERE username = :username
AND created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'
""")
my_activities = db.execute(my_activities_query, {"username": username}).scalar() or 0
# 접근 가능한 프로젝트 수 (임시)
accessible_projects = 5
return {
"title": "일반 사용자",
"subtitle": "할당된 업무를 수행하고 프로젝트에 참여합니다",
"metrics": [
{"label": "내 업무", "value": 6, "icon": "📋", "color": "#667eea"},
{"label": "완료율", "value": "75%", "icon": "📈", "color": "#48bb78"},
{"label": "대기 중", "value": 2, "icon": "", "color": "#ed8936"},
{"label": "이번 주 활동", "value": my_activities, "icon": "🎯", "color": "#9f7aea"}
]
}
except Exception as e:
logger.error(f"User stats error: {str(e)}")
raise
@router.get("/quick-actions")
async def get_quick_actions(
current_user: dict = Depends(get_current_user)
):
"""
사용자 역할별 빠른 작업 메뉴 조회
Returns:
dict: 역할별 빠른 작업 목록
"""
try:
user_role = current_user.get('role', 'user')
quick_actions = {
"admin": [
{"title": "사용자 관리", "icon": "👤", "path": "/admin/users", "color": "#667eea"},
{"title": "시스템 설정", "icon": "⚙️", "path": "/admin/settings", "color": "#48bb78"},
{"title": "백업 관리", "icon": "💾", "path": "/admin/backup", "color": "#ed8936"},
{"title": "활동 로그", "icon": "📊", "path": "/admin/logs", "color": "#9f7aea"}
],
"manager": [
{"title": "프로젝트 생성", "icon": "", "path": "/projects/new", "color": "#667eea"},
{"title": "팀 관리", "icon": "👥", "path": "/team", "color": "#48bb78"},
{"title": "진행 상황", "icon": "📊", "path": "/progress", "color": "#38b2ac"},
{"title": "승인 처리", "icon": "", "path": "/approvals", "color": "#ed8936"}
],
"designer": [
{"title": "BOM 업로드", "icon": "📤", "path": "/upload", "color": "#667eea"},
{"title": "자재 분류", "icon": "🔧", "path": "/materials", "color": "#48bb78"},
{"title": "리비전 관리", "icon": "🔄", "path": "/revisions", "color": "#38b2ac"},
{"title": "분류 검증", "icon": "", "path": "/verify", "color": "#ed8936"}
],
"purchaser": [
{"title": "구매 확정", "icon": "🛒", "path": "/purchase", "color": "#667eea"},
{"title": "발주 관리", "icon": "📋", "path": "/orders", "color": "#48bb78"},
{"title": "공급업체", "icon": "🏢", "path": "/suppliers", "color": "#38b2ac"},
{"title": "입고 처리", "icon": "📦", "path": "/receiving", "color": "#ed8936"}
],
"user": [
{"title": "내 업무", "icon": "📋", "path": "/my-tasks", "color": "#667eea"},
{"title": "프로젝트 보기", "icon": "👁️", "path": "/projects", "color": "#48bb78"},
{"title": "리포트 다운로드", "icon": "📊", "path": "/reports", "color": "#38b2ac"},
{"title": "도움말", "icon": "", "path": "/help", "color": "#9f7aea"}
]
}
return {
"success": True,
"user_role": user_role,
"quick_actions": quick_actions.get(user_role, quick_actions["user"])
}
except Exception as e:
logger.error(f"Quick actions error: {str(e)}")
raise HTTPException(status_code=500, detail=f"빠른 작업 조회 실패: {str(e)}")
@router.post("/projects")
async def create_project(
official_project_code: str = Query(..., description="프로젝트 코드"),
project_name: str = Query(..., description="프로젝트 이름"),
client_name: str = Query(None, description="고객사명"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
새 프로젝트 생성
Args:
official_project_code: 프로젝트 코드 (예: J24-001)
project_name: 프로젝트 이름
client_name: 고객사명 (선택)
Returns:
dict: 생성된 프로젝트 정보
"""
try:
# 중복 확인
check_query = text("SELECT id FROM projects WHERE official_project_code = :code")
existing = db.execute(check_query, {"code": official_project_code}).fetchone()
if existing:
raise HTTPException(status_code=400, detail="이미 존재하는 프로젝트 코드입니다")
# 프로젝트 생성
insert_query = text("""
INSERT INTO projects (official_project_code, project_name, client_name, status)
VALUES (:code, :name, :client, 'active')
RETURNING *
""")
new_project = db.execute(insert_query, {
"code": official_project_code,
"name": project_name,
"client": client_name
}).fetchone()
db.commit()
# TODO: 활동 로그 기록 (추후 구현)
# ActivityLogger 사용법 확인 필요
return {
"success": True,
"message": "프로젝트가 생성되었습니다",
"project": {
"id": new_project.id,
"official_project_code": new_project.official_project_code,
"project_name": new_project.project_name,
"client_name": new_project.client_name,
"status": new_project.status
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"프로젝트 생성 실패: {str(e)}")
raise HTTPException(status_code=500, detail=f"프로젝트 생성 실패: {str(e)}")
@router.get("/projects")
async def get_projects(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
프로젝트 목록 조회
Returns:
dict: 프로젝트 목록
"""
try:
query = text("""
SELECT
id,
official_project_code,
project_name,
client_name,
design_project_code,
design_project_name,
status,
created_at,
updated_at
FROM projects
ORDER BY created_at DESC
""")
results = db.execute(query).fetchall()
projects = []
for row in results:
projects.append({
"id": row.id,
"official_project_code": row.official_project_code,
"job_no": row.official_project_code, # job_no 필드 추가 (프론트엔드 호환성)
"project_name": row.project_name,
"job_name": row.project_name, # 호환성을 위해 추가
"client_name": row.client_name,
"design_project_code": row.design_project_code,
"design_project_name": row.design_project_name,
"status": row.status,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None
})
return {
"success": True,
"projects": projects,
"count": len(projects)
}
except Exception as e:
logger.error(f"프로젝트 목록 조회 실패: {str(e)}")
raise HTTPException(status_code=500, detail=f"프로젝트 목록 조회 실패: {str(e)}")
@router.patch("/projects/{project_id}")
async def update_project_name(
project_id: int,
job_name: str = Query(..., description="새 프로젝트 이름"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
프로젝트 이름 수정
Args:
project_id: 프로젝트 ID
job_name: 새 프로젝트 이름
Returns:
dict: 수정 결과
"""
try:
# 프로젝트 존재 확인
query = text("SELECT * FROM projects WHERE id = :project_id")
result = db.execute(query, {"project_id": project_id}).fetchone()
if not result:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
# 프로젝트 이름 업데이트
update_query = text("""
UPDATE projects
SET project_name = :project_name,
updated_at = CURRENT_TIMESTAMP
WHERE id = :project_id
RETURNING *
""")
updated = db.execute(update_query, {
"project_name": job_name,
"project_id": project_id
}).fetchone()
db.commit()
# TODO: 활동 로그 기록 (추후 구현)
return {
"success": True,
"message": "프로젝트 이름이 수정되었습니다",
"project": {
"id": updated.id,
"official_project_code": updated.official_project_code,
"project_name": updated.project_name,
"job_name": updated.project_name # 호환성
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"프로젝트 수정 실패: {str(e)}")
raise HTTPException(status_code=500, detail=f"프로젝트 수정 실패: {str(e)}")

View File

@@ -0,0 +1,591 @@
"""
엑셀 내보내기 및 구매 배치 관리 API
"""
from fastapi import APIRouter, Depends, HTTPException, status, Response
from fastapi.responses import FileResponse
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Optional, List, Dict, Any
from datetime import datetime
import json
import os
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
import uuid
from ..database import get_db
from ..auth.jwt_service import get_current_user
from ..utils.logger import logger
router = APIRouter(prefix="/export", tags=["Export Management"])
# 엑셀 파일 저장 경로
EXPORT_DIR = "exports"
os.makedirs(EXPORT_DIR, exist_ok=True)
def create_excel_from_materials(materials: List[Dict], batch_info: Dict) -> str:
"""
자재 목록으로 엑셀 파일 생성
"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = batch_info.get("category", "자재목록")
# 헤더 스타일
header_fill = PatternFill(start_color="B8E6FF", end_color="B8E6FF", fill_type="solid")
header_font = Font(bold=True, size=11)
header_alignment = Alignment(horizontal="center", vertical="center")
thin_border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# 배치 정보 추가 (상단 3줄)
ws.merge_cells('A1:J1')
ws['A1'] = f"구매 배치: {batch_info.get('batch_no', 'N/A')}"
ws['A1'].font = Font(bold=True, size=14)
ws.merge_cells('A2:J2')
ws['A2'] = f"프로젝트: {batch_info.get('job_no', '')} - {batch_info.get('job_name', '')}"
ws.merge_cells('A3:J3')
ws['A3'] = f"내보낸 날짜: {batch_info.get('export_date', datetime.now().strftime('%Y-%m-%d %H:%M'))}"
# 빈 줄
ws.append([])
# 헤더 행
headers = [
"No.", "카테고리", "자재 설명", "크기", "스케줄/등급",
"재질", "수량", "단위", "추가요구", "사용자요구",
"구매상태", "PR번호", "PO번호", "공급업체", "예정일"
]
for col, header in enumerate(headers, 1):
cell = ws.cell(row=5, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = header_alignment
cell.border = thin_border
# 데이터 행
row_num = 6
for idx, material in enumerate(materials, 1):
row_data = [
idx,
material.get("category", ""),
material.get("description", ""),
material.get("size", ""),
material.get("schedule", ""),
material.get("material_grade", ""),
material.get("quantity", ""),
material.get("unit", ""),
material.get("additional_req", ""),
material.get("user_requirement", ""),
material.get("purchase_status", "pending"),
material.get("purchase_request_no", ""),
material.get("purchase_order_no", ""),
material.get("vendor_name", ""),
material.get("expected_date", "")
]
for col, value in enumerate(row_data, 1):
cell = ws.cell(row=row_num, column=col, value=value)
cell.border = thin_border
if col == 11: # 구매상태 컬럼
if value == "pending":
cell.fill = PatternFill(start_color="FFFFE0", end_color="FFFFE0", fill_type="solid")
elif value == "requested":
cell.fill = PatternFill(start_color="FFE4B5", end_color="FFE4B5", fill_type="solid")
elif value == "ordered":
cell.fill = PatternFill(start_color="ADD8E6", end_color="ADD8E6", fill_type="solid")
elif value == "received":
cell.fill = PatternFill(start_color="90EE90", end_color="90EE90", fill_type="solid")
row_num += 1
# 열 너비 자동 조정
for column in ws.columns:
max_length = 0
column_letter = get_column_letter(column[0].column)
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column_letter].width = adjusted_width
# 파일 저장
file_name = f"batch_{batch_info.get('batch_no', uuid.uuid4().hex[:8])}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
file_path = os.path.join(EXPORT_DIR, file_name)
wb.save(file_path)
return file_name
@router.post("/create-batch")
async def create_export_batch(
file_id: int,
job_no: Optional[str] = None,
category: Optional[str] = None,
materials: List[Dict] = [],
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
엑셀 내보내기 배치 생성 (자재 그룹화)
"""
try:
# 배치 번호 생성 (YYYYMMDD-XXX 형식)
batch_date = datetime.now().strftime('%Y%m%d')
# 오늘 생성된 배치 수 확인
count_query = text("""
SELECT COUNT(*) as count
FROM excel_export_history
WHERE DATE(export_date) = CURRENT_DATE
""")
count_result = db.execute(count_query).fetchone()
batch_seq = (count_result.count + 1) if count_result else 1
batch_no = f"{batch_date}-{str(batch_seq).zfill(3)}"
# Job 정보 조회
job_name = ""
if job_no:
job_query = text("SELECT job_name FROM jobs WHERE job_no = :job_no")
job_result = db.execute(job_query, {"job_no": job_no}).fetchone()
if job_result:
job_name = job_result.job_name
# 배치 정보
batch_info = {
"batch_no": batch_no,
"job_no": job_no,
"job_name": job_name,
"category": category,
"export_date": datetime.now().strftime('%Y-%m-%d %H:%M')
}
# 엑셀 파일 생성
excel_file_name = create_excel_from_materials(materials, batch_info)
# 내보내기 이력 저장
insert_history = text("""
INSERT INTO excel_export_history (
file_id, job_no, exported_by, export_type,
category, material_count, file_name, notes
) VALUES (
:file_id, :job_no, :exported_by, :export_type,
:category, :material_count, :file_name, :notes
) RETURNING export_id
""")
result = db.execute(insert_history, {
"file_id": file_id,
"job_no": job_no,
"exported_by": current_user.get("user_id"),
"export_type": "batch",
"category": category,
"material_count": len(materials),
"file_name": excel_file_name,
"notes": f"배치번호: {batch_no}"
})
export_id = result.fetchone().export_id
# 자재별 내보내기 기록
material_ids = []
for material in materials:
material_id = material.get("id")
if material_id:
material_ids.append(material_id)
insert_material = text("""
INSERT INTO exported_materials (
export_id, material_id, purchase_status,
quantity_exported
) VALUES (
:export_id, :material_id, 'pending',
:quantity
)
""")
db.execute(insert_material, {
"export_id": export_id,
"material_id": material_id,
"quantity": material.get("quantity", 0)
})
db.commit()
logger.info(f"Export batch created: {batch_no} with {len(materials)} materials")
return {
"success": True,
"batch_no": batch_no,
"export_id": export_id,
"file_name": excel_file_name,
"material_count": len(materials),
"message": f"배치 {batch_no}가 생성되었습니다"
}
except Exception as e:
db.rollback()
logger.error(f"Failed to create export batch: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"배치 생성 실패: {str(e)}"
)
@router.get("/batches")
async def get_export_batches(
file_id: Optional[int] = None,
job_no: Optional[str] = None,
status: Optional[str] = None,
limit: int = 50,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
내보내기 배치 목록 조회
"""
try:
query = text("""
SELECT
eeh.export_id,
eeh.file_id,
eeh.job_no,
eeh.export_date,
eeh.category,
eeh.material_count,
eeh.file_name,
eeh.notes,
u.name as exported_by,
j.job_name,
f.original_filename,
-- 상태별 집계
COUNT(DISTINCT em.material_id) as total_materials,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'pending' THEN em.material_id END) as pending_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) as requested_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) as ordered_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) as received_count,
-- 전체 상태 계산
CASE
WHEN COUNT(DISTINCT em.material_id) = COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END)
THEN 'completed'
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) > 0
THEN 'in_progress'
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) > 0
THEN 'requested'
ELSE 'pending'
END as batch_status
FROM excel_export_history eeh
LEFT JOIN users u ON eeh.exported_by = u.user_id
LEFT JOIN jobs j ON eeh.job_no = j.job_no
LEFT JOIN files f ON eeh.file_id = f.id
LEFT JOIN exported_materials em ON eeh.export_id = em.export_id
WHERE eeh.export_type = 'batch'
AND (:file_id IS NULL OR eeh.file_id = :file_id)
AND (:job_no IS NULL OR eeh.job_no = :job_no)
GROUP BY
eeh.export_id, eeh.file_id, eeh.job_no, eeh.export_date,
eeh.category, eeh.material_count, eeh.file_name, eeh.notes,
u.name, j.job_name, f.original_filename
HAVING (:status IS NULL OR
CASE
WHEN COUNT(DISTINCT em.material_id) = COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END)
THEN 'completed'
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) > 0
THEN 'in_progress'
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) > 0
THEN 'requested'
ELSE 'pending'
END = :status)
ORDER BY eeh.export_date DESC
LIMIT :limit
""")
results = db.execute(query, {
"file_id": file_id,
"job_no": job_no,
"status": status,
"limit": limit
}).fetchall()
batches = []
for row in results:
# 배치 번호 추출 (notes에서)
batch_no = ""
if row.notes and "배치번호:" in row.notes:
batch_no = row.notes.split("배치번호:")[1].strip()
batches.append({
"export_id": row.export_id,
"batch_no": batch_no,
"file_id": row.file_id,
"job_no": row.job_no,
"job_name": row.job_name,
"export_date": row.export_date.isoformat() if row.export_date else None,
"category": row.category,
"material_count": row.total_materials,
"file_name": row.file_name,
"exported_by": row.exported_by,
"source_file": row.original_filename,
"batch_status": row.batch_status,
"status_detail": {
"pending": row.pending_count,
"requested": row.requested_count,
"ordered": row.ordered_count,
"received": row.received_count,
"total": row.total_materials
}
})
return {
"success": True,
"batches": batches,
"count": len(batches)
}
except Exception as e:
logger.error(f"Failed to get export batches: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"배치 목록 조회 실패: {str(e)}"
)
@router.get("/batch/{export_id}/materials")
async def get_batch_materials(
export_id: int,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
배치에 포함된 자재 목록 조회
"""
try:
query = text("""
SELECT
em.id as exported_material_id,
em.material_id,
m.original_description,
m.classified_category,
m.size_inch,
m.schedule,
m.material_grade,
m.quantity,
m.unit,
em.purchase_status,
em.purchase_request_no,
em.purchase_order_no,
em.vendor_name,
em.expected_date,
em.quantity_ordered,
em.quantity_received,
em.unit_price,
em.total_price,
em.notes,
ur.requirement as user_requirement
FROM exported_materials em
JOIN materials m ON em.material_id = m.id
LEFT JOIN user_requirements ur ON m.id = ur.material_id
WHERE em.export_id = :export_id
ORDER BY m.classified_category, m.original_description
""")
results = db.execute(query, {"export_id": export_id}).fetchall()
materials = []
for row in results:
materials.append({
"exported_material_id": row.exported_material_id,
"material_id": row.material_id,
"description": row.original_description,
"category": row.classified_category,
"size": row.size_inch,
"schedule": row.schedule,
"material_grade": row.material_grade,
"quantity": row.quantity,
"unit": row.unit,
"user_requirement": row.user_requirement,
"purchase_status": row.purchase_status,
"purchase_request_no": row.purchase_request_no,
"purchase_order_no": row.purchase_order_no,
"vendor_name": row.vendor_name,
"expected_date": row.expected_date.isoformat() if row.expected_date else None,
"quantity_ordered": row.quantity_ordered,
"quantity_received": row.quantity_received,
"unit_price": float(row.unit_price) if row.unit_price else None,
"total_price": float(row.total_price) if row.total_price else None,
"notes": row.notes
})
return {
"success": True,
"materials": materials,
"count": len(materials)
}
except Exception as e:
logger.error(f"Failed to get batch materials: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"배치 자재 조회 실패: {str(e)}"
)
@router.get("/batch/{export_id}/download")
async def download_batch_excel(
export_id: int,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
저장된 배치 엑셀 파일 다운로드
"""
try:
# 배치 정보 조회
query = text("""
SELECT file_name, notes
FROM excel_export_history
WHERE export_id = :export_id
""")
result = db.execute(query, {"export_id": export_id}).fetchone()
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="배치를 찾을 수 없습니다"
)
file_path = os.path.join(EXPORT_DIR, result.file_name)
if not os.path.exists(file_path):
# 파일이 없으면 재생성
materials = await get_batch_materials(export_id, current_user, db)
batch_no = ""
if result.notes and "배치번호:" in result.notes:
batch_no = result.notes.split("배치번호:")[1].strip()
batch_info = {
"batch_no": batch_no,
"export_date": datetime.now().strftime('%Y-%m-%d %H:%M')
}
file_name = create_excel_from_materials(materials["materials"], batch_info)
file_path = os.path.join(EXPORT_DIR, file_name)
# DB 업데이트
update_query = text("""
UPDATE excel_export_history
SET file_name = :file_name
WHERE export_id = :export_id
""")
db.execute(update_query, {
"file_name": file_name,
"export_id": export_id
})
db.commit()
return FileResponse(
path=file_path,
filename=result.file_name,
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to download batch excel: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"엑셀 다운로드 실패: {str(e)}"
)
@router.patch("/batch/{export_id}/status")
async def update_batch_status(
export_id: int,
status: str,
purchase_request_no: Optional[str] = None,
purchase_order_no: Optional[str] = None,
vendor_name: Optional[str] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
배치 전체 상태 일괄 업데이트
"""
try:
# 배치의 모든 자재 상태 업데이트
update_query = text("""
UPDATE exported_materials
SET
purchase_status = :status,
purchase_request_no = COALESCE(:pr_no, purchase_request_no),
purchase_order_no = COALESCE(:po_no, purchase_order_no),
vendor_name = COALESCE(:vendor, vendor_name),
updated_by = :updated_by,
requested_date = CASE WHEN :status = 'requested' THEN CURRENT_TIMESTAMP ELSE requested_date END,
ordered_date = CASE WHEN :status = 'ordered' THEN CURRENT_TIMESTAMP ELSE ordered_date END,
received_date = CASE WHEN :status = 'received' THEN CURRENT_TIMESTAMP ELSE received_date END
WHERE export_id = :export_id
""")
result = db.execute(update_query, {
"export_id": export_id,
"status": status,
"pr_no": purchase_request_no,
"po_no": purchase_order_no,
"vendor": vendor_name,
"updated_by": current_user.get("user_id")
})
# 이력 기록
history_query = text("""
INSERT INTO purchase_status_history (
exported_material_id, material_id,
previous_status, new_status,
changed_by, reason
)
SELECT
em.id, em.material_id,
em.purchase_status, :new_status,
:changed_by, :reason
FROM exported_materials em
WHERE em.export_id = :export_id
""")
db.execute(history_query, {
"export_id": export_id,
"new_status": status,
"changed_by": current_user.get("user_id"),
"reason": f"배치 일괄 업데이트"
})
db.commit()
logger.info(f"Batch {export_id} status updated to {status}")
return {
"success": True,
"message": f"배치의 모든 자재가 {status} 상태로 변경되었습니다",
"updated_count": result.rowcount
}
except Exception as e:
db.rollback()
logger.error(f"Failed to update batch status: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"배치 상태 업데이트 실패: {str(e)}"
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,399 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import List, Optional
import os
import shutil
from datetime import datetime
import uuid
import pandas as pd
import re
from pathlib import Path
from ..database import get_db
router = APIRouter()
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"}
@router.get("/")
async def get_files_info():
return {
"message": "파일 관리 API",
"allowed_extensions": list(ALLOWED_EXTENSIONS),
"upload_directory": str(UPLOAD_DIR)
}
@router.get("/test")
async def test_endpoint():
return {"status": "파일 API가 정상 작동합니다!"}
@router.post("/add-missing-columns")
async def add_missing_columns(db: Session = Depends(get_db)):
"""누락된 컬럼들 추가"""
try:
db.execute(text("ALTER TABLE files ADD COLUMN IF NOT EXISTS parsed_count INTEGER DEFAULT 0"))
db.execute(text("ALTER TABLE materials ADD COLUMN IF NOT EXISTS row_number INTEGER"))
db.commit()
return {
"success": True,
"message": "누락된 컬럼들이 추가되었습니다",
"added_columns": ["files.parsed_count", "materials.row_number"]
}
except Exception as e:
db.rollback()
return {"success": False, "error": f"컬럼 추가 실패: {str(e)}"}
def validate_file_extension(filename: str) -> bool:
return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
def generate_unique_filename(original_filename: str) -> str:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
unique_id = str(uuid.uuid4())[:8]
stem = Path(original_filename).stem
suffix = Path(original_filename).suffix
return f"{stem}_{timestamp}_{unique_id}{suffix}"
def parse_dataframe(df):
df = df.dropna(how='all')
df.columns = df.columns.str.strip().str.lower()
column_mapping = {
'description': ['description', 'item', 'material', '품명', '자재명'],
'quantity': ['qty', 'quantity', 'ea', '수량'],
'main_size': ['main_nom', 'nominal_diameter', 'nd', '주배관'],
'red_size': ['red_nom', 'reduced_diameter', '축소배관'],
'length': ['length', 'len', '길이'],
'weight': ['weight', 'wt', '중량'],
'dwg_name': ['dwg_name', 'drawing', '도면명'],
'line_num': ['line_num', 'line_number', '라인번호']
}
mapped_columns = {}
for standard_col, possible_names in column_mapping.items():
for possible_name in possible_names:
if possible_name in df.columns:
mapped_columns[standard_col] = possible_name
break
materials = []
for index, row in df.iterrows():
description = str(row.get(mapped_columns.get('description', ''), ''))
quantity_raw = row.get(mapped_columns.get('quantity', ''), 0)
try:
quantity = float(quantity_raw) if pd.notna(quantity_raw) else 0
except:
quantity = 0
material_grade = ""
if "ASTM" in description.upper():
astm_match = re.search(r'ASTM\s+([A-Z0-9\s]+)', description.upper())
if astm_match:
material_grade = astm_match.group(0).strip()
main_size = str(row.get(mapped_columns.get('main_size', ''), ''))
red_size = str(row.get(mapped_columns.get('red_size', ''), ''))
if main_size != 'nan' and red_size != 'nan' and red_size != '':
size_spec = f"{main_size} x {red_size}"
elif main_size != 'nan' and main_size != '':
size_spec = main_size
else:
size_spec = ""
if description and description not in ['nan', 'None', '']:
materials.append({
'original_description': description,
'quantity': quantity,
'unit': "EA",
'size_spec': size_spec,
'material_grade': material_grade,
'line_number': index + 1,
'row_number': index + 1
})
return materials
def parse_file_data(file_path):
file_extension = Path(file_path).suffix.lower()
try:
if file_extension == ".csv":
df = pd.read_csv(file_path, encoding='utf-8')
elif file_extension in [".xlsx", ".xls"]:
df = pd.read_excel(file_path, sheet_name=0)
else:
raise HTTPException(status_code=400, detail="지원하지 않는 파일 형식")
return parse_dataframe(df)
except Exception as e:
raise HTTPException(status_code=400, detail=f"파일 파싱 실패: {str(e)}")
@router.post("/upload")
async def upload_file(
file: UploadFile = File(...),
job_no: str = Form(...),
revision: str = Form("Rev.0"),
db: Session = Depends(get_db)
):
if not validate_file_extension(file.filename):
raise HTTPException(
status_code=400,
detail=f"지원하지 않는 파일 형식입니다. 허용된 확장자: {', '.join(ALLOWED_EXTENSIONS)}"
)
if file.size and file.size > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="파일 크기는 10MB를 초과할 수 없습니다")
unique_filename = generate_unique_filename(file.filename)
file_path = UPLOAD_DIR / unique_filename
try:
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
except Exception as e:
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
try:
materials_data = parse_file_data(str(file_path))
parsed_count = len(materials_data)
# 파일 정보 저장
file_insert_query = text("""
INSERT INTO files (filename, original_filename, file_path, job_no, revision, description, file_size, parsed_count, is_active)
VALUES (:filename, :original_filename, :file_path, :job_no, :revision, :description, :file_size, :parsed_count, :is_active)
RETURNING id
""")
file_result = db.execute(file_insert_query, {
"filename": unique_filename,
"original_filename": file.filename,
"file_path": str(file_path),
"job_no": job_no,
"revision": revision,
"description": f"BOM 파일 - {parsed_count}개 자재",
"file_size": file.size,
"parsed_count": parsed_count,
"is_active": True
})
file_id = file_result.fetchone()[0]
# 자재 데이터 저장
materials_inserted = 0
for material_data in materials_data:
material_insert_query = text("""
INSERT INTO materials (
file_id, original_description, quantity, unit, size_spec,
material_grade, line_number, row_number, classified_category,
classification_confidence, is_verified, created_at
)
VALUES (
:file_id, :original_description, :quantity, :unit, :size_spec,
:material_grade, :line_number, :row_number, :classified_category,
:classification_confidence, :is_verified, :created_at
)
""")
db.execute(material_insert_query, {
"file_id": file_id,
"original_description": material_data["original_description"],
"quantity": material_data["quantity"],
"unit": material_data["unit"],
"size_spec": material_data["size_spec"],
"material_grade": material_data["material_grade"],
"line_number": material_data["line_number"],
"row_number": material_data["row_number"],
"classified_category": None,
"classification_confidence": None,
"is_verified": False,
"created_at": datetime.now()
})
materials_inserted += 1
db.commit()
return {
"success": True,
"message": f"완전한 DB 저장 성공! {materials_inserted}개 자재 저장됨",
"original_filename": file.filename,
"file_id": file_id,
"parsed_materials_count": parsed_count,
"saved_materials_count": materials_inserted,
"sample_materials": materials_data[:3] if materials_data else []
}
except Exception as e:
db.rollback()
if os.path.exists(file_path):
os.remove(file_path)
raise HTTPException(status_code=500, detail=f"파일 처리 실패: {str(e)}")
@router.get("/materials")
async def get_materials(
job_no: Optional[str] = None,
file_id: Optional[str] = None,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db)
):
"""저장된 자재 목록 조회"""
try:
query = """
SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit,
m.size_spec, m.material_grade, m.line_number, m.row_number,
m.created_at,
f.original_filename, f.job_no,
j.job_no, j.job_name
FROM materials m
LEFT JOIN files f ON m.file_id = f.id
LEFT JOIN jobs j ON f.job_no = j.job_no
WHERE 1=1
"""
params = {}
if job_no:
query += " AND f.job_no = :job_no"
params["job_no"] = job_no
if file_id:
query += " AND m.file_id = :file_id"
params["file_id"] = file_id
query += " ORDER BY m.line_number ASC LIMIT :limit OFFSET :skip"
params["limit"] = limit
params["skip"] = skip
result = db.execute(text(query), params)
materials = result.fetchall()
# 전체 개수 조회
count_query = """
SELECT COUNT(*) as total
FROM materials m
LEFT JOIN files f ON m.file_id = f.id
WHERE 1=1
"""
count_params = {}
if job_no:
count_query += " AND f.job_no = :job_no"
count_params["job_no"] = job_no
if file_id:
count_query += " AND m.file_id = :file_id"
count_params["file_id"] = file_id
count_result = db.execute(text(count_query), count_params)
total_count = count_result.fetchone()[0]
return {
"success": True,
"total_count": total_count,
"returned_count": len(materials),
"skip": skip,
"limit": limit,
"materials": [
{
"id": m.id,
"file_id": m.file_id,
"filename": m.original_filename,
"job_no": m.job_no,
"project_code": m.official_project_code,
"project_name": m.project_name,
"original_description": m.original_description,
"quantity": float(m.quantity) if m.quantity else 0,
"unit": m.unit,
"size_spec": m.size_spec,
"material_grade": m.material_grade,
"line_number": m.line_number,
"row_number": m.row_number,
"created_at": m.created_at
}
for m in materials
]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"자재 조회 실패: {str(e)}")
@router.get("/materials/summary")
async def get_materials_summary(
job_no: Optional[str] = None,
file_id: Optional[str] = None,
db: Session = Depends(get_db)
):
"""자재 요약 통계"""
try:
query = """
SELECT
COUNT(*) as total_items,
COUNT(DISTINCT m.original_description) as unique_descriptions,
COUNT(DISTINCT m.size_spec) as unique_sizes,
COUNT(DISTINCT m.material_grade) as unique_materials,
SUM(m.quantity) as total_quantity,
AVG(m.quantity) as avg_quantity,
MIN(m.created_at) as earliest_upload,
MAX(m.created_at) as latest_upload
FROM materials m
LEFT JOIN files f ON m.file_id = f.id
WHERE 1=1
"""
params = {}
if job_no:
query += " AND f.job_no = :job_no"
params["job_no"] = job_no
if file_id:
query += " AND m.file_id = :file_id"
params["file_id"] = file_id
result = db.execute(text(query), params)
summary = result.fetchone()
return {
"success": True,
"summary": {
"total_items": summary.total_items,
"unique_descriptions": summary.unique_descriptions,
"unique_sizes": summary.unique_sizes,
"unique_materials": summary.unique_materials,
"total_quantity": float(summary.total_quantity) if summary.total_quantity else 0,
"avg_quantity": round(float(summary.avg_quantity), 2) if summary.avg_quantity else 0,
"earliest_upload": summary.earliest_upload,
"latest_upload": summary.latest_upload
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"요약 조회 실패: {str(e)}")
# Job 검증 함수 (파일 끝에 추가할 예정)
async def validate_job_exists(job_no: str, db: Session):
"""Job 존재 여부 및 활성 상태 확인"""
try:
query = text("SELECT job_no, job_name, status FROM jobs WHERE job_no = :job_no AND is_active = true")
job = db.execute(query, {"job_no": job_no}).fetchone()
if not job:
return {"valid": False, "error": f"Job No. '{job_no}'를 찾을 수 없습니다"}
if job.status == '완료':
return {"valid": False, "error": f"완료된 Job '{job.job_name}'에는 파일을 업로드할 수 없습니다"}
return {
"valid": True,
"job": {
"job_no": job.job_no,
"job_name": job.job_name,
"status": job.status
}
}
except Exception as e:
return {"valid": False, "error": f"Job 검증 실패: {str(e)}"}

View File

@@ -20,6 +20,7 @@ class JobCreate(BaseModel):
contract_date: Optional[date] = None
delivery_date: Optional[date] = None
delivery_terms: Optional[str] = None
project_type: Optional[str] = "냉동기"
description: Optional[str] = None
@router.get("/")
@@ -34,7 +35,7 @@ async def get_jobs(
query = """
SELECT job_no, job_name, client_name, end_user, epc_company,
project_site, contract_date, delivery_date, delivery_terms,
status, description, created_by, created_at, updated_at, is_active
project_type, status, description, created_by, created_at, updated_at, is_active
FROM jobs
WHERE is_active = true
"""
@@ -66,6 +67,7 @@ async def get_jobs(
"contract_date": job.contract_date,
"delivery_date": job.delivery_date,
"delivery_terms": job.delivery_terms,
"project_type": job.project_type,
"status": job.status,
"description": job.description,
"created_at": job.created_at,
@@ -85,7 +87,7 @@ async def get_job(job_no: str, db: Session = Depends(get_db)):
query = text("""
SELECT job_no, job_name, client_name, end_user, epc_company,
project_site, contract_date, delivery_date, delivery_terms,
status, description, created_by, created_at, updated_at, is_active
project_type, status, description, created_by, created_at, updated_at, is_active
FROM jobs
WHERE job_no = :job_no AND is_active = true
""")
@@ -108,6 +110,7 @@ async def get_job(job_no: str, db: Session = Depends(get_db)):
"contract_date": job.contract_date,
"delivery_date": job.delivery_date,
"delivery_terms": job.delivery_terms,
"project_type": job.project_type,
"status": job.status,
"description": job.description,
"created_by": job.created_by,
@@ -139,14 +142,14 @@ async def create_job(job: JobCreate, db: Session = Depends(get_db)):
INSERT INTO jobs (
job_no, job_name, client_name, end_user, epc_company,
project_site, contract_date, delivery_date, delivery_terms,
description, created_by, status, is_active
project_type, description, created_by, status, is_active
)
VALUES (
:job_no, :job_name, :client_name, :end_user, :epc_company,
:project_site, :contract_date, :delivery_date, :delivery_terms,
:description, :created_by, :status, :is_active
:project_type, :description, :created_by, :status, :is_active
)
RETURNING job_no, job_name, client_name
RETURNING job_no, job_name, client_name, project_type
""")
result = db.execute(insert_query, {
@@ -165,7 +168,8 @@ async def create_job(job: JobCreate, db: Session = Depends(get_db)):
"job": {
"job_no": new_job.job_no,
"job_name": new_job.job_name,
"client_name": new_job.client_name
"client_name": new_job.client_name,
"project_type": new_job.project_type
}
}

View File

@@ -157,6 +157,26 @@ async def confirm_material_purchase(
]
"""
try:
# 입력 데이터 검증
if not job_no or not revision:
raise HTTPException(status_code=400, detail="Job 번호와 리비전은 필수입니다")
if not confirmations:
raise HTTPException(status_code=400, detail="확정할 자재가 없습니다")
# 각 확정 항목 검증
for i, confirmation in enumerate(confirmations):
if not confirmation.get("material_hash"):
raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 material_hash가 없습니다")
confirmed_qty = confirmation.get("confirmed_quantity")
if confirmed_qty is None or confirmed_qty < 0:
raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 확정 수량이 유효하지 않습니다")
unit_price = confirmation.get("unit_price", 0)
if unit_price < 0:
raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 단가가 유효하지 않습니다")
confirmed_items = []
for confirmation in confirmations:
@@ -470,7 +490,7 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
"""파일의 자재를 해시별로 그룹화하여 조회"""
import hashlib
print(f"🚨🚨🚨 get_materials_by_hash 호출됨! file_id={file_id} 🚨🚨🚨")
# 로그 제거
query = text("""
SELECT
@@ -492,11 +512,7 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
result = db.execute(query, {"file_id": file_id})
materials = result.fetchall()
print(f"🔍 쿼리 결과 개수: {len(materials)}")
if len(materials) > 0:
print(f"🔍 첫 번째 자료 샘플: {materials[0]}")
else:
print(f"❌ 자료가 없음! file_id={file_id}")
# 로그 제거
# 🔄 같은 파이프들을 Python에서 올바르게 그룹핑
materials_dict = {}
@@ -505,38 +521,41 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
hash_source = f"{mat[1] or ''}|{mat[2] or ''}|{mat[3] or ''}"
material_hash = hashlib.md5(hash_source.encode()).hexdigest()
print(f"📝 개별 자재: {mat[1][:50]}... ({mat[2]}) - 수량: {mat[4]}, 길이: {mat[7]}mm")
# 개별 자재 로그 제거 (너무 많음)
if material_hash in materials_dict:
# 🔄 기존 항목에 수량 합계
existing = materials_dict[material_hash]
existing["quantity"] += float(mat[4]) if mat[4] else 0.0
# 파이프가 아닌 경우만 quantity 합산 (파이프는 개별 길이가 다르므로 합산하지 않음)
if mat[5] != 'PIPE':
existing["quantity"] += float(mat[4]) if mat[4] else 0.0
existing["line_number"] += f", {mat[8]}" if mat[8] else ""
# 파이프인 경우 길이 정보 합산
if mat[5] == 'PIPE' and mat[7] is not None:
if "pipe_details" in existing:
# 총길이 합산: 기존 총길이 + (현재 수량 × 현재 길이)
# 총길이 합산: 기존 총길이 + 현재 파이프의 실제 길이 (DB에 저장된 개별 길이)
current_total = existing["pipe_details"]["total_length_mm"]
current_count = existing["pipe_details"]["pipe_count"]
new_length = float(mat[4]) * float(mat[7]) # 수량 × 단위길이
existing["pipe_details"]["total_length_mm"] = current_total + new_length
existing["pipe_details"]["pipe_count"] = current_count + float(mat[4])
# ✅ DB에서 가져온 length_mm는 이미 개별 파이프의 실제 길이이므로 수량을 곱하지 않음
individual_length = float(mat[7]) # 개별 파이프의 실제 길이
existing["pipe_details"]["total_length_mm"] = current_total + individual_length
existing["pipe_details"]["pipe_count"] = current_count + 1 # 파이프 개수는 1개씩 증가
# 평균 단위 길이 재계산
total_length = existing["pipe_details"]["total_length_mm"]
total_count = existing["pipe_details"]["pipe_count"]
existing["pipe_details"]["length_mm"] = total_length / total_count
print(f"🔄 파이프 합산: {mat[1]} ({mat[2]}) - 총길이: {total_length}mm, 총개수: {total_count}개, 평균: {total_length/total_count:.1f}mm")
# 파이프 합산 로그 제거 (너무 많음)
else:
# 첫 파이프 정보 설정
pipe_length = float(mat[4]) * float(mat[7])
individual_length = float(mat[7]) # 개별 파이프의 실제 길이
existing["pipe_details"] = {
"length_mm": float(mat[7]),
"total_length_mm": pipe_length,
"pipe_count": float(mat[4])
"length_mm": individual_length,
"total_length_mm": individual_length, # 첫 번째 파이프이므로 개별 길이와 동일
"pipe_count": 1 # 첫 번째 파이프이므로 1개
}
else:
# 🆕 새 항목 생성
@@ -553,27 +572,22 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
# 파이프인 경우 pipe_details 정보 추가
if mat[5] == 'PIPE' and mat[7] is not None:
pipe_length = float(mat[4]) * float(mat[7]) # 수량 × 단위길이
individual_length = float(mat[7]) # 개별 파이프의 실제 길이
material_data["pipe_details"] = {
"length_mm": float(mat[7]), # 단위 길이
"total_length_mm": pipe_length, # 총 길이
"pipe_count": float(mat[4]) # 파이프 개수
"length_mm": individual_length, # 개별 파이프 길이
"total_length_mm": individual_length, # 첫 번째 파이프이므로 개별 길이와 동일
"pipe_count": 1 # 첫 번째 파이프이므로 1개
}
print(f"🆕 파이프 신규: {mat[1]} ({mat[2]}) - 단위: {mat[7]}mm, 총길이: {pipe_length}mm")
# 파이프는 quantity를 1로 설정 (pipe_count와 동일)
material_data["quantity"] = 1
materials_dict[material_hash] = material_data
# 파이프 데이터가 포함되었는지 확인
# 파이프 데이터 요약만 출력
pipe_count = sum(1 for data in materials_dict.values() if data.get('category') == 'PIPE')
pipe_with_details = sum(1 for data in materials_dict.values()
if data.get('category') == 'PIPE' and 'pipe_details' in data)
print(f"🔍 반환 결과: 총 {len(materials_dict)} 자재, 파이프 {pipe_count}, pipe_details 있는 파이프 {pipe_with_details}")
# 첫 번째 파이프 데이터 샘플 출력
for hash_key, data in materials_dict.items():
if data.get('category') == 'PIPE':
print(f"🔍 파이프 샘플: {data}")
break
print(f"✅ 자재 처리 완료: 총 {len(materials_dict)}개, 파이프 {pipe_count} (길이정보: {pipe_with_details})")
return materials_dict

View File

@@ -0,0 +1,161 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import text
from pydantic import BaseModel
from ..database import get_db
from ..auth.middleware import get_current_user
router = APIRouter(prefix="/materials", tags=["materials"])
class BrandUpdate(BaseModel):
brand: str
class UserRequirementUpdate(BaseModel):
user_requirement: str
@router.patch("/{material_id}/brand")
async def update_material_brand(
material_id: int,
brand_data: BrandUpdate,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""자재의 브랜드 정보를 업데이트합니다."""
try:
# 자재 존재 여부 확인
result = db.execute(
text("SELECT id FROM materials WHERE id = :material_id"),
{"material_id": material_id}
)
material = result.fetchone()
if not material:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="자재를 찾을 수 없습니다."
)
# 브랜드 업데이트
db.execute(
text("""
UPDATE materials
SET brand = :brand,
updated_by = :updated_by
WHERE id = :material_id
"""),
{
"brand": brand_data.brand.strip(),
"updated_by": current_user.get("username", "unknown"),
"material_id": material_id
}
)
db.commit()
return {
"success": True,
"message": "브랜드가 성공적으로 업데이트되었습니다.",
"material_id": material_id,
"brand": brand_data.brand.strip()
}
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"브랜드 업데이트 실패: {str(e)}"
)
@router.patch("/{material_id}/user-requirement")
async def update_material_user_requirement(
material_id: int,
requirement_data: UserRequirementUpdate,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""자재의 사용자 요구사항을 업데이트합니다."""
try:
# 자재 존재 여부 확인
result = db.execute(
text("SELECT id FROM materials WHERE id = :material_id"),
{"material_id": material_id}
)
material = result.fetchone()
if not material:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="자재를 찾을 수 없습니다."
)
# 사용자 요구사항 업데이트
db.execute(
text("""
UPDATE materials
SET user_requirement = :user_requirement,
updated_by = :updated_by
WHERE id = :material_id
"""),
{
"user_requirement": requirement_data.user_requirement.strip(),
"updated_by": current_user.get("username", "unknown"),
"material_id": material_id
}
)
db.commit()
return {
"success": True,
"message": "사용자 요구사항이 성공적으로 업데이트되었습니다.",
"material_id": material_id,
"user_requirement": requirement_data.user_requirement.strip()
}
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"사용자 요구사항 업데이트 실패: {str(e)}"
)
@router.get("/{material_id}")
async def get_material(
material_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""자재 정보를 조회합니다."""
try:
result = db.execute(
text("""
SELECT id, original_description, classified_category,
brand, user_requirement, created_at, updated_by
FROM materials
WHERE id = :material_id
"""),
{"material_id": material_id}
)
material = result.fetchone()
if not material:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="자재를 찾을 수 없습니다."
)
return {
"id": material.id,
"original_description": material.original_description,
"classified_category": material.classified_category,
"brand": material.brand,
"user_requirement": material.user_requirement,
"created_at": material.created_at,
"updated_by": material.updated_by
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"자재 조회 실패: {str(e)}"
)

View File

@@ -5,11 +5,13 @@
- 리비전 비교
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import List, Optional
from pydantic import BaseModel
import json
from datetime import datetime
from ..database import get_db
from ..services.purchase_calculator import (
@@ -21,6 +23,28 @@ from ..services.purchase_calculator import (
router = APIRouter(prefix="/purchase", tags=["purchase"])
# Pydantic 모델 (최적화된 구조)
class PurchaseItemMinimal(BaseModel):
"""구매 확정용 최소 필수 데이터"""
item_code: str
category: str
specification: str
size: str = ""
material: str = ""
bom_quantity: float
calculated_qty: float
unit: str = "EA"
safety_factor: float = 1.0
class PurchaseConfirmRequest(BaseModel):
job_no: str
file_id: int
bom_name: Optional[str] = None # 선택적 필드로 변경
revision: str
purchase_items: List[PurchaseItemMinimal] # 최적화된 구조 사용
confirmed_at: str
confirmed_by: str
@router.get("/items/calculate")
async def calculate_purchase_items(
job_no: str = Query(..., description="Job 번호"),
@@ -39,7 +63,7 @@ async def calculate_purchase_items(
file_query = text("""
SELECT id FROM files
WHERE job_no = :job_no AND revision = :revision AND is_active = TRUE
ORDER BY created_at DESC
ORDER BY updated_at DESC
LIMIT 1
""")
file_result = db.execute(file_query, {"job_no": job_no, "revision": revision}).fetchone()
@@ -62,6 +86,139 @@ async def calculate_purchase_items(
except Exception as e:
raise HTTPException(status_code=500, detail=f"구매 품목 계산 실패: {str(e)}")
@router.post("/confirm")
async def confirm_purchase_quantities(
request: PurchaseConfirmRequest,
db: Session = Depends(get_db)
):
"""
구매 수량 확정
- 계산된 구매 수량을 확정 상태로 저장
- 자재별 확정 수량 및 상태 업데이트
- 리비전 비교를 위한 기준 데이터 생성
"""
try:
# 1. 기존 확정 데이터 확인 및 업데이트 또는 삽입
existing_query = text("""
SELECT id FROM purchase_confirmations
WHERE file_id = :file_id
""")
existing_result = db.execute(existing_query, {"file_id": request.file_id}).fetchone()
if existing_result:
# 기존 데이터 업데이트
confirmation_id = existing_result[0]
update_query = text("""
UPDATE purchase_confirmations
SET job_no = :job_no,
bom_name = :bom_name,
revision = :revision,
confirmed_at = :confirmed_at,
confirmed_by = :confirmed_by,
is_active = TRUE,
updated_at = CURRENT_TIMESTAMP
WHERE id = :confirmation_id
""")
db.execute(update_query, {
"confirmation_id": confirmation_id,
"job_no": request.job_no,
"bom_name": request.bom_name or f"{request.job_no}_{request.revision}", # 기본값 제공
"revision": request.revision,
"confirmed_at": request.confirmed_at,
"confirmed_by": request.confirmed_by
})
# 기존 확정 품목들 삭제
delete_items_query = text("""
DELETE FROM confirmed_purchase_items
WHERE confirmation_id = :confirmation_id
""")
db.execute(delete_items_query, {"confirmation_id": confirmation_id})
else:
# 새로운 확정 데이터 삽입
confirm_query = text("""
INSERT INTO purchase_confirmations (
job_no, file_id, bom_name, revision,
confirmed_at, confirmed_by, is_active, created_at
) VALUES (
:job_no, :file_id, :bom_name, :revision,
:confirmed_at, :confirmed_by, TRUE, CURRENT_TIMESTAMP
) RETURNING id
""")
confirm_result = db.execute(confirm_query, {
"job_no": request.job_no,
"file_id": request.file_id,
"bom_name": request.bom_name or f"{request.job_no}_{request.revision}", # 기본값 제공
"revision": request.revision,
"confirmed_at": request.confirmed_at,
"confirmed_by": request.confirmed_by
})
confirmation_id = confirm_result.fetchone()[0]
# 3. 확정된 구매 품목들 저장
saved_items = 0
for item in request.purchase_items:
item_query = text("""
INSERT INTO confirmed_purchase_items (
confirmation_id, item_code, category, specification,
size, material, bom_quantity, calculated_qty,
unit, safety_factor, created_at
) VALUES (
:confirmation_id, :item_code, :category, :specification,
:size, :material, :bom_quantity, :calculated_qty,
:unit, :safety_factor, CURRENT_TIMESTAMP
)
""")
db.execute(item_query, {
"confirmation_id": confirmation_id,
"item_code": item.item_code or f"{item.category}-{saved_items+1}",
"category": item.category,
"specification": item.specification,
"size": item.size or "",
"material": item.material or "",
"bom_quantity": item.bom_quantity,
"calculated_qty": item.calculated_qty,
"unit": item.unit,
"safety_factor": item.safety_factor
})
saved_items += 1
# 4. 파일 상태를 확정으로 업데이트
file_update_query = text("""
UPDATE files
SET purchase_confirmed = TRUE,
confirmed_at = :confirmed_at,
confirmed_by = :confirmed_by,
updated_at = CURRENT_TIMESTAMP
WHERE id = :file_id
""")
db.execute(file_update_query, {
"file_id": request.file_id,
"confirmed_at": request.confirmed_at,
"confirmed_by": request.confirmed_by
})
db.commit()
return {
"success": True,
"message": "구매 수량이 성공적으로 확정되었습니다",
"confirmation_id": confirmation_id,
"confirmed_items": saved_items,
"job_no": request.job_no,
"revision": request.revision,
"confirmed_at": request.confirmed_at,
"confirmed_by": request.confirmed_by
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"구매 수량 확정 실패: {str(e)}")
@router.post("/items/save")
async def save_purchase_items(
job_no: str,

View File

@@ -0,0 +1,834 @@
"""
구매신청 관리 API
"""
from fastapi import APIRouter, Depends, HTTPException, status, Body, File, UploadFile, Form
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Optional, List, Dict
from datetime import datetime
from pathlib import Path
import os
import json
from ..database import get_db
from ..auth.middleware import get_current_user
from ..utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/purchase-request", tags=["Purchase Request"])
# 엑셀 파일 저장 경로
EXCEL_DIR = "uploads/excel_exports"
os.makedirs(EXCEL_DIR, exist_ok=True)
class PurchaseRequestCreate(BaseModel):
file_id: int
job_no: Optional[str] = None
category: Optional[str] = None
material_ids: List[int] = []
materials_data: List[Dict] = []
grouped_materials: Optional[List[Dict]] = [] # 그룹화된 자재 정보
@router.post("/create")
async def create_purchase_request(
request_data: PurchaseRequestCreate,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매신청 생성 (엑셀 내보내기 = 구매신청)
"""
try:
# 🔍 디버깅: 요청 데이터 로깅
logger.info(f"🔍 구매신청 생성 요청 - file_id: {request_data.file_id}, job_no: {request_data.job_no}")
logger.info(f"🔍 material_ids 개수: {len(request_data.material_ids)}")
logger.info(f"🔍 materials_data 개수: {len(request_data.materials_data)}")
if request_data.material_ids:
logger.info(f"🔍 material_ids 샘플: {request_data.material_ids[:5]}")
print(f"🔍 [DEBUG] 구매신청 API 호출됨 - material_ids: {len(request_data.material_ids)}")
# 구매신청 번호 생성
today = datetime.now().strftime('%Y%m%d')
count_query = text("""
SELECT COUNT(*) as count
FROM purchase_requests
WHERE request_no LIKE :pattern
""")
count = db.execute(count_query, {"pattern": f"PR-{today}%"}).fetchone().count
request_no = f"PR-{today}-{str(count + 1).zfill(3)}"
# 자재 데이터를 JSON과 엑셀 파일로 저장
json_filename = f"{request_no}.json"
excel_filename = f"{request_no}.xlsx"
json_path = os.path.join(EXCEL_DIR, json_filename)
excel_path = os.path.join(EXCEL_DIR, excel_filename)
# JSON 저장
save_materials_data(
request_data.materials_data,
json_path,
request_no,
request_data.job_no,
request_data.grouped_materials # 그룹화 정보 추가
)
# 엑셀 파일 생성 및 저장
create_excel_file(
request_data.grouped_materials or request_data.materials_data,
excel_path,
request_no,
request_data.job_no
)
# 구매신청 레코드 생성
insert_request = text("""
INSERT INTO purchase_requests (
request_no, file_id, job_no, category,
material_count, excel_file_path, requested_by
) VALUES (
:request_no, :file_id, :job_no, :category,
:material_count, :excel_file_path, :requested_by
) RETURNING request_id
""")
result = db.execute(insert_request, {
"request_no": request_no,
"file_id": request_data.file_id,
"job_no": request_data.job_no,
"category": request_data.category,
"material_count": len(request_data.material_ids),
"excel_file_path": excel_filename, # 엑셀 파일명 저장 (JSON 대신)
"requested_by": current_user.get("user_id")
})
request_id = result.fetchone().request_id
# 구매신청 자재 상세 저장
logger.info(f"Processing {len(request_data.material_ids)} material IDs for purchase request {request_no}")
logger.info(f"First 10 Material IDs: {request_data.material_ids[:10]}") # 처음 10개만 로그
logger.info(f"Category: {request_data.category}, Job: {request_data.job_no}")
inserted_count = 0
for i, material_id in enumerate(request_data.material_ids):
material_data = request_data.materials_data[i] if i < len(request_data.materials_data) else {}
# 이미 구매신청된 자재인지 확인
check_existing = text("""
SELECT 1 FROM purchase_request_items
WHERE material_id = :material_id
""")
existing = db.execute(check_existing, {"material_id": material_id}).fetchone()
if not existing:
insert_item = text("""
INSERT INTO purchase_request_items (
request_id, material_id, description, category, subcategory,
material_grade, size_spec, quantity, unit, drawing_name,
notes, user_requirement
) VALUES (
:request_id, :material_id, :description, :category, :subcategory,
:material_grade, :size_spec, :quantity, :unit, :drawing_name,
:notes, :user_requirement
)
""")
# quantity를 정수로 변환 (소수점 제거)
quantity_str = str(material_data.get("quantity", 0))
try:
quantity = int(float(quantity_str))
except (ValueError, TypeError):
quantity = 0
db.execute(insert_item, {
"request_id": request_id,
"material_id": material_id,
"description": material_data.get("description", material_data.get("original_description", "")),
"category": material_data.get("category", material_data.get("classified_category", "")),
"subcategory": material_data.get("subcategory", material_data.get("classified_subcategory", "")),
"material_grade": material_data.get("material_grade", ""),
"size_spec": material_data.get("size_spec", ""),
"quantity": quantity,
"unit": material_data.get("unit", "EA"),
"drawing_name": material_data.get("drawing_name", ""),
"notes": material_data.get("notes", ""),
"user_requirement": material_data.get("user_requirement", "")
})
inserted_count += 1
else:
logger.warning(f"Material {material_id} already in another purchase request, skipping")
# 🔥 중요: materials 테이블의 purchase_confirmed 업데이트
if request_data.material_ids:
print(f"🔥 [PURCHASE] purchase_confirmed 업데이트 시작: {len(request_data.material_ids)}개 자재")
print(f"🔥 [PURCHASE] material_ids: {request_data.material_ids[:5]}...") # 처음 5개만 로그
update_materials_query = text("""
UPDATE materials
SET purchase_confirmed = true,
purchase_confirmed_at = NOW(),
purchase_confirmed_by = :confirmed_by
WHERE id = ANY(:material_ids)
""")
result = db.execute(update_materials_query, {
"material_ids": request_data.material_ids,
"confirmed_by": current_user.get("username", "system")
})
print(f"🔥 [PURCHASE] UPDATE 결과: {result.rowcount}개 행 업데이트됨")
logger.info(f"{len(request_data.material_ids)}개 자재의 purchase_confirmed를 true로 업데이트")
else:
print(f"⚠️ [PURCHASE] material_ids가 비어있음!")
db.commit()
logger.info(f"Purchase request created: {request_no} with {inserted_count} materials (out of {len(request_data.material_ids)} requested)")
# 실제 저장된 자재 확인
verify_query = text("""
SELECT COUNT(*) as count FROM purchase_request_items WHERE request_id = :request_id
""")
verified_count = db.execute(verify_query, {"request_id": request_id}).fetchone().count
logger.info(f"✅ DB 검증: purchase_request_items에 {verified_count}개 저장됨")
# purchase_requests 테이블의 total_items 필드 업데이트
update_total_items = text("""
UPDATE purchase_requests
SET total_items = :total_items
WHERE request_id = :request_id
""")
db.execute(update_total_items, {
"request_id": request_id,
"total_items": verified_count
})
db.commit()
logger.info(f"✅ total_items 업데이트 완료: {verified_count}")
return {
"success": True,
"request_no": request_no,
"request_id": request_id,
"material_count": len(request_data.material_ids),
"inserted_count": inserted_count,
"verified_count": verified_count,
"message": f"구매신청 {request_no}이 생성되었습니다"
}
except Exception as e:
db.rollback()
logger.error(f"Failed to create purchase request: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"구매신청 생성 실패: {str(e)}"
)
@router.get("/list")
async def get_purchase_requests(
file_id: Optional[int] = None,
job_no: Optional[str] = None,
status: Optional[str] = None,
# current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매신청 목록 조회
"""
try:
query = text("""
SELECT
pr.request_id,
pr.request_no,
pr.file_id,
pr.job_no,
pr.total_items,
pr.request_date,
pr.status,
pr.requested_by_username as requested_by,
f.original_filename,
j.job_name,
COUNT(pri.item_id) as item_count
FROM purchase_requests pr
LEFT JOIN files f ON pr.file_id = f.id
LEFT JOIN jobs j ON pr.job_no = j.job_no
LEFT JOIN purchase_request_items pri ON pr.request_id = pri.request_id
WHERE 1=1
AND (:file_id IS NULL OR pr.file_id = :file_id)
AND (:job_no IS NULL OR pr.job_no = :job_no)
AND (:status IS NULL OR pr.status = :status)
GROUP BY
pr.request_id, pr.request_no, pr.file_id, pr.job_no,
pr.total_items, pr.request_date, pr.status,
pr.requested_by_username, f.original_filename, j.job_name
ORDER BY pr.request_date DESC
""")
results = db.execute(query, {
"file_id": file_id,
"job_no": job_no,
"status": status
}).fetchall()
requests = []
for row in results:
requests.append({
"request_id": row.request_id,
"request_no": row.request_no,
"file_id": row.file_id,
"job_no": row.job_no,
"job_name": row.job_name,
"category": "ALL", # 기본값
"material_count": row.item_count or 0, # 실제 자재 개수 사용
"item_count": row.item_count,
"excel_file_path": None, # 현재 테이블에 없음
"requested_at": row.request_date.isoformat() if row.request_date else None,
"status": row.status,
"requested_by": row.requested_by,
"source_file": row.original_filename
})
return {
"success": True,
"requests": requests,
"count": len(requests)
}
except Exception as e:
logger.error(f"Failed to get purchase requests: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"구매신청 목록 조회 실패: {str(e)}"
)
@router.get("/{request_id}/materials")
async def get_request_materials(
request_id: int,
# current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매신청에 포함된 자재 목록 조회 (그룹화 정보 포함)
"""
try:
# 구매신청 정보 조회하여 JSON 파일 경로 가져오기
info_query = text("""
SELECT excel_file_path
FROM purchase_requests
WHERE request_id = :request_id
""")
info_result = db.execute(info_query, {"request_id": request_id}).fetchone()
grouped_materials = []
if info_result and info_result.excel_file_path:
json_path = os.path.join(EXCEL_DIR, info_result.excel_file_path)
if os.path.exists(json_path):
try:
with open(json_path, 'r', encoding='utf-8', errors='ignore') as f:
data = json.load(f)
grouped_materials = data.get("grouped_materials", [])
except Exception as e:
print(f"⚠️ JSON 파일 읽기 오류 (무시): {e}")
grouped_materials = []
# 개별 자재 정보 조회 (기존 코드)
query = text("""
SELECT
pri.item_id,
pri.material_id,
pri.quantity as requested_quantity,
pri.unit as requested_unit,
pri.user_requirement,
pri.is_ordered,
pri.is_received,
m.original_description,
m.classified_category,
m.size_spec,
m.main_nom,
m.red_nom,
m.schedule,
m.material_grade,
m.full_material_grade,
m.quantity as original_quantity,
m.unit as original_unit,
m.classification_details,
pd.outer_diameter, pd.schedule as pipe_schedule, pd.material_spec, pd.manufacturing_method,
pd.end_preparation, pd.length_mm,
fd.fitting_type, fd.fitting_subtype, fd.connection_method as fitting_connection,
fd.pressure_rating as fitting_pressure, fd.schedule as fitting_schedule,
fld.flange_type, fld.facing_type,
fld.pressure_rating as flange_pressure,
gd.gasket_type, gd.gasket_subtype, gd.material_type as gasket_material,
gd.filler_material, gd.pressure_rating as gasket_pressure, gd.thickness as gasket_thickness,
bd.bolt_type, bd.material_standard as bolt_material, bd.length as bolt_length
FROM purchase_request_items pri
JOIN materials m ON pri.material_id = m.id
LEFT JOIN pipe_details pd ON m.id = pd.material_id
LEFT JOIN fitting_details fd ON m.id = fd.material_id
LEFT JOIN flange_details fld ON m.id = fld.material_id
LEFT JOIN gasket_details gd ON m.id = gd.material_id
LEFT JOIN bolt_details bd ON m.id = bd.material_id
WHERE pri.request_id = :request_id
ORDER BY m.classified_category, m.original_description
""")
# 🎯 데이터베이스 쿼리 실행
results = db.execute(query, {"request_id": request_id}).fetchall()
materials = []
# 🎯 안전한 문자열 변환 함수
def safe_str(value):
if value is None:
return ''
try:
if isinstance(value, bytes):
return value.decode('utf-8', errors='ignore')
return str(value)
except Exception:
return str(value) if value else ''
for row in results:
try:
# quantity를 정수로 변환 (소수점 제거)
qty = row.requested_quantity or row.original_quantity
try:
qty_int = int(float(qty)) if qty else 0
except (ValueError, TypeError):
qty_int = 0
# 안전한 문자열 변환
original_description = safe_str(row.original_description)
size_spec = safe_str(row.size_spec)
material_grade = safe_str(row.material_grade)
full_material_grade = safe_str(row.full_material_grade)
user_requirement = safe_str(row.user_requirement)
except Exception as e:
# 오류 발생 시 기본값 사용
qty_int = 0
original_description = ''
size_spec = ''
material_grade = ''
full_material_grade = ''
user_requirement = ''
# BOM 페이지와 동일한 형식으로 데이터 구성
material_dict = {
"item_id": row.item_id,
"material_id": row.material_id,
"id": row.material_id,
"original_description": original_description,
"classified_category": safe_str(row.classified_category),
"size_spec": size_spec,
"size_inch": safe_str(row.main_nom),
"main_nom": safe_str(row.main_nom),
"red_nom": safe_str(row.red_nom),
"schedule": safe_str(row.schedule),
"material_grade": material_grade,
"full_material_grade": full_material_grade,
"quantity": qty_int,
"unit": safe_str(row.requested_unit or row.original_unit),
"user_requirement": user_requirement,
"is_ordered": row.is_ordered,
"is_received": row.is_received,
"classification_details": safe_str(row.classification_details)
}
# 카테고리별 상세 정보 추가 (안전한 문자열 처리)
if row.classified_category == 'PIPE' and row.manufacturing_method:
material_dict["pipe_details"] = {
"manufacturing_method": safe_str(row.manufacturing_method),
"schedule": safe_str(row.pipe_schedule),
"material_spec": safe_str(row.material_spec),
"end_preparation": safe_str(row.end_preparation),
"length_mm": row.length_mm
}
elif row.classified_category == 'FITTING' and row.fitting_type:
material_dict["fitting_details"] = {
"fitting_type": safe_str(row.fitting_type),
"fitting_subtype": safe_str(row.fitting_subtype),
"connection_method": safe_str(row.fitting_connection),
"pressure_rating": safe_str(row.fitting_pressure),
"schedule": safe_str(row.fitting_schedule)
}
elif row.classified_category == 'FLANGE' and row.flange_type:
material_dict["flange_details"] = {
"flange_type": safe_str(row.flange_type),
"facing_type": safe_str(row.facing_type),
"pressure_rating": safe_str(row.flange_pressure)
}
elif row.classified_category == 'GASKET' and row.gasket_type:
material_dict["gasket_details"] = {
"gasket_type": safe_str(row.gasket_type),
"gasket_subtype": safe_str(row.gasket_subtype),
"material_type": safe_str(row.gasket_material),
"filler_material": safe_str(row.filler_material),
"pressure_rating": safe_str(row.gasket_pressure),
"thickness": safe_str(row.gasket_thickness)
}
elif row.classified_category == 'BOLT' and row.bolt_type:
material_dict["bolt_details"] = {
"bolt_type": safe_str(row.bolt_type),
"material_standard": safe_str(row.bolt_material),
"length": safe_str(row.bolt_length)
}
materials.append(material_dict)
return {
"success": True,
"materials": materials,
"grouped_materials": grouped_materials, # 그룹화 정보 추가
"count": len(grouped_materials) if grouped_materials else len(materials)
}
except Exception as e:
logger.error(f"Failed to get request materials: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"구매신청 자재 조회 실패: {str(e)}"
)
@router.get("/requested-materials")
async def get_requested_material_ids(
file_id: Optional[int] = None,
job_no: Optional[str] = None,
db: Session = Depends(get_db)
):
"""
구매신청된 자재 ID 목록 조회 (BOM 페이지에서 비활성화용)
"""
try:
query = text("""
SELECT DISTINCT pri.material_id
FROM purchase_request_items pri
JOIN purchase_requests pr ON pri.request_id = pr.request_id
WHERE 1=1
AND (:file_id IS NULL OR pr.file_id = :file_id)
AND (:job_no IS NULL OR pr.job_no = :job_no)
""")
results = db.execute(query, {
"file_id": file_id,
"job_no": job_no
}).fetchall()
material_ids = [row.material_id for row in results]
return {
"success": True,
"requested_material_ids": material_ids,
"count": len(material_ids)
}
except Exception as e:
logger.error(f"Failed to get requested material IDs: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"구매신청 자재 ID 조회 실패: {str(e)}"
)
@router.patch("/{request_id}/title")
async def update_request_title(
request_id: int,
title: str = Body(..., embed=True),
# current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매신청 제목(request_no) 업데이트
"""
try:
# 구매신청 존재 확인
check_query = text("""
SELECT request_no FROM purchase_requests
WHERE request_id = :request_id
""")
existing = db.execute(check_query, {"request_id": request_id}).fetchone()
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="구매신청을 찾을 수 없습니다"
)
# 제목 업데이트
update_query = text("""
UPDATE purchase_requests
SET request_no = :title
WHERE request_id = :request_id
""")
db.execute(update_query, {
"request_id": request_id,
"title": title
})
db.commit()
return {
"success": True,
"message": "구매신청 제목이 업데이트되었습니다",
"old_title": existing.request_no,
"new_title": title
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to update request title: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"구매신청 제목 업데이트 실패: {str(e)}"
)
@router.get("/{request_id}/download-excel")
async def download_request_excel(
request_id: int,
# current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매신청 엑셀 파일 직접 다운로드 (BOM 페이지에서 생성한 파일 그대로)
"""
from fastapi.responses import FileResponse
try:
# 구매신청 정보 조회
query = text("""
SELECT request_no, excel_file_path, job_no
FROM purchase_requests
WHERE request_id = :request_id
""")
result = db.execute(query, {"request_id": request_id}).fetchone()
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="구매신청을 찾을 수 없습니다"
)
excel_file_path = os.path.join(EXCEL_DIR, result.excel_file_path)
if not os.path.exists(excel_file_path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="엑셀 파일을 찾을 수 없습니다"
)
# 엑셀 파일 직접 다운로드
return FileResponse(
path=excel_file_path,
filename=f"{result.job_no}_{result.request_no}.xlsx",
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to download request excel: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"엑셀 다운로드 실패: {str(e)}"
)
def save_materials_data(materials_data: List[Dict], file_path: str, request_no: str, job_no: str, grouped_materials: List[Dict] = None):
"""
자재 데이터를 JSON으로 저장 (프론트엔드에서 동일한 엑셀 포맷으로 생성하기 위해)
"""
# 수량을 정수로 변환하여 저장
cleaned_materials = []
for material in materials_data:
cleaned_material = material.copy()
if 'quantity' in cleaned_material:
try:
cleaned_material['quantity'] = int(float(cleaned_material['quantity']))
except (ValueError, TypeError):
cleaned_material['quantity'] = 0
cleaned_materials.append(cleaned_material)
# 그룹화된 자재도 수량 정수 변환
cleaned_grouped = []
if grouped_materials:
for group in grouped_materials:
cleaned_group = group.copy()
if 'quantity' in cleaned_group:
try:
cleaned_group['quantity'] = int(float(cleaned_group['quantity']))
except (ValueError, TypeError):
cleaned_group['quantity'] = 0
cleaned_grouped.append(cleaned_group)
data_to_save = {
"request_no": request_no,
"job_no": job_no,
"created_at": datetime.now().isoformat(),
"materials": cleaned_materials,
"grouped_materials": cleaned_grouped or []
}
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data_to_save, f, ensure_ascii=False, indent=2)
def create_excel_file(materials_data: List[Dict], file_path: str, request_no: str, job_no: str):
"""
자재 데이터로 엑셀 파일 생성 (BOM 페이지와 동일한 형식)
"""
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
# 새 워크북 생성
wb = openpyxl.Workbook()
wb.remove(wb.active) # 기본 시트 제거
# 카테고리별 그룹화
category_groups = {}
for material in materials_data:
category = material.get('category', 'UNKNOWN')
if category not in category_groups:
category_groups[category] = []
category_groups[category].append(material)
# 각 카테고리별 시트 생성
for category, items in category_groups.items():
if not items:
continue
ws = wb.create_sheet(title=category)
# 헤더 정의 (P열에 납기일, 관리항목 통일)
headers = ['TAGNO', '품목명', '수량', '통화구분', '단가', '크기', '압력등급', '스케줄',
'재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3',
'관리항목4', '납기일(YYYY-MM-DD)', '관리항목5', '관리항목6', '관리항목7',
'관리항목8', '관리항목9', '관리항목10']
# 헤더 작성
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = Font(bold=True, color="000000", size=12, name="맑은 고딕")
cell.fill = PatternFill(start_color="B3D9FF", end_color="B3D9FF", fill_type="solid")
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.border = Border(
top=Side(style="thin", color="666666"),
bottom=Side(style="thin", color="666666"),
left=Side(style="thin", color="666666"),
right=Side(style="thin", color="666666")
)
# 데이터 작성
for row_idx, material in enumerate(items, 2):
data = [
'', # TAGNO
category, # 품목명
material.get('quantity', 0), # 수량
'KRW', # 통화구분
1, # 단가
material.get('size', '-'), # 크기
'-', # 압력등급 (추후 개선)
material.get('schedule', '-'), # 스케줄
material.get('material_grade', '-'), # 재질
'-', # 상세내역 (추후 개선)
material.get('user_requirement', ''), # 사용자요구
'', '', '', '', '', # 관리항목들
datetime.now().strftime('%Y-%m-%d') # 납기일
]
for col, value in enumerate(data, 1):
ws.cell(row=row_idx, column=col, value=value)
# 컬럼 너비 자동 조정
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max(max_length + 2, 10), 50)
ws.column_dimensions[column_letter].width = adjusted_width
# 파일 저장
wb.save(file_path)
@router.post("/upload-excel")
async def upload_request_excel(
excel_file: UploadFile = File(...),
request_id: int = Form(...),
category: str = Form(...),
# current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매신청에 대한 엑셀 파일 업로드 (BOM에서 생성한 원본 파일)
"""
try:
# 구매신청 정보 조회
query = text("""
SELECT request_no, job_no
FROM purchase_requests
WHERE request_id = :request_id
""")
result = db.execute(query, {"request_id": request_id}).fetchone()
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="구매신청을 찾을 수 없습니다"
)
# 엑셀 저장 디렉토리 생성
excel_dir = Path("uploads/excel_exports")
excel_dir.mkdir(parents=True, exist_ok=True)
# 파일명 생성
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_filename = f"{result.job_no}_{result.request_no}_{timestamp}.xlsx"
file_path = excel_dir / safe_filename
# 파일 저장
content = await excel_file.read()
with open(file_path, "wb") as f:
f.write(content)
# 구매신청 테이블에 엑셀 파일 경로 업데이트
update_query = text("""
UPDATE purchase_requests
SET excel_file_path = :excel_file_path
WHERE request_id = :request_id
""")
db.execute(update_query, {
"excel_file_path": safe_filename,
"request_id": request_id
})
db.commit()
logger.info(f"엑셀 파일 업로드 완료: {safe_filename}")
return {
"success": True,
"message": "엑셀 파일이 성공적으로 업로드되었습니다",
"file_path": safe_filename
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to upload excel file: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"엑셀 파일 업로드 실패: {str(e)}"
)

View File

@@ -0,0 +1,452 @@
"""
구매 추적 및 관리 API
엑셀 내보내기 이력 및 구매 상태 관리
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Optional, List, Dict, Any
from datetime import datetime, date
import json
from ..database import get_db
from ..auth.jwt_service import get_current_user
from ..utils.logger import logger
router = APIRouter(prefix="/purchase", tags=["Purchase Tracking"])
@router.post("/export-history")
async def create_export_history(
file_id: int,
job_no: Optional[str] = None,
export_type: str = "full",
category: Optional[str] = None,
material_ids: List[int] = [],
filters_applied: Optional[Dict] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
엑셀 내보내기 이력 생성 및 자재 추적
"""
try:
# 내보내기 이력 생성
insert_history = text("""
INSERT INTO excel_export_history (
file_id, job_no, exported_by, export_type,
category, material_count, filters_applied
) VALUES (
:file_id, :job_no, :exported_by, :export_type,
:category, :material_count, :filters_applied
) RETURNING export_id
""")
result = db.execute(insert_history, {
"file_id": file_id,
"job_no": job_no,
"exported_by": current_user.get("user_id"),
"export_type": export_type,
"category": category,
"material_count": len(material_ids),
"filters_applied": json.dumps(filters_applied) if filters_applied else None
})
export_id = result.fetchone().export_id
# 내보낸 자재들 기록
if material_ids:
for material_id in material_ids:
insert_material = text("""
INSERT INTO exported_materials (
export_id, material_id, purchase_status
) VALUES (
:export_id, :material_id, 'pending'
)
""")
db.execute(insert_material, {
"export_id": export_id,
"material_id": material_id
})
db.commit()
logger.info(f"Export history created: {export_id} with {len(material_ids)} materials")
return {
"success": True,
"export_id": export_id,
"message": f"{len(material_ids)}개 자재의 내보내기 이력이 저장되었습니다"
}
except Exception as e:
db.rollback()
logger.error(f"Failed to create export history: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"내보내기 이력 생성 실패: {str(e)}"
)
@router.get("/export-history")
async def get_export_history(
file_id: Optional[int] = None,
job_no: Optional[str] = None,
limit: int = 50,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
엑셀 내보내기 이력 조회
"""
try:
query = text("""
SELECT
eeh.export_id,
eeh.file_id,
eeh.job_no,
eeh.export_date,
eeh.export_type,
eeh.category,
eeh.material_count,
u.name as exported_by_name,
f.original_filename,
COUNT(DISTINCT em.material_id) as actual_material_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'pending' THEN em.material_id END) as pending_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) as requested_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) as ordered_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) as received_count
FROM excel_export_history eeh
LEFT JOIN users u ON eeh.exported_by = u.user_id
LEFT JOIN files f ON eeh.file_id = f.id
LEFT JOIN exported_materials em ON eeh.export_id = em.export_id
WHERE 1=1
AND (:file_id IS NULL OR eeh.file_id = :file_id)
AND (:job_no IS NULL OR eeh.job_no = :job_no)
GROUP BY
eeh.export_id, eeh.file_id, eeh.job_no, eeh.export_date,
eeh.export_type, eeh.category, eeh.material_count,
u.name, f.original_filename
ORDER BY eeh.export_date DESC
LIMIT :limit
""")
results = db.execute(query, {
"file_id": file_id,
"job_no": job_no,
"limit": limit
}).fetchall()
history = []
for row in results:
history.append({
"export_id": row.export_id,
"file_id": row.file_id,
"job_no": row.job_no,
"export_date": row.export_date.isoformat() if row.export_date else None,
"export_type": row.export_type,
"category": row.category,
"material_count": row.material_count,
"exported_by": row.exported_by_name,
"file_name": row.original_filename,
"status_summary": {
"total": row.actual_material_count,
"pending": row.pending_count,
"requested": row.requested_count,
"ordered": row.ordered_count,
"received": row.received_count
}
})
return {
"success": True,
"history": history,
"count": len(history)
}
except Exception as e:
logger.error(f"Failed to get export history: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"내보내기 이력 조회 실패: {str(e)}"
)
@router.get("/materials/status")
async def get_materials_by_status(
status: Optional[str] = None,
export_id: Optional[int] = None,
file_id: Optional[int] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매 상태별 자재 목록 조회
"""
try:
query = text("""
SELECT
em.id as exported_material_id,
em.material_id,
m.original_description,
m.classified_category,
m.quantity,
m.unit,
em.purchase_status,
em.purchase_request_no,
em.purchase_order_no,
em.vendor_name,
em.expected_date,
em.quantity_ordered,
em.quantity_received,
em.unit_price,
em.total_price,
em.notes,
em.updated_at,
eeh.export_date,
f.original_filename as file_name,
j.job_no,
j.job_name
FROM exported_materials em
JOIN materials m ON em.material_id = m.id
JOIN excel_export_history eeh ON em.export_id = eeh.export_id
LEFT JOIN files f ON eeh.file_id = f.id
LEFT JOIN jobs j ON eeh.job_no = j.job_no
WHERE 1=1
AND (:status IS NULL OR em.purchase_status = :status)
AND (:export_id IS NULL OR em.export_id = :export_id)
AND (:file_id IS NULL OR eeh.file_id = :file_id)
ORDER BY em.updated_at DESC
""")
results = db.execute(query, {
"status": status,
"export_id": export_id,
"file_id": file_id
}).fetchall()
materials = []
for row in results:
materials.append({
"exported_material_id": row.exported_material_id,
"material_id": row.material_id,
"description": row.original_description,
"category": row.classified_category,
"quantity": row.quantity,
"unit": row.unit,
"purchase_status": row.purchase_status,
"purchase_request_no": row.purchase_request_no,
"purchase_order_no": row.purchase_order_no,
"vendor_name": row.vendor_name,
"expected_date": row.expected_date.isoformat() if row.expected_date else None,
"quantity_ordered": row.quantity_ordered,
"quantity_received": row.quantity_received,
"unit_price": float(row.unit_price) if row.unit_price else None,
"total_price": float(row.total_price) if row.total_price else None,
"notes": row.notes,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"export_date": row.export_date.isoformat() if row.export_date else None,
"file_name": row.file_name,
"job_no": row.job_no,
"job_name": row.job_name
})
return {
"success": True,
"materials": materials,
"count": len(materials)
}
except Exception as e:
logger.error(f"Failed to get materials by status: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"구매 상태별 자재 조회 실패: {str(e)}"
)
@router.patch("/materials/{exported_material_id}/status")
async def update_purchase_status(
exported_material_id: int,
new_status: str,
purchase_request_no: Optional[str] = None,
purchase_order_no: Optional[str] = None,
vendor_name: Optional[str] = None,
expected_date: Optional[date] = None,
quantity_ordered: Optional[int] = None,
quantity_received: Optional[int] = None,
unit_price: Optional[float] = None,
notes: Optional[str] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
자재 구매 상태 업데이트
"""
try:
# 현재 상태 조회
get_current = text("""
SELECT purchase_status, material_id
FROM exported_materials
WHERE id = :id
""")
current = db.execute(get_current, {"id": exported_material_id}).fetchone()
if not current:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="해당 자재를 찾을 수 없습니다"
)
# 상태 업데이트
update_query = text("""
UPDATE exported_materials
SET
purchase_status = :new_status,
purchase_request_no = COALESCE(:pr_no, purchase_request_no),
purchase_order_no = COALESCE(:po_no, purchase_order_no),
vendor_name = COALESCE(:vendor, vendor_name),
expected_date = COALESCE(:expected_date, expected_date),
quantity_ordered = COALESCE(:qty_ordered, quantity_ordered),
quantity_received = COALESCE(:qty_received, quantity_received),
unit_price = COALESCE(:unit_price, unit_price),
total_price = CASE
WHEN :unit_price IS NOT NULL AND :qty_ordered IS NOT NULL
THEN :unit_price * :qty_ordered
WHEN :unit_price IS NOT NULL AND quantity_ordered IS NOT NULL
THEN :unit_price * quantity_ordered
ELSE total_price
END,
notes = COALESCE(:notes, notes),
updated_by = :updated_by,
requested_date = CASE WHEN :new_status = 'requested' THEN CURRENT_TIMESTAMP ELSE requested_date END,
ordered_date = CASE WHEN :new_status = 'ordered' THEN CURRENT_TIMESTAMP ELSE ordered_date END,
received_date = CASE WHEN :new_status = 'received' THEN CURRENT_TIMESTAMP ELSE received_date END
WHERE id = :id
""")
db.execute(update_query, {
"id": exported_material_id,
"new_status": new_status,
"pr_no": purchase_request_no,
"po_no": purchase_order_no,
"vendor": vendor_name,
"expected_date": expected_date,
"qty_ordered": quantity_ordered,
"qty_received": quantity_received,
"unit_price": unit_price,
"notes": notes,
"updated_by": current_user.get("user_id")
})
# 이력 기록
insert_history = text("""
INSERT INTO purchase_status_history (
exported_material_id, material_id,
previous_status, new_status,
changed_by, reason
) VALUES (
:em_id, :material_id,
:prev_status, :new_status,
:changed_by, :reason
)
""")
db.execute(insert_history, {
"em_id": exported_material_id,
"material_id": current.material_id,
"prev_status": current.purchase_status,
"new_status": new_status,
"changed_by": current_user.get("user_id"),
"reason": notes
})
db.commit()
logger.info(f"Purchase status updated: {exported_material_id} from {current.purchase_status} to {new_status}")
return {
"success": True,
"message": f"구매 상태가 {new_status}로 변경되었습니다"
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to update purchase status: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"구매 상태 업데이트 실패: {str(e)}"
)
@router.get("/status-summary")
async def get_status_summary(
file_id: Optional[int] = None,
job_no: Optional[str] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매 상태 요약 통계
"""
try:
query = text("""
SELECT
em.purchase_status,
COUNT(DISTINCT em.material_id) as material_count,
SUM(em.quantity_exported) as total_quantity,
SUM(em.total_price) as total_amount,
COUNT(DISTINCT em.export_id) as export_count
FROM exported_materials em
JOIN excel_export_history eeh ON em.export_id = eeh.export_id
WHERE 1=1
AND (:file_id IS NULL OR eeh.file_id = :file_id)
AND (:job_no IS NULL OR eeh.job_no = :job_no)
GROUP BY em.purchase_status
""")
results = db.execute(query, {
"file_id": file_id,
"job_no": job_no
}).fetchall()
summary = {}
total_materials = 0
total_amount = 0
for row in results:
summary[row.purchase_status] = {
"material_count": row.material_count,
"total_quantity": row.total_quantity,
"total_amount": float(row.total_amount) if row.total_amount else 0,
"export_count": row.export_count
}
total_materials += row.material_count
if row.total_amount:
total_amount += float(row.total_amount)
# 기본 상태들 추가 (없는 경우 0으로)
for status in ['pending', 'requested', 'ordered', 'received', 'cancelled']:
if status not in summary:
summary[status] = {
"material_count": 0,
"total_quantity": 0,
"total_amount": 0,
"export_count": 0
}
return {
"success": True,
"summary": summary,
"total_materials": total_materials,
"total_amount": total_amount
}
except Exception as e:
logger.error(f"Failed to get status summary: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"구매 상태 요약 조회 실패: {str(e)}"
)

View File

@@ -0,0 +1,327 @@
"""
간단한 리비전 관리 API
- 리비전 세션 생성 및 관리
- 자재 비교 및 변경사항 처리
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import List, Optional, Dict, Any
from datetime import datetime
from pydantic import BaseModel
from ..database import get_db
from ..auth.middleware import get_current_user
from ..services.revision_session_service import RevisionSessionService
from ..services.revision_comparison_service import RevisionComparisonService
router = APIRouter(prefix="/revision-management", tags=["revision-management"])
class RevisionSessionCreate(BaseModel):
job_no: str
current_file_id: int
previous_file_id: int
@router.post("/sessions")
async def create_revision_session(
session_data: RevisionSessionCreate,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""리비전 세션 생성"""
try:
session_service = RevisionSessionService(db)
# 실제 DB에 세션 생성
result = session_service.create_revision_session(
job_no=session_data.job_no,
current_file_id=session_data.current_file_id,
previous_file_id=session_data.previous_file_id,
username=current_user.get("username")
)
return {
"success": True,
"data": result,
"message": "리비전 세션이 생성되었습니다."
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"리비전 세션 생성 실패: {str(e)}")
@router.get("/sessions/{session_id}")
async def get_session_status(
session_id: int,
db: Session = Depends(get_db)
):
"""세션 상태 조회"""
try:
session_service = RevisionSessionService(db)
# 실제 DB에서 세션 상태 조회
result = session_service.get_session_status(session_id)
return {
"success": True,
"data": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"세션 상태 조회 실패: {str(e)}")
@router.get("/sessions/{session_id}/summary")
async def get_revision_summary(
session_id: int,
db: Session = Depends(get_db)
):
"""리비전 요약 조회"""
try:
comparison_service = RevisionComparisonService(db)
# 세션의 모든 변경사항 조회
changes = comparison_service.get_session_changes(session_id)
# 요약 통계 계산
summary = {
"session_id": session_id,
"total_changes": len(changes),
"new_materials": len([c for c in changes if c['change_type'] == 'added']),
"changed_materials": len([c for c in changes if c['change_type'] == 'quantity_changed']),
"removed_materials": len([c for c in changes if c['change_type'] == 'removed']),
"categories": {}
}
# 카테고리별 통계
for change in changes:
category = change['category']
if category not in summary["categories"]:
summary["categories"][category] = {
"total_changes": 0,
"added": 0,
"changed": 0,
"removed": 0
}
summary["categories"][category]["total_changes"] += 1
if change['change_type'] == 'added':
summary["categories"][category]["added"] += 1
elif change['change_type'] == 'quantity_changed':
summary["categories"][category]["changed"] += 1
elif change['change_type'] == 'removed':
summary["categories"][category]["removed"] += 1
return {
"success": True,
"data": summary
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"리비전 요약 조회 실패: {str(e)}")
@router.post("/sessions/{session_id}/compare/{category}")
async def compare_category(
session_id: int,
category: str,
db: Session = Depends(get_db)
):
"""카테고리별 자재 비교"""
try:
# 세션 정보 조회
session_service = RevisionSessionService(db)
session_status = session_service.get_session_status(session_id)
session_info = session_status["session_info"]
# 자재 비교 수행
comparison_service = RevisionComparisonService(db)
result = comparison_service.compare_materials_by_category(
current_file_id=session_info["current_file_id"],
previous_file_id=session_info["previous_file_id"],
category=category,
session_id=session_id
)
return {
"success": True,
"data": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"카테고리 비교 실패: {str(e)}")
@router.get("/history/{job_no}")
async def get_revision_history(
job_no: str,
db: Session = Depends(get_db)
):
"""리비전 히스토리 조회"""
try:
session_service = RevisionSessionService(db)
# 실제 DB에서 리비전 히스토리 조회
history = session_service.get_job_revision_history(job_no)
return {
"success": True,
"data": {
"job_no": job_no,
"history": history
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"리비전 히스토리 조회 실패: {str(e)}")
# 세션 변경사항 조회
@router.get("/sessions/{session_id}/changes")
async def get_session_changes(
session_id: int,
category: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
"""세션의 변경사항 조회"""
try:
comparison_service = RevisionComparisonService(db)
# 세션의 변경사항 조회
changes = comparison_service.get_session_changes(session_id, category)
return {
"success": True,
"data": {
"changes": changes,
"total_count": len(changes)
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"세션 변경사항 조회 실패: {str(e)}")
# 리비전 액션 처리
@router.post("/changes/{change_id}/process")
async def process_revision_action(
change_id: int,
action_data: Dict[str, Any],
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""리비전 액션 처리"""
try:
comparison_service = RevisionComparisonService(db)
# 액션 처리
result = comparison_service.process_revision_action(
change_id=change_id,
action=action_data.get("action"),
username=current_user.get("username"),
notes=action_data.get("notes")
)
return {
"success": True,
"data": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"리비전 액션 처리 실패: {str(e)}")
# 세션 완료
@router.post("/sessions/{session_id}/complete")
async def complete_revision_session(
session_id: int,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""리비전 세션 완료"""
try:
session_service = RevisionSessionService(db)
# 세션 완료 처리
result = session_service.complete_session(
session_id=session_id,
username=current_user.get("username")
)
return {
"success": True,
"data": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"리비전 세션 완료 실패: {str(e)}")
# 세션 취소
@router.post("/sessions/{session_id}/cancel")
async def cancel_revision_session(
session_id: int,
reason: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""리비전 세션 취소"""
try:
session_service = RevisionSessionService(db)
# 세션 취소 처리
result = session_service.cancel_session(
session_id=session_id,
username=current_user.get("username"),
reason=reason
)
return {
"success": True,
"data": {"cancelled": result}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"리비전 세션 취소 실패: {str(e)}")
@router.get("/categories")
async def get_supported_categories():
"""지원 카테고리 목록 조회"""
try:
categories = [
{"key": "PIPE", "name": "배관", "description": "파이프 및 배관 자재"},
{"key": "FITTING", "name": "피팅", "description": "배관 연결 부품"},
{"key": "FLANGE", "name": "플랜지", "description": "플랜지 및 연결 부품"},
{"key": "VALVE", "name": "밸브", "description": "각종 밸브류"},
{"key": "GASKET", "name": "가스켓", "description": "씰링 부품"},
{"key": "BOLT", "name": "볼트", "description": "체결 부품"},
{"key": "SUPPORT", "name": "서포트", "description": "지지대 및 구조물"},
{"key": "SPECIAL", "name": "특수자재", "description": "특수 목적 자재"},
{"key": "UNCLASSIFIED", "name": "미분류", "description": "분류되지 않은 자재"}
]
return {
"success": True,
"data": {
"categories": categories
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"지원 카테고리 조회 실패: {str(e)}")
@router.get("/actions")
async def get_supported_actions():
"""지원 액션 목록 조회"""
try:
actions = [
{"key": "new_material", "name": "신규 자재", "description": "새로 추가된 자재"},
{"key": "additional_purchase", "name": "추가 구매", "description": "구매된 자재의 수량 증가"},
{"key": "inventory_transfer", "name": "재고 이관", "description": "구매된 자재의 수량 감소 또는 제거"},
{"key": "purchase_cancel", "name": "구매 취소", "description": "미구매 자재의 제거"},
{"key": "quantity_update", "name": "수량 업데이트", "description": "미구매 자재의 수량 변경"},
{"key": "maintain", "name": "유지", "description": "변경사항 없음"}
]
return {
"success": True,
"data": {
"actions": actions
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"지원 액션 조회 실패: {str(e)}")

View File

@@ -0,0 +1,538 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import text
from typing import Optional, List
from datetime import datetime, date
from pydantic import BaseModel
from decimal import Decimal
from ..database import get_db
from ..models import (
TubingCategory, TubingSpecification, TubingManufacturer,
TubingProduct, MaterialTubingMapping, Material
)
router = APIRouter()
# ================================
# Pydantic 모델들
# ================================
class TubingCategoryResponse(BaseModel):
id: int
category_code: str
category_name: str
description: Optional[str] = None
is_active: bool
created_at: datetime
class Config:
from_attributes = True
class TubingManufacturerResponse(BaseModel):
id: int
manufacturer_code: str
manufacturer_name: str
country: Optional[str] = None
website: Optional[str] = None
is_active: bool
class Config:
from_attributes = True
class TubingSpecificationResponse(BaseModel):
id: int
spec_code: str
spec_name: str
category_name: Optional[str] = None
outer_diameter_mm: Optional[float] = None
wall_thickness_mm: Optional[float] = None
inner_diameter_mm: Optional[float] = None
material_grade: Optional[str] = None
material_standard: Optional[str] = None
max_pressure_bar: Optional[float] = None
max_temperature_c: Optional[float] = None
min_temperature_c: Optional[float] = None
standard_length_m: Optional[float] = None
surface_finish: Optional[str] = None
is_active: bool
class Config:
from_attributes = True
class TubingProductResponse(BaseModel):
id: int
specification_id: int
manufacturer_id: int
manufacturer_part_number: str
manufacturer_product_name: Optional[str] = None
spec_name: Optional[str] = None
manufacturer_name: Optional[str] = None
list_price: Optional[float] = None
currency: Optional[str] = 'KRW'
lead_time_days: Optional[int] = None
availability_status: Optional[str] = None
datasheet_url: Optional[str] = None
notes: Optional[str] = None
is_active: bool
class Config:
from_attributes = True
class TubingProductCreate(BaseModel):
specification_id: int
manufacturer_id: int
manufacturer_part_number: str
manufacturer_product_name: Optional[str] = None
list_price: Optional[float] = None
currency: str = 'KRW'
lead_time_days: Optional[int] = None
minimum_order_qty: Optional[float] = None
standard_packaging_qty: Optional[float] = None
availability_status: Optional[str] = None
datasheet_url: Optional[str] = None
catalog_page: Optional[str] = None
notes: Optional[str] = None
class MaterialTubingMappingCreate(BaseModel):
material_id: int
tubing_product_id: int
confidence_score: Optional[float] = None
mapping_method: str = 'manual'
required_length_m: Optional[float] = None
calculated_quantity: Optional[float] = None
notes: Optional[str] = None
# ================================
# API 엔드포인트들
# ================================
@router.get("/categories", response_model=List[TubingCategoryResponse])
async def get_tubing_categories(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_db)
):
"""Tubing 카테고리 목록 조회"""
try:
categories = db.query(TubingCategory)\
.filter(TubingCategory.is_active == True)\
.offset(skip)\
.limit(limit)\
.all()
return categories
except Exception as e:
raise HTTPException(status_code=500, detail=f"카테고리 조회 실패: {str(e)}")
@router.get("/manufacturers", response_model=List[TubingManufacturerResponse])
async def get_tubing_manufacturers(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
search: Optional[str] = Query(None),
country: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
"""Tubing 제조사 목록 조회"""
try:
query = db.query(TubingManufacturer)\
.filter(TubingManufacturer.is_active == True)
if search:
query = query.filter(
TubingManufacturer.manufacturer_name.ilike(f"%{search}%") |
TubingManufacturer.manufacturer_code.ilike(f"%{search}%")
)
if country:
query = query.filter(TubingManufacturer.country.ilike(f"%{country}%"))
manufacturers = query.offset(skip).limit(limit).all()
return manufacturers
except Exception as e:
raise HTTPException(status_code=500, detail=f"제조사 조회 실패: {str(e)}")
@router.get("/specifications", response_model=List[TubingSpecificationResponse])
async def get_tubing_specifications(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
category_id: Optional[int] = Query(None),
material_grade: Optional[str] = Query(None),
outer_diameter_min: Optional[float] = Query(None),
outer_diameter_max: Optional[float] = Query(None),
search: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
"""Tubing 규격 목록 조회"""
try:
query = db.query(TubingSpecification)\
.options(joinedload(TubingSpecification.category))\
.filter(TubingSpecification.is_active == True)
if category_id:
query = query.filter(TubingSpecification.category_id == category_id)
if material_grade:
query = query.filter(TubingSpecification.material_grade.ilike(f"%{material_grade}%"))
if outer_diameter_min:
query = query.filter(TubingSpecification.outer_diameter_mm >= outer_diameter_min)
if outer_diameter_max:
query = query.filter(TubingSpecification.outer_diameter_mm <= outer_diameter_max)
if search:
query = query.filter(
TubingSpecification.spec_name.ilike(f"%{search}%") |
TubingSpecification.spec_code.ilike(f"%{search}%") |
TubingSpecification.material_grade.ilike(f"%{search}%")
)
specifications = query.offset(skip).limit(limit).all()
# 응답 데이터 변환
result = []
for spec in specifications:
spec_dict = {
"id": spec.id,
"spec_code": spec.spec_code,
"spec_name": spec.spec_name,
"category_name": spec.category.category_name if spec.category else None,
"outer_diameter_mm": float(spec.outer_diameter_mm) if spec.outer_diameter_mm else None,
"wall_thickness_mm": float(spec.wall_thickness_mm) if spec.wall_thickness_mm else None,
"inner_diameter_mm": float(spec.inner_diameter_mm) if spec.inner_diameter_mm else None,
"material_grade": spec.material_grade,
"material_standard": spec.material_standard,
"max_pressure_bar": float(spec.max_pressure_bar) if spec.max_pressure_bar else None,
"max_temperature_c": float(spec.max_temperature_c) if spec.max_temperature_c else None,
"min_temperature_c": float(spec.min_temperature_c) if spec.min_temperature_c else None,
"standard_length_m": float(spec.standard_length_m) if spec.standard_length_m else None,
"surface_finish": spec.surface_finish,
"is_active": spec.is_active
}
result.append(spec_dict)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"규격 조회 실패: {str(e)}")
@router.get("/products", response_model=List[TubingProductResponse])
async def get_tubing_products(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
specification_id: Optional[int] = Query(None),
manufacturer_id: Optional[int] = Query(None),
search: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
"""Tubing 제품 목록 조회"""
try:
query = db.query(TubingProduct)\
.options(
joinedload(TubingProduct.specification),
joinedload(TubingProduct.manufacturer)
)\
.filter(TubingProduct.is_active == True)
if specification_id:
query = query.filter(TubingProduct.specification_id == specification_id)
if manufacturer_id:
query = query.filter(TubingProduct.manufacturer_id == manufacturer_id)
if search:
query = query.filter(
TubingProduct.manufacturer_part_number.ilike(f"%{search}%") |
TubingProduct.manufacturer_product_name.ilike(f"%{search}%")
)
products = query.offset(skip).limit(limit).all()
# 응답 데이터 변환
result = []
for product in products:
product_dict = {
"id": product.id,
"specification_id": product.specification_id,
"manufacturer_id": product.manufacturer_id,
"manufacturer_part_number": product.manufacturer_part_number,
"manufacturer_product_name": product.manufacturer_product_name,
"spec_name": product.specification.spec_name if product.specification else None,
"manufacturer_name": product.manufacturer.manufacturer_name if product.manufacturer else None,
"list_price": float(product.list_price) if product.list_price else None,
"currency": product.currency,
"lead_time_days": product.lead_time_days,
"availability_status": product.availability_status,
"datasheet_url": product.datasheet_url,
"notes": product.notes,
"is_active": product.is_active
}
result.append(product_dict)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"제품 조회 실패: {str(e)}")
@router.post("/products", response_model=TubingProductResponse)
async def create_tubing_product(
product_data: TubingProductCreate,
db: Session = Depends(get_db)
):
"""새 Tubing 제품 등록"""
try:
# 중복 확인
existing = db.query(TubingProduct)\
.filter(
TubingProduct.specification_id == product_data.specification_id,
TubingProduct.manufacturer_id == product_data.manufacturer_id,
TubingProduct.manufacturer_part_number == product_data.manufacturer_part_number
).first()
if existing:
raise HTTPException(
status_code=400,
detail=f"동일한 제품이 이미 등록되어 있습니다: {product_data.manufacturer_part_number}"
)
# 새 제품 생성
new_product = TubingProduct(**product_data.dict())
db.add(new_product)
db.commit()
db.refresh(new_product)
# 관련 정보와 함께 조회
product_with_relations = db.query(TubingProduct)\
.options(
joinedload(TubingProduct.specification),
joinedload(TubingProduct.manufacturer)
)\
.filter(TubingProduct.id == new_product.id)\
.first()
return {
"id": product_with_relations.id,
"specification_id": product_with_relations.specification_id,
"manufacturer_id": product_with_relations.manufacturer_id,
"manufacturer_part_number": product_with_relations.manufacturer_part_number,
"manufacturer_product_name": product_with_relations.manufacturer_product_name,
"spec_name": product_with_relations.specification.spec_name if product_with_relations.specification else None,
"manufacturer_name": product_with_relations.manufacturer.manufacturer_name if product_with_relations.manufacturer else None,
"list_price": float(product_with_relations.list_price) if product_with_relations.list_price else None,
"currency": product_with_relations.currency,
"lead_time_days": product_with_relations.lead_time_days,
"availability_status": product_with_relations.availability_status,
"datasheet_url": product_with_relations.datasheet_url,
"notes": product_with_relations.notes,
"is_active": product_with_relations.is_active
}
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"제품 등록 실패: {str(e)}")
@router.post("/material-mapping")
async def create_material_tubing_mapping(
mapping_data: MaterialTubingMappingCreate,
mapped_by: str = "admin",
db: Session = Depends(get_db)
):
"""BOM 자재와 Tubing 제품 매핑 생성"""
try:
# 기존 매핑 확인
existing = db.query(MaterialTubingMapping)\
.filter(
MaterialTubingMapping.material_id == mapping_data.material_id,
MaterialTubingMapping.tubing_product_id == mapping_data.tubing_product_id
).first()
if existing:
raise HTTPException(
status_code=400,
detail="이미 매핑된 자재와 제품입니다"
)
# 새 매핑 생성
new_mapping = MaterialTubingMapping(
**mapping_data.dict(),
mapped_by=mapped_by
)
db.add(new_mapping)
db.commit()
db.refresh(new_mapping)
return {
"success": True,
"message": "매핑이 성공적으로 생성되었습니다",
"mapping_id": new_mapping.id
}
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"매핑 생성 실패: {str(e)}")
@router.get("/material-mappings/{material_id}")
async def get_material_tubing_mappings(
material_id: int,
db: Session = Depends(get_db)
):
"""특정 자재의 Tubing 매핑 조회"""
try:
mappings = db.query(MaterialTubingMapping)\
.options(
joinedload(MaterialTubingMapping.tubing_product)
.joinedload(TubingProduct.specification),
joinedload(MaterialTubingMapping.tubing_product)
.joinedload(TubingProduct.manufacturer)
)\
.filter(MaterialTubingMapping.material_id == material_id)\
.all()
result = []
for mapping in mappings:
product = mapping.tubing_product
mapping_dict = {
"mapping_id": mapping.id,
"confidence_score": float(mapping.confidence_score) if mapping.confidence_score else None,
"mapping_method": mapping.mapping_method,
"mapped_by": mapping.mapped_by,
"mapped_at": mapping.mapped_at,
"required_length_m": float(mapping.required_length_m) if mapping.required_length_m else None,
"calculated_quantity": float(mapping.calculated_quantity) if mapping.calculated_quantity else None,
"is_verified": mapping.is_verified,
"tubing_product": {
"id": product.id,
"manufacturer_part_number": product.manufacturer_part_number,
"manufacturer_product_name": product.manufacturer_product_name,
"spec_name": product.specification.spec_name if product.specification else None,
"manufacturer_name": product.manufacturer.manufacturer_name if product.manufacturer else None,
"list_price": float(product.list_price) if product.list_price else None,
"currency": product.currency,
"availability_status": product.availability_status
}
}
result.append(mapping_dict)
return {
"success": True,
"material_id": material_id,
"mappings": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"매핑 조회 실패: {str(e)}")
@router.get("/search")
async def search_tubing_products(
query: str = Query(..., min_length=2),
category: Optional[str] = Query(None),
manufacturer: Optional[str] = Query(None),
min_diameter: Optional[float] = Query(None),
max_diameter: Optional[float] = Query(None),
material_grade: Optional[str] = Query(None),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db)
):
"""통합 Tubing 검색 (규격, 제품, 제조사)"""
try:
# SQL 쿼리로 복합 검색
sql_query = """
SELECT DISTINCT
tp.id as product_id,
tp.manufacturer_part_number,
tp.manufacturer_product_name,
tp.list_price,
tp.currency,
tp.availability_status,
ts.spec_code,
ts.spec_name,
ts.outer_diameter_mm,
ts.wall_thickness_mm,
ts.material_grade,
tc.category_name,
tm.manufacturer_name,
tm.country
FROM tubing_products tp
JOIN tubing_specifications ts ON tp.specification_id = ts.id
JOIN tubing_categories tc ON ts.category_id = tc.id
JOIN tubing_manufacturers tm ON tp.manufacturer_id = tm.id
WHERE tp.is_active = true
AND ts.is_active = true
AND tc.is_active = true
AND tm.is_active = true
AND (
tp.manufacturer_part_number ILIKE :query OR
tp.manufacturer_product_name ILIKE :query OR
ts.spec_name ILIKE :query OR
ts.spec_code ILIKE :query OR
ts.material_grade ILIKE :query OR
tm.manufacturer_name ILIKE :query
)
"""
params = {"query": f"%{query}%"}
# 필터 조건 추가
if category:
sql_query += " AND tc.category_code = :category"
params["category"] = category
if manufacturer:
sql_query += " AND tm.manufacturer_code = :manufacturer"
params["manufacturer"] = manufacturer
if min_diameter:
sql_query += " AND ts.outer_diameter_mm >= :min_diameter"
params["min_diameter"] = min_diameter
if max_diameter:
sql_query += " AND ts.outer_diameter_mm <= :max_diameter"
params["max_diameter"] = max_diameter
if material_grade:
sql_query += " AND ts.material_grade ILIKE :material_grade"
params["material_grade"] = f"%{material_grade}%"
sql_query += " ORDER BY tp.manufacturer_part_number LIMIT :limit"
params["limit"] = limit
result = db.execute(text(sql_query), params)
products = result.fetchall()
search_results = []
for product in products:
product_dict = {
"product_id": product.product_id,
"manufacturer_part_number": product.manufacturer_part_number,
"manufacturer_product_name": product.manufacturer_product_name,
"list_price": float(product.list_price) if product.list_price else None,
"currency": product.currency,
"availability_status": product.availability_status,
"spec_code": product.spec_code,
"spec_name": product.spec_name,
"outer_diameter_mm": float(product.outer_diameter_mm) if product.outer_diameter_mm else None,
"wall_thickness_mm": float(product.wall_thickness_mm) if product.wall_thickness_mm else None,
"material_grade": product.material_grade,
"category_name": product.category_name,
"manufacturer_name": product.manufacturer_name,
"country": product.country
}
search_results.append(product_dict)
return {
"success": True,
"query": query,
"total_results": len(search_results),
"results": search_results
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"검색 실패: {str(e)}")

View File

@@ -0,0 +1,69 @@
"""
스키마 모듈
API 요청/응답 모델 정의
"""
from .response_models import (
BaseResponse,
ErrorResponse,
SuccessResponse,
FileInfo,
FileListResponse,
FileDeleteResponse,
MaterialInfo,
MaterialListResponse,
JobInfo,
JobListResponse,
ClassificationResult,
ClassificationResponse,
MaterialStatistics,
ProjectStatistics,
StatisticsResponse,
CacheInfo,
SystemHealthResponse,
APIResponse,
# 열거형
FileStatus,
MaterialCategory,
JobStatus
)
__all__ = [
# 기본 응답 모델
"BaseResponse",
"ErrorResponse",
"SuccessResponse",
# 파일 관련
"FileInfo",
"FileListResponse",
"FileDeleteResponse",
# 자재 관련
"MaterialInfo",
"MaterialListResponse",
# 작업 관련
"JobInfo",
"JobListResponse",
# 분류 관련
"ClassificationResult",
"ClassificationResponse",
# 통계 관련
"MaterialStatistics",
"ProjectStatistics",
"StatisticsResponse",
# 시스템 관련
"CacheInfo",
"SystemHealthResponse",
# 유니온 타입
"APIResponse",
# 열거형
"FileStatus",
"MaterialCategory",
"JobStatus"
]

View File

@@ -0,0 +1,354 @@
"""
API 응답 모델 정의
타입 안정성 및 API 문서화를 위한 Pydantic 모델들
"""
from pydantic import BaseModel, Field, ConfigDict
from typing import List, Optional, Dict, Any, Union
from datetime import datetime
from enum import Enum
# ================================
# 기본 응답 모델
# ================================
class BaseResponse(BaseModel):
"""기본 응답 모델"""
success: bool = Field(description="요청 성공 여부")
message: Optional[str] = Field(None, description="응답 메시지")
timestamp: datetime = Field(default_factory=datetime.now, description="응답 시간")
class ErrorResponse(BaseResponse):
"""에러 응답 모델"""
success: bool = Field(False, description="요청 성공 여부")
error: Dict[str, Any] = Field(description="에러 정보")
model_config = ConfigDict(
json_schema_extra={
"example": {
"success": False,
"message": "요청 처리 중 오류가 발생했습니다",
"error": {
"code": "VALIDATION_ERROR",
"details": "입력 데이터가 올바르지 않습니다"
},
"timestamp": "2025-01-01T12:00:00"
}
}
)
class SuccessResponse(BaseResponse):
"""성공 응답 모델"""
success: bool = Field(True, description="요청 성공 여부")
data: Optional[Any] = Field(None, description="응답 데이터")
# ================================
# 열거형 정의
# ================================
class FileStatus(str, Enum):
"""파일 상태"""
ACTIVE = "active"
INACTIVE = "inactive"
PROCESSING = "processing"
ERROR = "error"
class MaterialCategory(str, Enum):
"""자재 카테고리"""
PIPE = "PIPE"
FITTING = "FITTING"
VALVE = "VALVE"
FLANGE = "FLANGE"
BOLT = "BOLT"
GASKET = "GASKET"
INSTRUMENT = "INSTRUMENT"
EXCLUDE = "EXCLUDE"
class JobStatus(str, Enum):
"""작업 상태"""
ACTIVE = "active"
COMPLETED = "completed"
ON_HOLD = "on_hold"
CANCELLED = "cancelled"
# ================================
# 파일 관련 모델
# ================================
class FileInfo(BaseModel):
"""파일 정보 모델"""
id: int = Field(description="파일 ID")
filename: str = Field(description="파일명")
original_filename: str = Field(description="원본 파일명")
job_no: Optional[str] = Field(None, description="작업 번호")
bom_name: Optional[str] = Field(None, description="BOM 이름")
revision: str = Field(default="Rev.0", description="리비전")
parsed_count: int = Field(default=0, description="파싱된 자재 수")
bom_type: str = Field(default="unknown", description="BOM 타입")
status: FileStatus = Field(description="파일 상태")
file_size: Optional[int] = Field(None, description="파일 크기 (bytes)")
upload_date: datetime = Field(description="업로드 일시")
description: Optional[str] = Field(None, description="파일 설명")
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": 1,
"filename": "BOM_Rev1.xlsx",
"original_filename": "BOM_Rev1.xlsx",
"job_no": "TK-2025-001",
"bom_name": "메인 BOM",
"revision": "Rev.1",
"parsed_count": 150,
"bom_type": "excel",
"status": "active",
"file_size": 2048576,
"upload_date": "2025-01-01T12:00:00",
"description": "파일: BOM_Rev1.xlsx"
}
}
)
class FileListResponse(BaseResponse):
"""파일 목록 응답 모델"""
success: bool = Field(True, description="요청 성공 여부")
data: List[FileInfo] = Field(description="파일 목록")
total_count: int = Field(description="전체 파일 수")
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
class FileDeleteResponse(BaseResponse):
"""파일 삭제 응답 모델"""
success: bool = Field(True, description="삭제 성공 여부")
message: str = Field(description="삭제 결과 메시지")
deleted_file_id: int = Field(description="삭제된 파일 ID")
# ================================
# 자재 관련 모델
# ================================
class MaterialInfo(BaseModel):
"""자재 정보 모델"""
id: int = Field(description="자재 ID")
file_id: int = Field(description="파일 ID")
line_number: Optional[int] = Field(None, description="엑셀 행 번호")
original_description: str = Field(description="원본 품명")
classified_category: Optional[MaterialCategory] = Field(None, description="분류된 카테고리")
classified_subcategory: Optional[str] = Field(None, description="세부 분류")
material_grade: Optional[str] = Field(None, description="재질 등급")
schedule: Optional[str] = Field(None, description="스케줄")
size_spec: Optional[str] = Field(None, description="사이즈 규격")
quantity: float = Field(description="수량")
unit: str = Field(description="단위")
classification_confidence: Optional[float] = Field(None, description="분류 신뢰도")
is_verified: bool = Field(default=False, description="검증 여부")
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": 1,
"file_id": 1,
"line_number": 5,
"original_description": "PIPE, SEAMLESS, A333-6, 6\", SCH40",
"classified_category": "PIPE",
"classified_subcategory": "SEAMLESS",
"material_grade": "A333-6",
"schedule": "SCH40",
"size_spec": "6\"",
"quantity": 12.5,
"unit": "EA",
"classification_confidence": 0.95,
"is_verified": False
}
}
)
class MaterialListResponse(BaseResponse):
"""자재 목록 응답 모델"""
success: bool = Field(True, description="요청 성공 여부")
data: List[MaterialInfo] = Field(description="자재 목록")
total_count: int = Field(description="전체 자재 수")
file_info: Optional[FileInfo] = Field(None, description="파일 정보")
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
# ================================
# 작업 관련 모델
# ================================
class JobInfo(BaseModel):
"""작업 정보 모델"""
job_no: str = Field(description="작업 번호")
job_name: str = Field(description="작업명")
client_name: Optional[str] = Field(None, description="고객사명")
end_user: Optional[str] = Field(None, description="최종 사용자")
epc_company: Optional[str] = Field(None, description="EPC 회사")
status: JobStatus = Field(description="작업 상태")
created_at: datetime = Field(description="생성 일시")
file_count: int = Field(default=0, description="파일 수")
material_count: int = Field(default=0, description="자재 수")
model_config = ConfigDict(
json_schema_extra={
"example": {
"job_no": "TK-2025-001",
"job_name": "석유화학 플랜트 배관 프로젝트",
"client_name": "한국석유화학",
"end_user": "울산공장",
"epc_company": "현대엔지니어링",
"status": "active",
"created_at": "2025-01-01T09:00:00",
"file_count": 3,
"material_count": 450
}
}
)
class JobListResponse(BaseResponse):
"""작업 목록 응답 모델"""
success: bool = Field(True, description="요청 성공 여부")
data: List[JobInfo] = Field(description="작업 목록")
total_count: int = Field(description="전체 작업 수")
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
# ================================
# 분류 관련 모델
# ================================
class ClassificationResult(BaseModel):
"""분류 결과 모델"""
category: MaterialCategory = Field(description="분류된 카테고리")
subcategory: Optional[str] = Field(None, description="세부 분류")
confidence: float = Field(description="분류 신뢰도 (0.0-1.0)")
material_grade: Optional[str] = Field(None, description="재질 등급")
size_spec: Optional[str] = Field(None, description="사이즈 규격")
schedule: Optional[str] = Field(None, description="스케줄")
details: Optional[Dict[str, Any]] = Field(None, description="분류 상세 정보")
model_config = ConfigDict(
json_schema_extra={
"example": {
"category": "PIPE",
"subcategory": "SEAMLESS",
"confidence": 0.95,
"material_grade": "A333-6",
"size_spec": "6\"",
"schedule": "SCH40",
"details": {
"matched_keywords": ["PIPE", "SEAMLESS", "A333-6"],
"size_detected": True,
"material_detected": True
}
}
}
)
class ClassificationResponse(BaseResponse):
"""분류 응답 모델"""
success: bool = Field(True, description="분류 성공 여부")
data: ClassificationResult = Field(description="분류 결과")
processing_time: float = Field(description="처리 시간 (초)")
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
# ================================
# 통계 관련 모델
# ================================
class MaterialStatistics(BaseModel):
"""자재 통계 모델"""
category: MaterialCategory = Field(description="자재 카테고리")
count: int = Field(description="개수")
percentage: float = Field(description="비율 (%)")
total_quantity: float = Field(description="총 수량")
unique_items: int = Field(description="고유 항목 수")
class ProjectStatistics(BaseModel):
"""프로젝트 통계 모델"""
job_no: str = Field(description="작업 번호")
total_materials: int = Field(description="총 자재 수")
total_files: int = Field(description="총 파일 수")
category_breakdown: List[MaterialStatistics] = Field(description="카테고리별 분석")
classification_accuracy: float = Field(description="분류 정확도")
verified_percentage: float = Field(description="검증 완료율")
model_config = ConfigDict(
json_schema_extra={
"example": {
"job_no": "TK-2025-001",
"total_materials": 450,
"total_files": 3,
"category_breakdown": [
{
"category": "PIPE",
"count": 180,
"percentage": 40.0,
"total_quantity": 1250.5,
"unique_items": 45
}
],
"classification_accuracy": 0.92,
"verified_percentage": 0.75
}
}
)
class StatisticsResponse(BaseResponse):
"""통계 응답 모델"""
success: bool = Field(True, description="요청 성공 여부")
data: ProjectStatistics = Field(description="통계 데이터")
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
# ================================
# 시스템 관련 모델
# ================================
class CacheInfo(BaseModel):
"""캐시 정보 모델"""
status: str = Field(description="캐시 상태")
used_memory: str = Field(description="사용 메모리")
connected_clients: int = Field(description="연결된 클라이언트 수")
hit_rate: float = Field(description="캐시 히트율 (%)")
total_commands: int = Field(description="총 명령 수")
class SystemHealthResponse(BaseResponse):
"""시스템 상태 응답 모델"""
success: bool = Field(True, description="요청 성공 여부")
data: Dict[str, Any] = Field(description="시스템 상태 정보")
cache_info: Optional[CacheInfo] = Field(None, description="캐시 정보")
database_status: str = Field(description="데이터베이스 상태")
api_version: str = Field(description="API 버전")
# ================================
# 유니온 타입 (여러 응답 타입)
# ================================
# API 응답으로 사용할 수 있는 모든 타입
APIResponse = Union[
SuccessResponse,
ErrorResponse,
FileListResponse,
FileDeleteResponse,
MaterialListResponse,
JobListResponse,
ClassificationResponse,
StatisticsResponse,
SystemHealthResponse
]

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

@@ -12,6 +12,55 @@ def classify_bolt_material(description: str) -> Dict:
desc_upper = description.upper()
# A320/A194M 동시 처리 (예: "ASTM A320/A194M GR B8/8") - 저온용 볼트 조합
if "A320" in desc_upper and "A194" in desc_upper:
# B8/8 등급 추출
bolt_grade = "UNKNOWN"
nut_grade = "UNKNOWN"
if "B8" in desc_upper:
bolt_grade = "B8"
nut_grade = "8" # A320/A194M의 경우 보통 B8/8 조합
elif "L7" in desc_upper:
bolt_grade = "L7"
elif "B8M" in desc_upper:
bolt_grade = "B8M"
combined_grade = f"{bolt_grade}/{nut_grade}" if bolt_grade != "UNKNOWN" and nut_grade != "UNKNOWN" else f"{bolt_grade}" if bolt_grade != "UNKNOWN" else "A320/A194M"
return {
"standard": "ASTM A320/A194M",
"grade": combined_grade,
"material_type": "LOW_TEMP_STAINLESS", # 저온용 스테인리스
"manufacturing": "FORGED",
"confidence": 0.95,
"evidence": ["ASTM_A320_A194M_COMBINED"]
}
# A193/A194 동시 처리 (예: "ASTM A193/A194 GR B7/2H")
if "A193" in desc_upper and "A194" in desc_upper:
# B7/2H 등급 추출
bolt_grade = "UNKNOWN"
nut_grade = "UNKNOWN"
if "B7" in desc_upper:
bolt_grade = "B7"
if "2H" in desc_upper:
nut_grade = "2H"
elif " 8" in desc_upper or "GR 8" in desc_upper:
nut_grade = "8"
combined_grade = f"{bolt_grade}/{nut_grade}" if bolt_grade != "UNKNOWN" and nut_grade != "UNKNOWN" else "A193/A194"
return {
"standard": "ASTM A193/A194",
"grade": combined_grade,
"material_type": "ALLOY_STEEL", # B7/2H 조합은 보통 합금강
"manufacturing": "FORGED",
"confidence": 0.95,
"evidence": ["ASTM_A193_A194_COMBINED"]
}
# ASTM A193 (볼트용 강재)
if any(pattern in desc_upper for pattern in ["A193", "ASTM A193"]):
# B7, B8 등 등급 추출 (GR B7/2H 형태도 지원)
@@ -112,6 +161,39 @@ def classify_bolt_material(description: str) -> Dict:
"evidence": ["ISO_4762_SOCKET_SCREW"]
}
# 일반적인 볼트 재질 패턴 추가 확인
if "B7" in desc_upper and "2H" in desc_upper:
return {
"standard": "ASTM A193/A194",
"grade": "B7/2H",
"material_type": "ALLOY_STEEL",
"manufacturing": "FORGED",
"confidence": 0.85,
"evidence": ["B7_2H_PATTERN"]
}
# 단독 B7 패턴
if "B7" in desc_upper:
return {
"standard": "ASTM A193",
"grade": "B7",
"material_type": "ALLOY_STEEL",
"manufacturing": "FORGED",
"confidence": 0.80,
"evidence": ["B7_PATTERN"]
}
# 단독 2H 패턴
if "2H" in desc_upper:
return {
"standard": "ASTM A194",
"grade": "2H",
"material_type": "ALLOY_STEEL",
"manufacturing": "FORGED",
"confidence": 0.80,
"evidence": ["2H_PATTERN"]
}
# 기본 재질 분류기 호출 (materials_schema 문제가 있어도 우회)
try:
return classify_material(description)
@@ -153,13 +235,49 @@ BOLT_TYPES = {
},
"FLANGE_BOLT": {
"dat_file_patterns": ["FLG_BOLT", "FLANGE_BOLT", "BLT_150", "BLT_300", "BLT_600"],
"description_keywords": ["FLANGE BOLT", "플랜지볼트", "150LB", "300LB", "600LB"],
"dat_file_patterns": ["FLG_BOLT", "FLANGE_BOLT"],
"description_keywords": ["FLANGE BOLT", "플랜지볼트"],
"characteristics": "플랜지 전용 볼트",
"applications": "플랜지 체결 전용",
"head_type": "HEXAGON"
},
"PSV_BOLT": {
"dat_file_patterns": ["PSV_BOLT", "PSV_BLT"],
"description_keywords": ["PSV", "PRESSURE SAFETY VALVE BOLT"],
"characteristics": "압력안전밸브용 특수 볼트",
"applications": "PSV 체결 전용",
"head_type": "HEXAGON",
"special_application": "PSV"
},
"LT_BOLT": {
"dat_file_patterns": ["LT_BOLT", "LT_BLT"],
"description_keywords": ["LT BOLT", "LT BLT", "LOW TEMP", "저온용"],
"characteristics": "저온용 특수 볼트",
"applications": "저온 환경 체결용",
"head_type": "HEXAGON",
"special_application": "LT"
},
"CK_BOLT": {
"dat_file_patterns": ["CK_BOLT", "CK_BLT", "CHECK_BOLT"],
"description_keywords": ["CK", "CHECK VALVE BOLT"],
"characteristics": "체크밸브용 특수 볼트",
"applications": "체크밸브 체결 전용",
"head_type": "HEXAGON",
"special_application": "CK"
},
"ORI_BOLT": {
"dat_file_patterns": ["ORI_BOLT", "ORI_BLT", "ORIFICE_BOLT"],
"description_keywords": ["ORI", "ORIFICE", "오리피스"],
"characteristics": "오리피스용 특수 볼트",
"applications": "오리피스 체결 전용",
"head_type": "HEXAGON",
"special_application": "ORI"
},
"MACHINE_SCREW": {
"dat_file_patterns": ["MACH_SCR", "M_SCR"],
"description_keywords": ["MACHINE SCREW", "머신스크류", "기계나사"],
@@ -272,11 +390,17 @@ THREAD_STANDARDS = {
},
"INCH": {
"patterns": [r"(\d+(?:/\d+)?)\s*[\"\']\s*UNC", r"(\d+(?:/\d+)?)\s*[\"\']\s*UNF",
r"(\d+(?:/\d+)?)\s*[\"\']-(\d+)"],
"patterns": [
r"(\d+(?:/\d+)?)\s*[\"\']\s*UNC", # 1/2" UNC
r"(\d+(?:/\d+)?)\s*[\"\']\s*UNF", # 1/2" UNF
r"(\d+(?:/\d+)?)\s*[\"\']-(\d+)", # 1/2"-13
r"(\d+\.\d+)", # 0.625 (소수점 인치)
r"(\d+(?:/\d+)?)\s*INCH", # 1/2 INCH
r"(\d+(?:/\d+)?)\s*IN" # 1/2 IN
],
"description": "인치 나사",
"thread_types": ["UNC", "UNF"],
"common_sizes": ["1/4\"", "5/16\"", "3/8\"", "1/2\"", "5/8\"", "3/4\"", "7/8\"", "1\""]
"common_sizes": ["1/4\"", "5/16\"", "3/8\"", "1/2\"", "5/8\"", "0.625\"", "3/4\"", "7/8\"", "1\""]
},
"BSW": {
@@ -302,6 +426,204 @@ BOLT_GRADES = {
}
}
def convert_decimal_to_fraction(decimal_str: str) -> str:
"""소수점 인치를 분수로 변환 (현장 표준)"""
try:
decimal = float(decimal_str)
# 일반적인 인치 분수 변환표
inch_fractions = {
0.125: "1/8",
0.1875: "3/16",
0.25: "1/4",
0.3125: "5/16",
0.375: "3/8",
0.4375: "7/16",
0.5: "1/2",
0.5625: "9/16",
0.625: "5/8",
0.6875: "11/16",
0.75: "3/4",
0.8125: "13/16",
0.875: "7/8",
0.9375: "15/16",
1.0: "1",
1.125: "1-1/8",
1.25: "1-1/4",
1.375: "1-3/8",
1.5: "1-1/2",
1.625: "1-5/8",
1.75: "1-3/4",
1.875: "1-7/8",
2.0: "2"
}
# 정확한 매칭 (소수점 오차 고려)
for dec_val, fraction in inch_fractions.items():
if abs(decimal - dec_val) < 0.001: # 1mm 오차 허용
return fraction
# 정확한 매칭이 없으면 가장 가까운 값 찾기
closest_decimal = min(inch_fractions.keys(), key=lambda x: abs(x - decimal))
if abs(closest_decimal - decimal) < 0.0625: # 1/16" 이내 오차만 허용
return inch_fractions[closest_decimal]
# 변환할 수 없으면 원래 값 반환
return str(decimal)
except ValueError:
return decimal_str
def classify_surface_treatment(description: str) -> Dict:
"""볼트 표면처리 분류 (아연도금, 스테인리스 등)"""
desc_upper = description.upper()
treatments = []
# 전기아연도금
if any(keyword in desc_upper for keyword in ["ELEC.GALV", "ELEC GALV", "ELECTRO GALV", "전기아연도금"]):
treatments.append({
"type": "ELECTRO_GALVANIZING",
"description": "전기아연도금",
"code": "ELEC.GALV",
"corrosion_resistance": "보통"
})
# 용융아연도금
if any(keyword in desc_upper for keyword in ["HOT DIP GALV", "HDG", "용융아연도금"]):
treatments.append({
"type": "HOT_DIP_GALVANIZING",
"description": "용융아연도금",
"code": "HDG",
"corrosion_resistance": "높음"
})
# 스테인리스 (표면처리 불필요)
if any(keyword in desc_upper for keyword in ["STAINLESS", "STS", "스테인리스"]):
treatments.append({
"type": "STAINLESS_STEEL",
"description": "스테인리스강",
"code": "STS",
"corrosion_resistance": "매우높음"
})
# 니켈도금
if any(keyword in desc_upper for keyword in ["NICKEL", "NI PLATING", "니켈도금"]):
treatments.append({
"type": "NICKEL_PLATING",
"description": "니켈도금",
"code": "NI",
"corrosion_resistance": "높음"
})
# 크롬도금
if any(keyword in desc_upper for keyword in ["CHROME", "CR PLATING", "크롬도금"]):
treatments.append({
"type": "CHROME_PLATING",
"description": "크롬도금",
"code": "CR",
"corrosion_resistance": "매우높음"
})
return {
"treatments": treatments,
"has_treatment": len(treatments) > 0,
"treatment_count": len(treatments),
"primary_treatment": treatments[0] if treatments else None
}
def classify_special_application_bolts(description: str) -> Dict:
"""
특수 용도 볼트 분류 및 카운팅 (PSV, LT, CK)
주의: 이 함수는 이미 BOLT로 분류된 아이템에서만 호출되어야 함
PSV, LT, CK는 해당 장비용 볼트를 의미하며, 장비 자체가 아님
"""
desc_upper = description.upper()
special_applications = []
special_details = {}
# PSV 볼트 확인 (압력안전밸브용 볼트)
psv_patterns = [
r'\bPSV\b', # 단어 경계로 PSV만
r'PRESSURE\s+SAFETY\s+VALVE',
r'압력안전밸브',
r'PSV\s+BOLT',
r'PSV\s+BLT'
]
import re
if any(re.search(pattern, desc_upper) for pattern in psv_patterns):
special_applications.append("PSV")
special_details["PSV"] = {
"type": "압력안전밸브용 볼트",
"application": "PSV 체결 전용",
"critical": True # 안전 장비용으로 중요
}
# LT 볼트 확인 (저온용 볼트)
lt_patterns = [
r'\bLT\s', # LT 다음에 공백이 있는 경우만 (LT BOLT, LT BLT)
r'^LT\b', # 문장 시작의 LT만
r'LOW\s+TEMP',
r'저온용',
r'CRYOGENIC',
r'LT\s+BOLT',
r'LT\s+BLT'
]
if any(re.search(pattern, desc_upper) for pattern in lt_patterns):
special_applications.append("LT")
special_details["LT"] = {
"type": "저온용 볼트",
"application": "저온 환경 체결용",
"critical": True # 저온 환경용으로 중요
}
# CK 볼트 확인 (체크밸브용 볼트)
ck_patterns = [
r'\bCK\b', # 단어 경계로 CK만
r'CHECK\s+VALVE',
r'체크밸브',
r'CK\s+BOLT',
r'CK\s+BLT'
]
if any(re.search(pattern, desc_upper) for pattern in ck_patterns):
special_applications.append("CK")
special_details["CK"] = {
"type": "체크밸브용 볼트",
"application": "체크밸브 체결 전용",
"critical": False # 일반적
}
# ORI 볼트 확인 (오리피스용 볼트)
ori_patterns = [
r'\bORI\b', # 단어 경계로 ORI만
r'ORIFICE',
r'오리피스',
r'ORI\s+BOLT',
r'ORI\s+BLT'
]
if any(re.search(pattern, desc_upper) for pattern in ori_patterns):
special_applications.append("ORI")
special_details["ORI"] = {
"type": "오리피스용 볼트",
"application": "오리피스 체결 전용",
"critical": True # 유량 측정용으로 중요
}
return {
"detected_applications": special_applications,
"special_details": special_details,
"is_special_bolt": len(special_applications) > 0,
"special_count": len(special_applications),
"classification_note": "특수 장비용 볼트 (장비 자체 아님)"
}
def classify_bolt(dat_file: str, description: str, main_nom: str, length: Optional[float] = None) -> Dict:
"""
완전한 BOLT 분류
@@ -337,7 +659,13 @@ def classify_bolt(dat_file: str, description: str, main_nom: str, length: Option
# 6. 등급 및 강도 분류
grade_result = classify_bolt_grade(description, thread_result)
# 7. 최종 결과 조합
# 7. 특수 용도 볼트 분류 (PSV, LT, CK)
special_result = classify_special_application_bolts(description)
# 8. 표면처리 분류 (ELEC.GALV 등)
surface_result = classify_surface_treatment(description)
# 9. 최종 결과 조합
return {
"category": "BOLT",
@@ -367,6 +695,7 @@ def classify_bolt(dat_file: str, description: str, main_nom: str, length: Option
"thread_specification": {
"standard": thread_result.get('standard', 'UNKNOWN'),
"size": thread_result.get('size', ''),
"size_fraction": thread_result.get('size_fraction', ''),
"pitch": thread_result.get('pitch', ''),
"thread_type": thread_result.get('thread_type', ''),
"confidence": thread_result.get('confidence', 0.0)
@@ -374,9 +703,11 @@ def classify_bolt(dat_file: str, description: str, main_nom: str, length: Option
"dimensions": {
"nominal_size": dimensions_result.get('nominal_size', main_nom),
"nominal_size_fraction": dimensions_result.get('nominal_size_fraction', main_nom),
"length": dimensions_result.get('length', ''),
"diameter": dimensions_result.get('diameter', ''),
"dimension_description": dimensions_result.get('dimension_description', '')
"dimension_description": dimensions_result.get('dimension_description', ''),
"bolts_per_flange": dimensions_result.get('bolts_per_flange', 1)
},
"grade_strength": {
@@ -386,6 +717,23 @@ def classify_bolt(dat_file: str, description: str, main_nom: str, length: Option
"confidence": grade_result.get('confidence', 0.0)
},
# 특수 용도 볼트 정보
"special_applications": {
"is_special_bolt": special_result.get('is_special_bolt', False),
"detected_applications": special_result.get('detected_applications', []),
"special_details": special_result.get('special_details', {}),
"special_count": special_result.get('special_count', 0),
"classification_note": special_result.get('classification_note', '')
},
# 표면처리 정보
"surface_treatment": {
"has_treatment": surface_result.get('has_treatment', False),
"treatments": surface_result.get('treatments', []),
"treatment_count": surface_result.get('treatment_count', 0),
"primary_treatment": surface_result.get('primary_treatment', None)
},
# 전체 신뢰도
"overall_confidence": calculate_bolt_confidence({
"material": material_result.get('confidence', 0),
@@ -536,9 +884,19 @@ def classify_thread_specification(main_nom: str, description: str) -> Dict:
thread_type = t_type
break
# 인치 사이즈를 분수로 변환
size_fraction = size
if standard == "INCH":
try:
if '.' in size and size.replace('.', '').isdigit():
size_fraction = convert_decimal_to_fraction(size).replace('"', '')
except:
size_fraction = size
return {
"standard": standard,
"size": size,
"size": size, # 원래 값
"size_fraction": size_fraction, # 분수 변환값
"pitch": pitch,
"thread_type": thread_type,
"confidence": 0.9,
@@ -559,28 +917,98 @@ def extract_bolt_dimensions(main_nom: str, description: str) -> Dict:
"""볼트 치수 정보 추출"""
desc_upper = description.upper()
actual_bolt_size = main_nom
# 실제 BOM 형태: "ORI, 0.75, 145.0000 LG, 300LB, ASTM A193/A194 GR B7/2H, ELEC.GALV"
# 첫 번째 숫자가 실제 볼트 사이즈 (접두사 건너뛰기)
import re
# 설명에서 첫 번째 숫자 추출 (볼트 사이즈)
first_number_match = re.search(r'(\d+(?:\.\d+)?)', description)
if first_number_match:
actual_bolt_size = first_number_match.group(1)
# 플랜지 볼트의 경우 실제 볼트 직경을 description에서 추출
if "FLANGE BOLT" in desc_upper or "FLG_BOLT" in desc_upper:
# 플랜지 볼트에서 실제 볼트 사이즈 패턴 찾기
# 예: "FLANGE BOLT 6" 150LB M16" → M16
# 예: "FLANGE BOLT 1-1/2" 5/8" x 100mm" → 5/8
bolt_size_patterns = [
r'M(\d+)', # M16, M20 등 메트릭
r'(\d+-\d+/\d+)\s*["\']?\s*X', # 1-1/2" X 등 (복합 분수)
r'(\d+/\d+)\s*["\']?\s*X', # 5/8" X, 3/4" X 등 (단순 분수)
r'(\d+(?:\.\d+)?)\s*["\']?\s*X', # 0.625" X 등 (소수)
r'(\d+-\d+/\d+)\s*["\']?\s*DIA', # 1-1/2" DIA 등 (복합 분수)
r'(\d+/\d+)\s*["\']?\s*DIA', # 5/8" DIA 등 (단순 분수)
r'(\d+(?:\.\d+)?)\s*["\']?\s*DIA', # 0.625" DIA 등 (소수)
r'(\d+-\d+/\d+)\s*["\']?\s*(?:LONG|LG|LENGTH)', # 복합 분수 + 길이
r'(\d+/\d+)\s*["\']?\s*(?:LONG|LG|LENGTH)', # 단순 분수 + 길이
r'(\d+(?:\.\d+)?)\s*["\']?\s*(?:LONG|LG|LENGTH)', # 소수 + 길이
r'(\d+(?:\.\d+)?)\s*MM\s*DIA', # 16MM DIA 등
]
for pattern in bolt_size_patterns:
match = re.search(pattern, desc_upper)
if match:
extracted_size = match.group(1)
# M16 같은 메트릭은 M 제거
if pattern.startswith(r'M'):
actual_bolt_size = extracted_size
else:
actual_bolt_size = extracted_size
break
# 볼트 사이즈를 분수로 변환 (인치인 경우)
nominal_size_fraction = actual_bolt_size
try:
# 소수점 인치를 분수로 변환
if '.' in actual_bolt_size and actual_bolt_size.replace('.', '').isdigit():
nominal_size_fraction = convert_decimal_to_fraction(actual_bolt_size)
except:
nominal_size_fraction = actual_bolt_size
# 플랜지당 볼트 세트 수 추출 (예: (8), (4))
bolts_per_flange = 1 # 기본값
flange_bolt_pattern = re.search(r'\((\d+)\)', description)
if flange_bolt_pattern:
bolts_per_flange = int(flange_bolt_pattern.group(1))
dimensions = {
"nominal_size": main_nom,
"nominal_size": actual_bolt_size, # 실제 볼트 사이즈
"nominal_size_fraction": nominal_size_fraction, # 분수 변환값
"length": "",
"diameter": "",
"dimension_description": main_nom
"dimension_description": nominal_size_fraction, # 분수로 표시
"bolts_per_flange": bolts_per_flange # 플랜지당 볼트 세트 수
}
# 길이 정보 추출
# 길이 정보 추출 (개선된 패턴)
length_patterns = [
r'(\d+(?:\.\d+)?)\s*LG', # 70.0000 LG 형태 (최우선)
r'(\d+(?:\.\d+)?)\s*LG', # 70.0000 LG, 145.0000 LG 형태 (최우선)
r'(\d+(?:\.\d+)?)\s*MM\s*LG', # 70MM LG 형태
r'L\s*(\d+(?:\.\d+)?)\s*MM',
r'LENGTH\s*(\d+(?:\.\d+)?)\s*MM',
r'(\d+(?:\.\d+)?)\s*MM\s*LONG',
r'X\s*(\d+(?:\.\d+)?)\s*MM' # M8 X 20MM 형태
r'X\s*(\d+(?:\.\d+)?)\s*MM', # M8 X 20MM 형태
r',\s*(\d+(?:\.\d+)?)\s*LG', # ", 145.0000 LG" 형태 (PSV, LT 볼트용)
r',\s*(\d+(?:\.\d+)?)\s+(?:CK|PSV|LT)', # ", 140 CK" 형태 (PSV 볼트용)
r'PSV\s+(\d+(?:\.\d+)?)', # PSV 140 형태 (PSV 볼트 전용)
r'(\d+(?:\.\d+)?)\s+PSV', # 140 PSV 형태 (PSV 볼트 전용)
r'(\d+(?:\.\d+)?)\s*MM(?:\s|,|$)', # 75MM 형태 (단독)
r'(\d+(?:\.\d+)?)\s*mm(?:\s|,|$)' # 75mm 형태 (단독)
]
for pattern in length_patterns:
match = re.search(pattern, desc_upper)
if match:
dimensions["length"] = f"{match.group(1)}mm"
length_value = match.group(1)
# 소수점 제거 (145.0000 → 145)
if '.' in length_value and length_value.endswith('.0000'):
length_value = length_value.split('.')[0]
elif '.' in length_value and all(c == '0' for c in length_value.split('.')[1]):
length_value = length_value.split('.')[0]
dimensions["length"] = f"{length_value}mm"
break
# 지름 정보 (이미 main_nom에 있지만 확인)
@@ -595,8 +1023,8 @@ def extract_bolt_dimensions(main_nom: str, description: str) -> Dict:
dimensions["diameter"] = f"{match.group(1)}mm"
break
# 치수 설명 조합
desc_parts = [main_nom]
# 치수 설명 조합 (분수 사용)
desc_parts = [nominal_size_fraction]
if dimensions["length"]:
desc_parts.append(f"L{dimensions['length']}")
@@ -705,6 +1133,68 @@ def calculate_bolt_confidence(confidence_scores: Dict) -> float:
# ========== 특수 기능들 ==========
def extract_bolt_additional_requirements(description: str) -> str:
"""볼트 설명에서 추가요구사항 추출 (표면처리, 특수 요구사항 등)"""
desc_upper = description.upper()
additional_reqs = []
# 표면처리 패턴들
surface_treatments = {
'ELEC.GALV': '전기아연도금',
'ELEC GALV': '전기아연도금',
'GALVANIZED': '아연도금',
'GALV': '아연도금',
'HOT DIP GALV': '용융아연도금',
'HDG': '용융아연도금',
'ZINC PLATED': '아연도금',
'ZINC': '아연도금',
'STAINLESS': '스테인리스',
'SS': '스테인리스',
'PASSIVATED': '부동태화',
'ANODIZED': '아노다이징',
'BLACK OXIDE': '흑색산화',
'PHOSPHATE': '인산처리',
'DACROMET': '다크로메트',
'GEOMET': '지오메트'
}
# 특수 요구사항 패턴들
special_requirements = {
'HEAVY HEX': '중육각',
'FULL THREAD': '전나사',
'PARTIAL THREAD': '부분나사',
'FINE THREAD': '세나사',
'COARSE THREAD': '조나사',
'LEFT HAND': '좌나사',
'RIGHT HAND': '우나사',
'SOCKET HEAD': '소켓헤드',
'BUTTON HEAD': '버튼헤드',
'FLAT HEAD': '평머리',
'PAN HEAD': '팬헤드',
'TRUSS HEAD': '트러스헤드',
'WASHER FACE': '와셔면',
'SERRATED': '톱니형',
'LOCK': '잠금',
'SPRING': '스프링',
'WAVE': '웨이브'
}
# 표면처리 확인
for pattern, korean in surface_treatments.items():
if pattern in desc_upper:
additional_reqs.append(korean)
# 특수 요구사항 확인
for pattern, korean in special_requirements.items():
if pattern in desc_upper:
additional_reqs.append(korean)
# 중복 제거 및 정렬
additional_reqs = list(set(additional_reqs))
return ', '.join(additional_reqs) if additional_reqs else ''
def get_bolt_purchase_info(bolt_result: Dict) -> Dict:
"""볼트 구매 정보 생성"""

View File

@@ -0,0 +1,157 @@
"""
자재 분류 시스템용 상수 및 키워드 정의
중복 로직 제거 및 유지보수성 향상을 위해 중앙 집중화됨
"""
from typing import Dict, List
# ==============================================================================
# 1. 압력 등급 (Pressure Ratings)
# ==============================================================================
# 단순 키워드 목록 (Integrated Classifier용)
LEVEL3_PRESSURE_KEYWORDS = [
"150LB", "300LB", "600LB", "900LB", "1500LB",
"2500LB", "3000LB", "6000LB", "9000LB"
]
# 상세 스펙 및 메타데이터 (Fitting Classifier용)
PRESSURE_RATINGS_SPECS = {
"150LB": {"max_pressure": "285 PSI", "common_use": "저압 일반용"},
"300LB": {"max_pressure": "740 PSI", "common_use": "중압용"},
"600LB": {"max_pressure": "1480 PSI", "common_use": "고압용"},
"900LB": {"max_pressure": "2220 PSI", "common_use": "고압용"},
"1500LB": {"max_pressure": "3705 PSI", "common_use": "고압용"},
"2500LB": {"max_pressure": "6170 PSI", "common_use": "초고압용"},
"3000LB": {"max_pressure": "7400 PSI", "common_use": "소구경 고압용"},
"6000LB": {"max_pressure": "14800 PSI", "common_use": "소구경 초고압용"},
"9000LB": {"max_pressure": "22200 PSI", "common_use": "소구경 극고압용"}
}
# 정규식 패턴 (Fitting Classifier용)
PRESSURE_PATTERNS = [
r"(\d+)LB",
r"CLASS\s*(\d+)",
r"CL\s*(\d+)",
r"(\d+)#",
r"(\d+)\s*LB"
]
# ==============================================================================
# 2. OLET 키워드 (OLET Keywords)
# ==============================================================================
# Fitting Classifier와 Integrated Classifier에서 공통 사용
OLET_KEYWORDS = [
# Full Names
"SOCK-O-LET", "WELD-O-LET", "ELL-O-LET", "THREAD-O-LET", "ELB-O-LET",
"NIP-O-LET", "COUP-O-LET",
# Variations
"SOCKOLET", "WELDOLET", "ELLOLET", "THREADOLET", "ELBOLET", "NIPOLET", "COUPOLET",
"OLET", "올렛", "O-LET", "SOCKLET", "SOCKET-O-LET", "WELD O-LET", "ELL O-LET",
"THREADED-O-LET", "ELBOW-O-LET", "NIPPLE-O-LET", "COUPLING-O-LET",
# Abbreviations (Caution: specific context needed sometimes)
"SOL", "WOL", "EOL", "TOL", "NOL", "COL"
]
# ==============================================================================
# 3. 연결 방식 (Connection Methods)
# ==============================================================================
LEVEL3_CONNECTION_KEYWORDS = {
"SW": ["SW", "SOCKET WELD", "소켓웰드", "SOCKET-WELD", "_SW_"],
"THD": ["THD", "THREADED", "NPT", "나사", "THRD", "TR", "_TR", "_THD"],
"BW": ["BW", "BUTT WELD", "맞대기용접", "BUTT-WELD", "_BW"],
"FL": ["FL", "FLANGED", "플랜지", "FLG", "_FL_"]
}
# ==============================================================================
# 4. 재질 키워드 (Material Keywords)
# ==============================================================================
LEVEL4_MATERIAL_KEYWORDS = {
"PIPE": ["A106", "A333", "A312", "A53"],
"FITTING": ["A234", "A403", "A420"],
"FLANGE": ["A182", "A350"],
"VALVE": ["A216", "A217", "A351", "A352"],
"BOLT": ["A193", "A194", "A320", "A325", "A490"]
}
GENERIC_MATERIALS = {
"A105": ["VALVE", "FLANGE", "FITTING"],
"316": ["VALVE", "FLANGE", "FITTING", "PIPE", "BOLT"],
"304": ["VALVE", "FLANGE", "FITTING", "PIPE", "BOLT"]
}
# ==============================================================================
# 5. 메인 분류 키워드 (Level 1 Type Keywords)
# ==============================================================================
LEVEL1_TYPE_KEYWORDS = {
"BOLT": [
"FLANGE BOLT", "U-BOLT", "U BOLT", "BOLT", "STUD", "NUT", "SCREW",
"WASHER", "볼트", "너트", "스터드", "나사", "와셔", "유볼트"
],
"VALVE": [
"VALVE", "GATE", "BALL", "GLOBE", "CHECK", "BUTTERFLY", "NEEDLE",
"RELIEF", "SIGHT GLASS", "STRAINER", "밸브", "게이트", "", "글로브",
"체크", "버터플라이", "니들", "릴리프", "사이트글라스", "스트레이너"
],
"FLANGE": [
"FLG", "FLANGE", "플랜지", "프랜지", "ORIFICE", "SPECTACLE", "PADDLE",
"SPACER", "BLIND", "REDUCING FLANGE", "RED FLANGE"
],
"PIPE": [
"PIPE", "TUBE", "파이프", "배관", "SMLS", "SEAMLESS"
],
"FITTING": [
# Standard Fittings
"ELBOW", "ELL", "TEE", "REDUCER", "CAP", "COUPLING", "NIPPLE", "SWAGE", "PLUG",
"엘보", "", "리듀서", "", "니플", "커플링", "플러그", "CONC", "ECC",
# Instrument Fittings
"SWAGELOK", "DK-LOK", "HY-LOK", "SUPERLOK", "TUBE FITTING", "COMPRESSION",
"UNION", "FERRULE", "NUT & FERRULE", "MALE CONNECTOR", "FEMALE CONNECTOR",
"TUBE ADAPTER", "PORT CONNECTOR", "CONNECTOR"
] + OLET_KEYWORDS, # OLET Keywords 병합
"GASKET": [
"GASKET", "GASK", "가스켓", "SWG", "SPIRAL"
],
"INSTRUMENT": [
"GAUGE", "TRANSMITTER", "SENSOR", "THERMOMETER", "계기", "게이지", "트랜스미터", "센서"
],
"SUPPORT": [
"URETHANE BLOCK", "URETHANE", "BLOCK SHOE", "CLAMP", "SUPPORT", "HANGER",
"SPRING", "우레탄", "블록", "클램프", "서포트", "행거", "스프링", "PIPE CLAMP"
],
"PLATE": [
"PLATE", "PL", "CHECKER PLATE", "판재", "철판"
],
"STRUCTURAL": [
"H-BEAM", "BEAM", "ANGLE", "CHANNEL", "H-SECTION", "I-BEAM", "형강", "앵글", "채널"
]
}
# ==============================================================================
# 6. 서브타입 키워드 (Level 2 Subtype Keywords)
# ==============================================================================
LEVEL2_SUBTYPE_KEYWORDS = {
"VALVE": {
"GATE": ["GATE VALVE", "GATE", "게이트 밸브"],
"BALL": ["BALL VALVE", "BALL", "볼 밸브"],
"GLOBE": ["GLOBE VALVE", "GLOBE", "글로브 밸브"],
"CHECK": ["CHECK VALVE", "CHECK", "체크 밸브", "역지 밸브"]
},
"FLANGE": {
"WELD_NECK": ["WELD NECK", "WN", "웰드넥"],
"SLIP_ON": ["SLIP ON", "SO", "슬립온"],
"BLIND": ["BLIND", "BL", "막음", "차단"],
"SOCKET_WELD": ["SOCKET WELD", "소켓웰드"]
},
"BOLT": {
"HEX_BOLT": ["HEX BOLT", "HEXAGON", "육각 볼트"],
"STUD_BOLT": ["STUD BOLT", "STUD", "스터드 볼트"],
"U_BOLT": ["U-BOLT", "U BOLT", "유볼트"]
},
"SUPPORT": {
"URETHANE_BLOCK": ["URETHANE BLOCK", "BLOCK SHOE", "우레탄 블록"],
"CLAMP": ["CLAMP", "클램프"],
"HANGER": ["HANGER", "SUPPORT", "행거", "서포트"],
"SPRING": ["SPRING", "스프링"]
}
}

View File

@@ -0,0 +1,300 @@
import pandas as pd
import re
from typing import List, Dict, Optional
import uuid
from datetime import datetime
from pathlib import Path
# 허용된 확장자
ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"}
class BOMParser:
"""BOM 파일 파싱을 담당하는 클래스"""
@staticmethod
def validate_extension(filename: str) -> bool:
return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
@staticmethod
def generate_unique_filename(original_filename: str) -> str:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
unique_id = str(uuid.uuid4())[:8]
stem = Path(original_filename).stem
suffix = Path(original_filename).suffix
return f"{stem}_{timestamp}_{unique_id}{suffix}"
@staticmethod
def detect_format(df: pd.DataFrame) -> str:
"""
엑셀 헤더를 분석하여 양식을 감지합니다.
Returns:
'INVENTOR': 인벤터 추출 양식 (NO., NAME, Q'ty, LENGTH & THICKNESS...)
'STANDARD': 기존 표준/퍼지 매핑 엑셀 (DWG_NAME, LINE_NUM, MAIN_NOM...)
"""
columns = [str(c).strip().upper() for c in df.columns]
# 인벤터 양식 특징 (오타 포함)
INVENTOR_KEYWORDS = ['LENGTH & THICKNESS', 'DESCIPTION', "Q'TY"]
for keyword in INVENTOR_KEYWORDS:
if any(keyword in col for col in columns):
return 'INVENTOR'
# 표준 양식 특징
STANDARD_KEYWORDS = ['MAIN_NOM', 'DWG_NAME', 'LINE_NUM']
for keyword in STANDARD_KEYWORDS:
if any(keyword in col for col in columns):
return 'STANDARD'
return 'STANDARD' # 기본값
@classmethod
def parse_file(cls, file_path: str) -> List[Dict]:
"""파일 경로를 받아 적절한 파서를 통해 데이터를 추출합니다."""
file_extension = Path(file_path).suffix.lower()
try:
if file_extension == ".csv":
df = pd.read_csv(file_path, encoding='utf-8')
elif file_extension in [".xlsx", ".xls"]:
# xlrd 엔진 명시 (xls 지원)
if file_extension == ".xls":
df = pd.read_excel(file_path, sheet_name=0, engine='xlrd')
else:
df = pd.read_excel(file_path, sheet_name=0, engine='openpyxl')
else:
raise ValueError("지원하지 않는 파일 형식")
# 데이터프레임 전처리 (빈 행 제거 등)
df = df.dropna(how='all')
# 양식 감지
format_type = cls.detect_format(df)
print(f"📋 감지된 BOM 양식: {format_type}")
if format_type == 'INVENTOR':
return cls._parse_inventor_bom(df)
else:
return cls._parse_standard_bom(df)
except Exception as e:
raise ValueError(f"파일 파싱 실패: {str(e)}")
@staticmethod
def _parse_standard_bom(df: pd.DataFrame) -> List[Dict]:
"""기존의 퍼지 매핑 방식 파서 (표준 양식)"""
# 컬럼명 전처리
df.columns = df.columns.str.strip().str.upper()
# 대소문자 구분 없는 매핑을 위해 컬럼명 대문자화 사용
column_mapping = {
'description': ['DESCRIPTION', 'ITEM', 'MATERIAL', '품명', '자재명', 'SPECIFICATION'],
'quantity': ['QTY', 'QUANTITY', 'EA', '수량', 'AMOUNT'],
'main_size': ['MAIN_NOM', 'NOMINAL_DIAMETER', 'ND', '주배관', 'SIZE'],
'red_size': ['RED_NOM', 'REDUCED_DIAMETER', '축소배관'],
'length': ['LENGTH', 'LEN', '길이'],
'weight': ['WEIGHT', 'WT', '중량'],
'dwg_name': ['DWG_NAME', 'DRAWING', '도면명', '도면번호'],
'line_num': ['LINE_NUM', 'LINE_NUMBER', '라인번호']
}
mapped_columns = {}
for standard_col, possible_names in column_mapping.items():
for possible_name in possible_names:
# 대문자로 비교
possible_upper = possible_name.upper()
if possible_upper in df.columns:
mapped_columns[standard_col] = possible_upper
break
print(f"📋 [Standard] 컬럼 매핑 결과: {mapped_columns}")
materials = []
for index, row in df.iterrows():
description = str(row.get(mapped_columns.get('description', ''), ''))
# 제외 항목 처리
description_upper = description.upper()
if ('WELD GAP' in description_upper or 'WELDING GAP' in description_upper or
'웰드갭' in description_upper or '용접갭' in description_upper):
continue
# 수량 처리
quantity_raw = row.get(mapped_columns.get('quantity', ''), 0)
try:
quantity = float(quantity_raw) if pd.notna(quantity_raw) else 0
except:
quantity = 0
# 재질 등급 추출 (ASTM)
material_grade = ""
if "ASTM" in description_upper:
astm_match = re.search(r'ASTM\s+(A\d{3,4}[A-Z]*(?:\s+(?:GR\s+)?[A-Z0-9]+)?)', description_upper)
if astm_match:
material_grade = astm_match.group(0).strip()
# 사이즈 처리
main_size = str(row.get(mapped_columns.get('main_size', ''), ''))
red_size = str(row.get(mapped_columns.get('red_size', ''), ''))
main_nom = main_size if main_size != 'nan' and main_size != '' else None
red_nom = red_size if red_size != 'nan' and red_size != '' else None
if main_size != 'nan' and red_size != 'nan' and red_size != '':
size_spec = f"{main_size} x {red_size}"
elif main_size != 'nan' and main_size != '':
size_spec = main_size
else:
size_spec = ""
# 길이 처리
length_raw = row.get(mapped_columns.get('length', ''), '')
length_value = None
if pd.notna(length_raw) and str(length_raw).strip() != '':
try:
length_value = float(str(length_raw).strip())
except:
length_value = None
# 도면/라인 번호
dwg_name = row.get(mapped_columns.get('dwg_name', ''), '')
dwg_name = str(dwg_name).strip() if pd.notna(dwg_name) and str(dwg_name).strip() not in ['', 'nan', 'None'] else None
line_num = row.get(mapped_columns.get('line_num', ''), '')
line_num = str(line_num).strip() if pd.notna(line_num) and str(line_num).strip() not in ['', 'nan', 'None'] else None
if description and description not in ['nan', 'None', '']:
materials.append({
'original_description': description,
'quantity': quantity,
'unit': "EA",
'size_spec': size_spec,
'main_nom': main_nom,
'red_nom': red_nom,
'material_grade': material_grade,
'length': length_value,
'dwg_name': dwg_name,
'line_num': line_num,
'line_number': index + 1,
'row_number': index + 1
})
return materials
@staticmethod
def _parse_inventor_bom(df: pd.DataFrame) -> List[Dict]:
"""
[신규] 인벤터 추출 양식 파서
헤더: NO., NAME, Q'ty, LENGTH & THICKNESS, WEIGHT, DESCIPTION, REMARK
특징: Size 컬럼 부재, NAME에 주요 정보 포함
"""
print("⚠️ [Inventor] 인벤터 양식 파서를 사용합니다.")
# 컬럼명 전처리 (좌우 공백 제거 및 대문자화)
df.columns = df.columns.str.strip().str.upper()
# 인벤터 전용 매핑
col_name = 'NAME'
col_qty = "Q'TY"
col_desc = 'DESCIPTION' # 오타 그대로 반영
col_remark = 'REMARK'
col_length = 'LENGTH & THICKNESS' # 길이 정보가 여기 있을 수 있음
materials = []
for index, row in df.iterrows():
# 1. 품명 (NAME 컬럼 우선 사용)
name_val = str(row.get(col_name, '')).strip()
desc_val = str(row.get(col_desc, '')).strip()
# NAME과 DESCIPTION 병합 (필요시)
# 보통 인벤터에서 NAME이 'ADAPTER OD 1/4" x NPT(F) 1/2"' 같이 상세 스펙
# DESCIPTION은 'SS-400-7-8' 같은 자재 코드일 수 있음
# 일단 NAME을 메인 description으로 사용하고, DESCIPTION을 괄호에 추가
if desc_val and desc_val not in ['nan', 'None', '']:
full_description = f"{name_val} ({desc_val})"
else:
full_description = name_val
if not full_description or full_description in ['nan', 'None', '']:
continue
# 2. 수량
qty_raw = row.get(col_qty, 0)
try:
quantity = float(qty_raw) if pd.notna(qty_raw) else 0
except:
quantity = 0
# 3. 사이즈 추출 (NAME 컬럼 분석)
# 패턴: 1/2", 1/4", 100A, 50A, 10x20 등
size_spec = ""
main_nom = None
red_nom = None
# 인치/MM 사이즈 추출 시도
# 예: "ADAPTER OD 1/4" x NPT(F) 1/2"" -> 1/4" x 1/2"
# 예: "ELBOW 90D 100A" -> 100A
# 인치 패턴 (1/2", 3/4" 등)
inch_sizes = re.findall(r'(\d+(?:/\d+)?)"', name_val)
# A단위 패턴 (100A, 50A 등)
a_sizes = re.findall(r'(\d+)A', name_val)
if inch_sizes:
if len(inch_sizes) >= 2:
main_nom = f'{inch_sizes[0]}"'
red_nom = f'{inch_sizes[1]}"'
size_spec = f'{main_nom} x {red_nom}'
else:
main_nom = f'{inch_sizes[0]}"'
size_spec = main_nom
elif a_sizes:
if len(a_sizes) >= 2:
main_nom = f'{a_sizes[0]}A'
red_nom = f'{a_sizes[1]}A'
size_spec = f'{main_nom} x {red_nom}'
else:
main_nom = f'{a_sizes[0]}A'
size_spec = main_nom
# 4. 재질 정보
material_grade = ""
# NAME이나 DESCIPTION에서 재질 키워드 찾기 (SS, SUS, A105 등)
combined_text = (full_description + " " + desc_val).upper()
if "SUS" in combined_text or "SS" in combined_text:
if "304" in combined_text: material_grade = "SUS304"
elif "316" in combined_text: material_grade = "SUS316"
else: material_grade = "SUS"
elif "A105" in combined_text:
material_grade = "A105"
# 5. 길이 정보
length_value = None
length_raw = row.get(col_length, '')
# 값이 있고 숫자로 변환 가능하면 사용
if pd.notna(length_raw) and str(length_raw).strip():
try:
# '100 mm' 등의 형식 처리 필요할 수 있음
length_str = str(length_raw).lower().replace('mm', '').strip()
length_value = float(length_str)
except:
pass
materials.append({
'original_description': full_description,
'quantity': quantity,
'unit': "EA",
'size_spec': size_spec,
'main_nom': main_nom,
'red_nom': red_nom,
'material_grade': material_grade,
'length': length_value,
'dwg_name': None, # 인벤터 파일엔 도면명 컬럼이 없음
'line_num': None,
'line_number': index + 1,
'row_number': index + 1
})
return materials

View File

@@ -8,11 +8,6 @@ from typing import Dict, List, Optional
# ========== 제외 대상 타입 ==========
EXCLUDE_TYPES = {
"WELD_GAP": {
"description_keywords": ["WELD GAP", "WELDING GAP", "GAP", "용접갭", "웰드갭"],
"characteristics": "용접 시 수축 고려용 계산 항목",
"reason": "실제 자재 아님 - 용접 갭 계산용"
},
"CUTTING_LOSS": {
"description_keywords": ["CUTTING LOSS", "CUT LOSS", "절단로스", "컷팅로스"],
"characteristics": "절단 시 손실 고려용 계산 항목",

View File

@@ -0,0 +1,333 @@
"""
파일 관리 비즈니스 로직
API 레이어에서 분리된 핵심 비즈니스 로직
"""
from typing import List, Dict, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import text
from fastapi import HTTPException
from ..utils.logger import get_logger
from ..utils.cache_manager import tkmp_cache
from ..utils.transaction_manager import TransactionManager, async_transactional
from ..schemas.response_models import FileInfo
from ..config import get_settings
logger = get_logger(__name__)
settings = get_settings()
class FileService:
"""파일 관리 서비스"""
def __init__(self, db: Session):
self.db = db
self.transaction_manager = TransactionManager(db)
async def get_files(
self,
job_no: Optional[str] = None,
show_history: bool = False,
use_cache: bool = True
) -> Tuple[List[Dict], bool]:
"""
파일 목록 조회
Args:
job_no: 작업 번호
show_history: 이력 표시 여부
use_cache: 캐시 사용 여부
Returns:
Tuple[List[Dict], bool]: (파일 목록, 캐시 히트 여부)
"""
try:
logger.info(f"파일 목록 조회 - job_no: {job_no}, show_history: {show_history}")
# 캐시 확인
if use_cache:
cached_files = tkmp_cache.get_file_list(job_no, show_history)
if cached_files:
logger.info(f"캐시에서 파일 목록 반환 - {len(cached_files)}개 파일")
return cached_files, True
# 데이터베이스에서 조회
query, params = self._build_file_query(job_no, show_history)
result = self.db.execute(text(query), params)
files = result.fetchall()
# 결과 변환
file_list = self._convert_files_to_dict(files)
# 캐시에 저장
if use_cache:
tkmp_cache.set_file_list(file_list, job_no, show_history)
logger.debug("파일 목록 캐시 저장 완료")
logger.info(f"파일 목록 조회 완료 - {len(file_list)}개 파일 반환")
return file_list, False
except Exception as e:
logger.error(f"파일 목록 조회 실패: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}")
def _build_file_query(self, job_no: Optional[str], show_history: bool) -> Tuple[str, Dict]:
"""파일 조회 쿼리 생성"""
if show_history:
# 전체 이력 표시
query = "SELECT * FROM files"
params = {}
if job_no:
query += " WHERE job_no = :job_no"
params["job_no"] = job_no
query += " ORDER BY original_filename, revision DESC"
else:
# 최신 리비전만 표시
if job_no:
query = """
SELECT f1.* FROM files f1
INNER JOIN (
SELECT original_filename, MAX(revision) as max_revision
FROM files
WHERE job_no = :job_no
GROUP BY original_filename
) f2 ON f1.original_filename = f2.original_filename
AND f1.revision = f2.max_revision
WHERE f1.job_no = :job_no
ORDER BY f1.upload_date DESC
"""
params = {"job_no": job_no}
else:
query = "SELECT * FROM files ORDER BY upload_date DESC"
params = {}
return query, params
def _convert_files_to_dict(self, files) -> List[Dict]:
"""파일 결과를 딕셔너리로 변환"""
return [
{
"id": f.id,
"filename": f.original_filename,
"original_filename": f.original_filename,
"name": f.original_filename,
"job_no": f.job_no,
"bom_name": f.bom_name or f.original_filename,
"revision": f.revision or "Rev.0",
"parsed_count": f.parsed_count or 0,
"bom_type": f.file_type or "unknown",
"status": "active" if f.is_active else "inactive",
"file_size": f.file_size,
"created_at": f.upload_date,
"upload_date": f.upload_date,
"description": f"파일: {f.original_filename}"
}
for f in files
]
async def delete_file(self, file_id: int) -> Dict:
"""
파일 삭제 (트랜잭션 관리 적용)
Args:
file_id: 파일 ID
Returns:
Dict: 삭제 결과
"""
try:
logger.info(f"파일 삭제 요청 - file_id: {file_id}")
# 트랜잭션 내에서 삭제 작업 수행
with self.transaction_manager.transaction():
# 파일 정보 조회
file_info = self._get_file_info(file_id)
if not file_info:
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
# 관련 데이터 삭제 (세이브포인트 사용)
with self.transaction_manager.savepoint("delete_related_data"):
self._delete_related_data(file_id)
# 파일 삭제
with self.transaction_manager.savepoint("delete_file_record"):
self._delete_file_record(file_id)
# 트랜잭션이 성공적으로 완료되면 캐시 무효화
self._invalidate_file_cache(file_id, file_info)
logger.info(f"파일 삭제 완료 - file_id: {file_id}, filename: {file_info.original_filename}")
return {
"success": True,
"message": "파일과 관련 데이터가 삭제되었습니다",
"deleted_file_id": file_id
}
except HTTPException:
raise
except Exception as e:
logger.error(f"파일 삭제 실패 - file_id: {file_id}, error: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {str(e)}")
def _get_file_info(self, file_id: int):
"""파일 정보 조회"""
file_query = text("SELECT * FROM files WHERE id = :file_id")
file_result = self.db.execute(file_query, {"file_id": file_id})
return file_result.fetchone()
def _delete_related_data(self, file_id: int):
"""관련 데이터 삭제"""
# 상세 테이블 목록
detail_tables = [
'pipe_details', 'fitting_details', 'valve_details',
'flange_details', 'bolt_details', 'gasket_details',
'instrument_details'
]
# 해당 파일의 materials ID 조회
material_ids_query = text("SELECT id FROM materials WHERE file_id = :file_id")
material_ids_result = self.db.execute(material_ids_query, {"file_id": file_id})
material_ids = [row[0] for row in material_ids_result]
if material_ids:
logger.info(f"관련 자재 데이터 삭제 - {len(material_ids)}개 자재")
# 각 상세 테이블에서 관련 데이터 삭제
for table in detail_tables:
delete_detail_query = text(f"DELETE FROM {table} WHERE material_id = ANY(:material_ids)")
self.db.execute(delete_detail_query, {"material_ids": material_ids})
# materials 테이블 데이터 삭제
materials_query = text("DELETE FROM materials WHERE file_id = :file_id")
self.db.execute(materials_query, {"file_id": file_id})
def _delete_file_record(self, file_id: int):
"""파일 레코드 삭제"""
delete_query = text("DELETE FROM files WHERE id = :file_id")
self.db.execute(delete_query, {"file_id": file_id})
def _invalidate_file_cache(self, file_id: int, file_info):
"""파일 관련 캐시 무효화"""
tkmp_cache.invalidate_file_cache(file_id)
if hasattr(file_info, 'job_no') and file_info.job_no:
tkmp_cache.invalidate_job_cache(file_info.job_no)
async def get_file_statistics(self, job_no: Optional[str] = None) -> Dict:
"""
파일 통계 조회
Args:
job_no: 작업 번호
Returns:
Dict: 파일 통계
"""
try:
# 캐시 확인
if job_no:
cached_stats = tkmp_cache.get_statistics(job_no, "file_stats")
if cached_stats:
return cached_stats
# 통계 쿼리 실행
stats_query = self._build_statistics_query(job_no)
result = self.db.execute(text(stats_query["query"]), stats_query["params"])
stats_data = result.fetchall()
# 통계 데이터 변환
statistics = self._convert_statistics_data(stats_data)
# 캐시에 저장
if job_no:
tkmp_cache.set_statistics(statistics, job_no, "file_stats")
return statistics
except Exception as e:
logger.error(f"파일 통계 조회 실패: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"파일 통계 조회 실패: {str(e)}")
def _build_statistics_query(self, job_no: Optional[str]) -> Dict:
"""통계 쿼리 생성"""
base_query = """
SELECT
COUNT(*) as total_files,
COUNT(DISTINCT job_no) as total_jobs,
SUM(CASE WHEN is_active = true THEN 1 ELSE 0 END) as active_files,
SUM(file_size) as total_size,
AVG(file_size) as avg_size,
MAX(upload_date) as latest_upload,
MIN(upload_date) as earliest_upload
FROM files
"""
params = {}
if job_no:
base_query += " WHERE job_no = :job_no"
params["job_no"] = job_no
return {"query": base_query, "params": params}
def _convert_statistics_data(self, stats_data) -> Dict:
"""통계 데이터 변환"""
if not stats_data:
return {
"total_files": 0,
"total_jobs": 0,
"active_files": 0,
"total_size": 0,
"avg_size": 0,
"latest_upload": None,
"earliest_upload": None
}
stats = stats_data[0]
return {
"total_files": stats.total_files or 0,
"total_jobs": stats.total_jobs or 0,
"active_files": stats.active_files or 0,
"total_size": stats.total_size or 0,
"total_size_mb": round((stats.total_size or 0) / (1024 * 1024), 2),
"avg_size": stats.avg_size or 0,
"avg_size_mb": round((stats.avg_size or 0) / (1024 * 1024), 2),
"latest_upload": stats.latest_upload,
"earliest_upload": stats.earliest_upload
}
async def validate_file_access(self, file_id: int, user_id: Optional[str] = None) -> bool:
"""
파일 접근 권한 검증
Args:
file_id: 파일 ID
user_id: 사용자 ID
Returns:
bool: 접근 권한 여부
"""
try:
# 파일 존재 여부 확인
file_info = self._get_file_info(file_id)
if not file_info:
return False
# 파일이 활성 상태인지 확인
if not file_info.is_active:
logger.warning(f"비활성 파일 접근 시도 - file_id: {file_id}")
return False
# 추가 권한 검증 로직 (필요시 구현)
# 예: 사용자별 프로젝트 접근 권한 등
return True
except Exception as e:
logger.error(f"파일 접근 권한 검증 실패: {str(e)}", exc_info=True)
return False
def get_file_service(db: Session) -> FileService:
"""파일 서비스 팩토리 함수"""
return FileService(db)

View File

@@ -6,13 +6,18 @@ FITTING 분류 시스템 V2
import re
from typing import Dict, List, Optional
from .material_classifier import classify_material, get_manufacturing_method_from_material
from .classifier_constants import PRESSURE_PATTERNS, PRESSURE_RATINGS_SPECS, OLET_KEYWORDS
# ========== FITTING 타입별 분류 (실제 BOM 기반) ==========
FITTING_TYPES = {
"ELBOW": {
"dat_file_patterns": ["90L_", "45L_", "ELL_", "ELBOW_"],
"description_keywords": ["ELBOW", "ELL", "엘보"],
"description_keywords": ["ELBOW", "ELL", "엘보", "90 ELBOW", "45 ELBOW", "LR ELBOW", "SR ELBOW", "90 ELL", "45 ELL"],
"subtypes": {
"90DEG_LONG_RADIUS": ["90 LR", "90° LR", "90DEG LR", "90도 장반경", "90 LONG RADIUS", "LR 90"],
"90DEG_SHORT_RADIUS": ["90 SR", "90° SR", "90DEG SR", "90도 단반경", "90 SHORT RADIUS", "SR 90"],
"45DEG_LONG_RADIUS": ["45 LR", "45° LR", "45DEG LR", "45도 장반경", "45 LONG RADIUS", "LR 45"],
"45DEG_SHORT_RADIUS": ["45 SR", "45° SR", "45DEG SR", "45도 단반경", "45 SHORT RADIUS", "SR 45"],
"90DEG": ["90", "90°", "90DEG", "90도"],
"45DEG": ["45", "45°", "45DEG", "45도"],
"LONG_RADIUS": ["LR", "LONG RADIUS", "장반경"],
@@ -98,11 +103,12 @@ FITTING_TYPES = {
},
"OLET": {
"dat_file_patterns": ["SOL_", "WOL_", "TOL_", "OLET_", "SOCK-O-LET", "WELD-O-LET"],
"description_keywords": ["OLET", "올렛", "O-LET", "SOCK-O-LET", "WELD-O-LET", "SOCKOLET", "WELDOLET", "THREAD-O-LET", "THREADOLET", "SOCKLET", "SOCKET"],
"dat_file_patterns": ["SOL_", "WOL_", "TOL_", "EOL_", "NOL_", "COL_", "OLET_", "SOCK-O-LET", "WELD-O-LET", "ELL-O-LET", "THREAD-O-LET", "ELB-O-LET", "NIP-O-LET", "COUP-O-LET"],
"description_keywords": OLET_KEYWORDS,
"subtypes": {
"SOCKOLET": ["SOCK-O-LET", "SOCKOLET", "SOL", "SOCK O-LET", "SOCKET-O-LET", "SOCKLET"],
"WELDOLET": ["WELD-O-LET", "WELDOLET", "WOL", "WELD O-LET", "WELDING-O-LET"],
"ELLOLET": ["ELL-O-LET", "ELLOLET", "EOL", "ELL O-LET", "ELBOW-O-LET"],
"THREADOLET": ["THREAD-O-LET", "THREADOLET", "TOL", "THREADED-O-LET"],
"ELBOLET": ["ELB-O-LET", "ELBOLET", "EOL", "ELBOW-O-LET"],
"NIPOLET": ["NIP-O-LET", "NIPOLET", "NOL", "NIPPLE-O-LET"],
@@ -164,24 +170,8 @@ CONNECTION_METHODS = {
# ========== 압력 등급별 분류 ==========
PRESSURE_RATINGS = {
"patterns": [
r"(\d+)LB",
r"CLASS\s*(\d+)",
r"CL\s*(\d+)",
r"(\d+)#",
r"(\d+)\s*LB"
],
"standard_ratings": {
"150LB": {"max_pressure": "285 PSI", "common_use": "저압 일반용"},
"300LB": {"max_pressure": "740 PSI", "common_use": "중압용"},
"600LB": {"max_pressure": "1480 PSI", "common_use": "고압용"},
"900LB": {"max_pressure": "2220 PSI", "common_use": "고압용"},
"1500LB": {"max_pressure": "3705 PSI", "common_use": "고압용"},
"2500LB": {"max_pressure": "6170 PSI", "common_use": "초고압용"},
"3000LB": {"max_pressure": "7400 PSI", "common_use": "소구경 고압용"},
"6000LB": {"max_pressure": "14800 PSI", "common_use": "소구경 초고압용"},
"9000LB": {"max_pressure": "22200 PSI", "common_use": "소구경 극고압용"}
}
"patterns": PRESSURE_PATTERNS,
"standard_ratings": PRESSURE_RATINGS_SPECS
}
def classify_fitting(dat_file: str, description: str, main_nom: str,
@@ -202,15 +192,24 @@ def classify_fitting(dat_file: str, description: str, main_nom: str,
desc_upper = description.upper()
dat_upper = dat_file.upper()
# 1. 명칭 우선 확인 (피팅 키워드가 있으면 피팅)
fitting_keywords = ['ELBOW', 'ELL', 'TEE', 'REDUCER', 'RED', 'CAP', 'NIPPLE', 'SWAGE', 'OLET', 'COUPLING', 'PLUG', 'SOCKLET', 'SOCKET', '엘보', '', '리듀서', '', '니플', '스웨지', '올렛', '커플링', 'SOCK-O-LET', 'WELD-O-LET', 'SOCKOLET', 'WELDOLET']
is_fitting = any(keyword in desc_upper or keyword in dat_upper for keyword in fitting_keywords)
# 1. 피팅 키워드 확인 (재질만 있어도 통합 분류기가 이미 피팅으로 분류했으므로 진행)
# OLET 키워드를 우선 확인하여 정확한 분류 수행
olet_keywords = OLET_KEYWORDS
has_olet_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in olet_keywords)
if not is_fitting:
fitting_keywords = ['ELBOW', 'ELL', 'TEE', 'REDUCER', 'RED', 'CAP', 'NIPPLE', 'SWAGE', 'COUPLING', 'PLUG', '엘보', '', '리듀서', '', '니플', '스웨지', '올렛', '커플링', '플러그'] + olet_keywords
has_fitting_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in fitting_keywords)
# 피팅 재질 확인 (A234, A403, A420)
fitting_materials = ['A234', 'A403', 'A420']
has_fitting_material = any(material in desc_upper for material in fitting_materials)
# 피팅 키워드도 없고 피팅 재질도 없으면 UNKNOWN
if not has_fitting_keyword and not has_fitting_material:
return {
"category": "UNKNOWN",
"overall_confidence": 0.0,
"reason": "피팅 키워드 없음"
"reason": "피팅 키워드 및 재질 없음"
}
# 2. 재질 분류 (공통 모듈 사용)
@@ -225,8 +224,8 @@ def classify_fitting(dat_file: str, description: str, main_nom: str,
# 4. 압력 등급 분류
pressure_result = classify_pressure_rating(dat_file, description)
# 4.5. 스케줄 분류 (니플 등에 중요)
schedule_result = classify_fitting_schedule(description)
# 4.5. 스케줄 분류 (니플 등에 중요) - 분리 스케줄 지원
schedule_result = classify_fitting_schedule_with_reducing(description, main_nom, red_nom)
# 5. 제작 방법 추정
manufacturing_result = determine_fitting_manufacturing(
@@ -234,71 +233,152 @@ def classify_fitting(dat_file: str, description: str, main_nom: str,
)
# 6. 최종 결과 조합
# --- 계장용(Instrument/Swagelok) 피팅 감지 로직 추가 ---
instrument_keywords = ["SWAGELOK", "DK-LOK", "TUBE FITTING", "UNION", "FERRULE", "MALE CONNECTOR", "FEMALE CONNECTOR"]
is_instrument = any(kw in desc_upper for kw in instrument_keywords)
if is_instrument:
fitting_type_result["category"] = "INSTRUMENT_FITTING"
if "SWAGELOK" in desc_upper: fitting_type_result["brand"] = "SWAGELOK"
# Tube OD 추출 (예: 1/4", 6MM, 12MM)
tube_match = re.search(r'(\d+(?:/\d+)?)\s*(?:\"|INCH|MM)\s*(?:OD|TUBE)', desc_upper)
if tube_match:
fitting_type_result["tube_od"] = tube_match.group(0)
return {
"category": "FITTING",
# 재질 정보 (공통 모듈)
"material": {
"standard": material_result.get('standard', 'UNKNOWN'),
"grade": material_result.get('grade', 'UNKNOWN'),
"material_type": material_result.get('material_type', 'UNKNOWN'),
"confidence": material_result.get('confidence', 0.0)
},
# 피팅 특화 정보
"fitting_type": {
"type": fitting_type_result.get('type', 'UNKNOWN'),
"subtype": fitting_type_result.get('subtype', 'UNKNOWN'),
"confidence": fitting_type_result.get('confidence', 0.0),
"evidence": fitting_type_result.get('evidence', [])
},
"connection_method": {
"method": connection_result.get('method', 'UNKNOWN'),
"confidence": connection_result.get('confidence', 0.0),
"matched_code": connection_result.get('matched_code', ''),
"size_range": connection_result.get('size_range', ''),
"pressure_range": connection_result.get('pressure_range', '')
},
"pressure_rating": {
"rating": pressure_result.get('rating', 'UNKNOWN'),
"confidence": pressure_result.get('confidence', 0.0),
"max_pressure": pressure_result.get('max_pressure', ''),
"common_use": pressure_result.get('common_use', '')
},
"manufacturing": {
"method": manufacturing_result.get('method', 'UNKNOWN'),
"confidence": manufacturing_result.get('confidence', 0.0),
"evidence": manufacturing_result.get('evidence', []),
"characteristics": manufacturing_result.get('characteristics', '')
},
"size_info": {
"main_size": main_nom,
"reduced_size": red_nom,
"size_description": format_fitting_size(main_nom, red_nom),
"requires_two_sizes": fitting_type_result.get('requires_two_sizes', False)
},
"schedule_info": {
"schedule": schedule_result.get('schedule', 'UNKNOWN'),
"schedule_number": schedule_result.get('schedule_number', ''),
"wall_thickness": schedule_result.get('wall_thickness', ''),
"pressure_class": schedule_result.get('pressure_class', ''),
"confidence": schedule_result.get('confidence', 0.0)
},
# 전체 신뢰도
"fitting_type": fitting_type_result,
"connection_method": connection_result,
"pressure_rating": pressure_result,
"schedule": schedule_result,
"manufacturing": manufacturing_result,
"overall_confidence": calculate_fitting_confidence({
"material": material_result.get('confidence', 0),
"fitting_type": fitting_type_result.get('confidence', 0),
"connection": connection_result.get('confidence', 0),
"pressure": pressure_result.get('confidence', 0)
"material": material_result.get("confidence", 0),
"fitting_type": fitting_type_result.get("confidence", 0),
"connection": connection_result.get("confidence", 0),
"pressure": pressure_result.get("confidence", 0)
})
}
def analyze_size_pattern_for_fitting_type(description: str, main_nom: str, red_nom: str = None) -> Dict:
"""
실제 BOM 패턴 기반 TEE vs REDUCER 구분
실제 패턴:
- TEE RED, SMLS, SCH 40 x SCH 80 → TEE (키워드 우선)
- RED CONC, SMLS, SCH 80 x SCH 80 → REDUCER (키워드 우선)
- 모두 A x B 형태 (메인 x 감소)
"""
desc_upper = description.upper()
# 1. 키워드 기반 분류 (최우선) - 실제 BOM 패턴
if "TEE RED" in desc_upper or "TEE REDUCING" in desc_upper:
return {
"type": "TEE",
"subtype": "REDUCING",
"confidence": 0.95,
"evidence": ["KEYWORD_TEE_RED"],
"subtype_confidence": 0.95,
"requires_two_sizes": False
}
if "RED CONC" in desc_upper or "REDUCER CONC" in desc_upper:
return {
"type": "REDUCER",
"subtype": "CONCENTRIC",
"confidence": 0.95,
"evidence": ["KEYWORD_RED_CONC"],
"subtype_confidence": 0.95,
"requires_two_sizes": True
}
if "RED ECC" in desc_upper or "REDUCER ECC" in desc_upper:
return {
"type": "REDUCER",
"subtype": "ECCENTRIC",
"confidence": 0.95,
"evidence": ["KEYWORD_RED_ECC"],
"subtype_confidence": 0.95,
"requires_two_sizes": True
}
# 2. 사이즈 패턴 분석 (보조) - 기존 로직 유지
# x 또는 × 기호로 연결된 사이즈들 찾기
connected_sizes = re.findall(r'(\d+(?:\s+\d+/\d+)?(?:\.\d+)?)"?\s*[xX×]\s*(\d+(?:\s+\d+/\d+)?(?:\.\d+)?)"?(?:\s*[xX×]\s*(\d+(?:\s+\d+/\d+)?(?:\.\d+)?)"?)?', description)
if connected_sizes:
# 연결된 사이즈들을 리스트로 변환
sizes = []
for size_group in connected_sizes:
for size in size_group:
if size.strip():
sizes.append(size.strip())
# 중복 제거하되 순서 유지
unique_sizes = []
for size in sizes:
if size not in unique_sizes:
unique_sizes.append(size)
sizes = unique_sizes
if len(sizes) == 3:
# A x B x B 패턴 → TEE REDUCING
if sizes[1] == sizes[2]:
return {
"type": "TEE",
"subtype": "REDUCING",
"confidence": 0.85,
"evidence": [f"SIZE_PATTERN_TEE_REDUCING: {' x '.join(sizes)}"],
"subtype_confidence": 0.85,
"requires_two_sizes": False
}
# A x B x C 패턴 → TEE REDUCING (모두 다른 사이즈)
else:
return {
"type": "TEE",
"subtype": "REDUCING",
"confidence": 0.80,
"evidence": [f"SIZE_PATTERN_TEE_REDUCING_UNEQUAL: {' x '.join(sizes)}"],
"subtype_confidence": 0.80,
"requires_two_sizes": False
}
elif len(sizes) == 2:
# A x B 패턴 → 키워드가 없으면 REDUCER로 기본 분류
if "CONC" in desc_upper or "CONCENTRIC" in desc_upper:
return {
"type": "REDUCER",
"subtype": "CONCENTRIC",
"confidence": 0.80,
"evidence": [f"SIZE_PATTERN_REDUCER_CONC: {' x '.join(sizes)}"],
"subtype_confidence": 0.80,
"requires_two_sizes": True
}
elif "ECC" in desc_upper or "ECCENTRIC" in desc_upper:
return {
"type": "REDUCER",
"subtype": "ECCENTRIC",
"confidence": 0.80,
"evidence": [f"SIZE_PATTERN_REDUCER_ECC: {' x '.join(sizes)}"],
"subtype_confidence": 0.80,
"requires_two_sizes": True
}
else:
# 키워드 없는 A x B 패턴은 낮은 신뢰도로 REDUCER
return {
"type": "REDUCER",
"subtype": "CONCENTRIC", # 기본값
"confidence": 0.60,
"evidence": [f"SIZE_PATTERN_REDUCER_DEFAULT: {' x '.join(sizes)}"],
"subtype_confidence": 0.60,
"requires_two_sizes": True
}
return {"confidence": 0.0}
def classify_fitting_type(dat_file: str, description: str,
main_nom: str, red_nom: str = None) -> Dict:
"""피팅 타입 분류"""
@@ -306,7 +386,28 @@ def classify_fitting_type(dat_file: str, description: str,
dat_upper = dat_file.upper()
desc_upper = description.upper()
# 1. DAT_FILE 패턴으로 1차 분류 (가장 신뢰도 높음)
# 0. OLET 우선 확인 (ELL과의 혼동 방지)
olet_specific_keywords = OLET_KEYWORDS
for keyword in olet_specific_keywords:
if keyword in desc_upper or keyword in dat_upper:
subtype_result = classify_fitting_subtype(
"OLET", desc_upper, main_nom, red_nom, FITTING_TYPES["OLET"]
)
return {
"type": "OLET",
"subtype": subtype_result["subtype"],
"confidence": 0.95,
"evidence": [f"OLET_PRIORITY_KEYWORD: {keyword}"],
"subtype_confidence": subtype_result["confidence"],
"requires_two_sizes": FITTING_TYPES["OLET"].get("requires_two_sizes", False)
}
# 1. 사이즈 패턴 분석으로 TEE vs REDUCER 구분
size_pattern_result = analyze_size_pattern_for_fitting_type(desc_upper, main_nom, red_nom)
if size_pattern_result.get("confidence", 0) > 0.85:
return size_pattern_result
# 2. DAT_FILE 패턴으로 1차 분류 (가장 신뢰도 높음)
for fitting_type, type_data in FITTING_TYPES.items():
for pattern in type_data["dat_file_patterns"]:
if pattern in dat_upper:
@@ -323,7 +424,7 @@ def classify_fitting_type(dat_file: str, description: str,
"requires_two_sizes": type_data.get("requires_two_sizes", False)
}
# 2. DESCRIPTION 키워드로 2차 분류
# 3. DESCRIPTION 키워드로 2차 분류
for fitting_type, type_data in FITTING_TYPES.items():
for keyword in type_data["description_keywords"]:
if keyword in desc_upper:
@@ -340,7 +441,7 @@ def classify_fitting_type(dat_file: str, description: str,
"requires_two_sizes": type_data.get("requires_two_sizes", False)
}
# 3. 분류 실패
# 4. 분류 실패
return {
"type": "UNKNOWN",
"subtype": "UNKNOWN",
@@ -353,18 +454,77 @@ def classify_fitting_subtype(fitting_type: str, description: str,
main_nom: str, red_nom: str, type_data: Dict) -> Dict:
"""피팅 서브타입 분류"""
desc_upper = description.upper()
subtypes = type_data.get("subtypes", {})
# 1. 키워드 기반 서브타입 분류 (우선)
# 1. 키워드 기반 서브타입 분류 (우선) - 대소문자 구분 없이
for subtype, keywords in subtypes.items():
for keyword in keywords:
if keyword in description:
if keyword.upper() in desc_upper:
return {
"subtype": subtype,
"confidence": 0.9,
"evidence": [f"SUBTYPE_KEYWORD: {keyword}"]
}
# 1.5. ELBOW 특별 처리 - 조합 키워드 우선 확인
if fitting_type == "ELBOW":
# 90도 + 반경 조합
if ("90" in desc_upper or "90°" in desc_upper or "90DEG" in desc_upper):
if ("LR" in desc_upper or "LONG RADIUS" in desc_upper or "장반경" in desc_upper):
return {
"subtype": "90DEG_LONG_RADIUS",
"confidence": 0.95,
"evidence": ["90DEG + LONG_RADIUS"]
}
elif ("SR" in desc_upper or "SHORT RADIUS" in desc_upper or "단반경" in desc_upper):
return {
"subtype": "90DEG_SHORT_RADIUS",
"confidence": 0.95,
"evidence": ["90DEG + SHORT_RADIUS"]
}
else:
return {
"subtype": "90DEG",
"confidence": 0.85,
"evidence": ["90DEG_DETECTED"]
}
# 45도 + 반경 조합
elif ("45" in desc_upper or "45°" in desc_upper or "45DEG" in desc_upper):
if ("LR" in desc_upper or "LONG RADIUS" in desc_upper or "장반경" in desc_upper):
return {
"subtype": "45DEG_LONG_RADIUS",
"confidence": 0.95,
"evidence": ["45DEG + LONG_RADIUS"]
}
elif ("SR" in desc_upper or "SHORT RADIUS" in desc_upper or "단반경" in desc_upper):
return {
"subtype": "45DEG_SHORT_RADIUS",
"confidence": 0.95,
"evidence": ["45DEG + SHORT_RADIUS"]
}
else:
return {
"subtype": "45DEG",
"confidence": 0.85,
"evidence": ["45DEG_DETECTED"]
}
# 반경만 있는 경우 (기본 90도 가정)
elif ("LR" in desc_upper or "LONG RADIUS" in desc_upper or "장반경" in desc_upper):
return {
"subtype": "90DEG_LONG_RADIUS",
"confidence": 0.8,
"evidence": ["LONG_RADIUS_DEFAULT_90DEG"]
}
elif ("SR" in desc_upper or "SHORT RADIUS" in desc_upper or "단반경" in desc_upper):
return {
"subtype": "90DEG_SHORT_RADIUS",
"confidence": 0.8,
"evidence": ["SHORT_RADIUS_DEFAULT_90DEG"]
}
# 2. 사이즈 분석이 필요한 경우 (TEE, REDUCER 등)
if type_data.get("size_analysis"):
if red_nom and str(red_nom).strip() and red_nom != main_nom:
@@ -674,3 +834,53 @@ def classify_fitting_schedule(description: str) -> Dict:
"confidence": 0.0,
"matched_pattern": ""
}
def classify_fitting_schedule_with_reducing(description: str, main_nom: str, red_nom: str = None) -> Dict:
"""
실제 BOM 패턴 기반 분리 스케줄 처리
실제 패턴:
- "TEE RED, SMLS, SCH 40 x SCH 80" → main: SCH 40, red: SCH 80
- "RED CONC, SMLS, SCH 40S x SCH 40S" → main: SCH 40S, red: SCH 40S
- "RED CONC, SMLS, SCH 80 x SCH 80" → main: SCH 80, red: SCH 80
"""
desc_upper = description.upper()
# 1. 분리 스케줄 패턴 확인 (SCH XX x SCH YY) - 개선된 패턴
separated_schedule_patterns = [
r'SCH\s*(\d+S?)\s*[xX×]\s*SCH\s*(\d+S?)', # SCH 40 x SCH 80
r'SCH\s*(\d+S?)\s*X\s*(\d+S?)', # SCH 40S X 40S (SCH 생략)
]
for pattern in separated_schedule_patterns:
separated_match = re.search(pattern, desc_upper)
if separated_match:
main_schedule = f"SCH {separated_match.group(1)}"
red_schedule = f"SCH {separated_match.group(2)}"
return {
"schedule": main_schedule, # 기본 스케줄 (호환성)
"main_schedule": main_schedule,
"red_schedule": red_schedule,
"has_different_schedules": main_schedule != red_schedule,
"confidence": 0.95,
"matched_pattern": separated_match.group(0),
"schedule_type": "SEPARATED"
}
# 2. 단일 스케줄 패턴 (기존 로직 사용)
basic_result = classify_fitting_schedule(description)
# 단일 스케줄을 main/red 모두에 적용
schedule = basic_result.get("schedule", "UNKNOWN")
return {
"schedule": schedule, # 기본 스케줄 (호환성)
"main_schedule": schedule,
"red_schedule": schedule if red_nom else None,
"has_different_schedules": False,
"confidence": basic_result.get("confidence", 0.0),
"matched_pattern": basic_result.get("matched_pattern", ""),
"schedule_type": "UNIFIED"
}

View File

@@ -181,15 +181,28 @@ def classify_flange(dat_file: str, description: str, main_nom: str,
desc_upper = description.upper()
dat_upper = dat_file.upper()
# 1. 명칭 우선 확인 (플랜지 키워드가 있으면 플랜지)
flange_keywords = ['FLG', 'FLANGE', '플랜지', 'ORIFICE', 'SPECTACLE', 'PADDLE', 'SPACER']
is_flange = any(keyword in desc_upper or keyword in dat_upper for keyword in flange_keywords)
# 1. 플랜지 키워드 확인 (재질만 있어도 통합 분류기가 이미 플랜지로 분류했으므로 진행)
# 사이트 글라스와 스트레이너는 밸브로 분류되어야 함
if 'SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or '사이트글라스' in desc_upper or '스트레이너' in desc_upper:
return {
"category": "VALVE",
"overall_confidence": 1.0,
"reason": "SIGHT GLASS 또는 STRAINER는 밸브로 분류"
}
if not is_flange:
flange_keywords = ['FLG', 'FLANGE', '플랜지', 'ORIFICE', 'SPECTACLE', 'PADDLE', 'SPACER']
has_flange_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in flange_keywords)
# 플랜지 재질 확인 (A182, A350, A105 - 범용이지만 플랜지에 많이 사용)
flange_materials = ['A182', 'A350', 'A105']
has_flange_material = any(material in desc_upper for material in flange_materials)
# 플랜지 키워드도 없고 플랜지 재질도 없으면 UNKNOWN
if not has_flange_keyword and not has_flange_material:
return {
"category": "UNKNOWN",
"overall_confidence": 0.0,
"reason": "플랜지 키워드 없음"
"reason": "플랜지 키워드 및 재질 없음"
}
# 2. 재질 분류 (공통 모듈 사용)

View File

@@ -0,0 +1,301 @@
"""
통합 자재 분류 시스템
메모리에 정의된 키워드 우선순위 체계를 적용
"""
import re
from typing import Dict, List, Optional, Tuple
from .fitting_classifier import classify_fitting
from .classifier_constants import (
LEVEL1_TYPE_KEYWORDS,
LEVEL2_SUBTYPE_KEYWORDS,
LEVEL3_CONNECTION_KEYWORDS,
LEVEL3_PRESSURE_KEYWORDS,
LEVEL4_MATERIAL_KEYWORDS,
GENERIC_MATERIALS
)
def classify_material_integrated(description: str, main_nom: str = "",
red_nom: str = "", length: float = None) -> Dict:
"""
통합 자재 분류 함수
Args:
description: 자재 설명
main_nom: 주 사이즈
red_nom: 축소 사이즈 (플랜지/피팅용)
length: 길이 (파이프용)
Returns:
분류 결과 딕셔너리
"""
desc_upper = description.upper()
# 최우선: SPECIAL 키워드 확인 (도면 업로드가 필요한 특수 자재)
# SPECIAL이 포함된 경우 (단, SPECIFICATION은 제외)
if 'SPECIAL' in desc_upper and 'SPECIFICATION' not in desc_upper:
return {
"category": "SPECIAL",
"confidence": 1.0,
"evidence": ["SPECIAL_KEYWORD"],
"classification_level": "LEVEL0_SPECIAL",
"reason": "SPECIAL 키워드 발견"
}
# 스페셜 관련 한글 키워드
if '스페셜' in desc_upper or 'SPL' in desc_upper:
return {
"category": "SPECIAL",
"confidence": 1.0,
"evidence": ["SPECIAL_KEYWORD"],
"classification_level": "LEVEL0_SPECIAL",
"reason": "스페셜 키워드 발견"
}
# VALVE 카테고리 우선 확인 (SIGHT GLASS, STRAINER)
if ('SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or
'사이트글라스' in desc_upper or '스트레이너' in desc_upper):
return {
"category": "VALVE",
"confidence": 1.0,
"evidence": ["VALVE_SPECIAL_KEYWORD"],
"classification_level": "LEVEL0_VALVE",
"reason": "SIGHT GLASS 또는 STRAINER 키워드 발견"
}
# SUPPORT 카테고리 우선 확인 (BOLT 카테고리보다 먼저)
# U-BOLT, CLAMP, URETHANE BLOCK 등
if ('U-BOLT' in desc_upper or 'U BOLT' in desc_upper or '유볼트' in desc_upper or
'URETHANE BLOCK' in desc_upper or 'BLOCK SHOE' in desc_upper or '우레탄' in desc_upper or
'CLAMP' in desc_upper or '클램프' in desc_upper or 'PIPE CLAMP' in desc_upper):
return {
"category": "SUPPORT",
"confidence": 1.0,
"evidence": ["SUPPORT_SYSTEM_KEYWORD"],
"classification_level": "LEVEL0_SUPPORT",
"reason": "SUPPORT 시스템 키워드 발견"
}
# [신규] Swagelok 스타일 파트 넘버 패턴 확인
# 예: SS-400-1-4, SS-810-6, B-400-9, SS-1610-P
swagelok_pattern = r'\b(SS|S|B|A|M)-([0-9]{3,4}|[0-9]+M[0-9]*)-([0-9A-Z])'
if re.search(swagelok_pattern, desc_upper):
return {
"category": "TUBE_FITTING",
"confidence": 0.98,
"evidence": ["SWAGELOK_PART_NO"],
"classification_level": "LEVEL0_PARTNO",
"reason": "Swagelok 스타일 파트넘버 감지"
}
# 쉼표로 구분된 각 부분을 별도로 체크 (예: "NIPPLE, SMLS, SCH 80")
desc_parts = [part.strip() for part in desc_upper.split(',')]
# 1단계: Level 1 키워드로 타입 식별
detected_types = []
# 특별 우선순위: REDUCING FLANGE 먼저 확인 (강화된 로직)
reducing_flange_patterns = [
"REDUCING FLANGE", "RED FLANGE", "REDUCER FLANGE",
"REDUCING FLG", "RED FLG", "REDUCER FLG"
]
# FLANGE와 REDUCING/RED/REDUCER가 함께 있는 경우도 확인
has_flange = any(flange_word in desc_upper for flange_word in ["FLANGE", "FLG"])
has_reducing = any(red_word in desc_upper for red_word in ["REDUCING", "RED", "REDUCER"])
# 직접 패턴 매칭 또는 FLANGE + REDUCING 조합
reducing_flange_detected = False
for pattern in reducing_flange_patterns:
if pattern in desc_upper:
detected_types.append(("FLANGE", "REDUCING FLANGE"))
reducing_flange_detected = True
break
# FLANGE와 REDUCING이 모두 있으면 REDUCING FLANGE로 분류
if not reducing_flange_detected and has_flange and has_reducing:
detected_types.append(("FLANGE", "REDUCING FLANGE"))
reducing_flange_detected = True
# REDUCING FLANGE가 감지되지 않은 경우에만 일반 키워드 검사
if not reducing_flange_detected:
for material_type, keywords in LEVEL1_TYPE_KEYWORDS.items():
type_found = False
# 긴 키워드부터 확인 (FLANGE BOLT가 FLANGE보다 먼저 매칭되도록)
sorted_keywords = sorted(keywords, key=len, reverse=True)
for keyword in sorted_keywords:
# [강화된 로직] 짧은 키워드나 중의적 키워드에 대한 엄격한 검사
is_strict_match = True
# 1. "PL" 키워드 검사 (PLATE)
if keyword == "PL":
# 단독 단어이거나 숫자 뒤에 붙는 경우만 허용 (예: 10PL, 10 PL)
# COUPLING, NIPPLE, PLUG 등에 포함된 PL은 제외
pl_pattern = r'(\b|\d)PL\b'
if not re.search(pl_pattern, desc_upper):
is_strict_match = False
# 2. "ANGLE" 키워드 검사 (STRUCTURAL)
elif keyword == "ANGLE" or keyword == "앵글":
# VALVE와 함께 쓰이면 제외 (ANGLE VALVE)
if "VALVE" in desc_upper or "밸브" in desc_upper:
is_strict_match = False
# 3. "UNION" 키워드 검사 (FITTING)
elif keyword == "UNION":
# 계장용인지 파이프용인지 구분은 fitting_classifier에서 하되,
# 여기서는 일단 FITTING으로 잡히도록 둠.
pass
# 4. "BEAM" 키워드 검사 (STRUCTURAL)
elif keyword == "BEAM":
# "BEAM CLAMP" 같은 경우 SUPPORT로 가야 함 (SUPPORT가 우선순위 높으므로 괜찮음)
pass
if not is_strict_match:
continue
# 전체 문자열에서 찾기
if keyword in desc_upper:
detected_types.append((material_type, keyword))
type_found = True
break
# 각 부분에서도 정확히 매칭되는지 확인
for part in desc_parts:
if keyword == part or keyword in part:
detected_types.append((material_type, keyword))
type_found = True
break
if type_found:
break
# 2단계: 복수 타입 감지 시 Level 2로 구체화
if len(detected_types) > 1:
# Level 2 키워드로 우선순위 결정
for material_type, subtype_dict in LEVEL2_SUBTYPE_KEYWORDS.items():
for subtype, keywords in subtype_dict.items():
for keyword in keywords:
if keyword in desc_upper:
return {
"category": material_type,
"confidence": 0.95,
"evidence": [f"L1_KEYWORD: {detected_types}", f"L2_KEYWORD: {keyword}"],
"classification_level": "LEVEL2"
}
# Level 2 키워드가 없으면 우선순위로 결정
# BOLT > SUPPORT > FITTING > VALVE > FLANGE > PIPE (볼트 우선, 더 구체적인 것 우선)
type_priority = ["BOLT", "SUPPORT", "FITTING", "VALVE", "FLANGE", "PIPE", "GASKET", "INSTRUMENT"]
for priority_type in type_priority:
for detected_type, keyword in detected_types:
if detected_type == priority_type:
return {
"category": priority_type,
"confidence": 0.85,
"evidence": [f"L1_MULTI_TYPE: {detected_types}", f"PRIORITY: {priority_type}"],
"classification_level": "LEVEL1_PRIORITY"
}
# 3단계: 단일 타입 확정 또는 Level 3/4로 판단
if len(detected_types) == 1:
material_type = detected_types[0][0]
# FITTING으로 분류된 경우 상세 분류기 호출
if material_type == "FITTING":
try:
detailed_result = classify_fitting("", description, main_nom, red_nom, length)
# 상세 분류 결과가 있으면 사용, 없으면 기본 FITTING 반환
if detailed_result and detailed_result.get("category"):
return detailed_result
except Exception as e:
# 상세 분류 실패 시 기본 FITTING으로 처리
pass
return {
"category": material_type,
"confidence": 0.9,
"evidence": [f"L1_KEYWORD: {detected_types[0][1]}"],
"classification_level": "LEVEL1"
}
# 4단계: Level 1 없으면 재질 기반 분류
if not detected_types:
# 전용 재질 확인
for material_type, materials in LEVEL4_MATERIAL_KEYWORDS.items():
for material in materials:
if material in desc_upper:
# 볼트 재질(A193, A194)은 다른 키워드가 있는지 확인
if material_type == "BOLT":
# 다른 타입 키워드가 있으면 볼트로 분류하지 않음
other_type_found = False
for other_type, keywords in LEVEL1_TYPE_KEYWORDS.items():
if other_type != "BOLT":
for keyword in keywords:
if keyword in desc_upper:
other_type_found = True
break
if other_type_found:
break
if other_type_found:
continue # 볼트로 분류하지 않음
# FITTING으로 분류된 경우 상세 분류기 호출
if material_type == "FITTING":
try:
detailed_result = classify_fitting("", description, main_nom, red_nom, length)
if detailed_result and detailed_result.get("category"):
return detailed_result
except Exception as e:
pass
return {
"category": material_type,
"confidence": 0.35, # 재질만으로 분류 시 낮은 신뢰도
"evidence": [f"L4_MATERIAL: {material}"],
"classification_level": "LEVEL4"
}
# 범용 재질 확인
for material, priority_types in GENERIC_MATERIALS.items():
if material in desc_upper:
# 우선순위에 따라 타입 결정
material_type = priority_types[0] # 첫 번째 우선순위
# FITTING으로 분류된 경우 상세 분류기 호출
if material_type == "FITTING":
try:
detailed_result = classify_fitting("", description, main_nom, red_nom, length)
if detailed_result and detailed_result.get("category"):
return detailed_result
except Exception as e:
pass
return {
"category": material_type,
"confidence": 0.3,
"evidence": [f"GENERIC_MATERIAL: {material}"],
"classification_level": "LEVEL4_GENERIC"
}
# 분류 실패
return {
"category": "UNCLASSIFIED",
"confidence": 0.0,
"evidence": ["NO_CLASSIFICATION_POSSIBLE"],
"classification_level": "NONE"
}
def should_exclude_material(description: str) -> bool:
"""
제외 대상 자재인지 확인
"""
exclude_keywords = [
"DUMMY", "RESERVED", "SPARE", "DELETED", "CANCELED",
"더미", "예비", "삭제", "취소", "예약"
]
desc_upper = description.upper()
return any(keyword in desc_upper for keyword in exclude_keywords)

View File

@@ -254,6 +254,10 @@ def check_generic_materials(description: str) -> Dict:
def determine_material_type(standard: str, grade: str) -> str:
"""규격과 등급으로 재질 타입 결정"""
# grade가 None이면 기본값 처리
if not grade:
grade = ""
# 스테인리스 등급
stainless_patterns = ["304", "316", "321", "347", "F304", "F316", "WP304", "CF8"]
if any(pattern in grade for pattern in stainless_patterns):

View File

@@ -0,0 +1,263 @@
"""
전체 재질명 추출기
원본 설명에서 완전한 재질명을 추출하여 축약되지 않은 형태로 제공
"""
import re
from typing import Optional, Dict
def extract_full_material_grade(description: str) -> str:
"""
원본 설명에서 전체 재질명 추출
Args:
description: 원본 자재 설명
Returns:
전체 재질명 (예: "ASTM A312 TP304", "ASTM A106 GR B")
"""
if not description:
return ""
desc_upper = description.upper().strip()
# 1. ASTM 규격 패턴들 (가장 구체적인 것부터)
astm_patterns = [
# A320 L7, A325, A490 등 단독 규격 (ASTM 없이)
r'\bA320\s+L[0-9]+\b', # A320 L7
r'\bA325\b', # A325
r'\bA490\b', # A490
# ASTM A193/A194 GR B7/2H (볼트용 조합 패턴) - 최우선
r'ASTM\s+A193/A194\s+GR\s+[A-Z0-9/]+',
r'ASTM\s+A193/A194\s+[A-Z0-9/]+',
# ASTM A320/A194M GR B8/8 (저온용 볼트 조합 패턴)
r'ASTM\s+A320[M]?/A194[M]?\s+GR\s+[A-Z0-9/]+',
r'ASTM\s+A320[M]?/A194[M]?\s+[A-Z0-9/]+',
# 단독 A193/A194 패턴 (ASTM 없이)
r'\bA193/A194\s+GR\s+[A-Z0-9/]+\b',
r'\bA193/A194\s+[A-Z0-9/]+\b',
# 단독 A320/A194M 패턴 (ASTM 없이)
r'\bA320[M]?/A194[M]?\s+GR\s+[A-Z0-9/]+\b',
r'\bA320[M]?/A194[M]?\s+[A-Z0-9/]+\b',
# ASTM A312 TP304, ASTM A312 TP316L 등
r'ASTM\s+A\d{3,4}[A-Z]*\s+TP\d+[A-Z]*',
# ASTM A182 F304, ASTM A182 F316L 등
r'ASTM\s+A\d{3,4}[A-Z]*\s+F\d+[A-Z]*',
# ASTM A403 WP304, ASTM A234 WPB 등
r'ASTM\s+A\d{3,4}[A-Z]*\s+WP[A-Z0-9]+',
# ASTM A351 CF8M, ASTM A216 WCB 등
r'ASTM\s+A\d{3,4}[A-Z]*\s+[A-Z]{2,4}[0-9]*[A-Z]*',
# ASTM A106 GR B, ASTM A105 등 - GR 포함
r'ASTM\s+A\d{3,4}[A-Z]*\s+GR\s+[A-Z0-9/]+',
r'ASTM\s+A\d{3,4}[A-Z]*\s+GRADE\s+[A-Z0-9/]+',
# ASTM A106 B (GR 없이 바로 등급) - 단일 문자 등급
r'ASTM\s+A\d{3,4}[A-Z]*\s+[A-Z](?=\s|$)',
# ASTM A105, ASTM A234 등 (등급 없는 경우)
r'ASTM\s+A\d{3,4}[A-Z]*(?!\s+[A-Z0-9])',
# 2자리 ASTM 규격도 지원 (A10, A36 등)
r'ASTM\s+A\d{2}(?:\s+GR\s+[A-Z0-9/]+)?',
]
for pattern in astm_patterns:
match = re.search(pattern, desc_upper)
if match:
full_grade = match.group(0).strip()
# 추가 정보가 있는지 확인 (PBE, BBE 등은 제외)
end_pos = match.end()
remaining = desc_upper[end_pos:].strip()
# 끝단 가공 정보는 제외
end_prep_codes = ['PBE', 'BBE', 'POE', 'BOE', 'TOE']
for code in end_prep_codes:
remaining = re.sub(rf'\b{code}\b', '', remaining).strip()
# 남은 재질 관련 정보가 있으면 추가
additional_info = []
if remaining:
# 일반적인 재질 추가 정보 패턴
additional_patterns = [
r'\bH\b', # H (고온용)
r'\bL\b', # L (저탄소)
r'\bN\b', # N (질소 첨가)
r'\bS\b', # S (황 첨가)
r'\bMOD\b', # MOD (개량형)
]
for add_pattern in additional_patterns:
if re.search(add_pattern, remaining):
additional_info.append(re.search(add_pattern, remaining).group(0))
if additional_info:
full_grade += ' ' + ' '.join(additional_info)
return full_grade
# 2. ASME 규격 패턴들
asme_patterns = [
r'ASME\s+SA\d+[A-Z]*\s+TP\d+[A-Z]*(?:\s+[A-Z]+)*',
r'ASME\s+SA\d+[A-Z]*\s+GR\s+[A-Z0-9]+(?:\s+[A-Z]+)*',
r'ASME\s+SA\d+[A-Z]*\s+F\d+[A-Z]*(?:\s+[A-Z]+)*',
r'ASME\s+SA\d+[A-Z]*(?!\s+[A-Z0-9])',
]
for pattern in asme_patterns:
match = re.search(pattern, desc_upper)
if match:
return match.group(0).strip()
# 3. KS 규격 패턴들
ks_patterns = [
r'KS\s+D\d+[A-Z]*\s+[A-Z0-9]+(?:\s+[A-Z]+)*',
r'KS\s+D\d+[A-Z]*(?!\s+[A-Z0-9])',
]
for pattern in ks_patterns:
match = re.search(pattern, desc_upper)
if match:
return match.group(0).strip()
# 4. JIS 규격 패턴들
jis_patterns = [
r'JIS\s+[A-Z]\d+[A-Z]*\s+[A-Z0-9]+(?:\s+[A-Z]+)*',
r'JIS\s+[A-Z]\d+[A-Z]*(?!\s+[A-Z0-9])',
]
for pattern in jis_patterns:
match = re.search(pattern, desc_upper)
if match:
return match.group(0).strip()
# 5. 특수 재질 패턴들
special_patterns = [
# Inconel, Hastelloy 등
r'INCONEL\s+\d+[A-Z]*',
r'HASTELLOY\s+[A-Z]\d*[A-Z]*',
r'MONEL\s+\d+[A-Z]*',
# Titanium
r'TITANIUM\s+GRADE\s+\d+[A-Z]*',
r'TI\s+GR\s*\d+[A-Z]*',
# 듀플렉스 스테인리스
r'DUPLEX\s+\d+[A-Z]*',
r'SUPER\s+DUPLEX\s+\d+[A-Z]*',
]
for pattern in special_patterns:
match = re.search(pattern, desc_upper)
if match:
return match.group(0).strip()
# 6. 일반 스테인리스 패턴들 (숫자만)
stainless_patterns = [
r'\b(304L?|316L?|321|347|310|317L?|904L?|254SMO)\b',
r'\bSS\s*(304L?|316L?|321|347|310|317L?|904L?|254SMO)\b',
r'\bSUS\s*(304L?|316L?|321|347|310|317L?|904L?|254SMO)\b',
]
for pattern in stainless_patterns:
match = re.search(pattern, desc_upper)
if match:
grade = match.group(1) if match.groups() else match.group(0)
if grade.startswith(('SS', 'SUS')):
return grade
else:
return f"SS{grade}"
# 7. 탄소강 패턴들
carbon_patterns = [
r'\bSM\d+[A-Z]*\b', # SM400, SM490 등
r'\bSS\d+[A-Z]*\b', # SS400, SS490 등 (스테인리스와 구분)
r'\bS\d+C\b', # S45C, S50C 등
]
for pattern in carbon_patterns:
match = re.search(pattern, desc_upper)
if match:
return match.group(0).strip()
# 8. 기존 material_grade가 있으면 그대로 반환
# (분류기에서 이미 처리된 경우)
return ""
def update_full_material_grades(db, batch_size: int = 1000) -> Dict:
"""
기존 자재들의 full_material_grade 업데이트
Args:
db: 데이터베이스 세션
batch_size: 배치 처리 크기
Returns:
업데이트 결과 통계
"""
from sqlalchemy import text
try:
# 전체 자재 수 조회
count_query = text("SELECT COUNT(*) FROM materials WHERE full_material_grade IS NULL OR full_material_grade = ''")
total_count = db.execute(count_query).scalar()
print(f"📊 업데이트 대상 자재: {total_count}")
updated_count = 0
processed_count = 0
# 배치 단위로 처리
offset = 0
while offset < total_count:
# 배치 조회
select_query = text("""
SELECT id, original_description, material_grade
FROM materials
WHERE full_material_grade IS NULL OR full_material_grade = ''
ORDER BY id
LIMIT :limit OFFSET :offset
""")
results = db.execute(select_query, {"limit": batch_size, "offset": offset}).fetchall()
if not results:
break
# 배치 업데이트
for material_id, original_description, current_grade in results:
full_grade = extract_full_material_grade(original_description)
# 전체 재질명이 추출되지 않으면 기존 grade 사용
if not full_grade and current_grade:
full_grade = current_grade
if full_grade:
update_query = text("""
UPDATE materials
SET full_material_grade = :full_grade
WHERE id = :material_id
""")
db.execute(update_query, {
"full_grade": full_grade,
"material_id": material_id
})
updated_count += 1
processed_count += 1
# 배치 커밋
db.commit()
offset += batch_size
print(f"📈 진행률: {processed_count}/{total_count} ({processed_count/total_count*100:.1f}%)")
return {
"total_processed": processed_count,
"updated_count": updated_count,
"success": True
}
except Exception as e:
db.rollback()
print(f"❌ 업데이트 실패: {str(e)}")
return {
"total_processed": 0,
"updated_count": 0,
"success": False,
"error": str(e)
}

View File

@@ -0,0 +1,592 @@
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import List, Dict, Optional
import json
from datetime import datetime
from app.services.integrated_classifier import classify_material_integrated, should_exclude_material
from app.services.bolt_classifier import classify_bolt
from app.services.flange_classifier import classify_flange
from app.services.fitting_classifier import classify_fitting
from app.services.gasket_classifier import classify_gasket
from app.services.instrument_classifier import classify_instrument
from app.services.valve_classifier import classify_valve
from app.services.support_classifier import classify_support
from app.services.plate_classifier import classify_plate
from app.services.structural_classifier import classify_structural
from app.services.pipe_classifier import classify_pipe_for_purchase, extract_end_preparation_info
from app.services.material_grade_extractor import extract_full_material_grade
class MaterialService:
"""자재 처리 및 저장을 담당하는 서비스"""
@staticmethod
def process_and_save_materials(
db: Session,
file_id: int,
materials_data: List[Dict],
revision_comparison: Optional[Dict] = None,
parent_file_id: Optional[int] = None,
purchased_materials_map: Optional[Dict] = None
) -> int:
"""
자재 목록을 분류하고 DB에 저장합니다.
Args:
db: DB 세션
file_id: 파일 ID
materials_data: 파싱된 자재 데이터 목록
revision_comparison: 리비전 비교 결과
parent_file_id: 이전 리비전 파일 ID
purchased_materials_map: 구매 확정된 자재 매핑 정보
Returns:
저장된 자재 수
"""
materials_inserted = 0
# 변경/신규 자재 키 집합 (리비전 추적용)
changed_materials_keys = set()
new_materials_keys = set()
# 리비전 업로드인 경우 변경사항 분석
if parent_file_id is not None:
MaterialService._analyze_changes(
db, parent_file_id, materials_data,
changed_materials_keys, new_materials_keys
)
# 변경 없는 자재 (확정된 자재) 먼저 처리
if revision_comparison and revision_comparison.get("has_previous_confirmation", False):
unchanged_materials = revision_comparison.get("unchanged_materials", [])
for material_data in unchanged_materials:
MaterialService._save_unchanged_material(db, file_id, material_data)
materials_inserted += 1
# 분류가 필요한 자재 처리 (신규 또는 변경된 자재)
# revision_comparison에서 필터링된 목록이 있으면 그것을 사용, 아니면 전체
materials_to_classify = materials_data
if revision_comparison and revision_comparison.get("materials_to_classify"):
materials_to_classify = revision_comparison.get("materials_to_classify")
print(f"🔧 자재 분류 및 저장 시작: {len(materials_to_classify)}")
for material_data in materials_to_classify:
MaterialService._classify_and_save_single_material(
db, file_id, material_data,
changed_materials_keys, new_materials_keys,
purchased_materials_map
)
materials_inserted += 1
return materials_inserted
@staticmethod
def _analyze_changes(db: Session, parent_file_id: int, materials_data: List[Dict],
changed_keys: set, new_keys: set):
"""이전 리비전과 비교하여 변경/신규 자재를 식별합니다."""
try:
prev_materials_query = text("""
SELECT original_description, size_spec, material_grade, main_nom,
drawing_name, line_no, quantity
FROM materials
WHERE file_id = :parent_file_id
""")
prev_materials = db.execute(prev_materials_query, {"parent_file_id": parent_file_id}).fetchall()
prev_dict = {}
for pm in prev_materials:
key = MaterialService._generate_material_key(
pm.drawing_name, pm.line_no, pm.original_description,
pm.size_spec, pm.material_grade
)
prev_dict[key] = float(pm.quantity) if pm.quantity else 0
for mat in materials_data:
new_key = MaterialService._generate_material_key(
mat.get("dwg_name"), mat.get("line_num"), mat["original_description"],
mat.get("size_spec"), mat.get("material_grade")
)
if new_key in prev_dict:
if abs(prev_dict[new_key] - float(mat.get("quantity", 0))) > 0.001:
changed_keys.add(new_key)
else:
new_keys.add(new_key)
except Exception as e:
print(f"❌ 변경사항 분석 실패: {e}")
@staticmethod
def _generate_material_key(dwg, line, desc, size, grade):
"""자재 고유 키 생성"""
parts = []
if dwg: parts.append(str(dwg))
elif line: parts.append(str(line))
parts.append(str(desc))
parts.append(str(size or ''))
parts.append(str(grade or ''))
return "|".join(parts)
@staticmethod
def _save_unchanged_material(db: Session, file_id: int, material_data: Dict):
"""변경 없는(확정된) 자재 저장"""
previous_item = material_data.get("previous_item", {})
query = text("""
INSERT INTO materials (
file_id, original_description, classified_category, confidence,
quantity, unit, size_spec, material_grade, specification,
reused_from_confirmation, created_at
) VALUES (
:file_id, :desc, :category, 1.0,
:qty, :unit, :size, :grade, :spec,
TRUE, :created_at
)
""")
db.execute(query, {
"file_id": file_id,
"desc": material_data["original_description"],
"category": previous_item.get("category", "UNCLASSIFIED"),
"qty": material_data["quantity"],
"unit": material_data.get("unit", "EA"),
"size": material_data.get("size_spec", ""),
"grade": previous_item.get("material", ""),
"spec": previous_item.get("specification", ""),
"created_at": datetime.now()
})
@staticmethod
def _classify_and_save_single_material(
db: Session, file_id: int, material_data: Dict,
changed_keys: set, new_keys: set, purchased_map: Optional[Dict]
):
"""단일 자재 분류 및 저장 (상세 정보 포함)"""
description = material_data["original_description"]
main_nom = material_data.get("main_nom", "")
red_nom = material_data.get("red_nom", "")
length_val = material_data.get("length")
# 1. 통합 분류
integrated_result = classify_material_integrated(description, main_nom, red_nom, length_val)
classification_result = integrated_result
# 2. 상세 분류
if not should_exclude_material(description):
category = integrated_result.get('category')
if category == "PIPE":
classification_result = classify_pipe_for_purchase("", description, main_nom, length_val)
elif category == "FITTING":
classification_result = classify_fitting("", description, main_nom, red_nom)
elif category == "FLANGE":
classification_result = classify_flange("", description, main_nom, red_nom)
elif category == "VALVE":
classification_result = classify_valve("", description, main_nom)
elif category == "BOLT":
classification_result = classify_bolt("", description, main_nom)
elif category == "GASKET":
classification_result = classify_gasket("", description, main_nom)
elif category == "INSTRUMENT":
classification_result = classify_instrument("", description, main_nom)
elif category == "SUPPORT":
classification_result = classify_support("", description, main_nom)
elif category == "PLATE":
classification_result = classify_plate("", description, main_nom)
elif category == "STRUCTURAL":
classification_result = classify_structural("", description, main_nom)
# 신뢰도 조정
if integrated_result.get('confidence', 0) < 0.5:
classification_result['overall_confidence'] = min(
classification_result.get('overall_confidence', 1.0),
integrated_result.get('confidence', 0.0) + 0.2
)
else:
classification_result = {"category": "EXCLUDE", "overall_confidence": 0.95}
# 3. 구매 확정 정보 상속 확인
is_purchase_confirmed = False
purchase_confirmed_at = None
purchase_confirmed_by = None
if purchased_map:
key = f"{description.strip().upper()}|{material_data.get('size_spec', '')}"
if key in purchased_map:
info = purchased_map[key]
is_purchase_confirmed = True
purchase_confirmed_at = info.get("purchase_confirmed_at")
purchase_confirmed_by = info.get("purchase_confirmed_by")
# 4. 자재 기본 정보 저장
full_grade = extract_full_material_grade(description) or material_data.get("material_grade", "")
insert_query = text("""
INSERT INTO materials (
file_id, original_description, quantity, unit, size_spec,
main_nom, red_nom, material_grade, full_material_grade, line_number, row_number,
classified_category, classification_confidence, is_verified,
drawing_name, line_no, created_at,
purchase_confirmed, purchase_confirmed_at, purchase_confirmed_by,
revision_status
) VALUES (
:file_id, :desc, :qty, :unit, :size,
:main, :red, :grade, :full_grade, :line_num, :row_num,
:category, :confidence, :verified,
:dwg, :line, :created_at,
:confirmed, :confirmed_at, :confirmed_by,
:status
) RETURNING id
""")
# 리비전 상태 결정
mat_key = MaterialService._generate_material_key(
material_data.get("dwg_name"), material_data.get("line_num"), description,
material_data.get("size_spec"), material_data.get("material_grade")
)
rev_status = 'changed' if mat_key in changed_keys else ('active' if mat_key in new_keys else None)
result = db.execute(insert_query, {
"file_id": file_id,
"desc": description,
"qty": material_data["quantity"],
"unit": material_data["unit"],
"size": material_data.get("size_spec", ""),
"main": main_nom,
"red": red_nom,
"grade": material_data.get("material_grade", ""),
"full_grade": full_grade,
"line_num": material_data.get("line_number"),
"row_num": material_data.get("row_number"),
"category": classification_result.get("category", "UNCLASSIFIED"),
"confidence": classification_result.get("overall_confidence", 0.0),
"verified": False,
"dwg": material_data.get("dwg_name"),
"line": material_data.get("line_num"),
"created_at": datetime.now(),
"confirmed": is_purchase_confirmed,
"confirmed_at": purchase_confirmed_at,
"confirmed_by": purchase_confirmed_by,
"status": rev_status
})
material_id = result.fetchone()[0]
# 5. 상세 정보 저장 (별도 메서드로 분리)
MaterialService._save_material_details(
db, material_id, file_id, classification_result, material_data
)
@staticmethod
def _save_material_details(db: Session, material_id: int, file_id: int,
result: Dict, data: Dict):
"""카테고리별 상세 정보 저장"""
category = result.get("category")
if category == "PIPE":
MaterialService._save_pipe_details(db, material_id, file_id, result, data)
elif category == "FITTING":
MaterialService._save_fitting_details(db, material_id, file_id, result, data)
elif category == "FLANGE":
MaterialService._save_flange_details(db, material_id, file_id, result, data)
elif category == "BOLT":
MaterialService._save_bolt_details(db, material_id, file_id, result, data)
elif category == "VALVE":
MaterialService._save_valve_details(db, material_id, file_id, result, data)
elif category == "GASKET":
MaterialService._save_gasket_details(db, material_id, file_id, result, data)
elif category == "SUPPORT":
MaterialService._save_support_details(db, material_id, file_id, result, data)
elif category == "PLATE":
MaterialService._save_plate_details(db, material_id, file_id, result, data)
elif category == "STRUCTURAL":
MaterialService._save_structural_details(db, material_id, file_id, result, data)
@staticmethod
def _save_plate_details(db: Session, mid: int, fid: int, res: Dict, data: Dict):
"""판재 정보 업데이트 (현재는 materials 테이블에 요약 정보 저장)"""
details = res.get("details", {})
spec = f"{details.get('thickness')}T x {details.get('dimensions')}"
db.execute(text("""
UPDATE materials
SET size_spec = :size, material_grade = :mat
WHERE id = :id
"""), {"size": spec, "mat": details.get("material"), "id": mid})
@staticmethod
def _save_structural_details(db: Session, mid: int, fid: int, res: Dict, data: Dict):
"""형강 정보 업데이트 (현재는 materials 테이블에 요약 정보 저장)"""
details = res.get("details", {})
spec = f"{details.get('type')} {details.get('dimension')}"
db.execute(text("""
UPDATE materials
SET size_spec = :size
WHERE id = :id
"""), {"size": spec, "id": mid})
# --- 각 카테고리별 상세 저장 메서드들 (기존 로직 이관) ---
@staticmethod
def inherit_purchase_requests(db: Session, current_file_id: int, parent_file_id: int):
"""이전 리비전의 구매신청 정보를 상속합니다."""
try:
print(f"🔄 구매신청 정보 상속 처리 시작...")
# 1. 이전 리비전에서 그룹별 구매신청 수량 집계
prev_purchase_summary = text("""
SELECT
m.original_description,
m.size_spec,
m.material_grade,
m.drawing_name,
COUNT(DISTINCT pri.material_id) as purchased_count,
SUM(pri.quantity) as total_purchased_qty,
MIN(pri.request_id) as request_id
FROM materials m
JOIN purchase_request_items pri ON m.id = pri.material_id
WHERE m.file_id = :parent_file_id
GROUP BY m.original_description, m.size_spec, m.material_grade, m.drawing_name
""")
prev_purchases = db.execute(prev_purchase_summary, {"parent_file_id": parent_file_id}).fetchall()
# 2. 새 리비전에서 같은 그룹의 자재에 수량만큼 구매신청 상속
for prev_purchase in prev_purchases:
purchased_count = prev_purchase.purchased_count
# 새 리비전에서 같은 그룹의 자재 조회 (순서대로)
new_group_materials = text("""
SELECT id, quantity
FROM materials
WHERE file_id = :file_id
AND original_description = :description
AND COALESCE(size_spec, '') = :size_spec
AND COALESCE(material_grade, '') = :material_grade
AND COALESCE(drawing_name, '') = :drawing_name
ORDER BY id
LIMIT :limit
""")
new_materials = db.execute(new_group_materials, {
"file_id": current_file_id,
"description": prev_purchase.original_description,
"size_spec": prev_purchase.size_spec or '',
"material_grade": prev_purchase.material_grade or '',
"drawing_name": prev_purchase.drawing_name or '',
"limit": purchased_count
}).fetchall()
# 구매신청 수량만큼만 상속
for new_mat in new_materials:
inherit_query = text("""
INSERT INTO purchase_request_items (
request_id, material_id, quantity, unit, user_requirement
) VALUES (
:request_id, :material_id, :quantity, 'EA', ''
)
ON CONFLICT DO NOTHING
""")
db.execute(inherit_query, {
"request_id": prev_purchase.request_id,
"material_id": new_mat.id,
"quantity": new_mat.quantity
})
inherited_count = len(new_materials)
if inherited_count > 0:
print(f"{prev_purchase.original_description[:30]}... (도면: {prev_purchase.drawing_name or 'N/A'}) → {inherited_count}/{purchased_count}개 상속")
# 커밋은 호출하는 쪽에서 일괄 처리하거나 여기서 처리
# db.commit()
print(f"✅ 구매신청 정보 상속 완료")
except Exception as e:
print(f"❌ 구매신청 정보 상속 실패: {str(e)}")
# 상속 실패는 전체 프로세스를 중단하지 않음
@staticmethod
def _save_pipe_details(db, mid, fid, res, data):
# PIPE 상세 저장 로직
end_prep_info = extract_end_preparation_info(data["original_description"])
# 1. End Prep 정보 저장
db.execute(text("""
INSERT INTO pipe_end_preparations (
material_id, file_id, end_preparation_type, end_preparation_code,
machining_required, cutting_note, original_description, confidence
) VALUES (
:mid, :fid, :type, :code, :req, :note, :desc, :conf
)
"""), {
"mid": mid, "fid": fid,
"type": end_prep_info["end_preparation_type"],
"code": end_prep_info["end_preparation_code"],
"req": end_prep_info["machining_required"],
"note": end_prep_info["cutting_note"],
"desc": end_prep_info["original_description"],
"conf": end_prep_info["confidence"]
})
# 2. Pipe Details 저장
length_info = res.get("length_info", {})
length_mm = length_info.get("length_mm") or data.get("length", 0.0)
mat_info = res.get("material", {})
sch_info = res.get("schedule", {})
# 재질 정보 업데이트
if mat_info.get("grade"):
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
{"g": mat_info.get("grade"), "id": mid})
db.execute(text("""
INSERT INTO pipe_details (
material_id, file_id, outer_diameter, schedule,
material_spec, manufacturing_method, length_mm
) VALUES (:mid, :fid, :od, :sch, :spec, :method, :len)
"""), {
"mid": mid, "fid": fid,
"od": data.get("main_nom") or data.get("size_spec"),
"sch": sch_info.get("schedule", "UNKNOWN") if isinstance(sch_info, dict) else str(sch_info),
"spec": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
"method": res.get("manufacturing", {}).get("method", "UNKNOWN") if isinstance(res.get("manufacturing"), dict) else "UNKNOWN",
"len": length_mm or 0.0
})
@staticmethod
def _save_fitting_details(db, mid, fid, res, data):
fit_type = res.get("fitting_type", {})
mat_info = res.get("material", {})
if mat_info.get("grade"):
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
{"g": mat_info.get("grade"), "id": mid})
db.execute(text("""
INSERT INTO fitting_details (
material_id, file_id, fitting_type, fitting_subtype,
connection_method, pressure_rating, material_grade,
main_size, reduced_size
) VALUES (:mid, :fid, :type, :subtype, :conn, :rating, :grade, :main, :red)
"""), {
"mid": mid, "fid": fid,
"type": fit_type.get("type", "UNKNOWN") if isinstance(fit_type, dict) else str(fit_type),
"subtype": fit_type.get("subtype", "UNKNOWN") if isinstance(fit_type, dict) else "UNKNOWN",
"conn": res.get("connection_method", {}).get("method", "UNKNOWN") if isinstance(res.get("connection_method"), dict) else "UNKNOWN",
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
"grade": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
"main": data.get("main_nom") or data.get("size_spec"),
"red": data.get("red_nom", "")
})
@staticmethod
def _save_flange_details(db, mid, fid, res, data):
flg_type = res.get("flange_type", {})
mat_info = res.get("material", {})
if mat_info.get("grade"):
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
{"g": mat_info.get("grade"), "id": mid})
db.execute(text("""
INSERT INTO flange_details (
material_id, file_id, flange_type, pressure_rating,
facing_type, material_grade, size_inches
) VALUES (:mid, :fid, :type, :rating, :face, :grade, :size)
"""), {
"mid": mid, "fid": fid,
"type": flg_type.get("type", "UNKNOWN") if isinstance(flg_type, dict) else str(flg_type),
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
"face": res.get("face_finish", {}).get("finish", "UNKNOWN") if isinstance(res.get("face_finish"), dict) else "UNKNOWN",
"grade": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
"size": data.get("main_nom") or data.get("size_spec")
})
@staticmethod
def _save_bolt_details(db, mid, fid, res, data):
fast_type = res.get("fastener_type", {})
mat_info = res.get("material", {})
dim_info = res.get("dimensions", {})
if mat_info.get("grade"):
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
{"g": mat_info.get("grade"), "id": mid})
# 볼트 타입 결정 (특수 용도 고려)
bolt_type = fast_type.get("type", "UNKNOWN") if isinstance(fast_type, dict) else str(fast_type)
special_apps = res.get("special_applications", {}).get("detected_applications", [])
if "LT" in special_apps: bolt_type = "LT_BOLT"
elif "PSV" in special_apps: bolt_type = "PSV_BOLT"
# 코팅 타입
desc_upper = data["original_description"].upper()
coating = "UNKNOWN"
if "GALV" in desc_upper: coating = "GALVANIZED"
elif "ZINC" in desc_upper: coating = "ZINC_PLATED"
db.execute(text("""
INSERT INTO bolt_details (
material_id, file_id, bolt_type, thread_type,
diameter, length, material_grade, coating_type
) VALUES (:mid, :fid, :type, :thread, :dia, :len, :grade, :coating)
"""), {
"mid": mid, "fid": fid,
"type": bolt_type,
"thread": res.get("thread_specification", {}).get("standard", "UNKNOWN") if isinstance(res.get("thread_specification"), dict) else "UNKNOWN",
"dia": dim_info.get("nominal_size", data.get("main_nom", "")),
"len": dim_info.get("length", ""),
"grade": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
"coating": coating
})
@staticmethod
def _save_valve_details(db, mid, fid, res, data):
val_type = res.get("valve_type", {})
mat_info = res.get("material", {})
if mat_info.get("grade"):
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
{"g": mat_info.get("grade"), "id": mid})
db.execute(text("""
INSERT INTO valve_details (
material_id, file_id, valve_type, connection_method,
pressure_rating, body_material, size_inches
) VALUES (:mid, :fid, :type, :conn, :rating, :body, :size)
"""), {
"mid": mid, "fid": fid,
"type": val_type.get("type", "UNKNOWN") if isinstance(val_type, dict) else str(val_type),
"conn": res.get("connection_method", {}).get("method", "UNKNOWN") if isinstance(res.get("connection_method"), dict) else "UNKNOWN",
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
"body": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
"size": data.get("main_nom") or data.get("size_spec")
})
@staticmethod
def _save_gasket_details(db, mid, fid, res, data):
gask_type = res.get("gasket_type", {})
db.execute(text("""
INSERT INTO gasket_details (
material_id, file_id, gasket_type, pressure_rating, size_inches
) VALUES (:mid, :fid, :type, :rating, :size)
"""), {
"mid": mid, "fid": fid,
"type": gask_type.get("type", "UNKNOWN") if isinstance(gask_type, dict) else str(gask_type),
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
"size": data.get("main_nom") or data.get("size_spec")
})
@staticmethod
def _save_support_details(db, mid, fid, res, data):
db.execute(text("""
INSERT INTO support_details (
material_id, file_id, support_type, pipe_size
) VALUES (:mid, :fid, :type, :size)
"""), {
"mid": mid, "fid": fid,
"type": res.get("support_type", "UNKNOWN"),
"size": res.get("size_info", {}).get("pipe_size", "")
})

View File

@@ -7,6 +7,60 @@ import re
from typing import Dict, List, Optional
from .material_classifier import classify_material, get_manufacturing_method_from_material
# ========== PIPE USER 요구사항 키워드 ==========
PIPE_USER_REQUIREMENTS = {
"IMPACT_TEST": {
"keywords": ["IMPACT", "충격시험", "CHARPY", "CVN", "IMPACT TEST", "충격", "NOTCH"],
"description": "충격시험 요구",
"confidence": 0.95
},
"ASME_CODE": {
"keywords": ["ASME", "ASME CODE", "CODE", "B31.1", "B31.3", "B31.4", "B31.8", "VIII"],
"description": "ASME 코드 준수",
"confidence": 0.95
},
"STRESS_RELIEF": {
"keywords": ["STRESS RELIEF", "SR", "응력제거", "열처리", "HEAT TREATMENT"],
"description": "응력제거 열처리",
"confidence": 0.90
},
"RADIOGRAPHIC_TEST": {
"keywords": ["RT", "RADIOGRAPHIC", "방사선시험", "X-RAY", "엑스레이"],
"description": "방사선 시험",
"confidence": 0.90
},
"ULTRASONIC_TEST": {
"keywords": ["UT", "ULTRASONIC", "초음파시험", "초음파"],
"description": "초음파 시험",
"confidence": 0.90
},
"MAGNETIC_PARTICLE": {
"keywords": ["MT", "MAGNETIC PARTICLE", "자분탐상", "자분"],
"description": "자분탐상 시험",
"confidence": 0.90
},
"LIQUID_PENETRANT": {
"keywords": ["PT", "LIQUID PENETRANT", "침투탐상", "침투"],
"description": "침투탐상 시험",
"confidence": 0.90
},
"HYDROSTATIC_TEST": {
"keywords": ["HYDROSTATIC", "수압시험", "PRESSURE TEST", "압력시험"],
"description": "수압 시험",
"confidence": 0.90
},
"LOW_TEMPERATURE": {
"keywords": ["LOW TEMP", "저온", "LTCS", "LOW TEMPERATURE", "CRYOGENIC"],
"description": "저온용",
"confidence": 0.85
},
"HIGH_TEMPERATURE": {
"keywords": ["HIGH TEMP", "고온", "HTCS", "HIGH TEMPERATURE"],
"description": "고온용",
"confidence": 0.85
}
}
# ========== PIPE 제조 방법별 분류 ==========
PIPE_MANUFACTURING = {
"SEAMLESS": {
@@ -29,13 +83,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 +99,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 +192,44 @@ PIPE_SCHEDULE = {
]
}
def extract_pipe_user_requirements(description: str) -> List[str]:
"""
파이프 설명에서 User 요구사항 추출
Args:
description: 파이프 설명
Returns:
발견된 요구사항 리스트
"""
desc_upper = description.upper()
found_requirements = []
for req_type, req_data in PIPE_USER_REQUIREMENTS.items():
for keyword in req_data["keywords"]:
if keyword in desc_upper:
found_requirements.append(req_data["description"])
break # 같은 타입에서 중복 방지
return found_requirements
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:
"""
@@ -98,14 +266,19 @@ def classify_pipe(dat_file: str, description: str, main_nom: str,
}
# 2. 파이프 키워드 확인
pipe_keywords = ['PIPE', 'TUBE', '파이프', '배관']
is_pipe = any(keyword in desc_upper for keyword in pipe_keywords)
pipe_keywords = ['PIPE', 'TUBE', '파이프', '배관', 'SMLS', 'SEAMLESS']
has_pipe_keyword = any(keyword in desc_upper for keyword in pipe_keywords)
if not is_pipe:
# 파이프 재질 확인 (A106, A333, A312, A53)
pipe_materials = ['A106', 'A333', 'A312', 'A53']
has_pipe_material = any(material in desc_upper for material in pipe_materials)
# 파이프 키워드도 없고 파이프 재질도 없으면 UNKNOWN
if not has_pipe_keyword and not has_pipe_material:
return {
"category": "UNKNOWN",
"overall_confidence": 0.0,
"reason": "파이프 키워드 없음"
"reason": "파이프 키워드 및 재질 없음"
}
# 3. 재질 분류 (공통 모듈 사용)
@@ -117,13 +290,16 @@ def classify_pipe(dat_file: str, description: str, main_nom: str,
# 3. 끝 가공 분류
end_prep_result = classify_pipe_end_preparation(description)
# 4. 스케줄 분류
schedule_result = classify_pipe_schedule(description)
# 4. 스케줄 분류 (재질 정보 전달)
schedule_result = classify_pipe_schedule(description, material_result)
# 5. 길이(절단 치수) 처리
length_info = extract_pipe_length_info(length, description)
# 6. 최종 결과 조합
# 6. User 요구사항 추출
user_requirements = extract_pipe_user_requirements(description)
# 7. 최종 결과 조합
return {
"category": "PIPE",
@@ -162,6 +338,9 @@ def classify_pipe(dat_file: str, description: str, main_nom: str,
"length_mm": length_info.get('length_mm')
},
# User 요구사항
"user_requirements": user_requirements,
# 전체 신뢰도
"overall_confidence": calculate_pipe_confidence({
"material": material_result.get('confidence', 0),
@@ -230,19 +409,43 @@ def classify_pipe_end_preparation(description: str) -> Dict:
"matched_code": "DEFAULT"
}
def classify_pipe_schedule(description: str) -> Dict:
"""파이프 스케줄 분류"""
def classify_pipe_schedule(description: str, material_result: Dict = None) -> Dict:
"""파이프 스케줄 분류 - 재질별 표현 개선"""
desc_upper = description.upper()
# 재질 정보 확인
material_type = "CARBON" # 기본값
if material_result:
material_grade = material_result.get('grade', '').upper()
material_standard = material_result.get('standard', '').upper()
# 스테인리스 스틸 판단
if any(sus_indicator in material_grade or sus_indicator in material_standard
for sus_indicator in ['SUS', 'SS', 'A312', 'A358', 'A376', '304', '316', '321', '347']):
material_type = "STAINLESS"
# 1. 스케줄 패턴 확인
for pattern in PIPE_SCHEDULE["patterns"]:
match = re.search(pattern, desc_upper)
if match:
schedule_num = match.group(1)
# 재질별 스케줄 표현
if material_type == "STAINLESS":
# 스테인리스 스틸: SCH 40S, SCH 80S
if schedule_num in ["10", "20", "40", "80", "120", "160"]:
schedule_display = f"SCH {schedule_num}S"
else:
schedule_display = f"SCH {schedule_num}"
else:
# 카본 스틸: SCH 40, SCH 80
schedule_display = f"SCH {schedule_num}"
return {
"schedule": f"SCH {schedule_num}",
"schedule": schedule_display,
"schedule_number": schedule_num,
"material_type": material_type,
"confidence": 0.95,
"matched_pattern": pattern
}
@@ -255,6 +458,7 @@ def classify_pipe_schedule(description: str) -> Dict:
return {
"schedule": f"{thickness}mm THK",
"wall_thickness": f"{thickness}mm",
"material_type": material_type,
"confidence": 0.9,
"matched_pattern": pattern
}
@@ -262,6 +466,7 @@ def classify_pipe_schedule(description: str) -> Dict:
# 3. 기본값
return {
"schedule": "UNKNOWN",
"material_type": material_type,
"confidence": 0.0
}

View File

@@ -0,0 +1,50 @@
import re
from typing import Dict, Optional
def classify_plate(tag: str, description: str, main_nom: str = "") -> Dict:
"""
판재(PLATE) 분류기
규격 예: PLATE 10T x 1219 x 2438
"""
desc_upper = description.upper()
# 1. 두께(Thickness) 추출
# 패턴: 10T, 10.5T, THK 10, THK. 10, t=10
thickness = None
t_match = re.search(r'(\d+(?:\.\d+)?)\s*T\b', desc_upper)
if not t_match:
t_match = re.search(r'(?:THK\.?|t=)\s*(\d+(?:\.\d+)?)', desc_upper, re.IGNORECASE)
if t_match:
thickness = t_match.group(1)
# 2. 규격(Dimensions) 추출
# 패턴: 1219x2438, 4'x8', 1000*2000
dimensions = ""
dim_match = re.search(r'(\d+(?:\.\d+)?)\s*[X\*]\s*(\d+(?:\.\d+)?)(?:\s*[X\*]\s*(\d+(?:\.\d+)?))?', desc_upper)
if dim_match:
groups = [g for g in dim_match.groups() if g]
dimensions = " x ".join(groups)
# 3. 재질 추출
material = "UNKNOWN"
# 압력용기용 및 일반 구조용 강판 재질 추가
plate_materials = [
"SUS304", "SUS316", "SUS321", "SS400", "A36", "SM490",
"SA516", "A516", "SA283", "A283", "SA537", "A537", "POS-M"
]
for mat in plate_materials:
if mat in desc_upper:
material = mat
break
return {
"category": "PLATE",
"overall_confidence": 0.9,
"details": {
"thickness": thickness,
"dimensions": dimensions,
"material": material
}
}

View File

@@ -10,26 +10,26 @@ from typing import Dict, List, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import text
# 자재별 기본 여유율
# 자재별 기본 여유율 (올바른 규칙으로 수정)
SAFETY_FACTORS = {
'PIPE': 1.15, # 15% 추가 (절단 손실)
'FITTING': 1.10, # 10% 추가 (연결 오차)
'VALVE': 1.50, # 50% 추가 (예비품)
'FLANGE': 1.10, # 10% 추가
'BOLT': 1.20, # 20% 추가 (분실율)
'GASKET': 1.25, # 25% 추가 (교체주기)
'INSTRUMENT': 1.00, # 0% 추가 (정확한 수량)
'DEFAULT': 1.10 # 기본 10% 추가
'PIPE': 1.00, # 0% 추가 (절단 손실은 별도 계산)
'FITTING': 1.00, # 0% 추가 (BOM 수량 그대로)
'VALVE': 1.00, # 0% 추가 (BOM 수량 그대로)
'FLANGE': 1.00, # 0% 추가 (BOM 수량 그대로)
'BOLT': 1.05, # 5% 추가 (분실율)
'GASKET': 1.00, # 0% 추가 (5의 배수 올림으로 처리)
'INSTRUMENT': 1.00, # 0% 추가 (BOM 수량 그대로)
'DEFAULT': 1.00 # 기본 0% 추가
}
# 최소 주문 수량 (자재별)
# 최소 주문 수량 (자재별) - 올바른 규칙으로 수정
MINIMUM_ORDER_QTY = {
'PIPE': 6000, # 6M 단위
'FITTING': 1, # 개별 주문 가능
'VALVE': 1, # 개별 주문 가능
'FLANGE': 1, # 개별 주문 가능
'BOLT': 50, # 박스 단위 (50개)
'GASKET': 10, # 세트 단위
'BOLT': 4, # 4의 배수 단위
'GASKET': 5, # 5의 배수 단위
'INSTRUMENT': 1, # 개별 주문 가능
'DEFAULT': 1
}
@@ -37,7 +37,7 @@ MINIMUM_ORDER_QTY = {
def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict:
"""
PIPE 구매 수량 계산
- 각 절단마다 3mm 손실
- 각 절단마다 2mm 손실 (올바른 규칙)
- 6,000mm (6M) 단위로 올림
"""
total_bom_length = 0
@@ -45,19 +45,23 @@ def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict:
pipe_details = []
for material in materials:
# 길이 정보 추출
# 길이 정보 추출 (Decimal 타입 처리)
length_mm = float(material.get('length_mm', 0) or 0)
quantity = float(material.get('quantity', 1) or 1)
if length_mm > 0:
total_bom_length += length_mm
cutting_count += 1
total_length = length_mm * quantity # 총 길이 = 단위길이 × 수량
total_bom_length += total_length
cutting_count += quantity # 절단 횟수 = 수량
pipe_details.append({
'description': material.get('original_description', ''),
'length_mm': length_mm,
'quantity': material.get('quantity', 1)
'quantity': quantity,
'total_length': total_length
})
# 절단 손실 계산 (각 절단마다 3mm)
cutting_loss = cutting_count * 3
# 절단 손실 계산 (각 절단마다 2mm - 올바른 규칙)
cutting_loss = cutting_count * 2
# 총 필요 길이 = BOM 길이 + 절단 손실
required_length = total_bom_length + cutting_loss
@@ -92,7 +96,8 @@ def calculate_standard_purchase_quantity(category: str, bom_quantity: float,
if safety_factor is None:
safety_factor = SAFETY_FACTORS.get(category, SAFETY_FACTORS['DEFAULT'])
# 1단계: 여유율 적용
# 1단계: 여유율 적용 (Decimal 타입 처리)
bom_quantity = float(bom_quantity) if bom_quantity else 0.0
safety_qty = bom_quantity * safety_factor
# 2단계: 최소 주문 수량 확인
@@ -101,9 +106,13 @@ def calculate_standard_purchase_quantity(category: str, bom_quantity: float,
# 3단계: 최소 주문 수량과 비교하여 큰 값 선택
calculated_qty = max(safety_qty, min_order_qty)
# 4단계: 특별 처리 (BOLT는 박스 단위로 올림)
if category == 'BOLT' and calculated_qty > min_order_qty:
calculated_qty = math.ceil(calculated_qty / min_order_qty) * min_order_qty
# 4단계: 특별 처리 (올바른 규칙 적용)
if category == 'BOLT':
# BOLT: 5% 여유율 후 4의 배수로 올림
calculated_qty = math.ceil(safety_qty / min_order_qty) * min_order_qty
elif category == 'GASKET':
# GASKET: 5의 배수로 올림 (여유율 없음)
calculated_qty = math.ceil(bom_quantity / min_order_qty) * min_order_qty
return {
'bom_quantity': bom_quantity,
@@ -120,16 +129,19 @@ def generate_purchase_items_from_materials(db: Session, file_id: int,
"""
자재 데이터로부터 구매 품목 생성
"""
# 1. 파일의 모든 자재 조회
# 1. 파일의 모든 자재 조회 (상세 테이블 정보 포함)
materials_query = text("""
SELECT m.*,
pd.length_mm, pd.outer_diameter, pd.schedule, pd.material_spec as pipe_material_spec,
fd.fitting_type, fd.connection_method as fitting_connection,
fd.fitting_type, fd.connection_method as fitting_connection, fd.main_size as fitting_main_size,
fd.reduced_size as fitting_reduced_size, fd.material_grade as fitting_material_grade,
vd.valve_type, vd.connection_method as valve_connection, vd.pressure_rating as valve_pressure,
fl.flange_type, fl.pressure_rating as flange_pressure,
gd.gasket_type, gd.material_type as gasket_material,
bd.bolt_type, bd.material_standard, bd.diameter,
id.instrument_type
vd.size_inches as valve_size,
fl.flange_type, fl.pressure_rating as flange_pressure, fl.size_inches as flange_size,
gd.gasket_type, gd.gasket_subtype, gd.material_type as gasket_material, gd.filler_material,
gd.size_inches as gasket_size, gd.pressure_rating as gasket_pressure, gd.thickness as gasket_thickness,
bd.bolt_type, bd.material_standard, bd.diameter as bolt_diameter, bd.length as bolt_length,
id.instrument_type, id.connection_size as instrument_size
FROM materials m
LEFT JOIN pipe_details pd ON m.id = pd.material_id
LEFT JOIN fitting_details fd ON m.id = fd.material_id
@@ -144,13 +156,65 @@ def generate_purchase_items_from_materials(db: Session, file_id: int,
materials = db.execute(materials_query, {"file_id": file_id}).fetchall()
# 2. 카테고리별로 그룹핑
grouped_materials = {}
for material in materials:
category = material.classified_category or 'OTHER'
if category not in grouped_materials:
grouped_materials[category] = []
grouped_materials[category].append(dict(material))
# Row 객체를 딕셔너리로 안전하게 변환
material_dict = {
'id': material.id,
'file_id': material.file_id,
'original_description': material.original_description,
'quantity': material.quantity,
'unit': material.unit,
'size_spec': material.size_spec,
'material_grade': material.material_grade,
'classified_category': material.classified_category,
'line_number': material.line_number,
# PIPE 상세 정보
'length_mm': getattr(material, 'length_mm', None),
'outer_diameter': getattr(material, 'outer_diameter', None),
'schedule': getattr(material, 'schedule', None),
'pipe_material_spec': getattr(material, 'pipe_material_spec', None),
# FITTING 상세 정보
'fitting_type': getattr(material, 'fitting_type', None),
'fitting_connection': getattr(material, 'fitting_connection', None),
'fitting_main_size': getattr(material, 'fitting_main_size', None),
'fitting_reduced_size': getattr(material, 'fitting_reduced_size', None),
'fitting_material_grade': getattr(material, 'fitting_material_grade', None),
# VALVE 상세 정보
'valve_type': getattr(material, 'valve_type', None),
'valve_connection': getattr(material, 'valve_connection', None),
'valve_pressure': getattr(material, 'valve_pressure', None),
'valve_size': getattr(material, 'valve_size', None),
# FLANGE 상세 정보
'flange_type': getattr(material, 'flange_type', None),
'flange_pressure': getattr(material, 'flange_pressure', None),
'flange_size': getattr(material, 'flange_size', None),
# GASKET 상세 정보
'gasket_type': getattr(material, 'gasket_type', None),
'gasket_subtype': getattr(material, 'gasket_subtype', None),
'gasket_material': getattr(material, 'gasket_material', None),
'filler_material': getattr(material, 'filler_material', None),
'gasket_size': getattr(material, 'gasket_size', None),
'gasket_pressure': getattr(material, 'gasket_pressure', None),
'gasket_thickness': getattr(material, 'gasket_thickness', None),
# BOLT 상세 정보
'bolt_type': getattr(material, 'bolt_type', None),
'material_standard': getattr(material, 'material_standard', None),
'bolt_diameter': getattr(material, 'bolt_diameter', None),
'bolt_length': getattr(material, 'bolt_length', None),
# INSTRUMENT 상세 정보
'instrument_type': getattr(material, 'instrument_type', None),
'instrument_size': getattr(material, 'instrument_size', None)
}
grouped_materials[category].append(material_dict)
# 3. 각 카테고리별로 구매 품목 생성
purchase_items = []
@@ -224,6 +288,9 @@ def generate_purchase_items_from_materials(db: Session, file_id: int,
'specification': spec_data.get('full_spec', spec_key),
'material_spec': spec_data.get('material_spec', ''),
'size_spec': spec_data.get('size_display', ''),
'size_fraction': spec_data.get('size_fraction', ''),
'surface_treatment': spec_data.get('surface_treatment', ''),
'special_applications': spec_data.get('special_applications', {}),
'unit': spec_data.get('unit', 'EA'),
**calc_result,
'job_no': job_no,
@@ -246,13 +313,41 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) -
if category == 'FITTING':
fitting_type = material.get('fitting_type', 'FITTING')
connection_method = material.get('fitting_connection', '')
material_spec = material.get('material_grade', '')
main_nom = material.get('main_nom', '')
red_nom = material.get('red_nom', '')
size_display = f"{main_nom} x {red_nom}" if red_nom else main_nom
# 상세 테이블의 재질 정보 우선 사용
material_spec = material.get('fitting_material_grade') or material.get('material_grade', '')
# 상세 테이블의 사이즈 정보 사용
main_size = material.get('fitting_main_size', '')
reduced_size = material.get('fitting_reduced_size', '')
# 사이즈 표시 생성 (축소형인 경우 main x reduced 형태)
if main_size and reduced_size and main_size != reduced_size:
size_display = f"{main_size} x {reduced_size}"
else:
size_display = main_size or material.get('size_spec', '')
# 기존 분류기 방식: 피팅 타입 + 연결방식 + 압력등급
# 예: "ELBOW, SOCKET WELD, 3000LB"
fitting_display = fitting_type.replace('_', ' ') if fitting_type else 'FITTING'
spec_parts = [fitting_display]
# 연결방식 추가
if connection_method and connection_method != 'UNKNOWN':
connection_display = connection_method.replace('_', ' ')
spec_parts.append(connection_display)
# 압력등급 추출 (description에서)
description = material.get('original_description', '').upper()
import re
pressure_match = re.search(r'(\d+)LB', description)
if pressure_match:
spec_parts.append(f"{pressure_match.group(1)}LB")
# 스케줄 정보 추출 (니플 등에 중요)
schedule_match = re.search(r'SCH\s*(\d+)', description)
if schedule_match:
spec_parts.append(f"SCH {schedule_match.group(1)}")
spec_parts = [fitting_type]
if connection_method: spec_parts.append(connection_method)
full_spec = ', '.join(spec_parts)
spec_key = f"FITTING|{full_spec}|{material_spec}|{size_display}"
@@ -269,19 +364,20 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) -
connection_method = material.get('valve_connection', '')
pressure_rating = material.get('valve_pressure', '')
material_spec = material.get('material_grade', '')
main_nom = material.get('main_nom', '')
# 상세 테이블의 사이즈 정보 우선 사용
size_display = material.get('valve_size') or material.get('size_spec', '')
spec_parts = [valve_type.replace('_', ' ')]
if connection_method: spec_parts.append(connection_method.replace('_', ' '))
if pressure_rating: spec_parts.append(pressure_rating)
full_spec = ', '.join(spec_parts)
spec_key = f"VALVE|{full_spec}|{material_spec}|{main_nom}"
spec_key = f"VALVE|{full_spec}|{material_spec}|{size_display}"
spec_data = {
'category': 'VALVE',
'category': 'VALVE',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': main_nom,
'size_display': size_display,
'unit': 'EA'
}
@@ -289,72 +385,198 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) -
flange_type = material.get('flange_type', 'FLANGE')
pressure_rating = material.get('flange_pressure', '')
material_spec = material.get('material_grade', '')
main_nom = material.get('main_nom', '')
# 상세 테이블의 사이즈 정보 우선 사용
size_display = material.get('flange_size') or material.get('size_spec', '')
spec_parts = [flange_type]
spec_parts = [flange_type.replace('_', ' ')]
if pressure_rating: spec_parts.append(pressure_rating)
full_spec = ', '.join(spec_parts)
spec_key = f"FLANGE|{full_spec}|{material_spec}|{main_nom}"
spec_key = f"FLANGE|{full_spec}|{material_spec}|{size_display}"
spec_data = {
'category': 'FLANGE',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': main_nom,
'size_display': size_display,
'unit': 'EA'
}
elif category == 'BOLT':
bolt_type = material.get('bolt_type', 'BOLT')
material_standard = material.get('material_standard', '')
diameter = material.get('diameter', material.get('main_nom', ''))
# 상세 테이블의 사이즈 정보 우선 사용
diameter = material.get('bolt_diameter') or material.get('size_spec', '')
length = material.get('bolt_length', '')
material_spec = material_standard or material.get('material_grade', '')
# 기존 분류기 방식에 따른 사이즈 표시 (분수 형태)
# 소수점을 분수로 변환 (예: 0.625 -> 5/8)
size_display = diameter
if diameter and '.' in diameter:
try:
decimal_val = float(diameter)
# 일반적인 볼트 사이즈 분수 변환
fraction_map = {
0.25: "1/4\"", 0.3125: "5/16\"", 0.375: "3/8\"",
0.4375: "7/16\"", 0.5: "1/2\"", 0.5625: "9/16\"",
0.625: "5/8\"", 0.6875: "11/16\"", 0.75: "3/4\"",
0.8125: "13/16\"", 0.875: "7/8\"", 1.0: "1\""
}
if decimal_val in fraction_map:
size_display = fraction_map[decimal_val]
except:
pass
# 길이 정보 포함한 사이즈 표시 (예: 5/8" x 165L)
if length:
# 길이에서 숫자만 추출
import re
length_match = re.search(r'(\d+(?:\.\d+)?)', str(length))
if length_match:
length_num = length_match.group(1)
size_display_with_length = f"{size_display} x {length_num}L"
else:
size_display_with_length = f"{size_display} x {length}"
else:
size_display_with_length = size_display
spec_parts = [bolt_type.replace('_', ' ')]
if material_standard: spec_parts.append(material_standard)
full_spec = ', '.join(spec_parts)
spec_key = f"BOLT|{full_spec}|{material_spec}|{diameter}"
# 사이즈+길이로 그룹핑
spec_key = f"BOLT|{full_spec}|{material_spec}|{size_display_with_length}"
spec_data = {
'category': 'BOLT',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': diameter,
'size_display': size_display_with_length,
'unit': 'EA'
}
elif category == 'GASKET':
# 상세 테이블 정보 우선 사용
gasket_type = material.get('gasket_type', 'GASKET')
gasket_subtype = material.get('gasket_subtype', '')
gasket_material = material.get('gasket_material', '')
material_spec = gasket_material or material.get('material_grade', '')
main_nom = material.get('main_nom', '')
filler_material = material.get('filler_material', '')
gasket_pressure = material.get('gasket_pressure', '')
gasket_thickness = material.get('gasket_thickness', '')
# 상세 테이블의 사이즈 정보 우선 사용
size_display = material.get('gasket_size') or material.get('size_spec', '')
# 기존 분류기 방식: 가스켓 타입 + 압력등급 + 재질
# 예: "SPIRAL WOUND, 150LB, 304SS + GRAPHITE"
spec_parts = [gasket_type.replace('_', ' ')]
# 서브타입 추가 (있는 경우)
if gasket_subtype and gasket_subtype != gasket_type:
spec_parts.append(gasket_subtype.replace('_', ' '))
# 상세 테이블의 압력등급 우선 사용, 없으면 description에서 추출
if gasket_pressure:
spec_parts.append(gasket_pressure)
else:
description = material.get('original_description', '').upper()
import re
pressure_match = re.search(r'(\d+)LB', description)
if pressure_match:
spec_parts.append(f"{pressure_match.group(1)}LB")
# 재질 정보 구성 (상세 테이블 정보 활용)
material_spec_parts = []
# SWG의 경우 메탈 + 필러 형태로 구성
if gasket_type == 'SPIRAL_WOUND':
# 기존 저장된 데이터가 부정확한 경우, 원본 description에서 직접 파싱
description = material.get('original_description', '').upper()
# SS304/GRAPHITE/CS/CS 패턴 파싱 (H/F/I/O 다음에 오는 재질 정보)
import re
material_spec = None
# H/F/I/O SS304/GRAPHITE/CS/CS 패턴 (전체 구성 표시)
hfio_material_match = re.search(r'H/F/I/O\s+([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)', description)
if hfio_material_match:
part1 = hfio_material_match.group(1) # SS304
part2 = hfio_material_match.group(2) # GRAPHITE
part3 = hfio_material_match.group(3) # CS
part4 = hfio_material_match.group(4) # CS
material_spec = f"{part1}/{part2}/{part3}/{part4}"
else:
# 단순 SS304/GRAPHITE/CS/CS 패턴 (전체 구성 표시)
simple_material_match = re.search(r'([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)', description)
if simple_material_match:
part1 = simple_material_match.group(1) # SS304
part2 = simple_material_match.group(2) # GRAPHITE
part3 = simple_material_match.group(3) # CS
part4 = simple_material_match.group(4) # CS
material_spec = f"{part1}/{part2}/{part3}/{part4}"
if not material_spec:
# 상세 테이블 정보 사용
if gasket_material and gasket_material != 'GRAPHITE': # 메탈 부분
material_spec_parts.append(gasket_material)
elif gasket_material == 'GRAPHITE':
# GRAPHITE만 있는 경우 description에서 메탈 부분 찾기
metal_match = re.search(r'(SS\d+|CS|INCONEL\d*)', description)
if metal_match:
material_spec_parts.append(metal_match.group(1))
if filler_material and filler_material != gasket_material: # 필러 부분
material_spec_parts.append(filler_material)
elif 'GRAPHITE' in description and 'GRAPHITE' not in material_spec_parts:
material_spec_parts.append('GRAPHITE')
if material_spec_parts:
material_spec = ' + '.join(material_spec_parts) # SS304 + GRAPHITE
else:
material_spec = material.get('material_grade', '')
else:
# 일반 가스켓의 경우
if gasket_material:
material_spec_parts.append(gasket_material)
if filler_material and filler_material != gasket_material:
material_spec_parts.append(filler_material)
if material_spec_parts:
material_spec = ', '.join(material_spec_parts)
else:
material_spec = material.get('material_grade', '')
if material_spec:
spec_parts.append(material_spec)
# 두께 정보 추가 (있는 경우)
if gasket_thickness:
spec_parts.append(f"THK {gasket_thickness}")
spec_parts = [gasket_type]
if gasket_material: spec_parts.append(gasket_material)
full_spec = ', '.join(spec_parts)
spec_key = f"GASKET|{full_spec}|{material_spec}|{main_nom}"
spec_key = f"GASKET|{full_spec}|{material_spec}|{size_display}"
spec_data = {
'category': 'GASKET',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': main_nom,
'size_display': size_display,
'unit': 'EA'
}
elif category == 'INSTRUMENT':
instrument_type = material.get('instrument_type', 'INSTRUMENT')
material_spec = material.get('material_grade', '')
main_nom = material.get('main_nom', '')
# 상세 테이블의 사이즈 정보 우선 사용
size_display = material.get('instrument_size') or material.get('size_spec', '')
full_spec = instrument_type.replace('_', ' ')
spec_key = f"INSTRUMENT|{full_spec}|{material_spec}|{main_nom}"
spec_key = f"INSTRUMENT|{full_spec}|{material_spec}|{size_display}"
spec_data = {
'category': 'INSTRUMENT',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': main_nom,
'size_display': size_display,
'unit': 'EA'
}
@@ -378,12 +600,18 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) -
**spec_data,
'totalQuantity': 0,
'count': 0,
'items': []
'items': [],
'special_applications': {'PSV': 0, 'LT': 0, 'CK': 0} if category == 'BOLT' else None
}
specs[spec_key]['totalQuantity'] += material.get('quantity', 0)
specs[spec_key]['count'] += 1
specs[spec_key]['items'].append(material)
# 볼트의 경우 특수 용도 정보 누적
if category == 'BOLT' and 'special_applications' in locals():
for app_type, count in special_applications.items():
specs[spec_key]['special_applications'][app_type] += count
return specs

View File

@@ -0,0 +1,417 @@
"""
리비전 비교 서비스
- 기존 확정 자재와 신규 자재 비교
- 변경된 자재만 분류 처리
- 리비전 업로드 최적화
"""
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:
"""
기존 확정 자재와 신규 자재 비교
"""
try:
from rapidfuzz import fuzz
# 이전 확정 자재 해시맵 생성
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
# 해시 역참조 맵 (유사도 비교용)
# 해시 -> 정규화된 설명 문자열 (비교 대상)
# 여기서는 specification 자체를 비교 대상으로 사용 (가장 정보량이 많음)
confirmed_specs = {
h: item["specification"] for h, item in confirmed_materials.items()
}
# 신규 자재 분석
unchanged_materials = []
changed_materials = []
new_materials_list = []
for new_material in new_materials:
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:
# 해시 불일치 - 유사도 검사 (Fuzzy Matching)
# 신규 자재 설명과 기존 확정 자재들의 스펙 비교
best_match_hash = None
best_match_score = 0
# 성능을 위해 간단한 필터링 후 정밀 비교 권장되나,
# 현재는 전체 비교 (데이터량이 많지 않다고 가정)
for h, spec in confirmed_specs.items():
score = fuzz.ratio(description.lower(), spec.lower())
if score > 85: # 85점 이상이면 매우 유사
if score > best_match_score:
best_match_score = score
best_match_hash = h
if best_match_hash:
# 유사한 자재 발견 (오타 또는 미세 변경 가능성)
similar_item = confirmed_materials[best_match_hash]
new_materials_list.append({
**new_material,
"change_type": "NEW_BUT_SIMILAR",
"similarity_score": best_match_score,
"similar_to": similar_item
})
else:
# 완전히 새로운 자재
new_materials_list.append({
**new_material,
"change_type": "NEW_MATERIAL"
})
# 삭제된 자재 찾기
new_material_hashes = set()
for material in new_materials:
d = material.get("description", "")
s = self._extract_size_from_description(d)
m = self._extract_material_from_description(d)
new_material_hashes.add(self._generate_material_hash(d, s, m))
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"리비전 비교 완료 (Fuzzy 적용): 변경없음 {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:
"""
자재 고유성 판단을 위한 해시 생성
Args:
description: 자재 설명
size: 자재 규격/크기
material: 자재 재질
Returns:
MD5 해시 문자열
"""
import re
def normalize(s: Optional[str]) -> str:
if s is None:
return ""
# 다중 공백을 단일 공백으로 치환하고 앞뒤 공백 제거
s = re.sub(r'\s+', ' ', str(s))
return s.strip().lower()
# 각 컴포넌트 정규화
d_norm = normalize(description)
s_norm = normalize(size)
m_norm = normalize(material)
# RULES.md의 코딩 컨벤션 준수 (pipe separator 사용)
# 값이 없는 경우에도 구분자를 포함하여 구조 유지 (예: "desc||mat")
hash_input = f"{d_norm}|{s_norm}|{m_norm}"
return hashlib.md5(hash_input.encode()).hexdigest()
def _extract_size_from_description(self, description: str) -> str:
"""
자재 설명에서 사이즈 정보 추출
지원하는 패턴 (단어 경계 \b 추가하여 정확도 향상):
- 1/2" (인치)
- 100A (A단위)
- 50mm (밀리미터)
- 10x20 (가로x세로)
- DN100 (DN단위)
"""
if not description:
return ""
import re
size_patterns = [
# 인치 패턴 (분수 포함): 1/2", 1.5", 1-1/2"
r'\b(\d+(?:[-/.]\d+)?)\s*(?:inch|인치|")',
# 밀리미터 패턴: 100mm, 100.5 MM
r'\b(\d+(?:\.\d+)?)\s*(?:mm|MM)\b',
# A단위 패턴: 100A, 100 A
r'\b(\d+)\s*A\b',
# DN단위 패턴: DN100, DN 100
r'DN\s*(\d+)\b',
# 치수 패턴: 10x20, 10*20
r'\b(\d+(?:\.\d+)?)\s*[xX*]\s*(\d+(?:\.\d+)?)\b'
]
for pattern in size_patterns:
match = re.search(pattern, description, re.IGNORECASE)
if match:
return match.group(0).strip()
return ""
def _load_materials_from_db(self) -> List[str]:
"""DB에서 자재 목록 동적 로딩 (캐싱 적용 고려 가능)"""
try:
# MaterialSpecification 및 SpecialMaterial 테이블에서 자재 코드 조회
query = text("""
SELECT spec_code FROM material_specifications
WHERE is_active = TRUE
UNION
SELECT grade_code FROM material_grades
WHERE is_active = TRUE
UNION
SELECT material_name FROM special_materials
WHERE is_active = TRUE
""")
result = self.db.execute(query).fetchall()
db_materials = [row[0] for row in result]
# 기본 하드코딩 리스트 (DB 조회 실패 시 또는 보완용)
default_materials = [
"SUS316L", "SUS316", "SUS304L", "SUS304",
"SS316L", "SS316", "SS304L", "SS304",
"A105N", "A105",
"A234 WPB", "A234",
"A106 Gr.B", "A106",
"WCB", "CF8M", "CF8",
"CS", "STS", "PVC", "PP", "PE"
]
# 합치고 중복 제거 후 길이 역순 정렬 (긴 단어 우선 매칭)
combined = list(set(db_materials + default_materials))
combined.sort(key=len, reverse=True)
return combined
except Exception as e:
logger.warning(f"DB 자재 로딩 실패 (기본값 사용): {str(e)}")
materials = [
"SUS316L", "SUS316", "SUS304L", "SUS304",
"SS316L", "SS316", "SS304L", "SS304",
"A105N", "A105",
"A234 WPB", "A234",
"A106 Gr.B", "A106",
"WCB", "CF8M", "CF8",
"CS", "STS", "PVC", "PP", "PE"
]
return materials
def _extract_material_from_description(self, description: str) -> str:
"""
자재 설명에서 재질 정보 추출
우선순위에 따라 매칭 (구체적인 재질 먼저)
"""
if not description:
return ""
# 자재 목록 로딩 (메모리 캐싱을 위해 클래스 속성으로 저장 고려 가능하지만 여기선 매번 호출로 단순화)
# 성능이 중요하다면 __init__ 시점에 로드하거나 lru_cache 사용 권장
materials = self._load_materials_from_db()
description_upper = description.upper()
for material in materials:
# 단어 매칭을 위해 간단한 검사 수행 (부분 문자열이 다른 단어의 일부가 아닌지)
if material.upper() 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)

View File

@@ -0,0 +1,457 @@
"""
리비전 비교 및 변경 처리 서비스
- 자재 비교 로직 (구매된/미구매 자재 구분)
- 리비전 액션 결정 (추가구매, 재고이관, 수량변경 등)
- GASKET/BOLT 특별 처리 (BOM 원본 수량 기준)
"""
import logging
from typing import Dict, List, Optional, Any, Tuple
from decimal import Decimal
from sqlalchemy.orm import Session
from sqlalchemy import text, and_, or_
from ..models import Material
logger = logging.getLogger(__name__)
class RevisionComparisonService:
"""리비전 비교 및 변경 처리 서비스"""
def __init__(self, db: Session):
self.db = db
def compare_materials_by_category(
self,
current_file_id: int,
previous_file_id: int,
category: str,
session_id: int
) -> Dict[str, Any]:
"""카테고리별 자재 비교 및 변경사항 기록"""
try:
logger.info(f"카테고리 {category} 자재 비교 시작 (현재: {current_file_id}, 이전: {previous_file_id})")
# 현재 파일의 자재 조회
current_materials = self._get_materials_by_category(current_file_id, category)
previous_materials = self._get_materials_by_category(previous_file_id, category)
logger.info(f"현재 자재: {len(current_materials)}개, 이전 자재: {len(previous_materials)}")
# 자재 그룹화 (동일 자재 식별)
current_grouped = self._group_materials_by_key(current_materials, category)
previous_grouped = self._group_materials_by_key(previous_materials, category)
# 비교 결과 저장
comparison_results = {
"added": [],
"removed": [],
"changed": [],
"unchanged": []
}
# 현재 자재 기준으로 비교
for key, current_group in current_grouped.items():
if key in previous_grouped:
previous_group = previous_grouped[key]
# 수량 비교 (GASKET/BOLT는 BOM 원본 수량 기준)
current_qty = self._get_comparison_quantity(current_group, category)
previous_qty = self._get_comparison_quantity(previous_group, category)
if current_qty != previous_qty:
# 수량 변경됨
change_record = self._create_change_record(
current_group, previous_group, "quantity_changed",
current_qty, previous_qty, category, session_id
)
comparison_results["changed"].append(change_record)
else:
# 수량 동일
unchanged_record = self._create_change_record(
current_group, previous_group, "unchanged",
current_qty, previous_qty, category, session_id
)
comparison_results["unchanged"].append(unchanged_record)
else:
# 새로 추가된 자재
current_qty = self._get_comparison_quantity(current_group, category)
added_record = self._create_change_record(
current_group, None, "added",
current_qty, 0, category, session_id
)
comparison_results["added"].append(added_record)
# 제거된 자재 확인
for key, previous_group in previous_grouped.items():
if key not in current_grouped:
previous_qty = self._get_comparison_quantity(previous_group, category)
removed_record = self._create_change_record(
None, previous_group, "removed",
0, previous_qty, category, session_id
)
comparison_results["removed"].append(removed_record)
# DB에 변경사항 저장
self._save_material_changes(comparison_results, session_id)
# 통계 정보
summary = {
"category": category,
"added_count": len(comparison_results["added"]),
"removed_count": len(comparison_results["removed"]),
"changed_count": len(comparison_results["changed"]),
"unchanged_count": len(comparison_results["unchanged"]),
"total_changes": len(comparison_results["added"]) + len(comparison_results["removed"]) + len(comparison_results["changed"])
}
logger.info(f"카테고리 {category} 비교 완료: {summary}")
return {
"summary": summary,
"changes": comparison_results
}
except Exception as e:
logger.error(f"카테고리 {category} 자재 비교 실패: {e}")
raise
def _get_materials_by_category(self, file_id: int, category: str) -> List[Material]:
"""파일의 특정 카테고리 자재 조회"""
return self.db.query(Material).filter(
and_(
Material.file_id == file_id,
Material.classified_category == category,
Material.is_active == True
)
).all()
def _group_materials_by_key(self, materials: List[Material], category: str) -> Dict[str, Dict]:
"""자재를 고유 키로 그룹화"""
grouped = {}
for material in materials:
# 카테고리별 고유 키 생성 전략
if category == "PIPE":
# PIPE: description + material_grade + main_nom
key_parts = [
material.original_description.strip().upper(),
material.material_grade or '',
material.main_nom or ''
]
elif category in ["GASKET", "BOLT"]:
# GASKET/BOLT: description + main_nom (집계 공식 적용 전 원본 기준)
key_parts = [
material.original_description.strip().upper(),
material.main_nom or ''
]
else:
# 기타: description + drawing + main_nom + red_nom
key_parts = [
material.original_description.strip().upper(),
material.drawing_name or '',
material.main_nom or '',
material.red_nom or ''
]
key = "|".join(key_parts)
if key in grouped:
# 동일한 자재가 있으면 수량 합산
grouped[key]['total_quantity'] += float(material.quantity)
grouped[key]['materials'].append(material)
# 구매 확정 상태 확인 (하나라도 구매 확정되면 전체가 구매 확정)
if getattr(material, 'purchase_confirmed', False):
grouped[key]['purchase_confirmed'] = True
grouped[key]['purchase_confirmed_at'] = getattr(material, 'purchase_confirmed_at', None)
else:
grouped[key] = {
'key': key,
'representative_material': material,
'materials': [material],
'total_quantity': float(material.quantity),
'purchase_confirmed': getattr(material, 'purchase_confirmed', False),
'purchase_confirmed_at': getattr(material, 'purchase_confirmed_at', None),
'category': category
}
return grouped
def _get_comparison_quantity(self, material_group: Dict, category: str) -> Decimal:
"""비교용 수량 계산 (GASKET/BOLT는 집계 공식 적용 전 원본 수량)"""
if category in ["GASKET", "BOLT"]:
# GASKET/BOLT: BOM 원본 수량 기준 (집계 공식 적용 전)
# 실제 BOM에서 읽은 원본 수량을 사용
original_quantity = 0
for material in material_group['materials']:
# classification_details에서 원본 수량 추출 시도
details = getattr(material, 'classification_details', {})
if isinstance(details, dict) and 'original_quantity' in details:
original_quantity += float(details['original_quantity'])
else:
# 원본 수량 정보가 없으면 현재 수량 사용
original_quantity += float(material.quantity)
return Decimal(str(original_quantity))
else:
# 기타 카테고리: 현재 수량 사용
return Decimal(str(material_group['total_quantity']))
def _create_change_record(
self,
current_group: Optional[Dict],
previous_group: Optional[Dict],
change_type: str,
current_qty: Decimal,
previous_qty: Decimal,
category: str,
session_id: int
) -> Dict[str, Any]:
"""변경 기록 생성"""
# 대표 자재 정보
if current_group:
material = current_group['representative_material']
material_id = material.id
description = material.original_description
purchase_status = 'purchased' if current_group['purchase_confirmed'] else 'not_purchased'
purchase_confirmed_at = current_group.get('purchase_confirmed_at')
else:
material = previous_group['representative_material']
material_id = None # 제거된 자재는 현재 material_id가 없음
description = material.original_description
purchase_status = 'purchased' if previous_group['purchase_confirmed'] else 'not_purchased'
purchase_confirmed_at = previous_group.get('purchase_confirmed_at')
# 리비전 액션 결정
revision_action = self._determine_revision_action(
change_type, current_qty, previous_qty, purchase_status, category
)
return {
"session_id": session_id,
"material_id": material_id,
"previous_material_id": material.id if previous_group else None,
"material_description": description,
"category": category,
"change_type": change_type,
"current_quantity": float(current_qty),
"previous_quantity": float(previous_qty),
"quantity_difference": float(current_qty - previous_qty),
"purchase_status": purchase_status,
"purchase_confirmed_at": purchase_confirmed_at,
"revision_action": revision_action
}
def _determine_revision_action(
self,
change_type: str,
current_qty: Decimal,
previous_qty: Decimal,
purchase_status: str,
category: str
) -> str:
"""리비전 액션 결정 로직"""
if change_type == "added":
return "new_material"
elif change_type == "removed":
if purchase_status == "purchased":
return "inventory_transfer" # 구매된 자재 → 재고 이관
else:
return "purchase_cancel" # 미구매 자재 → 구매 취소
elif change_type == "quantity_changed":
quantity_diff = current_qty - previous_qty
if purchase_status == "purchased":
if quantity_diff > 0:
return "additional_purchase" # 구매된 자재 수량 증가 → 추가 구매
else:
return "inventory_transfer" # 구매된 자재 수량 감소 → 재고 이관
else:
return "quantity_update" # 미구매 자재 → 수량 업데이트
else:
return "maintain" # 변경 없음
def _save_material_changes(self, comparison_results: Dict, session_id: int):
"""변경사항을 DB에 저장"""
try:
all_changes = []
for change_type, changes in comparison_results.items():
all_changes.extend(changes)
if not all_changes:
return
# 배치 삽입
insert_query = """
INSERT INTO revision_material_changes (
session_id, material_id, previous_material_id, material_description,
category, change_type, current_quantity, previous_quantity,
quantity_difference, purchase_status, purchase_confirmed_at, revision_action
) VALUES (
:session_id, :material_id, :previous_material_id, :material_description,
:category, :change_type, :current_quantity, :previous_quantity,
:quantity_difference, :purchase_status, :purchase_confirmed_at, :revision_action
)
"""
self.db.execute(text(insert_query), all_changes)
self.db.commit()
logger.info(f"변경사항 {len(all_changes)}건 저장 완료 (세션: {session_id})")
except Exception as e:
self.db.rollback()
logger.error(f"변경사항 저장 실패: {e}")
raise
def get_session_changes(self, session_id: int, category: str = None) -> List[Dict[str, Any]]:
"""세션의 변경사항 조회"""
try:
query = """
SELECT
id, material_id, material_description, category,
change_type, current_quantity, previous_quantity, quantity_difference,
purchase_status, revision_action, action_status,
processed_by, processed_at, processing_notes
FROM revision_material_changes
WHERE session_id = :session_id
"""
params = {"session_id": session_id}
if category:
query += " AND category = :category"
params["category"] = category
query += " ORDER BY category, material_description"
changes = self.db.execute(text(query), params).fetchall()
return [dict(change._mapping) for change in changes]
except Exception as e:
logger.error(f"세션 변경사항 조회 실패: {e}")
raise
def process_revision_action(
self,
change_id: int,
action: str,
username: str,
notes: str = None
) -> Dict[str, Any]:
"""리비전 액션 처리"""
try:
# 변경사항 조회
change = self.db.execute(text("""
SELECT * FROM revision_material_changes WHERE id = :change_id
"""), {"change_id": change_id}).fetchone()
if not change:
raise ValueError(f"변경사항을 찾을 수 없습니다: {change_id}")
result = {"success": False, "message": ""}
# 액션별 처리
if action == "additional_purchase":
result = self._process_additional_purchase(change, username, notes)
elif action == "inventory_transfer":
result = self._process_inventory_transfer(change, username, notes)
elif action == "purchase_cancel":
result = self._process_purchase_cancel(change, username, notes)
elif action == "quantity_update":
result = self._process_quantity_update(change, username, notes)
else:
result = {"success": True, "message": "처리 완료"}
# 처리 상태 업데이트
status = "completed" if result["success"] else "failed"
self.db.execute(text("""
UPDATE revision_material_changes
SET action_status = :status, processed_by = :username,
processed_at = CURRENT_TIMESTAMP, processing_notes = :notes
WHERE id = :change_id
"""), {
"change_id": change_id,
"status": status,
"username": username,
"notes": notes or result["message"]
})
# 액션 로그 기록
self.db.execute(text("""
INSERT INTO revision_action_logs (
session_id, revision_change_id, action_type, action_description,
executed_by, result, result_message
) VALUES (
:session_id, :change_id, :action, :description,
:username, :result, :message
)
"""), {
"session_id": change.session_id,
"change_id": change_id,
"action": action,
"description": f"{change.material_description} - {action}",
"username": username,
"result": "success" if result["success"] else "failed",
"message": result["message"]
})
self.db.commit()
return result
except Exception as e:
self.db.rollback()
logger.error(f"리비전 액션 처리 실패: {e}")
raise
def _process_additional_purchase(self, change, username: str, notes: str) -> Dict[str, Any]:
"""추가 구매 처리"""
# 구매 요청 생성 로직 구현
return {"success": True, "message": f"추가 구매 요청 생성: {change.quantity_difference}"}
def _process_inventory_transfer(self, change, username: str, notes: str) -> Dict[str, Any]:
"""재고 이관 처리"""
# 재고 이관 로직 구현
try:
self.db.execute(text("""
INSERT INTO inventory_transfers (
revision_change_id, material_description, category,
quantity, unit, transferred_by, storage_notes
) VALUES (
:change_id, :description, :category,
:quantity, 'EA', :username, :notes
)
"""), {
"change_id": change.id,
"description": change.material_description,
"category": change.category,
"quantity": abs(change.quantity_difference),
"username": username,
"notes": notes
})
return {"success": True, "message": f"재고 이관 완료: {abs(change.quantity_difference)}"}
except Exception as e:
return {"success": False, "message": f"재고 이관 실패: {str(e)}"}
def _process_purchase_cancel(self, change, username: str, notes: str) -> Dict[str, Any]:
"""구매 취소 처리"""
return {"success": True, "message": "구매 취소 완료"}
def _process_quantity_update(self, change, username: str, notes: str) -> Dict[str, Any]:
"""수량 업데이트 처리"""
return {"success": True, "message": f"수량 업데이트 완료: {change.current_quantity}"}

View File

@@ -0,0 +1,289 @@
"""
리비전 세션 관리 서비스
- 리비전 세션 생성, 관리, 완료 처리
- 자재 변경 사항 추적 및 처리
"""
import logging
from typing import Dict, List, Optional, Any
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy import text, and_, or_
from ..models import File, Material
from ..database import get_db
logger = logging.getLogger(__name__)
class RevisionSessionService:
"""리비전 세션 관리 서비스"""
def __init__(self, db: Session):
self.db = db
def create_revision_session(
self,
job_no: str,
current_file_id: int,
previous_file_id: int,
username: str
) -> Dict[str, Any]:
"""새로운 리비전 세션 생성"""
try:
# 파일 정보 조회
current_file = self.db.query(File).filter(File.id == current_file_id).first()
previous_file = self.db.query(File).filter(File.id == previous_file_id).first()
if not current_file or not previous_file:
raise ValueError("파일 정보를 찾을 수 없습니다")
# 기존 진행 중인 세션이 있는지 확인
existing_session = self.db.execute(text("""
SELECT id FROM revision_sessions
WHERE job_no = :job_no AND status = 'processing'
"""), {"job_no": job_no}).fetchone()
if existing_session:
logger.warning(f"기존 진행 중인 리비전 세션이 있습니다: {existing_session[0]}")
return {"session_id": existing_session[0], "status": "existing"}
# 새 세션 생성
session_data = {
"job_no": job_no,
"current_file_id": current_file_id,
"previous_file_id": previous_file_id,
"current_revision": current_file.revision,
"previous_revision": previous_file.revision,
"status": "processing",
"created_by": username
}
result = self.db.execute(text("""
INSERT INTO revision_sessions (
job_no, current_file_id, previous_file_id,
current_revision, previous_revision, status, created_by
) VALUES (
:job_no, :current_file_id, :previous_file_id,
:current_revision, :previous_revision, :status, :created_by
) RETURNING id
"""), session_data)
session_id = result.fetchone()[0]
self.db.commit()
logger.info(f"새 리비전 세션 생성: {session_id} (Job: {job_no})")
return {
"session_id": session_id,
"status": "created",
"job_no": job_no,
"current_revision": current_file.revision,
"previous_revision": previous_file.revision
}
except Exception as e:
self.db.rollback()
logger.error(f"리비전 세션 생성 실패: {e}")
raise
def get_session_status(self, session_id: int) -> Dict[str, Any]:
"""리비전 세션 상태 조회"""
try:
session_info = self.db.execute(text("""
SELECT
id, job_no, current_file_id, previous_file_id,
current_revision, previous_revision, status,
total_materials, processed_materials,
added_count, removed_count, changed_count, unchanged_count,
purchase_cancel_count, inventory_transfer_count, additional_purchase_count,
created_by, created_at, completed_at
FROM revision_sessions
WHERE id = :session_id
"""), {"session_id": session_id}).fetchone()
if not session_info:
raise ValueError(f"리비전 세션을 찾을 수 없습니다: {session_id}")
# 변경 사항 상세 조회
changes = self.db.execute(text("""
SELECT
category, change_type, revision_action, action_status,
COUNT(*) as count,
SUM(CASE WHEN purchase_status = 'purchased' THEN 1 ELSE 0 END) as purchased_count,
SUM(CASE WHEN purchase_status = 'not_purchased' THEN 1 ELSE 0 END) as unpurchased_count
FROM revision_material_changes
WHERE session_id = :session_id
GROUP BY category, change_type, revision_action, action_status
ORDER BY category, change_type
"""), {"session_id": session_id}).fetchall()
return {
"session_info": dict(session_info._mapping),
"changes_summary": [dict(change._mapping) for change in changes],
"progress_percentage": (
(session_info.processed_materials / session_info.total_materials * 100)
if session_info.total_materials > 0 else 0
)
}
except Exception as e:
logger.error(f"리비전 세션 상태 조회 실패: {e}")
raise
def update_session_progress(
self,
session_id: int,
total_materials: int = None,
processed_materials: int = None,
**counts
) -> bool:
"""리비전 세션 진행 상황 업데이트"""
try:
update_fields = []
update_values = {"session_id": session_id}
if total_materials is not None:
update_fields.append("total_materials = :total_materials")
update_values["total_materials"] = total_materials
if processed_materials is not None:
update_fields.append("processed_materials = :processed_materials")
update_values["processed_materials"] = processed_materials
# 카운트 필드들 업데이트
count_fields = [
"added_count", "removed_count", "changed_count", "unchanged_count",
"purchase_cancel_count", "inventory_transfer_count", "additional_purchase_count"
]
for field in count_fields:
if field in counts:
update_fields.append(f"{field} = :{field}")
update_values[field] = counts[field]
if not update_fields:
return True # 업데이트할 내용이 없음
query = f"""
UPDATE revision_sessions
SET {', '.join(update_fields)}
WHERE id = :session_id
"""
self.db.execute(text(query), update_values)
self.db.commit()
logger.info(f"리비전 세션 진행 상황 업데이트: {session_id}")
return True
except Exception as e:
self.db.rollback()
logger.error(f"리비전 세션 진행 상황 업데이트 실패: {e}")
raise
def complete_session(self, session_id: int, username: str) -> Dict[str, Any]:
"""리비전 세션 완료 처리"""
try:
# 세션 상태를 완료로 변경
self.db.execute(text("""
UPDATE revision_sessions
SET status = 'completed', completed_at = CURRENT_TIMESTAMP
WHERE id = :session_id AND status = 'processing'
"""), {"session_id": session_id})
# 완료 로그 기록
self.db.execute(text("""
INSERT INTO revision_action_logs (
session_id, action_type, action_description,
executed_by, result, result_message
) VALUES (
:session_id, 'session_complete', '리비전 세션 완료',
:username, 'success', '모든 리비전 처리 완료'
)
"""), {
"session_id": session_id,
"username": username
})
self.db.commit()
# 최종 상태 조회
final_status = self.get_session_status(session_id)
logger.info(f"리비전 세션 완료: {session_id}")
return {
"status": "completed",
"session_id": session_id,
"final_status": final_status
}
except Exception as e:
self.db.rollback()
logger.error(f"리비전 세션 완료 처리 실패: {e}")
raise
def cancel_session(self, session_id: int, username: str, reason: str = None) -> bool:
"""리비전 세션 취소"""
try:
# 세션 상태를 취소로 변경
self.db.execute(text("""
UPDATE revision_sessions
SET status = 'cancelled', completed_at = CURRENT_TIMESTAMP
WHERE id = :session_id AND status = 'processing'
"""), {"session_id": session_id})
# 취소 로그 기록
self.db.execute(text("""
INSERT INTO revision_action_logs (
session_id, action_type, action_description,
executed_by, result, result_message
) VALUES (
:session_id, 'session_cancel', '리비전 세션 취소',
:username, 'cancelled', :reason
)
"""), {
"session_id": session_id,
"username": username,
"reason": reason or "사용자 요청에 의한 취소"
})
self.db.commit()
logger.info(f"리비전 세션 취소: {session_id}, 사유: {reason}")
return True
except Exception as e:
self.db.rollback()
logger.error(f"리비전 세션 취소 실패: {e}")
raise
def get_job_revision_history(self, job_no: str) -> List[Dict[str, Any]]:
"""Job의 리비전 히스토리 조회"""
try:
sessions = self.db.execute(text("""
SELECT
rs.id, rs.current_revision, rs.previous_revision,
rs.status, rs.created_by, rs.created_at, rs.completed_at,
rs.added_count, rs.removed_count, rs.changed_count,
cf.filename as current_filename,
pf.filename as previous_filename
FROM revision_sessions rs
LEFT JOIN files cf ON rs.current_file_id = cf.id
LEFT JOIN files pf ON rs.previous_file_id = pf.id
WHERE rs.job_no = :job_no
ORDER BY rs.created_at DESC
"""), {"job_no": job_no}).fetchall()
return [dict(session._mapping) for session in sessions]
except Exception as e:
logger.error(f"리비전 히스토리 조회 실패: {e}")
raise

View File

@@ -0,0 +1,34 @@
import re
from typing import Dict
def classify_structural(tag: str, description: str, main_nom: str = "") -> Dict:
"""
형강(STRUCTURAL) 분류기
규격 예: H-BEAM 100x100x6x8
"""
desc_upper = description.upper()
# 1. 타입 식별
struct_type = "UNKNOWN"
if "H-BEAM" in desc_upper or "H-SECTION" in desc_upper: struct_type = "H-BEAM"
elif "ANGLE" in desc_upper or "L-SECTION" in desc_upper: struct_type = "ANGLE"
elif "CHANNEL" in desc_upper or "C-SECTION" in desc_upper: struct_type = "CHANNEL"
elif "BEAM" in desc_upper: struct_type = "I-BEAM"
# 2. 규격 추출
# 패턴: 100x100, 100x100x6x8, 50x50x5T, H-200x200
dimension = ""
# 숫자와 x 또는 *로 구성된 패턴, 앞에 H- 등이 붙을 수 있음
dim_match = re.search(r'(?:H-|L-|C-)?(\d+(?:\.\d+)?(?:\s*[X\*]\s*\d+(?:\.\d+)?){1,3})', desc_upper)
if dim_match:
dimension = dim_match.group(1).replace("*", "x")
return {
"category": "STRUCTURAL",
"overall_confidence": 0.9,
"details": {
"type": struct_type,
"dimension": dimension
}
}

View File

@@ -0,0 +1,329 @@
"""
SUPPORT 분류 시스템
배관 지지재, 우레탄 블록, 클램프 등 지지 부품 분류
"""
import re
from typing import Dict, List, Optional
from .material_classifier import classify_material
# ========== 서포트 타입별 분류 ==========
SUPPORT_TYPES = {
"URETHANE_BLOCK": {
"dat_file_patterns": ["URETHANE", "BLOCK", "SHOE"],
"description_keywords": ["URETHANE BLOCK", "BLOCK SHOE", "우레탄 블록", "우레탄", "URETHANE"],
"characteristics": "우레탄 블록 슈",
"applications": "배관 지지, 진동 흡수",
"material_type": "URETHANE"
},
"CLAMP": {
"dat_file_patterns": ["CLAMP", "CL-"],
"description_keywords": ["CLAMP", "클램프", "CL-1", "CL-2", "CL-3"],
"characteristics": "배관 클램프",
"applications": "배관 고정, 지지",
"material_type": "STEEL"
},
"HANGER": {
"dat_file_patterns": ["HANGER", "HANG", "SUPP"],
"description_keywords": ["HANGER", "SUPPORT", "행거", "서포트", "PIPE HANGER"],
"characteristics": "배관 행거",
"applications": "배관 매달기, 지지",
"material_type": "STEEL"
},
"SPRING_HANGER": {
"dat_file_patterns": ["SPRING", "SPR_"],
"description_keywords": ["SPRING HANGER", "SPRING", "스프링", "스프링 행거"],
"characteristics": "스프링 행거",
"applications": "가변 하중 지지",
"material_type": "STEEL"
},
"GUIDE": {
"dat_file_patterns": ["GUIDE", "GD_"],
"description_keywords": ["GUIDE", "가이드", "PIPE GUIDE"],
"characteristics": "배관 가이드",
"applications": "배관 방향 제어",
"material_type": "STEEL"
},
"ANCHOR": {
"dat_file_patterns": ["ANCHOR", "ANCH"],
"description_keywords": ["ANCHOR", "앵커", "PIPE ANCHOR"],
"characteristics": "배관 앵커",
"applications": "배관 고정점",
"material_type": "STEEL"
}
}
# ========== 하중 등급 분류 ==========
LOAD_RATINGS = {
"LIGHT": {
"patterns": [r"(\d+)T", r"(\d+)TON"],
"range": (0, 5), # 5톤 이하
"description": "경하중용"
},
"MEDIUM": {
"patterns": [r"(\d+)T", r"(\d+)TON"],
"range": (5, 20), # 5-20톤
"description": "중하중용"
},
"HEAVY": {
"patterns": [r"(\d+)T", r"(\d+)TON"],
"range": (20, 100), # 20-100톤
"description": "중하중용"
}
}
def classify_support(dat_file: str, description: str, main_nom: str,
length: Optional[float] = None) -> Dict:
"""
SUPPORT 분류 메인 함수
Args:
dat_file: DAT 파일명
description: 자재 설명
main_nom: 주 사이즈
length: 길이 (옵션)
Returns:
분류 결과 딕셔너리
"""
dat_upper = dat_file.upper()
desc_upper = description.upper()
combined_text = f"{dat_upper} {desc_upper}"
# 1. 서포트 타입 분류
support_type_result = classify_support_type(dat_file, description)
# 2. 재질 분류 (공통 모듈 사용)
material_result = classify_material(description)
# 3. 하중 등급 분류
load_result = classify_load_rating(description)
# 4. 사이즈 정보 추출
size_result = extract_support_size(description, main_nom)
# 5. 사용자 요구사항 추출
user_requirements = extract_support_user_requirements(description)
# 6. 우레탄 블럭슈 두께 정보 추출 및 Material Grade 보강
enhanced_material_grade = material_result.get('grade', 'UNKNOWN')
if support_type_result.get("support_type") == "URETHANE_BLOCK":
# 두께 정보 추출 (40t, 27t 등)
thickness_match = re.search(r'(\d+)\s*[tT](?![oO])', description.upper())
if thickness_match:
thickness = f"{thickness_match.group(1)}t"
if enhanced_material_grade == 'UNKNOWN' or not enhanced_material_grade:
enhanced_material_grade = thickness
elif thickness not in enhanced_material_grade:
enhanced_material_grade = f"{enhanced_material_grade} {thickness}"
# 7. 최종 결과 조합
return {
"category": "SUPPORT",
# 서포트 특화 정보
"support_type": support_type_result.get("support_type", "UNKNOWN"),
"support_subtype": support_type_result.get("subtype", ""),
"load_rating": load_result.get("load_rating", ""),
"load_capacity": load_result.get("capacity", ""),
# 재질 정보 (공통 모듈) - 우레탄 블럭슈 두께 정보 포함
"material": {
"standard": material_result.get('standard', 'UNKNOWN'),
"grade": enhanced_material_grade,
"material_type": material_result.get('material_type', 'UNKNOWN'),
"confidence": material_result.get('confidence', 0.0)
},
# 사이즈 정보
"size_info": size_result,
# 사용자 요구사항
"user_requirements": user_requirements,
# 전체 신뢰도
"overall_confidence": calculate_support_confidence({
"type": support_type_result.get('confidence', 0),
"material": material_result.get('confidence', 0),
"load": load_result.get('confidence', 0),
"size": size_result.get('confidence', 0)
}),
# 증거
"evidence": [
f"SUPPORT_TYPE: {support_type_result.get('support_type', 'UNKNOWN')}",
f"MATERIAL: {material_result.get('standard', 'UNKNOWN')}",
f"LOAD: {load_result.get('load_rating', 'UNKNOWN')}"
]
}
def classify_support_type(dat_file: str, description: str) -> Dict:
"""서포트 타입 분류"""
dat_upper = dat_file.upper()
desc_upper = description.upper()
combined_text = f"{dat_upper} {desc_upper}"
for support_type, type_data in SUPPORT_TYPES.items():
# DAT 파일 패턴 확인
for pattern in type_data["dat_file_patterns"]:
if pattern in dat_upper:
return {
"support_type": support_type,
"subtype": type_data["characteristics"],
"applications": type_data["applications"],
"confidence": 0.95,
"evidence": [f"DAT_PATTERN: {pattern}"]
}
# 설명 키워드 확인
for keyword in type_data["description_keywords"]:
if keyword in desc_upper:
return {
"support_type": support_type,
"subtype": type_data["characteristics"],
"applications": type_data["applications"],
"confidence": 0.9,
"evidence": [f"DESC_KEYWORD: {keyword}"]
}
return {
"support_type": "UNKNOWN",
"subtype": "",
"applications": "",
"confidence": 0.0,
"evidence": ["NO_SUPPORT_TYPE_FOUND"]
}
def extract_support_user_requirements(description: str) -> List[str]:
"""서포트 사용자 요구사항 추출"""
desc_upper = description.upper()
requirements = []
# 표면처리 관련
if 'GALV' in desc_upper or 'GALVANIZED' in desc_upper:
requirements.append('GALVANIZED')
if 'HDG' in desc_upper or 'HOT DIP' in desc_upper:
requirements.append('HOT DIP GALVANIZED')
if 'PAINT' in desc_upper or 'PAINTED' in desc_upper:
requirements.append('PAINTED')
# 재질 관련
if 'SS' in desc_upper or 'STAINLESS' in desc_upper:
requirements.append('STAINLESS STEEL')
if 'CARBON' in desc_upper:
requirements.append('CARBON STEEL')
# 특수 요구사항
if 'FIRE SAFE' in desc_upper:
requirements.append('FIRE SAFE')
if 'SEISMIC' in desc_upper or '내진' in desc_upper:
requirements.append('SEISMIC')
return requirements
def classify_load_rating(description: str) -> Dict:
"""하중 등급 분류"""
desc_upper = description.upper()
# 하중 패턴 찾기 (40T, 50TON 등)
for rating, rating_data in LOAD_RATINGS.items():
for pattern in rating_data["patterns"]:
match = re.search(pattern, desc_upper)
if match:
capacity = int(match.group(1))
min_load, max_load = rating_data["range"]
if min_load <= capacity <= max_load:
return {
"load_rating": rating,
"capacity": f"{capacity}T",
"description": rating_data["description"],
"confidence": 0.9,
"evidence": [f"LOAD_PATTERN: {match.group(0)}"]
}
# 특정 하중 값이 있지만 등급을 모르는 경우
load_match = re.search(r'(\d+)\s*[T톤]', desc_upper)
if load_match:
capacity = int(load_match.group(1))
return {
"load_rating": "CUSTOM",
"capacity": f"{capacity}T",
"description": f"{capacity}톤 하중",
"confidence": 0.7,
"evidence": [f"CUSTOM_LOAD: {load_match.group(0)}"]
}
return {
"load_rating": "UNKNOWN",
"capacity": "",
"description": "",
"confidence": 0.0,
"evidence": ["NO_LOAD_RATING_FOUND"]
}
def extract_support_size(description: str, main_nom: str) -> Dict:
"""서포트 사이즈 정보 추출"""
desc_upper = description.upper()
# 파이프 사이즈 (서포트가 지지하는 파이프 크기)
pipe_size = main_nom if main_nom else ""
# 서포트 자체 치수 (길이x폭x높이 등)
dimension_patterns = [
r'(\d+)\s*[X×]\s*(\d+)\s*[X×]\s*(\d+)', # 100x50x20
r'(\d+)\s*[X×]\s*(\d+)', # 100x50
r'L\s*(\d+)', # L100 (길이)
r'W\s*(\d+)', # W50 (폭)
r'H\s*(\d+)' # H20 (높이)
]
dimensions = {}
for pattern in dimension_patterns:
match = re.search(pattern, desc_upper)
if match:
if len(match.groups()) == 3:
dimensions = {
"length": f"{match.group(1)}mm",
"width": f"{match.group(2)}mm",
"height": f"{match.group(3)}mm"
}
elif len(match.groups()) == 2:
dimensions = {
"length": f"{match.group(1)}mm",
"width": f"{match.group(2)}mm"
}
break
return {
"pipe_size": pipe_size,
"dimensions": dimensions,
"confidence": 0.8 if dimensions else 0.3
}
def calculate_support_confidence(confidence_scores: Dict) -> float:
"""서포트 분류 전체 신뢰도 계산"""
weights = {
"type": 0.4, # 타입이 가장 중요
"material": 0.2, # 재질
"load": 0.2, # 하중
"size": 0.2 # 사이즈
}
weighted_sum = sum(
confidence_scores.get(key, 0) * weight
for key, weight in weights.items()
)
return round(weighted_sum, 2)

View File

@@ -89,6 +89,24 @@ VALVE_TYPES = {
"typical_connections": ["FLANGED", "THREADED"],
"pressure_range": "150LB ~ 600LB",
"special_features": ["LUBRICATED", "NON_LUBRICATED"]
},
"SIGHT_GLASS": {
"dat_file_patterns": ["SIGHT_", "SG_"],
"description_keywords": ["SIGHT GLASS", "SIGHT", "사이트글라스", "사이트 글라스"],
"characteristics": "유체 확인용 관찰창",
"typical_connections": ["FLANGED", "THREADED"],
"pressure_range": "150LB ~ 600LB",
"special_features": ["TRANSPARENT", "VISUAL_INSPECTION"]
},
"STRAINER": {
"dat_file_patterns": ["STRAINER_", "STR_"],
"description_keywords": ["STRAINER", "스트레이너", "여과기"],
"characteristics": "이물질 여과용",
"typical_connections": ["FLANGED", "THREADED"],
"pressure_range": "150LB ~ 600LB",
"special_features": ["MESH_FILTER", "Y_TYPE", "BASKET_TYPE"]
}
}
@@ -212,15 +230,25 @@ def classify_valve(dat_file: str, description: str, main_nom: str, length: float
desc_upper = description.upper()
dat_upper = dat_file.upper()
# 1. 명칭 우선 확인 (밸브 키워드가 있으면 밸브)
valve_keywords = ['VALVE', 'GATE', 'BALL', 'GLOBE', 'CHECK', 'BUTTERFLY', 'NEEDLE', 'RELIEF', 'SOLENOID', 'PLUG', '밸브', '이트', '', '글로브', '체크', '버터플라이', '니들', '릴리프', '솔레노이드', '플러그']
is_valve = any(keyword in desc_upper or keyword in dat_upper for keyword in valve_keywords)
# 1. 사이트 글라스와 스트레이너 우선 확인
if 'SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or '이트글라스' in desc_upper or '스트레이너' in desc_upper:
# 사이트 글라스와 스트레이너는 항상 밸브로 분류
pass
if not is_valve:
# 밸브 키워드 확인 (재질만 있어도 통합 분류기가 이미 밸브로 분류했으므로 진행)
valve_keywords = ['VALVE', 'GATE', 'BALL', 'GLOBE', 'CHECK', 'BUTTERFLY', 'NEEDLE', 'RELIEF', 'SOLENOID', 'SIGHT GLASS', 'STRAINER', '밸브', '게이트', '', '글로브', '체크', '버터플라이', '니들', '릴리프', '솔레노이드', '사이트글라스', '스트레이너']
has_valve_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in valve_keywords)
# 밸브 재질 확인 (A216, A217, A351, A352)
valve_materials = ['A216', 'A217', 'A351', 'A352']
has_valve_material = any(material in desc_upper for material in valve_materials)
# 밸브 키워드도 없고 밸브 재질도 없으면 UNKNOWN
if not has_valve_keyword and not has_valve_material:
return {
"category": "UNKNOWN",
"overall_confidence": 0.0,
"reason": "밸브 키워드 없음"
"reason": "밸브 키워드 및 재질 없음"
}
# 2. 재질 분류 (공통 모듈 사용)

View File

@@ -0,0 +1,12 @@
"""
유틸리티 모듈
"""
from .logger import get_logger, setup_logger, app_logger
from .file_validator import file_validator, validate_uploaded_file
from .error_handlers import ErrorResponse, TKMPException, setup_error_handlers
__all__ = [
"get_logger", "setup_logger", "app_logger",
"file_validator", "validate_uploaded_file",
"ErrorResponse", "TKMPException", "setup_error_handlers"
]

View File

@@ -0,0 +1,266 @@
"""
Redis 캐시 관리 유틸리티
성능 향상을 위한 캐싱 전략 구현
"""
import json
import redis
from typing import Any, Optional, Dict, List
from datetime import timedelta
import hashlib
import pickle
from ..config import get_settings
from .logger import get_logger
settings = get_settings()
logger = get_logger(__name__)
class CacheManager:
"""Redis 캐시 관리 클래스"""
def __init__(self):
try:
# Redis 연결 설정
self.redis_client = redis.from_url(
settings.redis.url,
decode_responses=False, # 바이너리 데이터 지원
socket_connect_timeout=5,
socket_timeout=5,
retry_on_timeout=True
)
# 연결 테스트
self.redis_client.ping()
logger.info("Redis 연결 성공")
except Exception as e:
logger.error(f"Redis 연결 실패: {e}")
self.redis_client = None
def _generate_key(self, prefix: str, *args, **kwargs) -> str:
"""캐시 키 생성"""
# 인자들을 문자열로 변환하여 해시 생성
key_parts = [str(arg) for arg in args]
key_parts.extend([f"{k}:{v}" for k, v in sorted(kwargs.items())])
if key_parts:
key_hash = hashlib.md5("|".join(key_parts).encode()).hexdigest()[:8]
return f"tkmp:{prefix}:{key_hash}"
else:
return f"tkmp:{prefix}"
def get(self, key: str) -> Optional[Any]:
"""캐시에서 데이터 조회"""
if not self.redis_client:
return None
try:
data = self.redis_client.get(key)
if data:
return pickle.loads(data)
return None
except Exception as e:
logger.warning(f"캐시 조회 실패 - key: {key}, error: {e}")
return None
def set(self, key: str, value: Any, expire: int = 3600) -> bool:
"""캐시에 데이터 저장"""
if not self.redis_client:
return False
try:
serialized_data = pickle.dumps(value)
result = self.redis_client.setex(key, expire, serialized_data)
logger.debug(f"캐시 저장 - key: {key}, expire: {expire}s")
return result
except Exception as e:
logger.warning(f"캐시 저장 실패 - key: {key}, error: {e}")
return False
def delete(self, key: str) -> bool:
"""캐시에서 데이터 삭제"""
if not self.redis_client:
return False
try:
result = self.redis_client.delete(key)
logger.debug(f"캐시 삭제 - key: {key}")
return bool(result)
except Exception as e:
logger.warning(f"캐시 삭제 실패 - key: {key}, error: {e}")
return False
def delete_pattern(self, pattern: str) -> int:
"""패턴에 맞는 캐시 키들 삭제"""
if not self.redis_client:
return 0
try:
keys = self.redis_client.keys(pattern)
if keys:
deleted = self.redis_client.delete(*keys)
logger.info(f"패턴 캐시 삭제 - pattern: {pattern}, deleted: {deleted}")
return deleted
return 0
except Exception as e:
logger.warning(f"패턴 캐시 삭제 실패 - pattern: {pattern}, error: {e}")
return 0
def exists(self, key: str) -> bool:
"""캐시 키 존재 여부 확인"""
if not self.redis_client:
return False
try:
return bool(self.redis_client.exists(key))
except Exception as e:
logger.warning(f"캐시 존재 확인 실패 - key: {key}, error: {e}")
return False
def get_ttl(self, key: str) -> int:
"""캐시 TTL 조회"""
if not self.redis_client:
return -1
try:
return self.redis_client.ttl(key)
except Exception as e:
logger.warning(f"캐시 TTL 조회 실패 - key: {key}, error: {e}")
return -1
class TKMPCache:
"""TK-MP 프로젝트 전용 캐시 래퍼"""
def __init__(self):
self.cache = CacheManager()
# 캐시 TTL 설정 (초 단위)
self.ttl_config = {
"file_list": 300, # 5분 - 파일 목록
"material_list": 600, # 10분 - 자재 목록
"job_list": 1800, # 30분 - 작업 목록
"classification": 3600, # 1시간 - 분류 결과
"statistics": 900, # 15분 - 통계 데이터
"comparison": 1800, # 30분 - 리비전 비교
}
def get_file_list(self, job_no: Optional[str] = None, show_history: bool = False) -> Optional[List[Dict]]:
"""파일 목록 캐시 조회"""
key = self.cache._generate_key("files", job_no=job_no, history=show_history)
return self.cache.get(key)
def set_file_list(self, files: List[Dict], job_no: Optional[str] = None, show_history: bool = False) -> bool:
"""파일 목록 캐시 저장"""
key = self.cache._generate_key("files", job_no=job_no, history=show_history)
return self.cache.set(key, files, self.ttl_config["file_list"])
def get_material_list(self, file_id: int) -> Optional[List[Dict]]:
"""자재 목록 캐시 조회"""
key = self.cache._generate_key("materials", file_id=file_id)
return self.cache.get(key)
def set_material_list(self, materials: List[Dict], file_id: int) -> bool:
"""자재 목록 캐시 저장"""
key = self.cache._generate_key("materials", file_id=file_id)
return self.cache.set(key, materials, self.ttl_config["material_list"])
def get_job_list(self) -> Optional[List[Dict]]:
"""작업 목록 캐시 조회"""
key = self.cache._generate_key("jobs")
return self.cache.get(key)
def set_job_list(self, jobs: List[Dict]) -> bool:
"""작업 목록 캐시 저장"""
key = self.cache._generate_key("jobs")
return self.cache.set(key, jobs, self.ttl_config["job_list"])
def get_classification_result(self, description: str, category: str) -> Optional[Dict]:
"""분류 결과 캐시 조회"""
key = self.cache._generate_key("classification", desc=description, cat=category)
return self.cache.get(key)
def set_classification_result(self, result: Dict, description: str, category: str) -> bool:
"""분류 결과 캐시 저장"""
key = self.cache._generate_key("classification", desc=description, cat=category)
return self.cache.set(key, result, self.ttl_config["classification"])
def get_statistics(self, job_no: str, stat_type: str) -> Optional[Dict]:
"""통계 데이터 캐시 조회"""
key = self.cache._generate_key("stats", job_no=job_no, type=stat_type)
return self.cache.get(key)
def set_statistics(self, stats: Dict, job_no: str, stat_type: str) -> bool:
"""통계 데이터 캐시 저장"""
key = self.cache._generate_key("stats", job_no=job_no, type=stat_type)
return self.cache.set(key, stats, self.ttl_config["statistics"])
def get_revision_comparison(self, job_no: str, rev1: str, rev2: str) -> Optional[Dict]:
"""리비전 비교 결과 캐시 조회"""
key = self.cache._generate_key("comparison", job_no=job_no, rev1=rev1, rev2=rev2)
return self.cache.get(key)
def set_revision_comparison(self, comparison: Dict, job_no: str, rev1: str, rev2: str) -> bool:
"""리비전 비교 결과 캐시 저장"""
key = self.cache._generate_key("comparison", job_no=job_no, rev1=rev1, rev2=rev2)
return self.cache.set(key, comparison, self.ttl_config["comparison"])
def invalidate_job_cache(self, job_no: str):
"""특정 작업의 모든 캐시 무효화"""
patterns = [
f"tkmp:files:*job_no:{job_no}*",
f"tkmp:materials:*job_no:{job_no}*",
f"tkmp:stats:*job_no:{job_no}*",
f"tkmp:comparison:*job_no:{job_no}*"
]
total_deleted = 0
for pattern in patterns:
deleted = self.cache.delete_pattern(pattern)
total_deleted += deleted
logger.info(f"작업 캐시 무효화 완료 - job_no: {job_no}, deleted: {total_deleted}")
return total_deleted
def invalidate_file_cache(self, file_id: int):
"""특정 파일의 모든 캐시 무효화"""
patterns = [
f"tkmp:materials:*file_id:{file_id}*",
f"tkmp:files:*" # 파일 목록도 갱신 필요
]
total_deleted = 0
for pattern in patterns:
deleted = self.cache.delete_pattern(pattern)
total_deleted += deleted
logger.info(f"파일 캐시 무효화 완료 - file_id: {file_id}, deleted: {total_deleted}")
return total_deleted
def get_cache_info(self) -> Dict[str, Any]:
"""캐시 상태 정보 조회"""
if not self.cache.redis_client:
return {"status": "disconnected"}
try:
info = self.cache.redis_client.info()
return {
"status": "connected",
"used_memory": info.get("used_memory_human", "N/A"),
"connected_clients": info.get("connected_clients", 0),
"total_commands_processed": info.get("total_commands_processed", 0),
"keyspace_hits": info.get("keyspace_hits", 0),
"keyspace_misses": info.get("keyspace_misses", 0),
"hit_rate": round(
info.get("keyspace_hits", 0) /
max(info.get("keyspace_hits", 0) + info.get("keyspace_misses", 0), 1) * 100, 2
)
}
except Exception as e:
logger.error(f"캐시 정보 조회 실패: {e}")
return {"status": "error", "error": str(e)}
# 전역 캐시 인스턴스
tkmp_cache = TKMPCache()

View File

@@ -0,0 +1,139 @@
"""
에러 처리 유틸리티
표준화된 에러 응답 및 예외 처리
"""
from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from sqlalchemy.exc import SQLAlchemyError
from typing import Dict, Any
import traceback
from .logger import get_logger
logger = get_logger(__name__)
class TKMPException(Exception):
"""TK-MP 프로젝트 커스텀 예외"""
def __init__(self, message: str, error_code: str = "TKMP_ERROR", status_code: int = 500):
self.message = message
self.error_code = error_code
self.status_code = status_code
super().__init__(self.message)
class ErrorResponse:
"""표준화된 에러 응답 생성기"""
@staticmethod
def create_error_response(
message: str,
error_code: str = "INTERNAL_ERROR",
status_code: int = 500,
details: Dict[str, Any] = None
) -> Dict[str, Any]:
"""표준화된 에러 응답 생성"""
response = {
"success": False,
"error": {
"code": error_code,
"message": message,
"timestamp": "2025-01-01T00:00:00Z" # 실제로는 datetime.utcnow().isoformat()
}
}
if details:
response["error"]["details"] = details
return response
@staticmethod
def validation_error_response(errors: list) -> Dict[str, Any]:
"""검증 에러 응답"""
return ErrorResponse.create_error_response(
message="입력 데이터 검증에 실패했습니다.",
error_code="VALIDATION_ERROR",
status_code=422,
details={"validation_errors": errors}
)
@staticmethod
def database_error_response(error: str) -> Dict[str, Any]:
"""데이터베이스 에러 응답"""
return ErrorResponse.create_error_response(
message="데이터베이스 작업 중 오류가 발생했습니다.",
error_code="DATABASE_ERROR",
status_code=500,
details={"db_error": error}
)
@staticmethod
def file_error_response(error: str) -> Dict[str, Any]:
"""파일 처리 에러 응답"""
return ErrorResponse.create_error_response(
message="파일 처리 중 오류가 발생했습니다.",
error_code="FILE_ERROR",
status_code=400,
details={"file_error": error}
)
async def tkmp_exception_handler(request: Request, exc: TKMPException):
"""TK-MP 커스텀 예외 핸들러"""
logger.error(f"TK-MP 예외 발생: {exc.message} (코드: {exc.error_code})")
return JSONResponse(
status_code=exc.status_code,
content=ErrorResponse.create_error_response(
message=exc.message,
error_code=exc.error_code,
status_code=exc.status_code
)
)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""검증 예외 핸들러"""
logger.warning(f"검증 오류: {exc.errors()}")
return JSONResponse(
status_code=422,
content=ErrorResponse.validation_error_response(exc.errors())
)
async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError):
"""SQLAlchemy 예외 핸들러"""
logger.error(f"데이터베이스 오류: {str(exc)}", exc_info=True)
return JSONResponse(
status_code=500,
content=ErrorResponse.database_error_response(str(exc))
)
async def general_exception_handler(request: Request, exc: Exception):
"""일반 예외 핸들러"""
logger.error(f"예상치 못한 오류: {str(exc)}", exc_info=True)
return JSONResponse(
status_code=500,
content=ErrorResponse.create_error_response(
message="서버 내부 오류가 발생했습니다.",
error_code="INTERNAL_SERVER_ERROR",
status_code=500,
details={"error": str(exc)} if logger.level <= 10 else None # DEBUG 레벨일 때만 상세 에러 표시
)
)
def setup_error_handlers(app):
"""FastAPI 앱에 에러 핸들러 등록"""
app.add_exception_handler(TKMPException, tkmp_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(SQLAlchemyError, sqlalchemy_exception_handler)
app.add_exception_handler(Exception, general_exception_handler)
logger.info("에러 핸들러 등록 완료")

View File

@@ -0,0 +1,335 @@
"""
대용량 파일 처리 최적화 유틸리티
메모리 효율적인 파일 처리 및 청크 기반 처리
"""
import pandas as pd
import asyncio
from typing import Iterator, List, Dict, Any, Optional, Callable
from pathlib import Path
import tempfile
import os
from concurrent.futures import ThreadPoolExecutor
import gc
from .logger import get_logger
from ..config import get_settings
logger = get_logger(__name__)
settings = get_settings()
class FileProcessor:
"""대용량 파일 처리 최적화 클래스"""
def __init__(self, chunk_size: int = 1000, max_workers: int = 4):
self.chunk_size = chunk_size
self.max_workers = max_workers
self.executor = ThreadPoolExecutor(max_workers=max_workers)
def read_excel_chunks(self, file_path: str, sheet_name: str = None) -> Iterator[pd.DataFrame]:
"""
엑셀 파일을 청크 단위로 읽기
Args:
file_path: 파일 경로
sheet_name: 시트명 (None이면 첫 번째 시트)
Yields:
DataFrame: 청크 단위 데이터
"""
try:
# 파일 크기 확인
file_size = os.path.getsize(file_path)
logger.info(f"엑셀 파일 처리 시작 - 파일: {file_path}, 크기: {file_size} bytes")
# 전체 행 수 확인 (메모리 효율적으로)
with pd.ExcelFile(file_path) as xls:
if sheet_name is None:
sheet_name = xls.sheet_names[0]
# 첫 번째 청크로 컬럼 정보 확인
first_chunk = pd.read_excel(xls, sheet_name=sheet_name, nrows=self.chunk_size)
total_rows = len(first_chunk)
# 전체 데이터를 청크로 나누어 처리
processed_rows = 0
chunk_num = 0
while processed_rows < total_rows:
try:
# 청크 읽기
chunk = pd.read_excel(
xls,
sheet_name=sheet_name,
skiprows=processed_rows + 1 if processed_rows > 0 else 0,
nrows=self.chunk_size,
header=0 if processed_rows == 0 else None
)
if chunk.empty:
break
# 첫 번째 청크가 아닌 경우 컬럼명 설정
if processed_rows > 0:
chunk.columns = first_chunk.columns
chunk_num += 1
processed_rows += len(chunk)
logger.debug(f"청크 {chunk_num} 처리 - 행 수: {len(chunk)}, 누적: {processed_rows}")
yield chunk
# 메모리 정리
del chunk
gc.collect()
except Exception as e:
logger.error(f"청크 {chunk_num} 처리 중 오류: {e}")
break
logger.info(f"엑셀 파일 처리 완료 - 총 {chunk_num}개 청크, {processed_rows}행 처리")
except Exception as e:
logger.error(f"엑셀 파일 읽기 실패: {e}")
raise
def read_csv_chunks(self, file_path: str, encoding: str = 'utf-8') -> Iterator[pd.DataFrame]:
"""
CSV 파일을 청크 단위로 읽기
Args:
file_path: 파일 경로
encoding: 인코딩 (기본: utf-8)
Yields:
DataFrame: 청크 단위 데이터
"""
try:
file_size = os.path.getsize(file_path)
logger.info(f"CSV 파일 처리 시작 - 파일: {file_path}, 크기: {file_size} bytes")
chunk_num = 0
total_rows = 0
# pandas의 chunksize 옵션 사용
for chunk in pd.read_csv(file_path, chunksize=self.chunk_size, encoding=encoding):
chunk_num += 1
total_rows += len(chunk)
logger.debug(f"CSV 청크 {chunk_num} 처리 - 행 수: {len(chunk)}, 누적: {total_rows}")
yield chunk
# 메모리 정리
gc.collect()
logger.info(f"CSV 파일 처리 완료 - 총 {chunk_num}개 청크, {total_rows}행 처리")
except Exception as e:
logger.error(f"CSV 파일 읽기 실패: {e}")
raise
async def process_file_async(
self,
file_path: str,
processor_func: Callable[[pd.DataFrame], List[Dict]],
file_type: str = "excel"
) -> List[Dict]:
"""
파일을 비동기적으로 처리
Args:
file_path: 파일 경로
processor_func: 각 청크를 처리할 함수
file_type: 파일 타입 ("excel" 또는 "csv")
Returns:
List[Dict]: 처리된 결과 리스트
"""
try:
logger.info(f"비동기 파일 처리 시작 - {file_path}")
results = []
chunk_futures = []
# 파일 타입에 따른 청크 리더 선택
if file_type.lower() == "csv":
chunk_reader = self.read_csv_chunks(file_path)
else:
chunk_reader = self.read_excel_chunks(file_path)
# 청크별 비동기 처리
for chunk in chunk_reader:
# 스레드 풀에서 청크 처리
future = asyncio.get_event_loop().run_in_executor(
self.executor,
processor_func,
chunk
)
chunk_futures.append(future)
# 너무 많은 청크가 동시에 처리되지 않도록 제한
if len(chunk_futures) >= self.max_workers:
# 완료된 작업들 수집
completed_results = await asyncio.gather(*chunk_futures)
for result in completed_results:
if result:
results.extend(result)
chunk_futures = []
gc.collect()
# 남은 청크들 처리
if chunk_futures:
completed_results = await asyncio.gather(*chunk_futures)
for result in completed_results:
if result:
results.extend(result)
logger.info(f"비동기 파일 처리 완료 - 총 {len(results)}개 항목 처리")
return results
except Exception as e:
logger.error(f"비동기 파일 처리 실패: {e}")
raise
def optimize_dataframe_memory(self, df: pd.DataFrame) -> pd.DataFrame:
"""
DataFrame 메모리 사용량 최적화
Args:
df: 최적화할 DataFrame
Returns:
DataFrame: 최적화된 DataFrame
"""
try:
original_memory = df.memory_usage(deep=True).sum()
# 수치형 컬럼 최적화
for col in df.select_dtypes(include=['int64']).columns:
col_min = df[col].min()
col_max = df[col].max()
if col_min >= -128 and col_max <= 127:
df[col] = df[col].astype('int8')
elif col_min >= -32768 and col_max <= 32767:
df[col] = df[col].astype('int16')
elif col_min >= -2147483648 and col_max <= 2147483647:
df[col] = df[col].astype('int32')
# 실수형 컬럼 최적화
for col in df.select_dtypes(include=['float64']).columns:
df[col] = pd.to_numeric(df[col], downcast='float')
# 문자열 컬럼 최적화 (카테고리형으로 변환)
for col in df.select_dtypes(include=['object']).columns:
if df[col].nunique() / len(df) < 0.5: # 고유값이 50% 미만인 경우
df[col] = df[col].astype('category')
optimized_memory = df.memory_usage(deep=True).sum()
memory_reduction = (original_memory - optimized_memory) / original_memory * 100
logger.debug(f"DataFrame 메모리 최적화 완료 - 감소율: {memory_reduction:.1f}%")
return df
except Exception as e:
logger.warning(f"DataFrame 메모리 최적화 실패: {e}")
return df
def create_temp_file(self, suffix: str = '.tmp') -> str:
"""
임시 파일 생성
Args:
suffix: 파일 확장자
Returns:
str: 임시 파일 경로
"""
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
temp_file.close()
logger.debug(f"임시 파일 생성: {temp_file.name}")
return temp_file.name
def cleanup_temp_file(self, file_path: str):
"""
임시 파일 정리
Args:
file_path: 삭제할 파일 경로
"""
try:
if os.path.exists(file_path):
os.unlink(file_path)
logger.debug(f"임시 파일 삭제: {file_path}")
except Exception as e:
logger.warning(f"임시 파일 삭제 실패: {file_path}, error: {e}")
def get_file_info(self, file_path: str) -> Dict[str, Any]:
"""
파일 정보 조회
Args:
file_path: 파일 경로
Returns:
Dict: 파일 정보
"""
try:
file_stat = os.stat(file_path)
file_ext = Path(file_path).suffix.lower()
info = {
"file_path": file_path,
"file_size": file_stat.st_size,
"file_size_mb": round(file_stat.st_size / (1024 * 1024), 2),
"file_extension": file_ext,
"is_large_file": file_stat.st_size > 10 * 1024 * 1024, # 10MB 이상
"recommended_chunk_size": self._calculate_optimal_chunk_size(file_stat.st_size)
}
# 파일 타입별 추가 정보
if file_ext in ['.xlsx', '.xls']:
info["file_type"] = "excel"
info["processing_method"] = "chunk_based" if info["is_large_file"] else "full_load"
elif file_ext == '.csv':
info["file_type"] = "csv"
info["processing_method"] = "chunk_based" if info["is_large_file"] else "full_load"
return info
except Exception as e:
logger.error(f"파일 정보 조회 실패: {e}")
return {"error": str(e)}
def _calculate_optimal_chunk_size(self, file_size: int) -> int:
"""
파일 크기에 따른 최적 청크 크기 계산
Args:
file_size: 파일 크기 (bytes)
Returns:
int: 최적 청크 크기
"""
# 파일 크기에 따른 청크 크기 조정
if file_size < 1024 * 1024: # 1MB 미만
return 500
elif file_size < 10 * 1024 * 1024: # 10MB 미만
return 1000
elif file_size < 50 * 1024 * 1024: # 50MB 미만
return 2000
else: # 50MB 이상
return 5000
def __del__(self):
"""소멸자 - 스레드 풀 정리"""
if hasattr(self, 'executor'):
self.executor.shutdown(wait=True)
# 전역 파일 프로세서 인스턴스
file_processor = FileProcessor()

View File

@@ -0,0 +1,169 @@
"""
파일 업로드 검증 유틸리티
보안 강화를 위한 파일 검증 로직
"""
import os
import magic
from pathlib import Path
from typing import List, Optional, Tuple
from fastapi import UploadFile, HTTPException
from ..config import get_settings
from .logger import get_logger
settings = get_settings()
logger = get_logger(__name__)
class FileValidator:
"""파일 업로드 검증 클래스"""
def __init__(self):
self.max_file_size = settings.security.max_file_size
self.allowed_extensions = settings.security.allowed_file_extensions
# MIME 타입 매핑
self.mime_type_mapping = {
'.xlsx': [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/octet-stream' # 일부 브라우저에서 xlsx를 이렇게 인식
],
'.xls': [
'application/vnd.ms-excel',
'application/octet-stream'
],
'.csv': [
'text/csv',
'text/plain',
'application/csv'
]
}
def validate_file_extension(self, filename: str) -> bool:
"""파일 확장자 검증"""
file_ext = Path(filename).suffix.lower()
is_valid = file_ext in self.allowed_extensions
if not is_valid:
logger.warning(f"허용되지 않은 파일 확장자: {file_ext}, 파일: {filename}")
return is_valid
def validate_file_size(self, file_size: int) -> bool:
"""파일 크기 검증"""
is_valid = file_size <= self.max_file_size
if not is_valid:
logger.warning(f"파일 크기 초과: {file_size} bytes (최대: {self.max_file_size} bytes)")
return is_valid
def validate_filename(self, filename: str) -> bool:
"""파일명 검증 (보안 위험 문자 체크)"""
# 위험한 문자들
dangerous_chars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|']
for char in dangerous_chars:
if char in filename:
logger.warning(f"위험한 문자 포함된 파일명: {filename}")
return False
# 파일명 길이 체크 (255자 제한)
if len(filename) > 255:
logger.warning(f"파일명이 너무 긺: {len(filename)} 문자")
return False
return True
def validate_mime_type(self, file_content: bytes, filename: str) -> bool:
"""MIME 타입 검증 (파일 내용 기반)"""
try:
# python-magic을 사용한 MIME 타입 검증
detected_mime = magic.from_buffer(file_content, mime=True)
file_ext = Path(filename).suffix.lower()
expected_mimes = self.mime_type_mapping.get(file_ext, [])
if detected_mime in expected_mimes:
return True
logger.warning(f"MIME 타입 불일치 - 파일: {filename}, 감지된 타입: {detected_mime}, 예상 타입: {expected_mimes}")
return False
except Exception as e:
logger.error(f"MIME 타입 검증 실패: {e}")
# magic 라이브러리 오류 시 확장자 검증으로 대체
return self.validate_file_extension(filename)
def sanitize_filename(self, filename: str) -> str:
"""파일명 정화 (안전한 파일명으로 변환)"""
# 위험한 문자들을 언더스코어로 대체
dangerous_chars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|']
sanitized = filename
for char in dangerous_chars:
sanitized = sanitized.replace(char, '_')
# 연속된 언더스코어 제거
while '__' in sanitized:
sanitized = sanitized.replace('__', '_')
# 앞뒤 공백 및 점 제거
sanitized = sanitized.strip(' .')
return sanitized
async def validate_upload_file(self, file: UploadFile) -> Tuple[bool, Optional[str]]:
"""
업로드 파일 종합 검증
Returns:
Tuple[bool, Optional[str]]: (검증 성공 여부, 에러 메시지)
"""
try:
# 1. 파일명 검증
if not self.validate_filename(file.filename):
return False, f"유효하지 않은 파일명: {file.filename}"
# 2. 확장자 검증
if not self.validate_file_extension(file.filename):
return False, f"허용되지 않은 파일 형식입니다. 허용 형식: {', '.join(self.allowed_extensions)}"
# 3. 파일 내용 읽기
file_content = await file.read()
await file.seek(0) # 파일 포인터 리셋
# 4. 파일 크기 검증
if not self.validate_file_size(len(file_content)):
return False, f"파일 크기가 너무 큽니다. 최대 크기: {self.max_file_size // (1024*1024)}MB"
# 5. MIME 타입 검증
if not self.validate_mime_type(file_content, file.filename):
return False, "파일 형식이 올바르지 않습니다."
logger.info(f"파일 검증 성공: {file.filename} ({len(file_content)} bytes)")
return True, None
except Exception as e:
logger.error(f"파일 검증 중 오류 발생: {e}", exc_info=True)
return False, f"파일 검증 중 오류가 발생했습니다: {str(e)}"
# 전역 파일 검증기 인스턴스
file_validator = FileValidator()
async def validate_uploaded_file(file: UploadFile) -> None:
"""
파일 검증 헬퍼 함수 (HTTPException 발생)
Args:
file: 업로드된 파일
Raises:
HTTPException: 검증 실패 시
"""
is_valid, error_message = await file_validator.validate_upload_file(file)
if not is_valid:
raise HTTPException(status_code=400, detail=error_message)

View File

@@ -0,0 +1,87 @@
"""
로깅 유틸리티 모듈
중앙화된 로깅 설정 및 관리
"""
import logging
import os
from logging.handlers import RotatingFileHandler
from typing import Optional
from ..config import get_settings
settings = get_settings()
def setup_logger(
name: str,
log_file: Optional[str] = None,
level: str = None
) -> logging.Logger:
"""
로거 설정 및 반환
Args:
name: 로거 이름
log_file: 로그 파일 경로 (선택사항)
level: 로그 레벨 (선택사항)
Returns:
설정된 로거 인스턴스
"""
logger = logging.getLogger(name)
# 이미 핸들러가 설정된 경우 중복 방지
if logger.handlers:
return logger
# 로그 레벨 설정
log_level = level or settings.logging.level
logger.setLevel(getattr(logging, log_level.upper()))
# 포맷터 설정
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s'
)
# 콘솔 핸들러
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# 파일 핸들러 (선택사항)
if log_file or settings.logging.file_path:
file_path = log_file or settings.logging.file_path
# 로그 디렉토리 생성
log_dir = os.path.dirname(file_path)
if log_dir and not os.path.exists(log_dir):
os.makedirs(log_dir, exist_ok=True)
# 로테이팅 파일 핸들러 (10MB, 5개 파일 유지)
file_handler = RotatingFileHandler(
file_path,
maxBytes=10*1024*1024, # 10MB
backupCount=5,
encoding='utf-8'
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger
def get_logger(name: str) -> logging.Logger:
"""
로거 인스턴스 반환 (간편 함수)
Args:
name: 로거 이름
Returns:
로거 인스턴스
"""
return setup_logger(name)
# 애플리케이션 전역 로거
app_logger = setup_logger("tk_mp_app", settings.logging.file_path)

View File

@@ -0,0 +1,355 @@
"""
트랜잭션 관리 유틸리티
데이터 일관성을 위한 트랜잭션 관리 및 데코레이터
"""
import functools
from typing import Any, Callable, Optional, TypeVar, Generic
from contextlib import contextmanager
from sqlalchemy.orm import Session
from sqlalchemy.exc import SQLAlchemyError
import asyncio
from .logger import get_logger
logger = get_logger(__name__)
T = TypeVar('T')
class TransactionManager:
"""트랜잭션 관리 클래스"""
def __init__(self, db: Session):
self.db = db
@contextmanager
def transaction(self, rollback_on_exception: bool = True):
"""
트랜잭션 컨텍스트 매니저
Args:
rollback_on_exception: 예외 발생 시 롤백 여부
"""
try:
logger.debug("트랜잭션 시작")
yield self.db
self.db.commit()
logger.debug("트랜잭션 커밋 완료")
except Exception as e:
if rollback_on_exception:
self.db.rollback()
logger.warning(f"트랜잭션 롤백 - 에러: {str(e)}")
else:
logger.error(f"트랜잭션 에러 (롤백 안함) - 에러: {str(e)}")
raise
@contextmanager
def savepoint(self, name: Optional[str] = None):
"""
세이브포인트 컨텍스트 매니저
Args:
name: 세이브포인트 이름
"""
savepoint_name = name or f"sp_{id(self)}"
try:
# 세이브포인트 생성
savepoint = self.db.begin_nested()
logger.debug(f"세이브포인트 생성: {savepoint_name}")
yield self.db
# 세이브포인트 커밋
savepoint.commit()
logger.debug(f"세이브포인트 커밋: {savepoint_name}")
except Exception as e:
# 세이브포인트 롤백
savepoint.rollback()
logger.warning(f"세이브포인트 롤백: {savepoint_name} - 에러: {str(e)}")
raise
def execute_in_transaction(self, func: Callable[..., T], *args, **kwargs) -> T:
"""
함수를 트랜잭션 내에서 실행
Args:
func: 실행할 함수
*args: 함수 인자
**kwargs: 함수 키워드 인자
Returns:
함수 실행 결과
"""
with self.transaction():
return func(*args, **kwargs)
async def execute_in_transaction_async(self, func: Callable[..., T], *args, **kwargs) -> T:
"""
비동기 함수를 트랜잭션 내에서 실행
Args:
func: 실행할 비동기 함수
*args: 함수 인자
**kwargs: 함수 키워드 인자
Returns:
함수 실행 결과
"""
with self.transaction():
if asyncio.iscoroutinefunction(func):
return await func(*args, **kwargs)
else:
return func(*args, **kwargs)
def transactional(rollback_on_exception: bool = True):
"""
트랜잭션 데코레이터
Args:
rollback_on_exception: 예외 발생 시 롤백 여부
"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs):
# 첫 번째 인자가 Session인지 확인
if args and isinstance(args[0], Session):
db = args[0]
transaction_manager = TransactionManager(db)
try:
with transaction_manager.transaction(rollback_on_exception):
return func(*args, **kwargs)
except Exception as e:
logger.error(f"트랜잭션 함수 실행 실패: {func.__name__} - {str(e)}")
raise
else:
# Session이 없으면 일반 함수로 실행
return func(*args, **kwargs)
return wrapper
return decorator
def async_transactional(rollback_on_exception: bool = True):
"""
비동기 트랜잭션 데코레이터
Args:
rollback_on_exception: 예외 발생 시 롤백 여부
"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
async def wrapper(*args, **kwargs):
# 첫 번째 인자가 Session인지 확인
if args and isinstance(args[0], Session):
db = args[0]
transaction_manager = TransactionManager(db)
try:
with transaction_manager.transaction(rollback_on_exception):
if asyncio.iscoroutinefunction(func):
return await func(*args, **kwargs)
else:
return func(*args, **kwargs)
except Exception as e:
logger.error(f"비동기 트랜잭션 함수 실행 실패: {func.__name__} - {str(e)}")
raise
else:
# Session이 없으면 일반 함수로 실행
if asyncio.iscoroutinefunction(func):
return await func(*args, **kwargs)
else:
return func(*args, **kwargs)
return wrapper
return decorator
class BatchProcessor:
"""배치 처리를 위한 트랜잭션 관리"""
def __init__(self, db: Session, batch_size: int = 1000):
self.db = db
self.batch_size = batch_size
self.transaction_manager = TransactionManager(db)
def process_in_batches(
self,
items: list,
process_func: Callable,
commit_per_batch: bool = True
):
"""
아이템들을 배치 단위로 처리
Args:
items: 처리할 아이템 리스트
process_func: 각 아이템을 처리할 함수
commit_per_batch: 배치마다 커밋 여부
"""
total_items = len(items)
processed_count = 0
failed_count = 0
logger.info(f"배치 처리 시작 - 총 {total_items}개 아이템, 배치 크기: {self.batch_size}")
for i in range(0, total_items, self.batch_size):
batch = items[i:i + self.batch_size]
batch_num = (i // self.batch_size) + 1
try:
if commit_per_batch:
with self.transaction_manager.transaction():
self._process_batch(batch, process_func)
else:
self._process_batch(batch, process_func)
processed_count += len(batch)
logger.debug(f"배치 {batch_num} 처리 완료 - {len(batch)}개 아이템")
except Exception as e:
failed_count += len(batch)
logger.error(f"배치 {batch_num} 처리 실패 - {str(e)}")
# 개별 아이템 처리 시도
if commit_per_batch:
self._process_batch_individually(batch, process_func)
# 전체 커밋 (배치마다 커밋하지 않은 경우)
if not commit_per_batch:
try:
self.db.commit()
logger.info("전체 배치 처리 커밋 완료")
except Exception as e:
self.db.rollback()
logger.error(f"전체 배치 처리 커밋 실패: {str(e)}")
raise
logger.info(f"배치 처리 완료 - 성공: {processed_count}, 실패: {failed_count}")
return {
"total_items": total_items,
"processed_count": processed_count,
"failed_count": failed_count,
"success_rate": (processed_count / total_items) * 100 if total_items > 0 else 0
}
def _process_batch(self, batch: list, process_func: Callable):
"""배치 처리"""
for item in batch:
process_func(item)
def _process_batch_individually(self, batch: list, process_func: Callable):
"""배치 내 아이템을 개별적으로 처리 (에러 복구용)"""
for item in batch:
try:
with self.transaction_manager.savepoint():
process_func(item)
except Exception as e:
logger.warning(f"개별 아이템 처리 실패: {str(e)}")
class DatabaseLock:
"""데이터베이스 레벨 락 관리"""
def __init__(self, db: Session):
self.db = db
@contextmanager
def advisory_lock(self, lock_id: int):
"""
PostgreSQL Advisory Lock
Args:
lock_id: 락 ID
"""
try:
# Advisory Lock 획득
result = self.db.execute(f"SELECT pg_advisory_lock({lock_id})")
logger.debug(f"Advisory Lock 획득: {lock_id}")
yield
finally:
# Advisory Lock 해제
self.db.execute(f"SELECT pg_advisory_unlock({lock_id})")
logger.debug(f"Advisory Lock 해제: {lock_id}")
@contextmanager
def table_lock(self, table_name: str, lock_mode: str = "ACCESS EXCLUSIVE"):
"""
테이블 레벨 락
Args:
table_name: 테이블명
lock_mode: 락 모드
"""
try:
# 테이블 락 획득
self.db.execute(f"LOCK TABLE {table_name} IN {lock_mode} MODE")
logger.debug(f"테이블 락 획득: {table_name} ({lock_mode})")
yield
except Exception as e:
logger.error(f"테이블 락 실패: {table_name} - {str(e)}")
raise
class TransactionStats:
"""트랜잭션 통계 수집"""
def __init__(self):
self.stats = {
"total_transactions": 0,
"successful_transactions": 0,
"failed_transactions": 0,
"rollback_count": 0,
"savepoint_count": 0
}
def record_transaction_start(self):
"""트랜잭션 시작 기록"""
self.stats["total_transactions"] += 1
def record_transaction_success(self):
"""트랜잭션 성공 기록"""
self.stats["successful_transactions"] += 1
def record_transaction_failure(self):
"""트랜잭션 실패 기록"""
self.stats["failed_transactions"] += 1
def record_rollback(self):
"""롤백 기록"""
self.stats["rollback_count"] += 1
def record_savepoint(self):
"""세이브포인트 기록"""
self.stats["savepoint_count"] += 1
def get_stats(self) -> dict:
"""통계 반환"""
total = self.stats["total_transactions"]
if total > 0:
self.stats["success_rate"] = (self.stats["successful_transactions"] / total) * 100
self.stats["failure_rate"] = (self.stats["failed_transactions"] / total) * 100
else:
self.stats["success_rate"] = 0
self.stats["failure_rate"] = 0
return self.stats.copy()
def reset_stats(self):
"""통계 초기화"""
for key in self.stats:
if key not in ["success_rate", "failure_rate"]:
self.stats[key] = 0
# 전역 트랜잭션 통계 인스턴스
transaction_stats = TransactionStats()

13
backend/entrypoint.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
set -e
# Wait for DB to be ready (optional, but good practice if not handled by docker-compose)
# /wait-for-it.sh db:5432 --
# Run migrations
echo "Running database migrations..."
alembic upgrade head
# Start application
echo "Starting application..."
exec "$@"

25
backend/env.example Normal file
View File

@@ -0,0 +1,25 @@
# TK-MP-Project 환경변수 설정 예시
# 실제 사용 시 .env 파일로 복사하여 사용
# 환경 설정 (development, production, synology)
ENVIRONMENT=development
# 애플리케이션 설정
APP_NAME=TK-MP BOM Management API
APP_VERSION=1.0.0
DEBUG=true
# 데이터베이스 설정
DATABASE_URL=postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom
# Redis 설정
REDIS_URL=redis://redis:6379
# 보안 설정
# CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"] # 필요시 직접 설정
MAX_FILE_SIZE=52428800 # 50MB in bytes
ALLOWED_FILE_EXTENSIONS=[".xlsx",".xls",".csv"]
# 로깅 설정
LOG_LEVEL=INFO
LOG_FILE=logs/app.log

View File

@@ -1,30 +0,0 @@
"""
수정된 스풀 시스템 사용 예시
"""
# 시나리오: A-1 도면에서 파이프 3개 발견
examples = [
{
"dwg_name": "A-1",
"pipes": [
{"description": "PIPE 1", "user_input_spool": "A"}, # A-1-A
{"description": "PIPE 2", "user_input_spool": "A"}, # A-1-A (같은 스풀)
{"description": "PIPE 3", "user_input_spool": "B"} # A-1-B (다른 스풀)
],
"area_assignment": "#01" # 별도: A-1 도면은 #01 구역에 위치
}
]
# 결과:
spool_identifiers = [
"A-1-A", # 파이프 1, 2가 속함
"A-1-B" # 파이프 3이 속함
]
area_assignment = {
"#01": ["A-1"] # A-1 도면은 #01 구역에 물리적으로 위치
}
print("✅ 수정된 스풀 구조가 적용되었습니다!")
print(f"스풀 식별자: {spool_identifiers}")
print(f"에리어 할당: {area_assignment}")

View File

@@ -0,0 +1,576 @@
{
"request_no": "PR-20251014-001",
"job_no": "테스트용",
"created_at": "2025-10-14T22:16:10.998006",
"materials": [
{
"material_id": 60768,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 11,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 60776,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A106 B",
"quantity": 92,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": [
{
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|1/2\"|undefined|ASTM A312 TP304",
"material_ids": [
60768
],
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 11,
"unit": "m",
"total_length": 1395.1,
"pipe_lengths": [
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 155,
"quantity": 1,
"totalLength": 155
},
{
"length": 155,
"quantity": 1,
"totalLength": 155
},
{
"length": 200,
"quantity": 1,
"totalLength": 200
},
{
"length": 245.1,
"quantity": 1,
"totalLength": 245.1
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
}
],
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|3/4\"|undefined|ASTM A106 B",
"material_ids": [
60776
],
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A106 B",
"quantity": 92,
"unit": "m",
"total_length": 7920.2,
"pipe_lengths": [
{
"length": 60,
"quantity": 1,
"totalLength": 60
},
{
"length": 60,
"quantity": 1,
"totalLength": 60
},
{
"length": 60,
"quantity": 1,
"totalLength": 60
},
{
"length": 60,
"quantity": 1,
"totalLength": 60
},
{
"length": 43.3,
"quantity": 1,
"totalLength": 43.3
},
{
"length": 43.3,
"quantity": 1,
"totalLength": 43.3
},
{
"length": 43.3,
"quantity": 1,
"totalLength": 43.3
},
{
"length": 43.3,
"quantity": 1,
"totalLength": 43.3
},
{
"length": 43.3,
"quantity": 1,
"totalLength": 43.3
},
{
"length": 43.3,
"quantity": 1,
"totalLength": 43.3
},
{
"length": 50,
"quantity": 1,
"totalLength": 50
},
{
"length": 50,
"quantity": 1,
"totalLength": 50
},
{
"length": 50,
"quantity": 1,
"totalLength": 50
},
{
"length": 50,
"quantity": 1,
"totalLength": 50
},
{
"length": 50,
"quantity": 1,
"totalLength": 50
},
{
"length": 50,
"quantity": 1,
"totalLength": 50
},
{
"length": 50,
"quantity": 1,
"totalLength": 50
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 76.2,
"quantity": 1,
"totalLength": 76.2
},
{
"length": 76.2,
"quantity": 1,
"totalLength": 76.2
},
{
"length": 76.2,
"quantity": 1,
"totalLength": 76.2
},
{
"length": 76.2,
"quantity": 1,
"totalLength": 76.2
},
{
"length": 76.2,
"quantity": 1,
"totalLength": 76.2
},
{
"length": 76.2,
"quantity": 1,
"totalLength": 76.2
},
{
"length": 77.6,
"quantity": 1,
"totalLength": 77.6
},
{
"length": 77.6,
"quantity": 1,
"totalLength": 77.6
},
{
"length": 77.6,
"quantity": 1,
"totalLength": 77.6
},
{
"length": 77.6,
"quantity": 1,
"totalLength": 77.6
},
{
"length": 77.6,
"quantity": 1,
"totalLength": 77.6
},
{
"length": 77.6,
"quantity": 1,
"totalLength": 77.6
},
{
"length": 80,
"quantity": 1,
"totalLength": 80
},
{
"length": 80,
"quantity": 1,
"totalLength": 80
},
{
"length": 80,
"quantity": 1,
"totalLength": 80
},
{
"length": 80,
"quantity": 1,
"totalLength": 80
},
{
"length": 80,
"quantity": 1,
"totalLength": 80
},
{
"length": 80,
"quantity": 1,
"totalLength": 80
},
{
"length": 80,
"quantity": 1,
"totalLength": 80
},
{
"length": 80,
"quantity": 1,
"totalLength": 80
},
{
"length": 80,
"quantity": 1,
"totalLength": 80
},
{
"length": 88.6,
"quantity": 1,
"totalLength": 88.6
},
{
"length": 88.6,
"quantity": 1,
"totalLength": 88.6
},
{
"length": 98.4,
"quantity": 1,
"totalLength": 98.4
},
{
"length": 98.4,
"quantity": 1,
"totalLength": 98.4
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 120,
"quantity": 1,
"totalLength": 120
},
{
"length": 120,
"quantity": 1,
"totalLength": 120
},
{
"length": 150,
"quantity": 1,
"totalLength": 150
},
{
"length": 150,
"quantity": 1,
"totalLength": 150
},
{
"length": 150,
"quantity": 1,
"totalLength": 150
},
{
"length": 150,
"quantity": 1,
"totalLength": 150
},
{
"length": 150,
"quantity": 1,
"totalLength": 150
},
{
"length": 223.6,
"quantity": 1,
"totalLength": 223.6
}
],
"user_requirement": ""
}
]
}

View File

@@ -0,0 +1,101 @@
{
"request_no": "PR-20251014-002",
"job_no": "TKG-25000P",
"created_at": "2025-10-14T06:54:44.585437",
"materials": [
{
"material_id": 5552,
"description": "SIGHT GLASS, FLG, 150LB",
"category": "FLANGE",
"size": "1\"",
"material_grade": "SS",
"quantity": 6,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5558,
"description": "SIGHT GLASS, FLG, 150LB",
"category": "FLANGE",
"size": "1/2\"",
"material_grade": "SS",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5563,
"description": "SIGHT GLASS, FLG, 150LB",
"category": "FLANGE",
"size": "2\"",
"material_grade": "SS",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5566,
"description": "STRAINER, FLG, 150LB",
"category": "FLANGE",
"size": "2\"",
"material_grade": "-",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": [
{
"group_key": "SIGHT GLASS, FLG, 150LB|1\"|undefined|SS",
"material_ids": [
5552
],
"description": "SIGHT GLASS, FLG, 150LB",
"category": "FLANGE",
"size": "1\"",
"material_grade": "SS",
"quantity": 6,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "SIGHT GLASS, FLG, 150LB|1/2\"|undefined|SS",
"material_ids": [
5558
],
"description": "SIGHT GLASS, FLG, 150LB",
"category": "FLANGE",
"size": "1/2\"",
"material_grade": "SS",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "SIGHT GLASS, FLG, 150LB|2\"|undefined|SS",
"material_ids": [
5563
],
"description": "SIGHT GLASS, FLG, 150LB",
"category": "FLANGE",
"size": "2\"",
"material_grade": "SS",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "STRAINER, FLG, 150LB|2\"|undefined|-",
"material_ids": [
5566
],
"description": "STRAINER, FLG, 150LB",
"category": "FLANGE",
"size": "2\"",
"material_grade": "-",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
}
]
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,745 @@
{
"request_no": "PR-20251015-002",
"job_no": "TKG-20000P",
"created_at": "2025-10-15T05:53:13.449375",
"materials": [
{
"material_id": 76366,
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "10\"",
"material_grade": "ASTM A182 F304",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76371,
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "12\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76372,
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
"category": "FLANGE",
"size": "2\"",
"material_grade": "ASTM A105",
"quantity": 36,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76408,
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
"category": "FLANGE",
"size": "2\"",
"material_grade": "ASTM A105",
"quantity": 6,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76414,
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "2\"",
"material_grade": "ASTM A182 F304",
"quantity": 7,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76422,
"description": "FLG WELD NECK RF SCH 40, 600LB, ASTM A105",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A105",
"quantity": 8,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76427,
"description": "FLG WELD NECK RF SCH 40S, 600LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A182 F304",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76429,
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A105",
"quantity": 12,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76441,
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76446,
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A182 F304",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76455,
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
"category": "FLANGE",
"size": "4\"",
"material_grade": "ASTM A105",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76458,
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
"category": "FLANGE",
"size": "6\"",
"material_grade": "ASTM A105",
"quantity": 10,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76468,
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1/2\"",
"material_grade": "ASTM A182 F304",
"quantity": 14,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76480,
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A105",
"quantity": 15,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76484,
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1\"",
"material_grade": "ASTM A182 F304",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76485,
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1\"",
"material_grade": "ASTM A105",
"quantity": 66,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76489,
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
"category": "FLANGE",
"size": "1\"",
"material_grade": "ASTM A105",
"quantity": 6,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76491,
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1 1/2\"",
"material_grade": "ASTM A182 F304",
"quantity": 8,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76499,
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 40,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76535,
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76540,
"description": "RED. FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1 1/2\" x 3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76542,
"description": "RED. FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 4,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76546,
"description": "RED. FLG SWRF SCH 80, 600LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76556,
"description": "FLG SWRF SCH 40S, 600LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76624,
"description": "RED. FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76629,
"description": "FLG SWRF SCH 80, 300LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76634,
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1/2\"",
"material_grade": "ASTM A105",
"quantity": 57,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76691,
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 7,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76698,
"description": "FLG SWRF SCH 40S, 300LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76699,
"description": "FLG SWRF SCH 40S, 600LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76711,
"description": "FLG SWRF SCH 80, 300LB, ASTM A105",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 76713,
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A105",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": [
{
"group_key": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304|10\"|undefined|ASTM A182 F304",
"material_ids": [
76366
],
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "10\"",
"material_grade": "ASTM A182 F304",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304|12\"|undefined|ASTM A182 F304",
"material_ids": [
76371
],
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "12\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105|2\"|undefined|ASTM A105",
"material_ids": [
76372
],
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
"category": "FLANGE",
"size": "2\"",
"material_grade": "ASTM A105",
"quantity": 36,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105|2\"|undefined|ASTM A105",
"material_ids": [
76408
],
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
"category": "FLANGE",
"size": "2\"",
"material_grade": "ASTM A105",
"quantity": 6,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304|2\"|undefined|ASTM A182 F304",
"material_ids": [
76414
],
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "2\"",
"material_grade": "ASTM A182 F304",
"quantity": 7,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG WELD NECK RF SCH 40, 600LB, ASTM A105|3\"|undefined|ASTM A105",
"material_ids": [
76422
],
"description": "FLG WELD NECK RF SCH 40, 600LB, ASTM A105",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A105",
"quantity": 8,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG WELD NECK RF SCH 40S, 600LB, ASTM A182 F304|3\"|undefined|ASTM A182 F304",
"material_ids": [
76427
],
"description": "FLG WELD NECK RF SCH 40S, 600LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A182 F304",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105|3\"|undefined|ASTM A105",
"material_ids": [
76429
],
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A105",
"quantity": 12,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105|3\"|undefined|ASTM A105",
"material_ids": [
76441
],
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304|3\"|undefined|ASTM A182 F304",
"material_ids": [
76446
],
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A182 F304",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105|4\"|undefined|ASTM A105",
"material_ids": [
76455
],
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
"category": "FLANGE",
"size": "4\"",
"material_grade": "ASTM A105",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105|6\"|undefined|ASTM A105",
"material_ids": [
76458
],
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
"category": "FLANGE",
"size": "6\"",
"material_grade": "ASTM A105",
"quantity": 10,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304|1/2\"|undefined|ASTM A182 F304",
"material_ids": [
76468
],
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1/2\"",
"material_grade": "ASTM A182 F304",
"quantity": 14,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG SWRF SCH 80, 150LB, ASTM A105|3/4\"|undefined|ASTM A105",
"material_ids": [
76480
],
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A105",
"quantity": 15,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304|1\"|undefined|ASTM A182 F304",
"material_ids": [
76484
],
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1\"",
"material_grade": "ASTM A182 F304",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG SWRF SCH 80, 150LB, ASTM A105|1\"|undefined|ASTM A105",
"material_ids": [
76485
],
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1\"",
"material_grade": "ASTM A105",
"quantity": 66,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG SWRF SCH 80, 600LB, ASTM A105|1\"|undefined|ASTM A105",
"material_ids": [
76489
],
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
"category": "FLANGE",
"size": "1\"",
"material_grade": "ASTM A105",
"quantity": 6,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304|1 1/2\"|undefined|ASTM A182 F304",
"material_ids": [
76491
],
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1 1/2\"",
"material_grade": "ASTM A182 F304",
"quantity": 8,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG SWRF SCH 80, 150LB, ASTM A105|1 1/2\"|undefined|ASTM A105",
"material_ids": [
76499
],
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 40,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG SWRF SCH 80, 600LB, ASTM A105|1 1/2\"|undefined|ASTM A105",
"material_ids": [
76535
],
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "RED. FLG SWRF SCH 40S, 150LB, ASTM A182 F304|1 1/2\" x 3/4\"|undefined|ASTM A182 F304",
"material_ids": [
76540
],
"description": "RED. FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1 1/2\" x 3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "RED. FLG SWRF SCH 80, 150LB, ASTM A105|1 1/2\" x 3/4\"|undefined|ASTM A105",
"material_ids": [
76542
],
"description": "RED. FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 4,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "RED. FLG SWRF SCH 80, 600LB, ASTM A105|1 1/2\" x 3/4\"|undefined|ASTM A105",
"material_ids": [
76546
],
"description": "RED. FLG SWRF SCH 80, 600LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG SWRF SCH 40S, 600LB, ASTM A182 F304|1\"|undefined|ASTM A182 F304",
"material_ids": [
76556
],
"description": "FLG SWRF SCH 40S, 600LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "RED. FLG SWRF SCH 80, 150LB, ASTM A105|1\" x 3/4\"|undefined|ASTM A105",
"material_ids": [
76624
],
"description": "RED. FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG SWRF SCH 80, 300LB, ASTM A105|1 1/2\"|undefined|ASTM A105",
"material_ids": [
76629
],
"description": "FLG SWRF SCH 80, 300LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG SWRF SCH 80, 150LB, ASTM A105|1/2\"|undefined|ASTM A105",
"material_ids": [
76634
],
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1/2\"",
"material_grade": "ASTM A105",
"quantity": 57,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304|3/4\"|undefined|ASTM A182 F304",
"material_ids": [
76691
],
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 7,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG SWRF SCH 40S, 300LB, ASTM A182 F304|3/4\"|undefined|ASTM A182 F304",
"material_ids": [
76698
],
"description": "FLG SWRF SCH 40S, 300LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG SWRF SCH 40S, 600LB, ASTM A182 F304|3/4\"|undefined|ASTM A182 F304",
"material_ids": [
76699
],
"description": "FLG SWRF SCH 40S, 600LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG SWRF SCH 80, 300LB, ASTM A105|3/4\"|undefined|ASTM A105",
"material_ids": [
76711
],
"description": "FLG SWRF SCH 80, 300LB, ASTM A105",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG SWRF SCH 80, 600LB, ASTM A105|3/4\"|undefined|ASTM A105",
"material_ids": [
76713
],
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A105",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
}
]
}

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,168 @@
{
"request_no": "PR-20251016-001",
"job_no": "1",
"created_at": "2025-10-16T05:40:46.947440",
"materials": [
{
"material_id": 3543,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 11,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3551,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A106 B",
"quantity": 92,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3555,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1\"",
"material_grade": "ASTM A312 TP304",
"quantity": 23,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3565,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1\"",
"material_grade": "ASTM A106 B",
"quantity": 139,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3574,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1 1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 14,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3588,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1 1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 98,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3844,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 82,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3926,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "10\"",
"material_grade": "ASTM A312 TP304",
"quantity": 4,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3930,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "12\"",
"material_grade": "ASTM A312 TP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3931,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "2\"",
"material_grade": "ASTM A106 B",
"quantity": 50,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3981,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3990,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "3\"",
"material_grade": "ASTM A106 B",
"quantity": 25,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3998,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "3\"",
"material_grade": "ASTM A312 TP304",
"quantity": 8,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4023,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A312 TP304",
"quantity": 15,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4126,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "4\"",
"material_grade": "ASTM A106 B",
"quantity": 12,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4138,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "6\"",
"material_grade": "ASTM A106 B",
"quantity": 13,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": []
}

Binary file not shown.

View File

@@ -0,0 +1,778 @@
{
"request_no": "PR-20251016-002",
"job_no": "1",
"created_at": "2025-10-16T05:44:08.264221",
"materials": [
{
"material_id": 3540,
"description": "HALF NIPPLE, SMLS, SCH 80S, ASTM A312 TP304 SW X NPT",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3542,
"description": "HALF NIPPLE, SMLS, SCH 80S, ASTM A312 TP304 SW X NPT",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3682,
"description": "HALF NIPPLE, SMLS, SCH 160, ASTM A106 B SW * NPT",
"category": "FITTING",
"size": "1\"",
"material_grade": "ASTM A106 B",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3831,
"description": "HALF NIPPLE, SMLS, SCH 160, ASTM A106 B SW X NPT",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 4,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3835,
"description": "HALF NIPPLE, SMLS, SCH 160, ASTM A106 B SW X NPT",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3838,
"description": "HALF NIPPLE, SMLS, SCH 160, ASTM A106 B SW X NPT",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3840,
"description": "NIPPLE, SMLS, SCH 160, ASTM A106 B",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4151,
"description": "90 LR ELL, SMLS, SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "10\"",
"material_grade": "ASTM A403 WP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4152,
"description": "90 LR ELL, SMLS, SCH 40, ASTM A234 WPB",
"category": "FITTING",
"size": "2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 25,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4177,
"description": "90 LR ELL, SMLS, SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "2\"",
"material_grade": "ASTM A403 WP304",
"quantity": 6,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4183,
"description": "90 LR ELL, SMLS, SCH 40, ASTM A234 WPB",
"category": "FITTING",
"size": "3\"",
"material_grade": "ASTM A234 WPB",
"quantity": 12,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4195,
"description": "90 LR ELL, SMLS, SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "3\"",
"material_grade": "ASTM A403 WP304",
"quantity": 4,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4199,
"description": "90 LR ELL, SMLS, SCH 40, ASTM A234 WPB",
"category": "FITTING",
"size": "4\"",
"material_grade": "ASTM A234 WPB",
"quantity": 7,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4206,
"description": "90 SR ELL, SMLS, SCH 40, ASTM A234 WPB",
"category": "FITTING",
"size": "4\"",
"material_grade": "ASTM A234 WPB",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4207,
"description": "90 LR ELL, SMLS, SCH 40, ASTM A234 WPB",
"category": "FITTING",
"size": "6\"",
"material_grade": "ASTM A234 WPB",
"quantity": 7,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4214,
"description": "45 ELL, SMLS, SCH 40, ASTM A234 WPB",
"category": "FITTING",
"size": "6\"",
"material_grade": "ASTM A234 WPB",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4216,
"description": "TEE, SMLS, SCH 40, ASTM A234 WPB",
"category": "FITTING",
"size": "2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 4,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4220,
"description": "TEE, SMLS, SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "2\"",
"material_grade": "ASTM A403 WP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4221,
"description": "TEE, SMLS, SCH 40, ASTM A234 WPB",
"category": "FITTING",
"size": "3\"",
"material_grade": "ASTM A234 WPB",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4222,
"description": "TEE, SMLS, SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "3\"",
"material_grade": "ASTM A403 WP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4223,
"description": "TEE RED, SMLS, SCH 40 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "2\" x 1 1/2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4226,
"description": "TEE RED, SMLS, SCH 40S X SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "2\" x 1 1/2\"",
"material_grade": "ASTM A403 WP304",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4228,
"description": "TEE RED, SMLS, SCH 40 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "3\" x 1 1/2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4231,
"description": "TEE RED, SMLS, SCH 40 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "4\" x 1 1/2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4233,
"description": "RED CONC, SMLS, SCH 80 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "1 1/2\" x 1\"",
"material_grade": "ASTM A234 WPB",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4238,
"description": "RED CONC, SMLS, SCH 80 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "1 1/2\" x 3/4\"",
"material_grade": "ASTM A234 WPB",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4240,
"description": "RED CONC, SMLS, SCH 80 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "1\" x 3/4\"",
"material_grade": "ASTM A234 WPB",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4245,
"description": "RED CONC, SMLS, SCH 40S X SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "12\" x 10\"",
"material_grade": "ASTM A403 WP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4246,
"description": "RED CONC, SMLS, SCH 40 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "2\" x 1 1/2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 6,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4252,
"description": "RED CONC, SMLS, SCH 40S X SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "2\" x 1 1/2\"",
"material_grade": "ASTM A403 WP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4253,
"description": "RED CONC, SMLS, SCH 40 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "2\" x 1\"",
"material_grade": "ASTM A234 WPB",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4254,
"description": "RED CONC, SMLS, SCH 40 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "3\" x 1 1/2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4256,
"description": "RED CONC, SMLS, SCH 40S X SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "3\" x 1\"",
"material_grade": "ASTM A403 WP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4257,
"description": "RED CONC, SMLS, SCH 40 X SCH 40, ASTM A234 WPB",
"category": "FITTING",
"size": "3\" x 2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4258,
"description": "RED CONC, SMLS, SCH 40S X SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "3\" x 2\"",
"material_grade": "ASTM A403 WP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4259,
"description": "RED CONC, SMLS, SCH 80 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "3/4\" x 1/2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5136,
"description": "90 ELL, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A182 F304",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5138,
"description": "90 ELL, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1\"",
"material_grade": "ASTM A105",
"quantity": 57,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5142,
"description": "90 ELL, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "1\"",
"material_grade": "ASTM A182 F304",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5146,
"description": "90 ELL, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 32,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5178,
"description": "90 ELL, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "1 1/2\"",
"material_grade": "ASTM A182 F304",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5245,
"description": "90 ELL, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A105",
"quantity": 32,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5277,
"description": "90 ELL, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "3/4\"",
"material_grade": "ASTM A105",
"quantity": 24,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5301,
"description": "TEE, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 7,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5308,
"description": "TEE, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1\"",
"material_grade": "ASTM A105",
"quantity": 15,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5323,
"description": "TEE, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "1\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5324,
"description": "TEE, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5326,
"description": "TEE RED, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1 1/2\" x 1\"",
"material_grade": "ASTM A105",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5331,
"description": "TEE RED, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1 1/2\" x 1/2\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5333,
"description": "TEE RED, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1 1/2\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 6,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5339,
"description": "TEE RED, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1\" x 1/2\"",
"material_grade": "ASTM A105",
"quantity": 7,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5346,
"description": "TEE RED, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "1\" x 1/2\"",
"material_grade": "ASTM A182 F304",
"quantity": 6,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5349,
"description": "TEE RED, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5355,
"description": "TEE RED, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "2\" x 1\"",
"material_grade": "ASTM A105",
"quantity": 4,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5359,
"description": "TEE RED, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "3/4\" x 1/2\"",
"material_grade": "ASTM A105",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5364,
"description": "CAP, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5365,
"description": "SOLID HEX. PLUG, NPT(M), 3000LB, ASTM A105",
"category": "FITTING",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5366,
"description": "CAP, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5367,
"description": "CAP, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "1\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5368,
"description": "SOLID HEX. PLUG, NPT(M), 3000LB, ASTM A105",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5370,
"description": "SOLID HEX. PLUG, NPT(M), 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5371,
"description": "CAP, SMLS, SCH 40, BW, ASTM A234 WPB",
"category": "FITTING",
"size": "2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5372,
"description": "CAP, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "3/4\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5373,
"description": "SOLID HEX. PLUG, NPT(M), 3000LB, ASTM A105",
"category": "FITTING",
"size": "3/4\"",
"material_grade": "ASTM A105",
"quantity": 36,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5409,
"description": "SOLID HEX. PLUG, NPT(M), 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5426,
"description": "SOCK O LET, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "10\" x 1 1/2\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5427,
"description": "SOCK O LET, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "10\" x 1\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5428,
"description": "SOCK O LET, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "10\" x 3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5429,
"description": "SOCK O LET, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "2\" x 1/2\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5431,
"description": "ELL O LET, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "2\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5432,
"description": "ELL O LET, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "3\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5433,
"description": "SOCK O LET, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "3\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5442,
"description": "SOCK O LET, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "3\" x 3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5445,
"description": "SOCK O LET, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "3\" x 1\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5446,
"description": "SOCK O LET, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "4\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5447,
"description": "SOCK O LET, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "6\" x 1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5449,
"description": "SOCK O LET, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "6\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": []
}

Binary file not shown.

View File

@@ -0,0 +1,168 @@
{
"request_no": "PR-20251016-003",
"job_no": "2",
"created_at": "2025-10-16T06:01:25.896639",
"materials": [
{
"material_id": 7082,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 11,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7090,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A106 B",
"quantity": 92,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7094,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1\"",
"material_grade": "ASTM A312 TP304",
"quantity": 23,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7104,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1\"",
"material_grade": "ASTM A106 B",
"quantity": 139,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7113,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1 1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 14,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7127,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1 1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 98,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7383,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 82,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7465,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "10\"",
"material_grade": "ASTM A312 TP304",
"quantity": 4,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7469,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "12\"",
"material_grade": "ASTM A312 TP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7470,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "2\"",
"material_grade": "ASTM A106 B",
"quantity": 50,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7520,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7529,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "3\"",
"material_grade": "ASTM A106 B",
"quantity": 25,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7537,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "3\"",
"material_grade": "ASTM A312 TP304",
"quantity": 8,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7562,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A312 TP304",
"quantity": 15,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7665,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "4\"",
"material_grade": "ASTM A106 B",
"quantity": 12,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7677,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "6\"",
"material_grade": "ASTM A106 B",
"quantity": 13,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": []
}

Binary file not shown.

View File

@@ -0,0 +1,408 @@
{
"request_no": "PR-20251016-004",
"job_no": "5",
"created_at": "2025-10-16T05:24:45.921468",
"materials": [
{
"material_id": 118834,
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "10\"",
"material_grade": "ASTM A182 F304",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118839,
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "12\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118840,
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
"category": "FLANGE",
"size": "2\"",
"material_grade": "ASTM A105",
"quantity": 36,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118876,
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
"category": "FLANGE",
"size": "2\"",
"material_grade": "ASTM A105",
"quantity": 6,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118882,
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "2\"",
"material_grade": "ASTM A182 F304",
"quantity": 7,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118890,
"description": "FLG WELD NECK RF SCH 40, 600LB, ASTM A105",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A105",
"quantity": 8,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118895,
"description": "FLG WELD NECK RF SCH 40S, 600LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A182 F304",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118897,
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A105",
"quantity": 12,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118909,
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118914,
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A182 F304",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118923,
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
"category": "FLANGE",
"size": "4\"",
"material_grade": "ASTM A105",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118926,
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
"category": "FLANGE",
"size": "6\"",
"material_grade": "ASTM A105",
"quantity": 10,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118936,
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1/2\"",
"material_grade": "ASTM A182 F304",
"quantity": 14,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118948,
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A105",
"quantity": 15,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118952,
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1\"",
"material_grade": "ASTM A182 F304",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118953,
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1\"",
"material_grade": "ASTM A105",
"quantity": 66,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118957,
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
"category": "FLANGE",
"size": "1\"",
"material_grade": "ASTM A105",
"quantity": 6,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118959,
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1 1/2\"",
"material_grade": "ASTM A182 F304",
"quantity": 8,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118967,
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 40,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119003,
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119008,
"description": "RED. FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1 1/2\" x 3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119010,
"description": "RED. FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 4,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119014,
"description": "RED. FLG SWRF SCH 80, 600LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119024,
"description": "FLG SWRF SCH 40S, 600LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119092,
"description": "RED. FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119097,
"description": "FLG SWRF SCH 80, 300LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119102,
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1/2\"",
"material_grade": "ASTM A105",
"quantity": 57,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119159,
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 7,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119166,
"description": "FLG SWRF SCH 40S, 300LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119167,
"description": "FLG SWRF SCH 40S, 600LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119179,
"description": "FLG SWRF SCH 80, 300LB, ASTM A105",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119181,
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A105",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119985,
"description": "ORIFICE, 150LB",
"category": "FLANGE",
"size": "10\"",
"material_grade": "-",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119987,
"description": "WOOD ORIFICE, 300LB",
"category": "FLANGE",
"size": "10\"",
"material_grade": "-",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119989,
"description": "WOOD ORIFICE, 600LB",
"category": "FLANGE",
"size": "3\"",
"material_grade": "-",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119990,
"description": "WOOD ORIFICE, 300LB",
"category": "FLANGE",
"size": "4\"",
"material_grade": "-",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119992,
"description": "WOOD ORIFICE, 300LB",
"category": "FLANGE",
"size": "5\"",
"material_grade": "-",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119993,
"description": "WOOD ORIFICE, 600LB",
"category": "FLANGE",
"size": "5\"",
"material_grade": "-",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119994,
"description": "WOOD ORIFICE, 150LB",
"category": "FLANGE",
"size": "6\"",
"material_grade": "-",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119996,
"description": "WOOD ORIFICE, 300LB",
"category": "FLANGE",
"size": "8\"",
"material_grade": "-",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": []
}

Binary file not shown.

60
backend/pytest.ini Normal file
View File

@@ -0,0 +1,60 @@
[tool:pytest]
# pytest 설정 파일
# 테스트 디렉토리
testpaths = tests
# 테스트 파일 패턴
python_files = test_*.py *_test.py
# 테스트 클래스 패턴
python_classes = Test*
# 테스트 함수 패턴
python_functions = test_*
# 마커 정의
markers =
unit: 단위 테스트
integration: 통합 테스트
performance: 성능 테스트
slow: 느린 테스트 (시간이 오래 걸리는 테스트)
api: API 테스트
database: 데이터베이스 테스트
cache: 캐시 테스트
classifier: 분류기 테스트
# 출력 설정
addopts =
-v
--tb=short
--strict-markers
--disable-warnings
--color=yes
--durations=10
--cov=app
--cov-report=term-missing
--cov-report=html:htmlcov
--cov-fail-under=80
# 최소 커버리지 (80%)
# --cov-fail-under=80
# 로그 설정
log_cli = true
log_cli_level = INFO
log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s
log_cli_date_format = %Y-%m-%d %H:%M:%S
# 경고 필터
filterwarnings =
ignore::DeprecationWarning
ignore::PendingDeprecationWarning
ignore::UserWarning:sqlalchemy.*
# 테스트 발견 설정
minversion = 6.0
required_plugins =
pytest-cov
pytest-asyncio
pytest-mock

View File

@@ -1,32 +1,63 @@
# FastAPI 웹 프레임워크
fastapi==0.104.1
uvicorn[standard]==0.24.0
# 데이터베이스
sqlalchemy==2.0.23
psycopg2-binary==2.9.9
alembic==1.13.1
# 파일 처리
pandas==2.1.4
annotated-types==0.7.0
anyio==3.7.1
async-timeout==5.0.1
bcrypt==4.1.2
black==23.11.0
certifi==2026.1.4
click==8.1.8
coverage==7.10.7
dnspython==2.7.0
email-validator==2.3.0
et_xmlfile==2.0.0
exceptiongroup==1.3.1
fastapi==0.104.1
flake8==6.1.0
h11==0.16.0
httpcore==1.0.9
httptools==0.7.1
httpx==0.25.2
idna==3.11
iniconfig==2.1.0
Mako==1.3.10
MarkupSafe==3.0.3
mccabe==0.7.0
mypy_extensions==1.1.0
numpy==1.26.4
openpyxl==3.1.2
xlrd>=2.0.1
python-multipart==0.0.6
# 데이터 검증
packaging==25.0
pandas==2.1.4
pathspec==1.0.1
platformdirs==4.4.0
pluggy==1.6.0
psycopg2-binary==2.9.9
pycodestyle==2.11.1
pydantic==2.5.2
pydantic-settings==2.1.0
# 기타 유틸리티
python-dotenv==1.0.0
httpx==0.25.2
redis==5.0.1
# 개발 도구
pydantic_core==2.14.5
pyflakes==3.1.0
PyJWT==2.8.0
pytest==7.4.3
pytest-asyncio==0.21.1
black==23.11.0
flake8==6.1.0
pytest-cov==4.1.0
pytest-mock==3.12.0
python-dateutil==2.9.0.post0
python-dotenv==1.0.0
python-magic==0.4.27
python-multipart==0.0.6
openpyxl==3.1.2
pytz==2025.2
PyYAML==6.0.3
RapidFuzz==3.13.0
redis==5.0.1
six==1.17.0
sniffio==1.3.1
SQLAlchemy==2.0.23
starlette==0.27.0
tomli==2.3.0
typing_extensions==4.15.0
tzdata==2025.3
uvicorn==0.24.0
uvloop==0.22.1
watchfiles==1.1.1
websockets==15.0.1
xlrd==2.0.1

View File

@@ -1,108 +0,0 @@
#!/usr/bin/env python3
"""
더미 프로젝트 데이터 생성 스크립트
"""
import sys
import os
from datetime import datetime, date
from sqlalchemy import create_engine, text
# 프로젝트 루트를 Python path에 추가
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def create_dummy_jobs():
"""더미 Job 데이터 생성"""
# 간단한 SQLite 연결 (실제 DB 설정에 맞게 수정)
try:
# 실제 프로젝트의 database.py 설정 사용
from app.database import engine
print("✅ 데이터베이스 연결 성공")
except ImportError:
# 직접 연결 (개발용)
DATABASE_URL = "sqlite:///./test.db" # 실제 DB URL로 변경
engine = create_engine(DATABASE_URL)
print("⚠️ 직접 데이터베이스 연결")
# 더미 데이터 정의
dummy_jobs = [
{
'job_no': 'J24-001',
'job_name': '울산 SK에너지 정유시설 증설 배관공사',
'client_name': '삼성엔지니어링',
'end_user': 'SK에너지',
'epc_company': '삼성엔지니어링',
'project_site': '울산광역시 온산공단 SK에너지 정유공장',
'contract_date': '2024-03-15',
'delivery_date': '2024-08-30',
'delivery_terms': 'FOB 울산항',
'status': '진행중',
'description': '정유시설 증설을 위한 배관 자재 공급 프로젝트. 고온고압 배관 및 특수 밸브 포함.',
'created_by': 'admin'
},
{
'job_no': 'J24-002',
'job_name': '포스코 광양 제철소 배관 정비공사',
'client_name': '포스코',
'end_user': '포스코',
'epc_company': None,
'project_site': '전남 광양시 포스코 광양제철소',
'contract_date': '2024-04-02',
'delivery_date': '2024-07-15',
'delivery_terms': 'DDP 광양제철소 현장',
'status': '진행중',
'description': '제철소 정기 정비를 위한 배관 부품 교체. 내열성 특수강 배관 포함.',
'created_by': 'admin'
}
]
try:
with engine.connect() as conn:
# 기존 더미 데이터 삭제 (개발용)
print("🧹 기존 더미 데이터 정리...")
conn.execute(text("DELETE FROM jobs WHERE job_no IN ('J24-001', 'J24-002')"))
# 새 더미 데이터 삽입
print("📝 더미 데이터 삽입 중...")
for job in dummy_jobs:
insert_query = text("""
INSERT INTO jobs (
job_no, job_name, client_name, end_user, epc_company,
project_site, contract_date, delivery_date, delivery_terms,
status, description, created_by, is_active
) VALUES (
:job_no, :job_name, :client_name, :end_user, :epc_company,
:project_site, :contract_date, :delivery_date, :delivery_terms,
:status, :description, :created_by, :is_active
)
""")
conn.execute(insert_query, {**job, 'is_active': True})
print(f"{job['job_no']}: {job['job_name']}")
# 커밋
conn.commit()
# 결과 확인
result = conn.execute(text("""
SELECT job_no, job_name, client_name, status
FROM jobs
WHERE job_no IN ('J24-001', 'J24-002')
"""))
jobs = result.fetchall()
print(f"\n🎉 총 {len(jobs)}개 더미 Job 생성 완료!")
print("\n📋 생성된 더미 데이터:")
for job in jobs:
print(f"{job[0]}: {job[1]} ({job[2]}) - {job[3]}")
return True
except Exception as e:
print(f"❌ 더미 데이터 생성 실패: {e}")
return False
if __name__ == "__main__":
create_dummy_jobs()

View File

@@ -0,0 +1,184 @@
-- ================================
-- Tubing 제품 관리 시스템
-- 실행일: 2025.08.01
-- ================================
-- 1. Tubing 카테고리 테이블 (일반, VCR, 기타 등)
CREATE TABLE IF NOT EXISTS tubing_categories (
id SERIAL PRIMARY KEY,
category_code VARCHAR(20) UNIQUE NOT NULL,
category_name VARCHAR(100) NOT NULL,
description TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 2. Tubing 규격 마스터 테이블
CREATE TABLE IF NOT EXISTS tubing_specifications (
id SERIAL PRIMARY KEY,
category_id INTEGER REFERENCES tubing_categories(id),
spec_code VARCHAR(50) UNIQUE NOT NULL,
spec_name VARCHAR(200) NOT NULL,
-- 물리적 규격
outer_diameter_mm DECIMAL(8,3), -- 외경 (mm)
wall_thickness_mm DECIMAL(6,3), -- 두께 (mm)
inner_diameter_mm DECIMAL(8,3), -- 내경 (mm, 계산 또는 실측)
-- 재질 정보
material_grade VARCHAR(100), -- SS316, SS316L, Inconel625 등
material_standard VARCHAR(100), -- ASTM A269, JIS G3463 등
-- 압력/온도 등급
max_pressure_bar DECIMAL(8,2), -- 최대 압력 (bar)
max_temperature_c DECIMAL(6,2), -- 최대 온도 (°C)
min_temperature_c DECIMAL(6,2), -- 최소 온도 (°C)
-- 표준 규격
standard_length_m DECIMAL(8,3), -- 표준 길이 (m)
bend_radius_min_mm DECIMAL(8,2), -- 최소 벤딩 반경 (mm)
-- 기타 정보
surface_finish VARCHAR(100), -- 표면 마감 (BA, #4, 2B 등)
hardness VARCHAR(50), -- 경도
notes TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 3. 제조사 정보 테이블
CREATE TABLE IF NOT EXISTS tubing_manufacturers (
id SERIAL PRIMARY KEY,
manufacturer_code VARCHAR(20) UNIQUE NOT NULL,
manufacturer_name VARCHAR(200) NOT NULL,
country VARCHAR(100),
website VARCHAR(500),
contact_info JSONB, -- 연락처 정보 (JSON)
quality_certs JSONB, -- 품질 인증서 정보 (ISO, API 등)
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 4. 제조사별 제품 테이블 (품목번호 매핑)
CREATE TABLE IF NOT EXISTS tubing_products (
id SERIAL PRIMARY KEY,
specification_id INTEGER REFERENCES tubing_specifications(id),
manufacturer_id INTEGER REFERENCES tubing_manufacturers(id),
-- 제조사 품목번호 정보
manufacturer_part_number VARCHAR(200) NOT NULL, -- 제조사 품목번호
manufacturer_product_name VARCHAR(300), -- 제조사 제품명
-- 가격/공급 정보
list_price DECIMAL(12,2), -- 정가
currency VARCHAR(10) DEFAULT 'KRW', -- 통화
lead_time_days INTEGER, -- 리드타임 (일)
minimum_order_qty DECIMAL(10,3), -- 최소 주문 수량
standard_packaging_qty DECIMAL(10,3), -- 표준 포장 수량
-- 가용성 정보
availability_status VARCHAR(50), -- 재고 상태
last_price_update DATE, -- 마지막 가격 업데이트
-- 추가 정보
datasheet_url VARCHAR(500), -- 데이터시트 URL
catalog_page VARCHAR(100), -- 카탈로그 페이지
notes TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- 유니크 제약 (같은 규격의 같은 제조사 제품은 하나만)
UNIQUE(specification_id, manufacturer_id, manufacturer_part_number)
);
-- 5. BOM에서 사용되는 Tubing 매핑 테이블
CREATE TABLE IF NOT EXISTS material_tubing_mapping (
id SERIAL PRIMARY KEY,
material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE,
tubing_product_id INTEGER REFERENCES tubing_products(id),
-- 매핑 정보
confidence_score DECIMAL(3,2), -- 매핑 신뢰도 (0.00-1.00)
mapping_method VARCHAR(50), -- 매핑 방법 (auto/manual)
mapped_by VARCHAR(100), -- 매핑한 사용자
mapped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- 수량 정보
required_length_m DECIMAL(10,3), -- 필요 길이 (m)
calculated_quantity DECIMAL(10,3), -- 계산된 주문 수량
-- 검증 정보
is_verified BOOLEAN DEFAULT FALSE,
verified_by VARCHAR(100),
verified_at TIMESTAMP,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ================================
-- 인덱스 생성
-- ================================
-- Tubing 규격 관련 인덱스
CREATE INDEX idx_tubing_specs_category ON tubing_specifications(category_id);
CREATE INDEX idx_tubing_specs_material ON tubing_specifications(material_grade);
CREATE INDEX idx_tubing_specs_diameter ON tubing_specifications(outer_diameter_mm, wall_thickness_mm);
-- 제품 관련 인덱스
CREATE INDEX idx_tubing_products_spec ON tubing_products(specification_id);
CREATE INDEX idx_tubing_products_manufacturer ON tubing_products(manufacturer_id);
CREATE INDEX idx_tubing_products_part_number ON tubing_products(manufacturer_part_number);
-- 매핑 관련 인덱스
CREATE INDEX idx_material_tubing_mapping_material ON material_tubing_mapping(material_id);
CREATE INDEX idx_material_tubing_mapping_product ON material_tubing_mapping(tubing_product_id);
-- ================================
-- 기초 데이터 입력
-- ================================
-- Tubing 카테고리 기초 데이터
INSERT INTO tubing_categories (category_code, category_name, description) VALUES
('GENERAL', '일반 Tubing', '일반적인 스테인리스 스틸 튜빙'),
('VCR', 'VCR Tubing', 'VCR (Vacuum Coupling Radiation) 연결용 튜빙'),
('SANITARY', 'Sanitary Tubing', '위생용 튜빙 (식품, 제약 등)'),
('HVAC', 'HVAC Tubing', '공조용 튜빙'),
('HYDRAULIC', 'Hydraulic Tubing', '유압용 튜빙'),
('PNEUMATIC', 'Pneumatic Tubing', '공압용 튜빙'),
('PROCESS', 'Process Tubing', '공정용 특수 튜빙'),
('EXOTIC', 'Exotic Material', '특수 재질 튜빙 (Hastelloy, Inconel 등)')
ON CONFLICT (category_code) DO NOTHING;
-- 주요 제조사 기초 데이터
INSERT INTO tubing_manufacturers (manufacturer_code, manufacturer_name, country) VALUES
('SWAGELOK', 'Swagelok Company', 'USA'),
('PARKER', 'Parker Hannifin', 'USA'),
('HAM_LET', 'Ham-Let Group', 'Israel'),
('SUPERLOK', 'Superlok USA', 'USA'),
('FITOK', 'Fitok Group', 'China'),
('DK_LOK', 'DK-Lok Corporation', 'South Korea'),
('GYROLOK', 'Gyrolok (Oliver Valves)', 'UK'),
('AS_ONE', 'AS ONE Corporation', 'Japan')
ON CONFLICT (manufacturer_code) DO NOTHING;
-- 기본 스테인리스 스틸 튜빙 규격 예시
INSERT INTO tubing_specifications (
category_id, spec_code, spec_name,
outer_diameter_mm, wall_thickness_mm, inner_diameter_mm,
material_grade, material_standard,
max_pressure_bar, max_temperature_c, min_temperature_c,
standard_length_m
) VALUES
(1, 'SS316-6MM-1MM', '6mm OD x 1mm WT SS316 Tubing', 6.0, 1.0, 4.0, 'SS316', 'ASTM A269', 413, 815, -196, 6.0),
(1, 'SS316-8MM-1MM', '8mm OD x 1mm WT SS316 Tubing', 8.0, 1.0, 6.0, 'SS316', 'ASTM A269', 310, 815, -196, 6.0),
(1, 'SS316-10MM-1MM', '10mm OD x 1mm WT SS316 Tubing', 10.0, 1.0, 8.0, 'SS316', 'ASTM A269', 248, 815, -196, 6.0),
(1, 'SS316-12MM-1.5MM', '12mm OD x 1.5mm WT SS316 Tubing', 12.0, 1.5, 9.0, 'SS316', 'ASTM A269', 310, 815, -196, 6.0),
(1, 'SS316L-6MM-1MM', '6mm OD x 1mm WT SS316L Tubing', 6.0, 1.0, 4.0, 'SS316L', 'ASTM A269', 413, 815, -196, 6.0)
ON CONFLICT (spec_code) DO NOTHING;

View File

@@ -0,0 +1,163 @@
-- ================================
-- 성능 최적화를 위한 추가 인덱스
-- 생성일: 2025.01 (Phase 2)
-- ================================
-- 1. 복합 인덱스 (자주 함께 사용되는 컬럼들)
-- ================================
-- files 테이블: job_no + revision 조합 (리비전 비교 시 자주 사용)
CREATE INDEX IF NOT EXISTS idx_files_job_revision
ON files(job_no, revision)
WHERE is_active = true;
-- files 테이블: job_no + upload_date (최신 파일 조회)
CREATE INDEX IF NOT EXISTS idx_files_job_date
ON files(job_no, upload_date DESC)
WHERE is_active = true;
-- materials 테이블: file_id + category (자재 분류별 조회)
CREATE INDEX IF NOT EXISTS idx_materials_file_category
ON materials(file_id, classified_category);
-- materials 테이블: category + material_grade (자재 종류별 재질 검색)
CREATE INDEX IF NOT EXISTS idx_materials_category_grade
ON materials(classified_category, material_grade);
-- 2. 검색 성능 향상 인덱스
-- ================================
-- materials 테이블: description 텍스트 검색 (GIN 인덱스)
CREATE INDEX IF NOT EXISTS idx_materials_description_gin
ON materials USING gin(to_tsvector('english', original_description));
-- materials 테이블: 해시 기반 중복 검색
CREATE INDEX IF NOT EXISTS idx_materials_hash
ON materials(material_hash)
WHERE material_hash IS NOT NULL;
-- 3. 정렬 성능 향상 인덱스
-- ================================
-- jobs 테이블: 상태별 생성일 정렬
CREATE INDEX IF NOT EXISTS idx_jobs_status_created
ON jobs(status, created_at DESC)
WHERE is_active = true;
-- materials 테이블: 수량별 정렬 (대용량 자재 우선 표시)
CREATE INDEX IF NOT EXISTS idx_materials_quantity_desc
ON materials(quantity DESC);
-- 4. 조건부 인덱스 (특정 조건에서만 사용)
-- ================================
-- 검증되지 않은 자재만 (분류 검토 필요한 항목)
CREATE INDEX IF NOT EXISTS idx_materials_unverified
ON materials(classified_category, classification_confidence)
WHERE is_verified = false;
-- 신뢰도가 낮은 분류 (0.8 미만)
CREATE INDEX IF NOT EXISTS idx_materials_low_confidence
ON materials(file_id, classified_category)
WHERE classification_confidence < 0.8;
-- 5. 외래키 성능 향상
-- ================================
-- pipe_details 테이블
CREATE INDEX IF NOT EXISTS idx_pipe_details_material
ON pipe_details(material_id);
-- fitting_details 테이블
CREATE INDEX IF NOT EXISTS idx_fitting_details_material
ON fitting_details(material_id);
-- valve_details 테이블
CREATE INDEX IF NOT EXISTS idx_valve_details_material
ON valve_details(material_id);
-- flange_details 테이블
CREATE INDEX IF NOT EXISTS idx_flange_details_material
ON flange_details(material_id);
-- bolt_details 테이블
CREATE INDEX IF NOT EXISTS idx_bolt_details_material
ON bolt_details(material_id);
-- gasket_details 테이블
CREATE INDEX IF NOT EXISTS idx_gasket_details_material
ON gasket_details(material_id);
-- instrument_details 테이블
CREATE INDEX IF NOT EXISTS idx_instrument_details_material
ON instrument_details(material_id);
-- 6. 통계 및 집계 성능 향상
-- ================================
-- 프로젝트별 자재 통계 (job_no 기준)
CREATE INDEX IF NOT EXISTS idx_materials_job_stats
ON materials(
(SELECT job_no FROM files WHERE files.id = materials.file_id),
classified_category
);
-- 파이프 길이 집계용 (파이프 cutting 계산)
CREATE INDEX IF NOT EXISTS idx_pipe_length_aggregation
ON pipe_details(material_id, length_mm)
WHERE length_mm > 0;
-- 7. 성능 모니터링을 위한 뷰 생성
-- ================================
-- 인덱스 사용률 모니터링 뷰
CREATE OR REPLACE VIEW index_usage_stats AS
SELECT
schemaname,
tablename,
indexname,
idx_tup_read,
idx_tup_fetch,
idx_scan,
CASE
WHEN idx_scan = 0 THEN 'UNUSED'
WHEN idx_scan < 10 THEN 'LOW_USAGE'
WHEN idx_scan < 100 THEN 'MEDIUM_USAGE'
ELSE 'HIGH_USAGE'
END as usage_level
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY idx_scan DESC;
-- 테이블 크기 및 성능 모니터링 뷰
CREATE OR REPLACE VIEW table_performance_stats AS
SELECT
schemaname,
tablename,
n_tup_ins as inserts,
n_tup_upd as updates,
n_tup_del as deletes,
seq_scan as sequential_scans,
seq_tup_read as sequential_reads,
idx_scan as index_scans,
idx_tup_fetch as index_reads,
CASE
WHEN seq_scan + idx_scan = 0 THEN 0
ELSE ROUND((idx_scan::numeric / (seq_scan + idx_scan)) * 100, 2)
END as index_usage_percentage
FROM pg_stat_user_tables
WHERE schemaname = 'public'
ORDER BY seq_scan + idx_scan DESC;
-- ================================
-- 인덱스 생성 완료 로그
-- ================================
-- 성능 최적화 인덱스 생성 완료 확인
DO $$
BEGIN
RAISE NOTICE '성능 최적화 인덱스 생성 완료 - Phase 2 (2025.01)';
RAISE NOTICE '총 생성된 인덱스: 복합 인덱스 4개, 검색 인덱스 2개, 정렬 인덱스 2개';
RAISE NOTICE '조건부 인덱스 2개, 외래키 인덱스 7개, 집계 인덱스 2개';
RAISE NOTICE '모니터링 뷰 2개 생성';
END $$;

View File

@@ -0,0 +1,29 @@
-- jobs 테이블에 project_type 컬럼 추가
-- TK-MP-Project 프로젝트 유형 관리를 위한 스키마 업데이트
-- project_type 컬럼 추가 (기존 데이터가 있을 수 있으므로 안전하게 추가)
DO $$
BEGIN
-- project_type 컬럼이 존재하지 않으면 추가
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'jobs'
AND column_name = 'project_type'
) THEN
ALTER TABLE jobs ADD COLUMN project_type VARCHAR(50) DEFAULT '냉동기';
-- 기존 데이터에 대한 기본값 설정
UPDATE jobs SET project_type = '냉동기' WHERE project_type IS NULL;
-- NOT NULL 제약 조건 추가
ALTER TABLE jobs ALTER COLUMN project_type SET NOT NULL;
-- 인덱스 추가 (프로젝트 유형별 조회 성능 향상)
CREATE INDEX IF NOT EXISTS idx_jobs_project_type ON jobs(project_type);
RAISE NOTICE 'project_type 컬럼이 성공적으로 추가되었습니다.';
ELSE
RAISE NOTICE 'project_type 컬럼이 이미 존재합니다.';
END IF;
END $$;

View File

@@ -0,0 +1,242 @@
-- TK-MP-Project 인증 시스템을 위한 사용자 및 로그인 테이블 생성
-- TK-FB-Project 인증 시스템을 참고하여 구현
-- 1. 사용자 테이블 생성
CREATE TABLE IF NOT EXISTS users (
user_id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
email VARCHAR(100),
-- 권한 관리
role VARCHAR(20) DEFAULT 'user' CHECK (role IN ('admin', 'system', 'leader', 'support', 'user')),
access_level VARCHAR(20) DEFAULT 'worker' CHECK (access_level IN ('admin', 'system', 'group_leader', 'support_team', 'worker')),
-- 계정 상태 관리
is_active BOOLEAN DEFAULT true,
failed_login_attempts INT DEFAULT 0,
locked_until TIMESTAMP NULL,
-- 추가 정보
department VARCHAR(50),
position VARCHAR(50),
phone VARCHAR(20),
-- 타임스탬프
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP NULL
);
-- 2. 로그인 이력 테이블 생성
CREATE TABLE IF NOT EXISTS login_logs (
log_id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(user_id) ON DELETE CASCADE,
login_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address VARCHAR(45),
user_agent TEXT,
login_status VARCHAR(20) CHECK (login_status IN ('success', 'failed')),
failure_reason VARCHAR(100),
session_duration INT, -- 세션 지속 시간 (초)
-- 인덱스를 위한 컬럼
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 3. 사용자 세션 테이블 (JWT Refresh Token 관리)
CREATE TABLE IF NOT EXISTS user_sessions (
session_id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(user_id) ON DELETE CASCADE,
refresh_token VARCHAR(500) NOT NULL,
expires_at TIMESTAMP NOT NULL,
ip_address VARCHAR(45),
user_agent TEXT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 4. 권한 테이블 (확장 가능한 권한 시스템)
CREATE TABLE IF NOT EXISTS permissions (
permission_id SERIAL PRIMARY KEY,
permission_name VARCHAR(50) UNIQUE NOT NULL,
description TEXT,
module VARCHAR(30), -- 모듈별 권한 관리 (bom, project, purchase 등)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 5. 역할-권한 매핑 테이블
CREATE TABLE IF NOT EXISTS role_permissions (
role_permission_id SERIAL PRIMARY KEY,
role VARCHAR(20) NOT NULL,
permission_id INT REFERENCES permissions(permission_id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(role, permission_id)
);
-- 6. 인덱스 생성 (성능 최적화)
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active);
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
CREATE INDEX IF NOT EXISTS idx_login_logs_user_id ON login_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_login_logs_login_time ON login_logs(login_time);
CREATE INDEX IF NOT EXISTS idx_login_logs_ip_address ON login_logs(ip_address);
CREATE INDEX IF NOT EXISTS idx_login_logs_status ON login_logs(login_status);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_refresh_token ON user_sessions(refresh_token);
CREATE INDEX IF NOT EXISTS idx_user_sessions_expires_at ON user_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_user_sessions_is_active ON user_sessions(is_active);
CREATE INDEX IF NOT EXISTS idx_permissions_module ON permissions(module);
CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON role_permissions(role);
-- 7. 기본 권한 데이터 삽입
INSERT INTO permissions (permission_name, description, module) VALUES
-- BOM 관리 권한
('bom.view', 'BOM 조회 권한', 'bom'),
('bom.create', 'BOM 생성 권한', 'bom'),
('bom.edit', 'BOM 수정 권한', 'bom'),
('bom.delete', 'BOM 삭제 권한', 'bom'),
('bom.approve', 'BOM 승인 권한', 'bom'),
-- 프로젝트 관리 권한
('project.view', '프로젝트 조회 권한', 'project'),
('project.create', '프로젝트 생성 권한', 'project'),
('project.edit', '프로젝트 수정 권한', 'project'),
('project.delete', '프로젝트 삭제 권한', 'project'),
('project.manage', '프로젝트 관리 권한', 'project'),
-- 파일 관리 권한
('file.upload', '파일 업로드 권한', 'file'),
('file.download', '파일 다운로드 권한', 'file'),
('file.delete', '파일 삭제 권한', 'file'),
-- 사용자 관리 권한
('user.view', '사용자 조회 권한', 'user'),
('user.create', '사용자 생성 권한', 'user'),
('user.edit', '사용자 수정 권한', 'user'),
('user.delete', '사용자 삭제 권한', 'user'),
-- 시스템 관리 권한
('system.admin', '시스템 관리 권한', 'system'),
('system.logs', '로그 조회 권한', 'system'),
('system.settings', '시스템 설정 권한', 'system')
ON CONFLICT (permission_name) DO NOTHING;
-- 8. 역할별 기본 권한 할당
INSERT INTO role_permissions (role, permission_id)
SELECT 'admin', permission_id FROM permissions
ON CONFLICT (role, permission_id) DO NOTHING;
INSERT INTO role_permissions (role, permission_id)
SELECT 'system', permission_id FROM permissions
ON CONFLICT (role, permission_id) DO NOTHING;
INSERT INTO role_permissions (role, permission_id)
SELECT 'leader', permission_id FROM permissions
WHERE permission_name IN (
'bom.view', 'bom.create', 'bom.edit', 'bom.approve',
'project.view', 'project.create', 'project.edit', 'project.manage',
'file.upload', 'file.download', 'file.delete',
'user.view'
)
ON CONFLICT (role, permission_id) DO NOTHING;
INSERT INTO role_permissions (role, permission_id)
SELECT 'support', permission_id FROM permissions
WHERE permission_name IN (
'bom.view', 'bom.create', 'bom.edit',
'project.view', 'project.create', 'project.edit',
'file.upload', 'file.download'
)
ON CONFLICT (role, permission_id) DO NOTHING;
INSERT INTO role_permissions (role, permission_id)
SELECT 'user', permission_id FROM permissions
WHERE permission_name IN (
'bom.view',
'project.view',
'file.upload', 'file.download'
)
ON CONFLICT (role, permission_id) DO NOTHING;
-- 9. 기본 관리자 계정 생성 (비밀번호: admin123)
-- bcrypt 해시: $2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi
INSERT INTO users (username, password, name, email, role, access_level, department, position) VALUES
('admin', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '시스템 관리자', 'admin@tkmp.com', 'admin', 'admin', 'IT', '시스템 관리자'),
('system', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '시스템 계정', 'system@tkmp.com', 'system', 'system', 'IT', '시스템 계정')
ON CONFLICT (username) DO NOTHING;
-- 10. 트리거 함수 생성 (updated_at 자동 업데이트)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- 11. 트리거 적용
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_user_sessions_updated_at BEFORE UPDATE ON user_sessions
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- 12. 뷰 생성 (사용자 정보 조회용)
CREATE OR REPLACE VIEW user_info_view AS
SELECT
u.user_id,
u.username,
u.name,
u.email,
u.role,
u.access_level,
u.department,
u.position,
u.is_active,
u.created_at,
u.last_login_at,
COUNT(ll.log_id) as login_count,
MAX(ll.login_time) as last_successful_login
FROM users u
LEFT JOIN login_logs ll ON u.user_id = ll.user_id AND ll.login_status = 'success'
GROUP BY u.user_id, u.username, u.name, u.email, u.role, u.access_level,
u.department, u.position, u.is_active, u.created_at, u.last_login_at;
-- 완료 메시지
DO $$
BEGIN
RAISE NOTICE '✅ TK-MP-Project 인증 시스템 데이터베이스 스키마가 성공적으로 생성되었습니다!';
RAISE NOTICE '📋 생성된 테이블: users, login_logs, user_sessions, permissions, role_permissions';
RAISE NOTICE '👤 기본 계정: admin/admin123, system/admin123';
RAISE NOTICE '🔐 권한 시스템: 5단계 역할 + 모듈별 세분화된 권한';
END $$;

View File

@@ -0,0 +1,142 @@
-- 사용자 추적 및 담당자 기록 필드 추가
-- 생성일: 2025.01
-- 목적: RULES 가이드라인에 따른 사용자 추적 시스템 구축
-- ================================
-- 1. 기존 테이블에 담당자 필드 추가
-- ================================
-- files 테이블 수정 (uploaded_by는 이미 존재)
ALTER TABLE files
ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
-- jobs 테이블 수정
ALTER TABLE jobs
ADD COLUMN IF NOT EXISTS created_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS assigned_to VARCHAR(100);
-- materials 테이블 수정
ALTER TABLE materials
ADD COLUMN IF NOT EXISTS classified_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS classified_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100);
-- ================================
-- 2. 사용자 활동 로그 테이블 생성
-- ================================
CREATE TABLE IF NOT EXISTS user_activity_logs (
id SERIAL PRIMARY KEY,
user_id INTEGER, -- users 테이블 참조 (외래키 제약 없음 - 유연성)
username VARCHAR(100) NOT NULL, -- 사용자명 (필수)
-- 활동 정보
activity_type VARCHAR(50) NOT NULL, -- 'FILE_UPLOAD', 'PROJECT_CREATE', 'PURCHASE_CONFIRM' 등
activity_description TEXT, -- 상세 활동 내용
-- 대상 정보
target_id INTEGER, -- 대상 ID (파일, 프로젝트 등)
target_type VARCHAR(50), -- 'FILE', 'PROJECT', 'MATERIAL', 'PURCHASE' 등
-- 세션 정보
ip_address VARCHAR(45), -- IP 주소
user_agent TEXT, -- 브라우저 정보
-- 추가 메타데이터 (JSON)
metadata JSONB, -- 추가 정보 (파일 크기, 처리 시간 등)
-- 시간 정보
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ================================
-- 3. 구매 관련 테이블 수정
-- ================================
-- purchase_items 테이블 수정 (이미 created_by 존재하는지 확인 후 추가)
ALTER TABLE purchase_items
ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS approved_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMP;
-- material_purchase_tracking 테이블 수정 (이미 confirmed_by 존재)
ALTER TABLE material_purchase_tracking
ADD COLUMN IF NOT EXISTS ordered_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS ordered_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS approved_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMP;
-- ================================
-- 4. 인덱스 생성 (성능 최적화)
-- ================================
-- 사용자 활동 로그 인덱스
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_username ON user_activity_logs(username);
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_activity_type ON user_activity_logs(activity_type);
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_created_at ON user_activity_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_target ON user_activity_logs(target_type, target_id);
-- 담당자 필드 인덱스
CREATE INDEX IF NOT EXISTS idx_files_uploaded_by ON files(uploaded_by);
CREATE INDEX IF NOT EXISTS idx_files_updated_by ON files(updated_by);
CREATE INDEX IF NOT EXISTS idx_jobs_created_by ON jobs(created_by);
CREATE INDEX IF NOT EXISTS idx_jobs_assigned_to ON jobs(assigned_to);
CREATE INDEX IF NOT EXISTS idx_materials_classified_by ON materials(classified_by);
-- ================================
-- 5. 트리거 생성 (자동 updated_at 갱신)
-- ================================
-- files 테이블 updated_at 자동 갱신
CREATE OR REPLACE FUNCTION update_files_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
DROP TRIGGER IF EXISTS trigger_files_updated_at ON files;
CREATE TRIGGER trigger_files_updated_at
BEFORE UPDATE ON files
FOR EACH ROW
EXECUTE FUNCTION update_files_updated_at();
-- jobs 테이블 updated_at 자동 갱신
CREATE OR REPLACE FUNCTION update_jobs_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
DROP TRIGGER IF EXISTS trigger_jobs_updated_at ON jobs;
CREATE TRIGGER trigger_jobs_updated_at
BEFORE UPDATE ON jobs
FOR EACH ROW
EXECUTE FUNCTION update_jobs_updated_at();
-- ================================
-- 6. 기본 데이터 설정
-- ================================
-- 기존 데이터에 기본 담당자 설정 (시스템 마이그레이션용)
UPDATE files SET uploaded_by = 'system' WHERE uploaded_by IS NULL;
UPDATE jobs SET created_by = 'system' WHERE created_by IS NULL;
-- ================================
-- 7. 권한 및 보안 설정
-- ================================
-- 활동 로그 테이블은 INSERT만 허용 (수정/삭제 방지)
-- 실제 운영에서는 별도 권한 관리 필요
COMMENT ON TABLE user_activity_logs IS '사용자 활동 로그 - 모든 업무 활동 추적';
COMMENT ON COLUMN user_activity_logs.activity_type IS '활동 유형: FILE_UPLOAD, PROJECT_CREATE, PURCHASE_CONFIRM, MATERIAL_CLASSIFY 등';
COMMENT ON COLUMN user_activity_logs.metadata IS '추가 정보 JSON: 파일크기, 처리시간, 변경내용 등';
-- 완료 메시지
SELECT 'User tracking system tables created successfully!' as result;

View File

@@ -0,0 +1,50 @@
-- 파이프 끝단 가공 정보 테이블 생성
-- 각 파이프별로 끝단 가공 정보를 별도 저장
CREATE TABLE IF NOT EXISTS pipe_end_preparations (
id SERIAL PRIMARY KEY,
material_id INTEGER NOT NULL REFERENCES materials(id) ON DELETE CASCADE,
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
-- 끝단 가공 정보
end_preparation_type VARCHAR(50) DEFAULT 'PBE', -- PBE(양쪽무개선), BBE(양쪽개선), POE(한쪽개선), PE(무개선)
end_preparation_code VARCHAR(20), -- 원본 코드 (BBE, POE, PBE 등)
machining_required BOOLEAN DEFAULT FALSE, -- 가공 필요 여부
cutting_note TEXT, -- 가공 메모
-- 원본 정보 보존
original_description TEXT NOT NULL, -- 끝단 가공 포함된 원본 설명
clean_description TEXT NOT NULL, -- 끝단 가공 제외한 구매용 설명
-- 메타데이터
confidence FLOAT DEFAULT 0.0, -- 분류 신뢰도
matched_pattern VARCHAR(100), -- 매칭된 패턴
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_pipe_end_preparations_material_id ON pipe_end_preparations(material_id);
CREATE INDEX IF NOT EXISTS idx_pipe_end_preparations_file_id ON pipe_end_preparations(file_id);
CREATE INDEX IF NOT EXISTS idx_pipe_end_preparations_type ON pipe_end_preparations(end_preparation_type);
-- 기본 끝단 가공 타입 정의
COMMENT ON COLUMN pipe_end_preparations.end_preparation_type IS 'PBE: 양쪽무개선(기본값), BBE: 양쪽개선, POE: 한쪽개선, PE: 무개선';
COMMENT ON COLUMN pipe_end_preparations.machining_required IS '가공이 필요한지 여부 (개선 작업 등)';
COMMENT ON COLUMN pipe_end_preparations.clean_description IS '구매 시 사용할 끝단 가공 정보가 제거된 설명';
-- 트리거: updated_at 자동 업데이트
CREATE OR REPLACE FUNCTION update_pipe_end_preparations_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_pipe_end_preparations_updated_at
BEFORE UPDATE ON pipe_end_preparations
FOR EACH ROW
EXECUTE FUNCTION update_pipe_end_preparations_updated_at();

View File

@@ -0,0 +1,19 @@
-- 사용자 요구사항 테이블에 material_id 컬럼 추가
-- 2025.09.24 - 사용자 피드백 기반 개선사항 #1
-- material_id 컬럼 추가 (nullable로 시작)
ALTER TABLE user_requirements
ADD COLUMN IF NOT EXISTS material_id INTEGER;
-- 외래키 제약조건 추가
ALTER TABLE user_requirements
ADD CONSTRAINT fk_user_requirements_material_id
FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE CASCADE;
-- 인덱스 추가 (성능 향상)
CREATE INDEX IF NOT EXISTS idx_user_requirements_material_id ON user_requirements(material_id);
-- 기존 데이터 정리 (필요시)
-- DELETE FROM user_requirements WHERE material_id IS NULL;
COMMENT ON COLUMN user_requirements.material_id IS '자재 ID (개별 자재별 요구사항 연결)';

Some files were not shown because too many files have changed in this diff Show More