Compare commits

..

31 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
116 changed files with 32896 additions and 3169 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

175
RULES.md
View File

@@ -1255,6 +1255,8 @@ const purchaseQuantity = Math.ceil(bomQuantity / 5) * 5;
- 자동 에러 핸들링 (검증, DB, 일반 예외)
### 📊 구현된 페이지들
#### **📋 기존 페이지들**
- MainPage: 메인 대시보드
- JobSelectionPage: 프로젝트 선택
- JobRegistrationPage: 프로젝트 등록
@@ -1264,6 +1266,179 @@ const purchaseQuantity = Math.ceil(bomQuantity / 5) * 5;
- PurchaseConfirmationPage: 구매 확인
- RevisionPurchasePage: 리비전별 구매
#### **🎨 신규 모던 UI 페이지들 (2025.10.16 추가)**
##### **DashboardPage.jsx** - 프로젝트 중심 대시보드
```jsx
// 위치: frontend/src/pages/DashboardPage.jsx
// 특징: 데본씽크 스타일의 모던한 디자인
// 기능:
// - 프로젝트 선택 및 관리 (카드 형태)
// - 권한별 기능 카드 (BOM 관리, 자재 관리, 구매 관리)
// - 관리자 전용 기능 (사용자 관리, 시스템 설정)
// - 시스템 현황 대시보드
// - 프로젝트 생성 모달
// 디자인 특징:
// - 글래스모피즘 효과 (backdrop-filter: blur(10px))
// - 그라데이션 배경 및 버튼
// - 카드 호버 애니메이션
// - 타이포그래피 중심 디자인 (이모지 제거)
// - 반응형 그리드 레이아웃
// 주요 기능:
// 1. 프로젝트 선택 시스템
// - 프로젝트 목록을 카드 형태로 표시
// - 선택된 프로젝트 하이라이트
// - 프로젝트 정보 (코드, 이름, 고객사) 표시
// 2. 권한 기반 기능 접근
// - 프로젝트 선택 후에만 BOM/자재 관리 접근 가능
// - 관리자 전용 메뉴 분리 표시
// 3. 프로젝트 생성 기능
// - 모달 형태의 프로젝트 생성 폼
// - 프로젝트 코드, 이름, 고객사 입력
```
##### **UserMenu.jsx** - 사용자 메뉴 컴포넌트
```jsx
// 위치: frontend/src/components/UserMenu.jsx
// 특징: 드롭다운 형태의 사용자 메뉴
// 기능:
// - 사용자 프로필 표시 (아바타, 이름, 역할)
// - 계정 설정 링크
// - 관리자 전용 메뉴 (권한별 표시)
// - 로그아웃 기능
// 디자인 특징:
// - 원형 아바타 (그라데이션 배경)
// - 드롭다운 애니메이션
// - 호버 효과
// - 역할별 색상 구분
// 주요 기능:
// 1. 사용자 정보 표시
// - 이름 첫 글자로 아바타 생성
// - 역할 표시 (시스템 관리자, 관리자, 사용자)
// 2. 권한별 메뉴
// - 관리자: 사용자 관리, 시스템 설정, 시스템 로그
// - 일반 사용자: 계정 설정만
// 3. 네비게이션 연동
// - onNavigate 콜백을 통한 페이지 이동
// - onLogout 콜백을 통한 로그아웃 처리
```
#### **🎨 UI/UX 디자인 시스템**
##### **색상 팔레트**
```css
/* 주요 색상 */
--primary-gradient: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
--background-gradient: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
--glass-background: rgba(255, 255, 255, 0.9);
--text-primary: #0f172a;
--text-secondary: #64748b;
--border-color: rgba(255, 255, 255, 0.2);
/* 그림자 */
--shadow-card: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--shadow-button: 0 4px 14px 0 rgba(59, 130, 246, 0.39);
--shadow-hover: 0 8px 25px 0 rgba(59, 130, 246, 0.5);
```
##### **타이포그래피**
```css
/* 폰트 시스템 */
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
/* 제목 */
--heading-1: 36px, weight: 800, letter-spacing: -0.025em;
--heading-2: 24px, weight: 700, letter-spacing: -0.025em;
--heading-3: 18px, weight: 600;
/* 본문 */
--body-large: 18px, weight: 400;
--body-medium: 16px, weight: 400;
--body-small: 14px, weight: 400;
--caption: 12px, weight: 400;
```
##### **컴포넌트 스타일**
```css
/* 카드 */
.modern-card {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 32px;
box-shadow: var(--shadow-card);
border: 1px solid var(--border-color);
transition: all 0.2s ease;
}
.modern-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-hover);
}
/* 버튼 */
.modern-button {
background: var(--primary-gradient);
color: white;
border: none;
border-radius: 12px;
padding: 12px 20px;
font-weight: 600;
box-shadow: var(--shadow-button);
transition: all 0.2s ease;
letter-spacing: 0.025em;
}
.modern-button:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-hover);
}
```
#### **🔧 컴포넌트 사용 가이드**
##### **DashboardPage 사용법**
```jsx
import DashboardPage from './pages/DashboardPage';
// App.jsx에서 사용
case 'dashboard':
return (
<DashboardPage
user={user}
onNavigate={navigateToPage}
pendingSignupCount={pendingSignupCount}
/>
);
```
##### **UserMenu 사용법**
```jsx
import UserMenu from './components/UserMenu';
// 헤더에서 사용
<UserMenu
user={user}
onNavigate={navigateToPage}
onLogout={handleLogout}
/>
```
#### **📱 반응형 디자인**
- **데스크톱**: 1200px 이상 - 3-4열 그리드
- **태블릿**: 768px-1199px - 2열 그리드
- **모바일**: 767px 이하 - 1열 스택
#### **♿ 접근성 고려사항**
- 키보드 네비게이션 지원
- 충분한 색상 대비 (WCAG 2.1 AA 준수)
- 스크린 리더 호환성
- 포커스 표시 명확화
---
## 🌐 시놀로지 NAS 배포 가이드 ⭐

View File

@@ -11,6 +11,7 @@ RUN apt-get update && apt-get install -y \
libpq-dev \
libmagic1 \
libmagic-dev \
netcat-openbsd \
&& rm -rf /var/lib/apt/lists/*
# requirements.txt 복사 및 의존성 설치
@@ -27,4 +28,4 @@ EXPOSE 8000
ENV PYTHONPATH=/app
# 서버 실행
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
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

@@ -323,6 +323,75 @@ async def verify_token(
# 관리자 전용 엔드포인트들
@router.get("/users/suspended")
async def get_suspended_users(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
):
"""
정지된 사용자 목록 조회 (관리자 전용)
Args:
credentials: JWT 토큰
db: 데이터베이스 세션
Returns:
Dict: 정지된 사용자 목록
"""
try:
# 토큰 검증 및 권한 확인
payload = jwt_service.verify_access_token(credentials.credentials)
if payload['role'] not in ['admin', 'system']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자 이상의 권한이 필요합니다"
)
# 정지된 사용자 조회
from sqlalchemy import text
query = text("""
SELECT
user_id, username, name, email, role, department, position,
phone, status, created_at, updated_at
FROM users
WHERE status = 'suspended'
ORDER BY updated_at DESC
""")
results = db.execute(query).fetchall()
suspended_users = []
for row in results:
suspended_users.append({
"user_id": row.user_id,
"username": row.username,
"name": row.name,
"email": row.email,
"role": row.role,
"department": row.department,
"position": row.position,
"phone": row.phone,
"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,
"users": suspended_users,
"count": len(suspended_users)
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get suspended users: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="정지된 사용자 목록 조회 중 오류가 발생했습니다"
)
@router.get("/users")
async def get_all_users(
skip: int = 0,
@@ -371,6 +440,155 @@ async def get_all_users(
)
@router.patch("/users/{user_id}/suspend")
async def suspend_user(
user_id: int,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
):
"""
사용자 정지 (관리자 전용)
Args:
user_id: 정지할 사용자 ID
credentials: JWT 토큰
db: 데이터베이스 세션
Returns:
Dict: 정지 결과
"""
try:
# 토큰 검증 및 권한 확인
payload = jwt_service.verify_access_token(credentials.credentials)
if payload['role'] not in ['system', 'admin']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자만 사용자를 정지할 수 있습니다"
)
# 자기 자신 정지 방지
if payload['user_id'] == user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="자기 자신은 정지할 수 없습니다"
)
# 사용자 정지
from sqlalchemy import text
update_query = text("""
UPDATE users
SET status = 'suspended',
is_active = FALSE,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = :user_id AND status = 'active'
RETURNING user_id, username, name, status
""")
result = db.execute(update_query, {"user_id": user_id}).fetchone()
db.commit()
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="사용자를 찾을 수 없거나 이미 정지된 상태입니다"
)
logger.info(f"User {result.username} suspended by {payload['username']}")
return {
"success": True,
"message": f"{result.name} 사용자가 정지되었습니다",
"user": {
"user_id": result.user_id,
"username": result.username,
"name": result.name,
"status": result.status
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to suspend user {user_id}: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"사용자 정지 중 오류가 발생했습니다: {str(e)}"
)
@router.patch("/users/{user_id}/reactivate")
async def reactivate_user(
user_id: int,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
):
"""
사용자 재활성화 (관리자 전용)
Args:
user_id: 재활성화할 사용자 ID
credentials: JWT 토큰
db: 데이터베이스 세션
Returns:
Dict: 재활성화 결과
"""
try:
# 토큰 검증 및 권한 확인
payload = jwt_service.verify_access_token(credentials.credentials)
if payload['role'] not in ['system', 'admin']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자만 사용자를 재활성화할 수 있습니다"
)
# 사용자 재활성화
from sqlalchemy import text
update_query = text("""
UPDATE users
SET status = 'active',
is_active = TRUE,
failed_login_attempts = 0,
locked_until = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = :user_id AND status = 'suspended'
RETURNING user_id, username, name, status
""")
result = db.execute(update_query, {"user_id": user_id}).fetchone()
db.commit()
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="사용자를 찾을 수 없거나 정지 상태가 아닙니다"
)
logger.info(f"User {result.username} reactivated by {payload['username']}")
return {
"success": True,
"message": f"{result.name} 사용자가 재활성화되었습니다",
"user": {
"user_id": result.user_id,
"username": result.username,
"name": result.name,
"status": result.status
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to reactivate user {user_id}: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"사용자 재활성화 중 오류가 발생했습니다: {str(e)}"
)
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
@@ -391,10 +609,11 @@ async def delete_user(
try:
# 토큰 검증 및 권한 확인
payload = jwt_service.verify_access_token(credentials.credentials)
if payload['role'] != 'system':
# admin role도 사용자 삭제 가능하도록 수정
if payload['role'] not in ['system', 'admin']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="사용자 삭제는 시스템 관리자만 가능합니다"
detail="사용자 삭제는 관리자만 가능합니다"
)
# 자기 자신 삭제 방지
@@ -404,7 +623,30 @@ async def delete_user(
detail="자기 자신은 삭제할 수 없습니다"
)
# 사용자 조회 및 삭제
# BOM 데이터 존재 여부 확인
from sqlalchemy import text
# files 테이블에서 uploaded_by가 이 사용자인 레코드 확인
check_files = text("""
SELECT COUNT(*) as count
FROM files
WHERE uploaded_by = :user_id
""")
files_result = db.execute(check_files, {"user_id": user_id}).fetchone()
has_files = files_result.count > 0 if files_result else False
# user_requirements 테이블 확인
check_requirements = text("""
SELECT COUNT(*) as count
FROM user_requirements
WHERE created_by = :user_id
""")
requirements_result = db.execute(check_requirements, {"user_id": user_id}).fetchone()
has_requirements = requirements_result.count > 0 if requirements_result else False
has_bom_data = has_files or has_requirements
# 사용자 조회
user_repo = UserRepository(db)
user = user_repo.find_by_id(user_id)
@@ -414,15 +656,39 @@ async def delete_user(
detail="해당 사용자를 찾을 수 없습니다"
)
user_repo.delete_user(user)
logger.info(f"User deleted by admin: {user.username} (deleted by: {payload['username']})")
return {
'success': True,
'message': '사용자가 삭제되었습니다',
'deleted_user_id': user_id
}
if has_bom_data:
# BOM 데이터가 있으면 소프트 삭제 (status='deleted')
soft_delete = text("""
UPDATE users
SET status = 'deleted',
is_active = FALSE,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = :user_id
RETURNING username, name
""")
result = db.execute(soft_delete, {"user_id": user_id}).fetchone()
db.commit()
logger.info(f"User soft-deleted (has BOM data): {result.username} (deleted by: {payload['username']})")
return {
'success': True,
'message': f'{result.name} 사용자가 비활성화되었습니다 (BOM 데이터 보존)',
'soft_deleted': True,
'deleted_user_id': user_id
}
else:
# BOM 데이터가 없으면 완전 삭제
user_repo.delete_user(user)
logger.info(f"User hard-deleted (no BOM data): {user.username} (deleted by: {payload['username']})")
return {
'success': True,
'message': '사용자가 완전히 삭제되었습니다',
'soft_deleted': False,
'deleted_user_id': user_id
}
except HTTPException:
raise

View File

@@ -62,14 +62,38 @@ class AuthService:
message="아이디 또는 비밀번호가 올바르지 않습니다"
)
# 계정 활성화 상태 확인
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="비활성화된 계정입니다. 관리자에게 문의하세요"
)
# 계정 상태 확인 (새로운 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():

View File

@@ -32,7 +32,8 @@ class User(Base):
access_level = Column(String(20), default='worker', nullable=False) # 호환성 유지
# 계정 상태 관리
is_active = Column(Boolean, default=True, 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)
@@ -302,9 +303,15 @@ class UserRepository:
raise
def get_all_users(self, skip: int = 0, limit: int = 100) -> List[User]:
"""모든 사용자 조회"""
"""활성 사용자 조회 (status='active')"""
try:
return self.db.query(User).offset(skip).limit(limit).all()
# 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 []

View File

@@ -83,7 +83,8 @@ async def signup_request(
'position': signup_data.position,
'phone': signup_data.phone,
'role': 'user',
'is_active': False # 비활성 상태로 승인 대기 표시
'is_active': False, # 하위 호환성
'status': 'pending' # 새로운 status 체계: 승인 대기
})
# 가입 사유 저장 (notes 컬럼 활용)
@@ -130,13 +131,13 @@ async def get_signup_requests(
detail="관리자만 접근 가능합니다"
)
# 승인 대기 중인 사용자 조회 (is_active=False인 사용자)
# 승인 대기 중인 사용자 조회 (status='pending'인 사용자)
query = text("""
SELECT
user_id as id, username, name, email, department, position,
phone, notes, created_at
user_id, username, name, email, department, position,
phone, created_at, role, is_active, status
FROM users
WHERE is_active = FALSE
WHERE status = 'pending'
ORDER BY created_at DESC
""")
@@ -145,15 +146,18 @@ async def get_signup_requests(
pending_users = []
for row in results:
pending_users.append({
"id": row.id,
"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,
"reason": row.notes,
"requested_at": row.created_at.isoformat() if row.created_at else None
"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 {
@@ -172,6 +176,39 @@ async def get_signup_requests(
)
@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,
@@ -201,9 +238,10 @@ async def approve_signup(
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 is_active = FALSE
WHERE user_id = :user_id AND status = 'pending'
RETURNING user_id as id, username, name
""")

View File

@@ -43,7 +43,7 @@ class SecuritySettings(BaseSettings):
"""보안 설정"""
cors_origins: List[str] = Field(default=[], description="CORS 허용 도메인")
cors_methods: List[str] = Field(
default=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
default=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
description="CORS 허용 메서드"
)
cors_headers: List[str] = Field(
@@ -147,6 +147,7 @@ class Settings(BaseSettings):
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = False
extra = "ignore"
def __init__(self, **kwargs):
super().__init__(**kwargs)

View File

@@ -9,8 +9,16 @@ DATABASE_URL = os.getenv(
"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

@@ -91,12 +91,49 @@ try:
except ImportError:
logger.warning("dashboard 라우터를 찾을 수 없습니다")
# 리비전 관리 라우터 (임시 비활성화)
# 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
@@ -204,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")

View File

@@ -526,6 +526,7 @@ async def get_projects(
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,

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

@@ -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

@@ -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

@@ -706,7 +706,8 @@ def classify_bolt(dat_file: str, description: str, main_nom: str, length: Option
"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": {
@@ -966,12 +967,19 @@ def extract_bolt_dimensions(main_nom: str, description: str) -> Dict:
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": actual_bolt_size, # 실제 볼트 사이즈
"nominal_size_fraction": nominal_size_fraction, # 분수 변환값
"length": "",
"diameter": "",
"dimension_description": nominal_size_fraction # 분수로 표시
"dimension_description": nominal_size_fraction, # 분수로 표시
"bolts_per_flange": bolts_per_flange # 플랜지당 볼트 세트 수
}
# 길이 정보 추출 (개선된 패턴)
@@ -984,6 +992,8 @@ def extract_bolt_dimensions(main_nom: str, description: str) -> Dict:
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 형태 (단독)
]

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

@@ -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,
@@ -203,7 +193,11 @@ def classify_fitting(dat_file: str, description: str, main_nom: str,
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']
# OLET 키워드를 우선 확인하여 정확한 분류 수행
olet_keywords = OLET_KEYWORDS
has_olet_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in olet_keywords)
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)
@@ -239,71 +233,35 @@ 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 구분
@@ -428,12 +386,28 @@ def classify_fitting_type(dat_file: str, description: str,
dat_upper = dat_file.upper()
desc_upper = description.upper()
# 0. 사이즈 패턴 분석으로 TEE vs REDUCER 구분 (최우선)
# 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
# 1. DAT_FILE 패턴으로 1차 분류 (가장 신뢰도 높음)
# 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:
@@ -450,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:
@@ -467,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",
@@ -480,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:

View File

@@ -182,6 +182,14 @@ def classify_flange(dat_file: str, description: str, main_nom: str,
dat_upper = dat_file.upper()
# 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는 밸브로 분류"
}
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)

View File

@@ -6,71 +6,14 @@
import re
from typing import Dict, List, Optional, Tuple
from .fitting_classifier import classify_fitting
# Level 1: 명확한 타입 키워드 (최우선)
LEVEL1_TYPE_KEYWORDS = {
"BOLT": ["FLANGE BOLT", "U-BOLT", "U BOLT", "BOLT", "STUD", "NUT", "SCREW", "WASHER", "볼트", "너트", "스터드", "나사", "와셔", "유볼트"],
"VALVE": ["VALVE", "GATE", "BALL", "GLOBE", "CHECK", "BUTTERFLY", "NEEDLE", "RELIEF", "밸브", "게이트", "", "글로브", "체크", "버터플라이", "니들", "릴리프"],
"FLANGE": ["FLG", "FLANGE", "플랜지", "프랜지", "ORIFICE", "SPECTACLE", "PADDLE", "SPACER", "BLIND"],
"PIPE": ["PIPE", "TUBE", "파이프", "배관", "SMLS", "SEAMLESS"],
"FITTING": ["ELBOW", "ELL", "TEE", "REDUCER", "RED", "CAP", "COUPLING", "NIPPLE", "SWAGE", "OLET", "PLUG", "엘보", "", "리듀서", "", "니플", "커플링", "플러그", "CONC", "ECC", "SOCK-O-LET", "WELD-O-LET", "SOCKOLET", "WELDOLET", "THREADOLET"],
"GASKET": ["GASKET", "GASK", "가스켓", "SWG", "SPIRAL"],
"INSTRUMENT": ["GAUGE", "TRANSMITTER", "SENSOR", "THERMOMETER", "계기", "게이지", "트랜스미터", "센서"],
"SUPPORT": ["URETHANE BLOCK", "URETHANE", "BLOCK SHOE", "CLAMP", "SUPPORT", "HANGER", "SPRING", "우레탄", "블록", "클램프", "서포트", "행거", "스프링"]
}
# Level 2: 서브타입 키워드 (구체화)
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", "스프링"]
}
}
# Level 3: 연결/압력 키워드 (공용)
LEVEL3_CONNECTION_KEYWORDS = {
"SW": ["SW", "SOCKET WELD", "소켓웰드"],
"THD": ["THD", "THREADED", "NPT", "나사"],
"FL": ["FL", "FLANGED", "플랜지형"],
"BW": ["BW", "BUTT WELD", "맞대기용접"]
}
LEVEL3_PRESSURE_KEYWORDS = ["150LB", "300LB", "600LB", "900LB", "1500LB", "2500LB", "3000LB", "6000LB"]
# Level 4: 재질 키워드 (최후 판단)
LEVEL4_MATERIAL_KEYWORDS = {
"PIPE": ["A106", "A333", "A312", "A53"],
"FITTING": ["A234", "A403", "A420"],
"FLANGE": ["A182", "A350"], # A105 제거 (범용 재질로 이동)
"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"]
}
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:
@@ -90,26 +33,61 @@ def classify_material_integrated(description: str, main_nom: str = "",
desc_upper = description.upper()
# 최우선: SPECIAL 키워드 확인 (도면 업로드가 필요한 특수 자재)
special_keywords = ['SPECIAL', '스페셜', 'SPEC', 'SPL']
for keyword in special_keywords:
if keyword in desc_upper:
return {
"category": "SPECIAL",
"confidence": 1.0,
"evidence": [f"SPECIAL_KEYWORD: {keyword}"],
"classification_level": "LEVEL0_SPECIAL",
"reason": f"스페셜 키워드 발견: {keyword}"
}
# U-BOLT 및 관련 부품 우선 확인 (BOLT 카테고리보다 먼저)
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):
# SPECIAL이 포함된 경우 (단, SPECIFICATION은 제외)
if 'SPECIAL' in desc_upper and 'SPECIFICATION' not in desc_upper:
return {
"category": "U_BOLT",
"category": "SPECIAL",
"confidence": 1.0,
"evidence": ["U_BOLT_SYSTEM_KEYWORD"],
"classification_level": "LEVEL0_U_BOLT",
"reason": "U-BOLT 시스템 키워드 발견"
"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")
@@ -117,24 +95,81 @@ def classify_material_integrated(description: str, main_nom: str = "",
# 1단계: Level 1 키워드로 타입 식별
detected_types = []
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:
# 전체 문자열에서 찾기
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:
# 특별 우선순위: 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
if type_found:
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:
@@ -247,7 +282,7 @@ def classify_material_integrated(description: str, main_nom: str = "",
# 분류 실패
return {
"category": "UNKNOWN",
"category": "UNCLASSIFIED",
"confidence": 0.0,
"evidence": ["NO_CLASSIFICATION_POSSIBLE"],
"classification_level": "NONE"
@@ -263,4 +298,4 @@ def should_exclude_material(description: str) -> bool:
]
desc_upper = description.upper()
return any(keyword in desc_upper for keyword in exclude_keywords)
return any(keyword in desc_upper for keyword in exclude_keywords)

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": {
@@ -138,6 +192,27 @@ 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:
"""구매용 파이프 분류 - 끝단 가공 정보 제외"""
@@ -215,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",
@@ -260,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),
@@ -328,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
}
@@ -353,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
}
@@ -360,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

@@ -93,16 +93,11 @@ class RevisionComparator:
def compare_materials(self, previous_confirmed: Dict, new_materials: List[Dict]) -> Dict:
"""
기존 확정 자재와 신규 자재 비교
Args:
previous_confirmed: 이전 확정 자재 정보
new_materials: 신규 업로드된 자재 목록
Returns:
비교 결과 딕셔너리
"""
try:
# 이전 확정 자재를 해시맵으로 변환 (빠른 검색을 위해)
from rapidfuzz import fuzz
# 이전 확정 자재 해시맵 생성
confirmed_materials = {}
for item in previous_confirmed["items"]:
material_hash = self._generate_material_hash(
@@ -112,13 +107,19 @@ class RevisionComparator:
)
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 = [] # 신규 추가 (분류 필요)
unchanged_materials = []
changed_materials = []
new_materials_list = []
for new_material in new_materials:
# 자재 해시 생성 (description 기반)
description = new_material.get("description", "")
size = self._extract_size_from_description(description)
material = self._extract_material_from_description(description)
@@ -126,13 +127,13 @@ class RevisionComparator:
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: # 수량 변경
if abs(new_qty - confirmed_qty) > 0.001:
changed_materials.append({
**new_material,
"change_type": "QUANTITY_CHANGED",
@@ -140,27 +141,49 @@ class RevisionComparator:
"previous_item": confirmed_item
})
else:
# 수량 동일 - 기존 분류 결과 재사용
unchanged_materials.append({
**new_material,
"reuse_classification": True,
"previous_item": confirmed_item
})
else:
# 신규 자재
new_materials_list.append({
**new_material,
"change_type": "NEW_MATERIAL"
})
# 해시 불일치 - 유사도 검사 (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:
description = material.get("description", "")
size = self._extract_size_from_description(description)
material_grade = self._extract_material_from_description(description)
hash_key = self._generate_material_hash(description, size, material_grade)
new_material_hashes.add(hash_key)
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():
@@ -186,7 +209,7 @@ class RevisionComparator:
"removed_materials": removed_materials
}
logger.info(f"리비전 비교 완료: 변경없음 {len(unchanged_materials)}, "
logger.info(f"리비전 비교 완료 (Fuzzy 적용): 변경없음 {len(unchanged_materials)}, "
f"변경됨 {len(changed_materials)}, 신규 {len(new_materials_list)}, "
f"삭제됨 {len(removed_materials)}")
@@ -195,7 +218,7 @@ class RevisionComparator:
except Exception as e:
logger.error(f"자재 비교 실패: {str(e)}")
raise
def _extract_revision_number(self, revision: str) -> int:
"""리비전 문자열에서 숫자 추출 (Rev.1 → 1)"""
try:
@@ -206,37 +229,136 @@ class RevisionComparator:
return 0
def _generate_material_hash(self, description: str, size: str, material: str) -> str:
"""자재 고유성 판단을 위한 해시 생성"""
# RULES.md의 코딩 컨벤션 준수
hash_input = f"{description}|{size}|{material}".lower().strip()
"""
자재 고유성 판단을 위한 해시 생성
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 = [
r'(\d+(?:\.\d+)?)\s*(?:mm|MM|인치|inch|")',
r'(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)',
r'DN\s*(\d+)',
r'(\d+)\s*A'
# 인치 패턴 (분수 포함): 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)
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:
"""자재 설명에서 재질 정보 추출"""
# 일반적인 재질 패턴
materials = ["SS304", "SS316", "SS316L", "A105", "WCB", "CF8M", "CF8", "CS"]
"""
자재 설명에서 재질 정보 추출
우선순위에 따라 매칭 (구체적인 재질 먼저)
"""
if not description:
return ""
# 자재 목록 로딩 (메모리 캐싱을 위해 클래스 속성으로 저장 고려 가능하지만 여기선 매번 호출로 단순화)
# 성능이 중요하다면 __init__ 시점에 로드하거나 lru_cache 사용 권장
materials = self._load_materials_from_db()
description_upper = description.upper()
for material in materials:
if material in description_upper:
# 단어 매칭을 위해 간단한 검사 수행 (부분 문자열이 다른 단어의 일부가 아닌지)
if material.upper() in description_upper:
return material
return ""

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

@@ -108,7 +108,22 @@ def classify_support(dat_file: str, description: str, main_nom: str,
# 4. 사이즈 정보 추출
size_result = extract_support_size(description, main_nom)
# 5. 최종 결과 조합
# 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",
@@ -118,10 +133,10 @@ def classify_support(dat_file: str, description: str, main_nom: str,
"load_rating": load_result.get("load_rating", ""),
"load_capacity": load_result.get("capacity", ""),
# 재질 정보 (공통 모듈)
# 재질 정보 (공통 모듈) - 우레탄 블럭슈 두께 정보 포함
"material": {
"standard": material_result.get('standard', 'UNKNOWN'),
"grade": material_result.get('grade', 'UNKNOWN'),
"grade": enhanced_material_grade,
"material_type": material_result.get('material_type', 'UNKNOWN'),
"confidence": material_result.get('confidence', 0.0)
},
@@ -129,6 +144,9 @@ def classify_support(dat_file: str, description: str, main_nom: str,
# 사이즈 정보
"size_info": size_result,
# 사용자 요구사항
"user_requirements": user_requirements,
# 전체 신뢰도
"overall_confidence": calculate_support_confidence({
"type": support_type_result.get('confidence', 0),
@@ -183,6 +201,34 @@ def classify_support_type(dat_file: str, description: str) -> Dict:
"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:
"""하중 등급 분류"""

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,8 +230,13 @@ 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', '밸브', '이트', '', '글로브', '체크', '버터플라이', '니들', '릴리프', '솔레노이드']
# 1. 사이트 글라스와 스트레이너 우선 확인
if 'SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or '이트글라스' in desc_upper or '스트레이너' in desc_upper:
# 사이트 글라스와 스트레이너는 항상 밸브로 분류
pass
# 밸브 키워드 확인 (재질만 있어도 통합 분류기가 이미 밸브로 분류했으므로 진행)
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)

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 "$@"

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.

View File

@@ -1,41 +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
python-magic==0.4.27
# 인증 시스템
pydantic_core==2.14.5
pyflakes==3.1.0
PyJWT==2.8.0
bcrypt==4.1.2
python-multipart==0.0.6
email-validator==2.3.0
# 개발 도구
pytest==7.4.3
pytest-asyncio==0.21.1
pytest-cov==4.1.0
pytest-mock==3.12.0
black==23.11.0
flake8==6.1.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

@@ -0,0 +1,28 @@
-- users 테이블에 status 컬럼 추가 및 기존 데이터 마이그레이션
-- 1. status 컬럼 추가 (기본값은 'active')
ALTER TABLE users
ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'active';
-- 2. status 컬럼에 CHECK 제약 조건 추가
ALTER TABLE users
ADD CONSTRAINT users_status_check
CHECK (status IN ('pending', 'active', 'suspended', 'deleted'));
-- 3. 기존 데이터 마이그레이션
-- is_active가 false인 사용자는 'pending'으로
-- is_active가 true인 사용자는 'active'로
UPDATE users
SET status = CASE
WHEN is_active = FALSE THEN 'pending'
WHEN is_active = TRUE THEN 'active'
ELSE 'active'
END;
-- 4. status 컬럼에 인덱스 추가 (조회 성능 향상)
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
-- 5. 향후 is_active 컬럼은 deprecated로 간주
-- 하지만 하위 호환성을 위해 당분간 유지
COMMENT ON COLUMN users.status IS 'User account status: pending, active, suspended, deleted';
COMMENT ON COLUMN users.is_active IS 'DEPRECATED: Use status column instead. Kept for backward compatibility.';

View File

@@ -0,0 +1,135 @@
-- 엑셀 내보내기 이력 및 구매 상태 관리 테이블
-- 1. 엑셀 내보내기 이력 테이블
CREATE TABLE IF NOT EXISTS excel_export_history (
export_id SERIAL PRIMARY KEY,
file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
job_no VARCHAR(50) REFERENCES jobs(job_no),
exported_by INTEGER REFERENCES users(user_id),
export_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
export_type VARCHAR(50), -- 'full', 'category', 'filtered'
category VARCHAR(50), -- PIPE, FLANGE, VALVE 등
material_count INTEGER,
file_name VARCHAR(255),
notes TEXT,
-- 메타데이터
filters_applied JSONB, -- 적용된 필터 조건들
export_options JSONB -- 내보내기 옵션들
);
-- 2. 내보낸 자재 상세 (어떤 자재들이 내보내졌는지 추적)
CREATE TABLE IF NOT EXISTS exported_materials (
id SERIAL PRIMARY KEY,
export_id INTEGER REFERENCES excel_export_history(export_id) ON DELETE CASCADE,
material_id INTEGER REFERENCES materials(id),
purchase_status VARCHAR(50) DEFAULT 'pending', -- pending, requested, ordered, received, cancelled
purchase_request_no VARCHAR(100), -- 구매요청 번호
purchase_order_no VARCHAR(100), -- 구매주문 번호
requested_date TIMESTAMP,
ordered_date TIMESTAMP,
expected_date DATE,
received_date TIMESTAMP,
quantity_exported INTEGER, -- 내보낸 수량
quantity_ordered INTEGER, -- 주문 수량
quantity_received INTEGER, -- 입고 수량
unit_price DECIMAL(15, 2),
total_price DECIMAL(15, 2),
vendor_name VARCHAR(255),
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by INTEGER REFERENCES users(user_id)
);
-- 3. 구매 상태 이력 (상태 변경 추적)
CREATE TABLE IF NOT EXISTS purchase_status_history (
history_id SERIAL PRIMARY KEY,
exported_material_id INTEGER REFERENCES exported_materials(id) ON DELETE CASCADE,
material_id INTEGER REFERENCES materials(id),
previous_status VARCHAR(50),
new_status VARCHAR(50),
changed_by INTEGER REFERENCES users(user_id),
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
reason TEXT,
metadata JSONB -- 추가 정보 (예: 문서 번호, 승인자 등)
);
-- 4. 구매 문서 관리
CREATE TABLE IF NOT EXISTS purchase_documents (
document_id SERIAL PRIMARY KEY,
export_id INTEGER REFERENCES excel_export_history(export_id),
document_type VARCHAR(50), -- 'purchase_request', 'purchase_order', 'invoice', 'receipt'
document_no VARCHAR(100),
document_date DATE,
file_path VARCHAR(500),
uploaded_by INTEGER REFERENCES users(user_id),
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
notes TEXT
);
-- 인덱스 추가
CREATE INDEX IF NOT EXISTS idx_export_history_file_id ON excel_export_history(file_id);
CREATE INDEX IF NOT EXISTS idx_export_history_job_no ON excel_export_history(job_no);
CREATE INDEX IF NOT EXISTS idx_export_history_date ON excel_export_history(export_date);
CREATE INDEX IF NOT EXISTS idx_exported_materials_export_id ON exported_materials(export_id);
CREATE INDEX IF NOT EXISTS idx_exported_materials_material_id ON exported_materials(material_id);
CREATE INDEX IF NOT EXISTS idx_exported_materials_status ON exported_materials(purchase_status);
CREATE INDEX IF NOT EXISTS idx_exported_materials_pr_no ON exported_materials(purchase_request_no);
CREATE INDEX IF NOT EXISTS idx_exported_materials_po_no ON exported_materials(purchase_order_no);
CREATE INDEX IF NOT EXISTS idx_purchase_history_material ON purchase_status_history(material_id);
CREATE INDEX IF NOT EXISTS idx_purchase_history_date ON purchase_status_history(changed_at);
-- 뷰 생성: 구매 상태별 자재 현황
CREATE OR REPLACE VIEW v_purchase_status_summary AS
SELECT
em.purchase_status,
COUNT(DISTINCT em.material_id) as material_count,
COUNT(DISTINCT em.export_id) as export_count,
SUM(em.quantity_exported) as total_quantity_exported,
SUM(em.quantity_ordered) as total_quantity_ordered,
SUM(em.quantity_received) as total_quantity_received,
SUM(em.total_price) as total_amount,
MAX(em.updated_at) as last_updated
FROM exported_materials em
GROUP BY em.purchase_status;
-- 뷰 생성: 자재별 최신 구매 상태
CREATE OR REPLACE VIEW v_material_latest_purchase_status AS
SELECT DISTINCT ON (m.id)
m.id as material_id,
m.original_description,
m.classified_category,
em.purchase_status,
em.purchase_request_no,
em.purchase_order_no,
em.vendor_name,
em.expected_date,
em.quantity_ordered,
em.quantity_received,
em.updated_at as status_updated_at,
eeh.export_date as last_exported_date
FROM materials m
LEFT JOIN exported_materials em ON m.id = em.material_id
LEFT JOIN excel_export_history eeh ON em.export_id = eeh.export_id
ORDER BY m.id, em.updated_at DESC;
-- 트리거: updated_at 자동 업데이트
CREATE OR REPLACE FUNCTION update_exported_materials_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_exported_materials_updated_at_trigger
BEFORE UPDATE ON exported_materials
FOR EACH ROW
EXECUTE FUNCTION update_exported_materials_updated_at();
-- 코멘트 추가
COMMENT ON TABLE excel_export_history IS '엑셀 내보내기 이력 관리';
COMMENT ON TABLE exported_materials IS '내보낸 자재의 구매 상태 추적';
COMMENT ON TABLE purchase_status_history IS '구매 상태 변경 이력';
COMMENT ON TABLE purchase_documents IS '구매 관련 문서 관리';
COMMENT ON COLUMN exported_materials.purchase_status IS 'pending: 구매신청 전, requested: 구매신청, ordered: 구매주문, received: 입고완료, cancelled: 취소';

View File

@@ -0,0 +1,44 @@
-- 구매신청 관리 테이블
-- 구매신청 그룹 (같이 신청한 항목들의 묶음)
CREATE TABLE IF NOT EXISTS purchase_requests (
request_id SERIAL PRIMARY KEY,
request_no VARCHAR(50) UNIQUE, -- PR-20241014-001 형식
file_id INTEGER REFERENCES files(id),
job_no VARCHAR(50) REFERENCES jobs(job_no),
category VARCHAR(50),
material_count INTEGER,
excel_file_path VARCHAR(500), -- 저장된 엑셀 파일 경로
requested_by INTEGER REFERENCES users(user_id),
requested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(20) DEFAULT 'requested', -- requested, ordered, received
notes TEXT
);
-- 구매신청 자재 상세
CREATE TABLE IF NOT EXISTS purchase_request_items (
item_id SERIAL PRIMARY KEY,
request_id INTEGER REFERENCES purchase_requests(request_id) ON DELETE CASCADE,
material_id INTEGER REFERENCES materials(id),
quantity INTEGER,
unit VARCHAR(20),
user_requirement TEXT,
is_ordered BOOLEAN DEFAULT FALSE,
is_received BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 인덱스
CREATE INDEX IF NOT EXISTS idx_purchase_requests_file_id ON purchase_requests(file_id);
CREATE INDEX IF NOT EXISTS idx_purchase_requests_job_no ON purchase_requests(job_no);
CREATE INDEX IF NOT EXISTS idx_purchase_requests_status ON purchase_requests(status);
CREATE INDEX IF NOT EXISTS idx_purchase_request_items_request_id ON purchase_request_items(request_id);
CREATE INDEX IF NOT EXISTS idx_purchase_request_items_material_id ON purchase_request_items(material_id);
-- 뷰: 구매신청된 자재 ID 목록
CREATE OR REPLACE VIEW v_requested_material_ids AS
SELECT DISTINCT material_id
FROM purchase_request_items;
COMMENT ON TABLE purchase_requests IS '구매신청 그룹 관리';
COMMENT ON TABLE purchase_request_items IS '구매신청 자재 상세';

View File

@@ -0,0 +1,20 @@
-- 리비전 관리 개선: 자재 상태 추적
-- 리비전 업로드 시 삭제된 자재의 상태를 추적
-- materials 테이블에 revision_status 컬럼 추가
ALTER TABLE materials ADD COLUMN IF NOT EXISTS revision_status VARCHAR(20) DEFAULT 'active';
-- 가능한 값: 'active', 'inventory', 'deleted_not_purchased', 'changed'
-- revision_status 설명:
-- 'active': 정상 활성 자재 (기본값)
-- 'inventory': 재고품 (구매신청 후 리비전에서 삭제됨 - 연노랑색 표시)
-- 'deleted_not_purchased': 구매신청 전 삭제됨 (숨김 처리)
-- 'changed': 변경된 자재 (추가 구매 필요)
-- 인덱스 추가 (성능 최적화)
CREATE INDEX IF NOT EXISTS idx_materials_revision_status ON materials(revision_status);
CREATE INDEX IF NOT EXISTS idx_materials_drawing_name ON materials(drawing_name);
CREATE INDEX IF NOT EXISTS idx_materials_line_no ON materials(line_no);
COMMENT ON COLUMN materials.revision_status IS '리비전 자재 상태: active(활성), inventory(재고품), deleted_not_purchased(삭제됨), changed(변경됨)';

View File

@@ -0,0 +1,654 @@
#!/usr/bin/env python3
"""
누락된 테이블 생성 스크립트
- support_details
- special_material_details
- purchase_requests
- purchase_request_items
"""
import sys
import os
import psycopg2
from psycopg2.extras import RealDictCursor
import bcrypt
# 프로젝트 루트를 Python path에 추가
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def get_db_connection():
"""데이터베이스 연결"""
try:
# Docker 환경에서는 서비스명으로 연결
conn = psycopg2.connect(
host="tk-mp-postgres",
port="5432",
database="tk_mp_bom",
user="tkmp_user",
password="tkmp_password_2025"
)
return conn
except Exception as e:
print(f"❌ DB 연결 실패: {e}")
return None
def create_admin_user(cursor):
"""기본 admin 계정 생성"""
try:
# admin 계정이 이미 있는지 확인
cursor.execute("SELECT COUNT(*) FROM users WHERE username = 'admin';")
if cursor.fetchone()[0] > 0:
print("✅ admin 계정이 이미 존재합니다.")
return
# bcrypt로 비밀번호 해시 생성
password = "admin123"
hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
# admin 계정 생성
cursor.execute("""
INSERT INTO users (
username, password, name, email, role, access_level,
is_active, status, department, position
) VALUES (
'admin', %s, 'System Administrator', 'admin@example.com',
'admin', 'admin', true, 'active', 'IT', 'Administrator'
);
""", (hashed_password,))
print("✅ admin 계정 생성 완료 (username: admin, password: admin123)")
except Exception as e:
print(f"⚠️ admin 계정 생성 실패: {e}")
def add_missing_columns(cursor):
"""누락된 컬럼들 추가"""
try:
print("🔧 누락된 컬럼 확인 및 추가 중...")
# users 테이블에 status 컬럼 확인 및 추가
cursor.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'status';
""")
if not cursor.fetchone():
print(" users 테이블에 status 컬럼 추가 중...")
cursor.execute("""
ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'active';
""")
print("✅ users.status 컬럼 추가 완료")
else:
print("✅ users.status 컬럼이 이미 존재합니다")
# files 테이블에 누락된 컬럼들 확인 및 추가
files_columns = {
'job_no': 'VARCHAR(50)',
'bom_name': 'VARCHAR(255)',
'description': 'TEXT',
'parsed_count': 'INTEGER DEFAULT 0',
'classification_completed': 'BOOLEAN DEFAULT FALSE'
}
for column_name, column_type in files_columns.items():
cursor.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'files' AND column_name = %s;
""", (column_name,))
if not cursor.fetchone():
print(f" files 테이블에 {column_name} 컬럼 추가 중...")
cursor.execute(f"""
ALTER TABLE files ADD COLUMN {column_name} {column_type};
""")
print(f"✅ files.{column_name} 컬럼 추가 완료")
else:
print(f"✅ files.{column_name} 컬럼이 이미 존재합니다")
# materials 테이블에 누락된 컬럼들 확인 및 추가
materials_columns = {
'main_nom': 'VARCHAR(50)',
'red_nom': 'VARCHAR(50)',
'full_material_grade': 'TEXT',
'row_number': 'INTEGER',
'length': 'NUMERIC(10,3)',
'purchase_confirmed': 'BOOLEAN DEFAULT FALSE',
'confirmed_quantity': 'NUMERIC(10,3)',
'purchase_status': 'VARCHAR(20)',
'purchase_confirmed_by': 'VARCHAR(100)',
'purchase_confirmed_at': 'TIMESTAMP',
'revision_status': 'VARCHAR(20)',
'material_hash': 'VARCHAR(64)',
'normalized_description': 'TEXT',
'drawing_reference': 'VARCHAR(100)',
'notes': 'TEXT',
'created_at': 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP',
'brand': 'VARCHAR(100)',
'user_requirement': 'TEXT',
'is_active': 'BOOLEAN DEFAULT TRUE',
'total_length': 'NUMERIC(10,3)'
}
for column_name, column_type in materials_columns.items():
cursor.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'materials' AND column_name = %s;
""", (column_name,))
if not cursor.fetchone():
print(f" materials 테이블에 {column_name} 컬럼 추가 중...")
cursor.execute(f"""
ALTER TABLE materials ADD COLUMN {column_name} {column_type};
""")
print(f"✅ materials.{column_name} 컬럼 추가 완료")
else:
print(f"✅ materials.{column_name} 컬럼이 이미 존재합니다")
# purchase_requests 테이블에 누락된 컬럼들 확인 및 추가
purchase_requests_columns = {
'file_id': 'INTEGER REFERENCES files(id)'
}
for column_name, column_type in purchase_requests_columns.items():
cursor.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'purchase_requests' AND column_name = %s;
""", (column_name,))
if not cursor.fetchone():
print(f" purchase_requests 테이블에 {column_name} 컬럼 추가 중...")
cursor.execute(f"""
ALTER TABLE purchase_requests ADD COLUMN {column_name} {column_type};
""")
print(f"✅ purchase_requests.{column_name} 컬럼 추가 완료")
else:
print(f"✅ purchase_requests.{column_name} 컬럼이 이미 존재합니다")
# material_purchase_tracking 테이블에 누락된 컬럼들 확인 및 추가
mpt_columns = {
'description': 'TEXT',
'purchase_status': 'VARCHAR(20) DEFAULT \'pending\''
}
for column_name, column_type in mpt_columns.items():
cursor.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'material_purchase_tracking' AND column_name = %s;
""", (column_name,))
if not cursor.fetchone():
print(f" material_purchase_tracking 테이블에 {column_name} 컬럼 추가 중...")
cursor.execute(f"""
ALTER TABLE material_purchase_tracking ADD COLUMN {column_name} {column_type};
""")
print(f"✅ material_purchase_tracking.{column_name} 컬럼 추가 완료")
else:
print(f"✅ material_purchase_tracking.{column_name} 컬럼이 이미 존재합니다")
# purchase_requests 테이블에 누락된 컬럼들 확인 및 추가
purchase_requests_columns = {
'file_id': 'INTEGER REFERENCES files(id)',
'category': 'VARCHAR(50)',
'material_count': 'INTEGER DEFAULT 0',
'excel_file_path': 'VARCHAR(500)'
}
for column_name, column_type in purchase_requests_columns.items():
cursor.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'purchase_requests' AND column_name = %s;
""", (column_name,))
if not cursor.fetchone():
print(f" purchase_requests 테이블에 {column_name} 컬럼 추가 중...")
cursor.execute(f"""
ALTER TABLE purchase_requests ADD COLUMN {column_name} {column_type};
""")
print(f"✅ purchase_requests.{column_name} 컬럼 추가 완료")
else:
print(f"✅ purchase_requests.{column_name} 컬럼이 이미 존재합니다")
# purchase_request_items 테이블에 누락된 컬럼들 확인 및 추가
purchase_request_items_columns = {
'user_requirement': 'TEXT',
'description': 'TEXT',
'category': 'VARCHAR(50)',
'subcategory': 'VARCHAR(100)',
'material_grade': 'VARCHAR(50)',
'size_spec': 'VARCHAR(50)',
'drawing_name': 'VARCHAR(100)',
'notes': 'TEXT',
'is_ordered': 'BOOLEAN DEFAULT FALSE',
'is_received': 'BOOLEAN DEFAULT FALSE'
}
for column_name, column_type in purchase_request_items_columns.items():
cursor.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'purchase_request_items' AND column_name = %s;
""", (column_name,))
if not cursor.fetchone():
print(f" purchase_request_items 테이블에 {column_name} 컬럼 추가 중...")
cursor.execute(f"""
ALTER TABLE purchase_request_items ADD COLUMN {column_name} {column_type};
""")
print(f"✅ purchase_request_items.{column_name} 컬럼 추가 완료")
else:
print(f"✅ purchase_request_items.{column_name} 컬럼이 이미 존재합니다")
print("✅ 모든 누락된 컬럼 추가 완료!")
except Exception as e:
print(f"⚠️ 컬럼 추가 실패: {e}")
def create_missing_tables():
"""누락된 테이블들 생성 (처음 설치 시에만)"""
conn = get_db_connection()
if not conn:
return False
try:
cursor = conn.cursor()
# 이미 설치되어 있는지 확인 (핵심 테이블들이 모두 존재하는지 체크)
print("🔍 기존 설치 상태 확인 중...")
cursor.execute("""
SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('support_details', 'special_material_details', 'purchase_requests', 'purchase_request_items');
""")
existing_tables = cursor.fetchone()[0]
if existing_tables == 4:
print("✅ 모든 테이블이 이미 존재합니다.")
# 컬럼 체크는 항상 수행
print("🔧 누락된 컬럼 확인 중...")
add_missing_columns(cursor)
# admin 계정 확인
cursor.execute("SELECT COUNT(*) FROM users WHERE username = 'admin';")
admin_exists = cursor.fetchone()[0]
if admin_exists == 0:
print("👤 admin 계정 생성 중...")
create_admin_user(cursor)
conn.commit()
print("✅ admin 계정 생성 완료")
else:
print("✅ admin 계정이 이미 존재합니다.")
conn.commit()
return True
print(f"🔍 누락된 테이블 확인 및 생성 중... ({existing_tables}/4개 존재)")
# 1. support_details 테이블
print("📋 1. support_details 테이블 확인...")
cursor.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'support_details'
);
""")
if not cursor.fetchone()[0]:
print(" support_details 테이블 생성 중...")
cursor.execute("""
CREATE TABLE support_details (
id SERIAL PRIMARY KEY,
material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE,
file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
support_type VARCHAR(50),
support_subtype VARCHAR(100),
load_rating VARCHAR(50),
load_capacity VARCHAR(50),
material_standard VARCHAR(100),
material_grade VARCHAR(50),
pipe_size VARCHAR(20),
length_mm NUMERIC(10,2),
width_mm NUMERIC(10,2),
height_mm NUMERIC(10,2),
classification_confidence NUMERIC(3,2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_support_details_material_id ON support_details(material_id);
CREATE INDEX idx_support_details_file_id ON support_details(file_id);
""")
print("✅ support_details 테이블 생성 완료")
else:
print("✅ support_details 테이블 이미 존재")
# 2. special_material_details 테이블
print("📋 2. special_material_details 테이블 확인...")
cursor.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'special_material_details'
);
""")
if not cursor.fetchone()[0]:
print(" special_material_details 테이블 생성 중...")
cursor.execute("""
CREATE TABLE special_material_details (
id SERIAL PRIMARY KEY,
material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE,
file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
special_type VARCHAR(50),
special_subtype VARCHAR(100),
material_standard VARCHAR(100),
material_grade VARCHAR(50),
specifications TEXT,
dimensions VARCHAR(100),
weight_kg NUMERIC(10,3),
classification_confidence NUMERIC(3,2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_special_material_details_material_id ON special_material_details(material_id);
CREATE INDEX idx_special_material_details_file_id ON special_material_details(file_id);
""")
print("✅ special_material_details 테이블 생성 완료")
else:
print("✅ special_material_details 테이블 이미 존재")
# 3. purchase_requests 테이블
print("📋 3. purchase_requests 테이블 확인...")
cursor.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'purchase_requests'
);
""")
if not cursor.fetchone()[0]:
print(" purchase_requests 테이블 생성 중...")
cursor.execute("""
CREATE TABLE purchase_requests (
request_id SERIAL PRIMARY KEY,
request_no VARCHAR(50) UNIQUE NOT NULL,
file_id INTEGER REFERENCES files(id),
job_no VARCHAR(50) NOT NULL,
category VARCHAR(50),
material_count INTEGER DEFAULT 0,
excel_file_path VARCHAR(500),
project_name VARCHAR(200),
requested_by INTEGER REFERENCES users(user_id),
requested_by_username VARCHAR(100),
request_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(20) DEFAULT 'pending',
total_items INTEGER DEFAULT 0,
notes TEXT,
approved_by INTEGER REFERENCES users(user_id),
approved_by_username VARCHAR(100),
approved_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_purchase_requests_job_no ON purchase_requests(job_no);
CREATE INDEX idx_purchase_requests_status ON purchase_requests(status);
CREATE INDEX idx_purchase_requests_requested_by ON purchase_requests(requested_by);
""")
print("✅ purchase_requests 테이블 생성 완료")
else:
print("✅ purchase_requests 테이블 이미 존재")
# 4. purchase_request_items 테이블
print("📋 4. purchase_request_items 테이블 확인...")
cursor.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'purchase_request_items'
);
""")
if not cursor.fetchone()[0]:
print(" purchase_request_items 테이블 생성 중...")
cursor.execute("""
CREATE TABLE purchase_request_items (
item_id SERIAL PRIMARY KEY,
request_id INTEGER REFERENCES purchase_requests(request_id) ON DELETE CASCADE,
material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE,
description TEXT NOT NULL,
category VARCHAR(50),
subcategory VARCHAR(100),
material_grade VARCHAR(50),
size_spec VARCHAR(50),
quantity NUMERIC(10,3) NOT NULL,
unit VARCHAR(10) NOT NULL,
drawing_name VARCHAR(100),
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_purchase_request_items_request_id ON purchase_request_items(request_id);
CREATE INDEX idx_purchase_request_items_material_id ON purchase_request_items(material_id);
CREATE INDEX idx_purchase_request_items_category ON purchase_request_items(category);
""")
print("✅ purchase_request_items 테이블 생성 완료")
else:
print("✅ purchase_request_items 테이블 이미 존재")
# 5. revision_sessions 테이블
print("📋 5. revision_sessions 테이블 확인...")
cursor.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'revision_sessions'
);
""")
if not cursor.fetchone()[0]:
print(" revision_sessions 테이블 생성 중...")
cursor.execute("""
CREATE TABLE revision_sessions (
id SERIAL PRIMARY KEY,
job_no VARCHAR(50) NOT NULL,
current_file_id INTEGER REFERENCES files(id),
previous_file_id INTEGER REFERENCES files(id),
current_revision VARCHAR(20) NOT NULL,
previous_revision VARCHAR(20) NOT NULL,
status VARCHAR(20) DEFAULT 'processing',
total_materials INTEGER DEFAULT 0,
processed_materials INTEGER DEFAULT 0,
added_count INTEGER DEFAULT 0,
removed_count INTEGER DEFAULT 0,
changed_count INTEGER DEFAULT 0,
unchanged_count INTEGER DEFAULT 0,
purchase_cancel_count INTEGER DEFAULT 0,
inventory_transfer_count INTEGER DEFAULT 0,
additional_purchase_count INTEGER DEFAULT 0,
created_by VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP
);
CREATE INDEX idx_revision_sessions_job_no ON revision_sessions(job_no);
CREATE INDEX idx_revision_sessions_status ON revision_sessions(status);
""")
print("✅ revision_sessions 테이블 생성 완료")
else:
print("✅ revision_sessions 테이블 이미 존재")
# 6. revision_material_changes 테이블
print("📋 6. revision_material_changes 테이블 확인...")
cursor.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'revision_material_changes'
);
""")
if not cursor.fetchone()[0]:
print(" revision_material_changes 테이블 생성 중...")
cursor.execute("""
CREATE TABLE revision_material_changes (
id SERIAL PRIMARY KEY,
session_id INTEGER REFERENCES revision_sessions(id) ON DELETE CASCADE,
material_id INTEGER REFERENCES materials(id),
previous_material_id INTEGER,
material_description TEXT NOT NULL,
category VARCHAR(50) NOT NULL,
change_type VARCHAR(20) NOT NULL,
previous_quantity NUMERIC(10,3),
current_quantity NUMERIC(10,3),
quantity_difference NUMERIC(10,3),
purchase_status VARCHAR(20) NOT NULL,
purchase_confirmed_at TIMESTAMP,
revision_action VARCHAR(30),
action_status VARCHAR(20) DEFAULT 'pending',
processed_by VARCHAR(100),
processed_at TIMESTAMP,
processing_notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_revision_changes_session ON revision_material_changes(session_id);
CREATE INDEX idx_revision_changes_action ON revision_material_changes(revision_action);
CREATE INDEX idx_revision_changes_status ON revision_material_changes(action_status);
""")
print("✅ revision_material_changes 테이블 생성 완료")
else:
print("✅ revision_material_changes 테이블 이미 존재")
# 7. inventory_transfers 테이블
print("📋 7. inventory_transfers 테이블 확인...")
cursor.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'inventory_transfers'
);
""")
if not cursor.fetchone()[0]:
print(" inventory_transfers 테이블 생성 중...")
cursor.execute("""
CREATE TABLE inventory_transfers (
id SERIAL PRIMARY KEY,
revision_change_id INTEGER REFERENCES revision_material_changes(id),
material_description TEXT NOT NULL,
category VARCHAR(50) NOT NULL,
quantity NUMERIC(10,3) NOT NULL,
unit VARCHAR(10) NOT NULL,
inventory_location VARCHAR(100),
storage_notes TEXT,
transferred_by VARCHAR(100) NOT NULL,
transferred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(20) DEFAULT 'transferred'
);
CREATE INDEX idx_inventory_transfers_material ON inventory_transfers(material_description);
CREATE INDEX idx_inventory_transfers_date ON inventory_transfers(transferred_at);
""")
print("✅ inventory_transfers 테이블 생성 완료")
else:
print("✅ inventory_transfers 테이블 이미 존재")
# 8. revision_action_logs 테이블
print("📋 8. revision_action_logs 테이블 확인...")
cursor.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'revision_action_logs'
);
""")
if not cursor.fetchone()[0]:
print(" revision_action_logs 테이블 생성 중...")
cursor.execute("""
CREATE TABLE revision_action_logs (
id SERIAL PRIMARY KEY,
session_id INTEGER REFERENCES revision_sessions(id),
revision_change_id INTEGER REFERENCES revision_material_changes(id),
action_type VARCHAR(30) NOT NULL,
action_description TEXT,
executed_by VARCHAR(100) NOT NULL,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
result VARCHAR(20) NOT NULL,
result_message TEXT,
result_data JSONB
);
CREATE INDEX idx_revision_logs_session ON revision_action_logs(session_id);
CREATE INDEX idx_revision_logs_type ON revision_action_logs(action_type);
CREATE INDEX idx_revision_logs_date ON revision_action_logs(executed_at);
""")
print("✅ revision_action_logs 테이블 생성 완료")
else:
print("✅ revision_action_logs 테이블 이미 존재")
# 변경사항 커밋
conn.commit()
print("\n🎉 누락된 테이블 생성 완료!")
# 최종 테이블 목록 확인
print("\n📋 현재 테이블 목록:")
cursor.execute("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name;
""")
tables = cursor.fetchall()
for table in tables:
print(f" - {table[0]}")
print(f"\n{len(tables)}개 테이블 존재")
# users 테이블에 status 컬럼 추가 (필요한 경우)
add_missing_columns(cursor)
# admin 계정 생성
create_admin_user(cursor)
conn.commit()
return True
except Exception as e:
print(f"❌ 테이블 생성 실패: {e}")
conn.rollback()
return False
finally:
if conn:
conn.close()
if __name__ == "__main__":
print("🚀 누락된 테이블 생성 시작...")
success = create_missing_tables()
if success:
print("✅ 모든 작업 완료!")
sys.exit(0)
else:
print("❌ 작업 실패!")
sys.exit(1)

25
backend/start.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
echo "🚀 TK-MP Backend 시작 중..."
# 데이터베이스 연결 대기
echo "⏳ 데이터베이스 연결 대기 중..."
while ! nc -z tk-mp-postgres 5432; do
sleep 1
done
echo "✅ 데이터베이스 연결 확인"
# 자동 마이그레이션 실행 (처음 설치 시에만)
echo "🔧 자동 마이그레이션 실행 중..."
python scripts/create_missing_tables.py
migration_result=$?
if [ $migration_result -eq 0 ]; then
echo "✅ 마이그레이션 완료"
else
echo "⚠️ 마이그레이션에 문제가 있었지만 서버를 시작합니다..."
fi
# FastAPI 서버 시작
echo "🌟 FastAPI 서버 시작..."
exec uvicorn app.main:app --host 0.0.0.0 --port 8000

View File

@@ -0,0 +1,53 @@
import pytest
from app.services.integrated_classifier import classify_material_integrated
from app.services.fitting_classifier import classify_fitting
from app.services.classifier_constants import LEVEL1_TYPE_KEYWORDS
def test_classify_simple_pipe():
result = classify_material_integrated("PIPE, A106 Gr.B, 2 INCH")
# LEVEL1_TYPE_KEYWORDS["PIPE"] contains "PIPE"
assert result["category"] == "PIPE"
def test_classify_fitting_elbow():
result = classify_material_integrated("ELBOW 90DEG, BW")
# Should route to FITTING and then call fitting_classifier
assert result["category"] == "FITTING"
# detail check
if "fitting_type" in result:
assert result["fitting_type"]["type"] == "ELBOW"
def test_classify_swagelok_partno():
# Regex check in integrated_classifier
result = classify_material_integrated("SS-400-1-4 CONNECTOR")
# Should be detected by swagelok_pattern as TUBE_FITTING (Level 0)
assert result["category"] == "TUBE_FITTING"
def test_classify_swagelok_keyword():
# Keyword check
result = classify_material_integrated("SWAGELOK UNION 1/4 INCH")
# 'SWAGELOK' is in FITTING list in constants.
# So it should be FITTING?
# BUT integrated_classifier has logic: if detected_type == FITTING -> call classify_fitting
# classify_fitting checks 'SWAGELOK' -> sets category 'INSTRUMENT_FITTING'
# Let's see what meaningful category it returns.
# The return from classify_fitting overrides integrated result if present.
assert result["category"] in ["FITTING", "INSTRUMENT_FITTING"]
def test_classify_u_bolt():
# Priority check: U-BOLT is in BOLT keywords but integrated_classifier has early check for SUPPORT
result = classify_material_integrated("U-BOLT, 2 INCH")
assert result["category"] == "SUPPORT"
def test_classify_pressure_constants_usage():
# fitting_classifier uses imported constants
# Test if it recognizes 3000LB (from constants)
result = classify_fitting("P_DAT", "COUPLING, 3000LB, SW", "2")
assert result["pressure_rating"]["rating"] == "3000LB"
assert result["pressure_rating"]["confidence"] > 0.9
def test_classify_olet_constants_usage():
# Detect OLET
result = classify_fitting("P_DAT", "WELDOLET, 3000LB", "2", "1")
assert result["fitting_type"]["type"] == "OLET"

View File

@@ -0,0 +1,199 @@
import pytest
from unittest.mock import MagicMock
from app.services.revision_comparator import RevisionComparator
@pytest.fixture
def mock_db():
return MagicMock()
@pytest.fixture
def comparator(mock_db):
return RevisionComparator(mock_db)
def test_generate_material_hash(comparator):
"""해시 생성 로직 테스트"""
# 1. 기본 케이스
desc = "PIPE 100A SCH40"
size = "100A"
mat = "A105"
hash1 = comparator._generate_material_hash(desc, size, mat)
# 2. 공백/대소문자 차이 (정규화 확인)
hash2 = comparator._generate_material_hash(" pipe 100a sch40 ", "100A", "a105")
assert hash1 == hash2, "공백과 대소문자는 무시되어야 합니다."
# 3. None 값 처리 (Robustness)
hash3 = comparator._generate_material_hash(None, None, None)
assert isinstance(hash3, str)
assert len(hash3) == 32 # MD5 checking
# 4. 빈 문자열 처리
hash4 = comparator._generate_material_hash("", "", "")
assert hash3 == hash4, "None과 빈 문자열은 동일하게 처리되어야 합니다."
def test_extract_size_from_description(comparator):
"""사이즈 추출 로직 테스트"""
# 1. inch patterns
assert comparator._extract_size_from_description('PIPE 1/2" SCH40') == '1/2"'
assert comparator._extract_size_from_description('ELBOW 1.5inch 90D') == '1.5inch'
# 2. mm patterns
assert comparator._extract_size_from_description('Plate 100mm') == '100mm'
assert comparator._extract_size_from_description('Bar 50.5 MM') == '50.5 MM'
# 3. A patterns
assert comparator._extract_size_from_description('PIPE 100A') == '100A'
assert comparator._extract_size_from_description('FLANGE 50 A') == '50 A'
# 4. DN patterns
assert comparator._extract_size_from_description('VALVE DN100') == 'DN100'
# 5. Dimensions
assert comparator._extract_size_from_description('GASKET 10x20') == '10x20'
assert comparator._extract_size_from_description('SHEET 10*20') == '10*20'
# 6. No match
assert comparator._extract_size_from_description('Just Text') == ""
assert comparator._extract_size_from_description(None) == ""
def test_extract_material_from_description(comparator):
"""재질 추출 로직 테스트"""
# 1. Standard materials
assert comparator._extract_material_from_description('PIPE A106 Gr.B') == 'A106 Gr.B' # Should match longer first if implemented correctly
assert comparator._extract_material_from_description('FLANGE A105') == 'A105'
# 2. Stainless Steel
assert comparator._extract_material_from_description('PIPE SUS304L') == 'SUS304L'
assert comparator._extract_material_from_description('PIPE SS316') == 'SS316'
# 3. Case Insensitivity
assert comparator._extract_material_from_description('pipe sus316l') == 'SUS316L'
# 4. Partial matches (should prioritize specific)
# If "A106" is checked before "A106 Gr.B", it might return "A106".
# The implementation list order matters.
# In our impl: "A106 Gr.B" is before "A106", so it should work.
assert comparator._extract_material_from_description('Material A106 Gr.B Spec') == 'A106 Gr.B'
def test_compare_materials_logic_flow(comparator):
"""비교 로직 통합 테스트"""
previous_confirmed = {
"revision": "Rev.0",
"confirmed_at": "2024-01-01",
"items": [
{
"specification": "PIPE 100A A106",
"size": "100A",
"material": "A106", # Extraction will find 'A106' from 'PIPE 100A A106'
"bom_quantity": 10.0
}
]
}
# Case 1: Identical (Normalized)
new_materials_same = [{"description": "pipe 100a", "quantity": 10.0}]
# Note: extraction logic needs to find size/mat from description to match hash.
# "pipe 100a" -> size="100A", mat="" (A106 not in desc)
# The hash will be MD5("pipe 100a|100a|")
# Previous hash was MD5("pipe 100a|100a|a106")
# They WON'T match if extraction fails to find "A106".
# Let's provide description that allows extraction to work fully
new_materials_full = [{"description": "PIPE 100A A106", "quantity": 10.0}]
result = comparator.compare_materials(previous_confirmed, new_materials_full)
assert result["unchanged_count"] == 1
# Case 2: Quantity Changed
new_materials_qty = [{"description": "PIPE 100A A106", "quantity": 20.0}]
result = comparator.compare_materials(previous_confirmed, new_materials_qty)
assert result["changed_count"] == 1
assert result["changed_materials"][0]["change_type"] == "QUANTITY_CHANGED"
# Case 3: New Item
new_materials_new = [{"description": "NEW ITEM SUS304", "quantity": 1.0}]
result = comparator.compare_materials(previous_confirmed, new_materials_new)
assert result["new_count"] == 1
def test_compare_materials_fuzzy_match(comparator):
"""Fuzzy Matching 테스트"""
previous_confirmed = {
"revision": "Rev.0",
"confirmed_at": "2024-01-01",
"items": [
{
"specification": "PIPE 100A SCH40 A106",
"size": "100A",
"material": "A106",
"bom_quantity": 10.0
}
]
}
# 오타가 포함된 유사 자재 (PIPEE -> PIPE, A106 -> A106)
# 정규화/해시는 다르지만 텍스트 유사도는 높음
new_materials_typo = [{
"description": "PIPEE 100A SCH40 A106",
"quantity": 10.0
}]
# RapidFuzz가 설치되어 있어야 동작
try:
import rapidfuzz
result = comparator.compare_materials(previous_confirmed, new_materials_typo)
# 해시는 다르므로 new_count에 포함되거나 유사 자재로 분류됨
# 구현에 따라 "new_materials" 리스트에 "change_type": "NEW_BUT_SIMILAR" 로 들어감
assert result["new_count"] == 1
new_item = result["new_materials"][0]
assert new_item["change_type"] == "NEW_BUT_SIMILAR"
assert new_item["similarity_score"] > 85
except ImportError:
pytest.skip("rapidfuzz not installed")
def test_extract_size_word_boundary(comparator):
"""Regex Word Boundary 테스트"""
# 1. mm boundary check
# "100mm" -> ok, "100mm2" -> fail if boundary used
assert comparator._extract_size_from_description("100mm") == "100mm"
# assert comparator._extract_size_from_description("100mmm") == "" # This depends on implementation strictness
# 2. inch boundary
assert comparator._extract_size_from_description("1/2 inch") == "1/2 inch"
# 3. DN boundary
assert comparator._extract_size_from_description("DN100") == "DN100"
# "DN100A" should ideally not match DN100 if we want strictness, or it might match 100A.
def test_dynamic_material_loading(comparator, mock_db):
"""DB 기반 동적 자재 로딩 테스트"""
# Mocking DB result
# fetchall returns list of rows (tuples)
mock_db.execute.return_value.fetchall.return_value = [
("TITANIUM_GR2",),
("INCONEL625",),
]
# 1. Check if DB loaded materials are correctly extracted
# Test with a material that is ONLY in the mocked DB response, not in default list
description = "PIPE 100A TITANIUM_GR2"
material = comparator._extract_material_from_description(description)
assert material == "TITANIUM_GR2"
# Verify DB execute was called
assert mock_db.execute.called
# 2. Test fallback mechanism (simulate DB connection failure)
# mock_db.execute.side_effect = Exception("DB Connection Error")
# Reset mock to raise exception on next call
mock_db.execute.side_effect = Exception("DB Fail")
# SUS316L is in the default fallback list
description_fallback = "PIPE 100A SUS316L"
material_fallback = comparator._extract_material_from_description(description_fallback)
# Should still work using default list
assert material_fallback == "SUS316L"

620
current_schema.txt Normal file
View File

@@ -0,0 +1,620 @@
table_name | column_name | data_type | is_nullable | column_default
-------------------------------+----------------------------+-----------------------------+-------------+--------------------------------------------------------------
bolt_details | id | integer | NO | nextval('bolt_details_id_seq'::regclass)
bolt_details | material_id | integer | YES |
bolt_details | file_id | integer | YES |
bolt_details | bolt_type | character varying | YES |
bolt_details | thread_type | character varying | YES |
bolt_details | diameter | character varying | YES |
bolt_details | length | character varying | YES |
bolt_details | material_standard | character varying | YES |
bolt_details | material_grade | character varying | YES |
bolt_details | coating_type | character varying | YES |
bolt_details | pressure_rating | character varying | YES |
bolt_details | includes_nut | boolean | YES |
bolt_details | includes_washer | boolean | YES |
bolt_details | nut_type | character varying | YES |
bolt_details | washer_type | character varying | YES |
bolt_details | classification_confidence | double precision | YES |
bolt_details | additional_info | jsonb | YES |
bolt_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
bolt_details | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
confirmed_purchase_items | id | integer | NO | nextval('confirmed_purchase_items_id_seq'::regclass)
confirmed_purchase_items | confirmation_id | integer | YES |
confirmed_purchase_items | item_code | character varying | NO |
confirmed_purchase_items | category | character varying | NO |
confirmed_purchase_items | specification | text | YES |
confirmed_purchase_items | size | character varying | YES |
confirmed_purchase_items | material | character varying | YES |
confirmed_purchase_items | bom_quantity | numeric | NO | 0
confirmed_purchase_items | calculated_qty | numeric | NO | 0
confirmed_purchase_items | unit | character varying | NO | 'EA'::character varying
confirmed_purchase_items | safety_factor | numeric | NO | 1.0
confirmed_purchase_items | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
files | id | integer | NO | nextval('files_id_seq'::regclass)
files | project_id | integer | YES |
files | filename | character varying | NO |
files | original_filename | character varying | NO |
files | file_path | character varying | NO |
files | revision | character varying | YES | 'Rev.0'::character varying
files | upload_date | timestamp without time zone | YES | CURRENT_TIMESTAMP
files | uploaded_by | character varying | YES |
files | file_type | character varying | YES |
files | file_size | integer | YES |
files | is_active | boolean | YES | true
files | purchase_confirmed | boolean | YES | false
files | confirmed_at | timestamp without time zone | YES |
files | confirmed_by | character varying | YES |
files | job_no | character varying | YES |
files | bom_name | character varying | YES |
files | description | text | YES |
files | parsed_count | integer | YES | 0
fitting_details | id | integer | NO | nextval('fitting_details_id_seq'::regclass)
fitting_details | material_id | integer | YES |
fitting_details | file_id | integer | YES |
fitting_details | fitting_type | character varying | YES |
fitting_details | fitting_subtype | character varying | YES |
fitting_details | connection_method | character varying | YES |
fitting_details | connection_code | character varying | YES |
fitting_details | pressure_rating | character varying | YES |
fitting_details | max_pressure | character varying | YES |
fitting_details | manufacturing_method | character varying | YES |
fitting_details | material_standard | character varying | YES |
fitting_details | material_grade | character varying | YES |
fitting_details | material_type | character varying | YES |
fitting_details | main_size | character varying | YES |
fitting_details | reduced_size | character varying | YES |
fitting_details | length_mm | numeric | YES |
fitting_details | schedule | character varying | YES |
fitting_details | classification_confidence | double precision | YES |
fitting_details | additional_info | jsonb | YES |
fitting_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
fitting_details | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
flange_details | id | integer | NO | nextval('flange_details_id_seq'::regclass)
flange_details | material_id | integer | YES |
flange_details | file_id | integer | YES |
flange_details | flange_type | character varying | YES |
flange_details | facing_type | character varying | YES |
flange_details | pressure_rating | character varying | YES |
flange_details | material_standard | character varying | YES |
flange_details | material_grade | character varying | YES |
flange_details | size_inches | character varying | YES |
flange_details | bolt_hole_count | integer | YES |
flange_details | bolt_hole_size | character varying | YES |
flange_details | classification_confidence | double precision | YES |
flange_details | additional_info | jsonb | YES |
flange_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
flange_details | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
gasket_details | id | integer | NO | nextval('gasket_details_id_seq'::regclass)
gasket_details | material_id | integer | YES |
gasket_details | file_id | integer | YES |
gasket_details | gasket_type | character varying | YES |
gasket_details | gasket_subtype | character varying | YES |
gasket_details | material_type | character varying | YES |
gasket_details | filler_material | character varying | YES |
gasket_details | size_inches | character varying | YES |
gasket_details | pressure_rating | character varying | YES |
gasket_details | thickness | character varying | YES |
gasket_details | temperature_range | character varying | YES |
gasket_details | fire_safe | boolean | YES |
gasket_details | classification_confidence | double precision | YES |
gasket_details | additional_info | jsonb | YES |
gasket_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
gasket_details | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
instrument_details | id | integer | NO | nextval('instrument_details_id_seq'::regclass)
instrument_details | material_id | integer | YES |
instrument_details | file_id | integer | YES |
instrument_details | instrument_type | character varying | YES |
instrument_details | instrument_subtype | character varying | YES |
instrument_details | measurement_type | character varying | YES |
instrument_details | measurement_range | character varying | YES |
instrument_details | accuracy | character varying | YES |
instrument_details | connection_type | character varying | YES |
instrument_details | connection_size | character varying | YES |
instrument_details | body_material | character varying | YES |
instrument_details | wetted_parts_material | character varying | YES |
instrument_details | electrical_rating | character varying | YES |
instrument_details | output_signal | character varying | YES |
instrument_details | classification_confidence | double precision | YES |
instrument_details | additional_info | jsonb | YES |
instrument_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
instrument_details | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
jobs | job_no | character varying | NO |
jobs | job_name | character varying | NO |
jobs | client_name | character varying | NO |
jobs | end_user | character varying | YES |
jobs | epc_company | character varying | YES |
jobs | project_site | character varying | YES |
jobs | contract_date | date | YES |
jobs | delivery_date | date | YES |
jobs | delivery_terms | character varying | YES |
jobs | status | character varying | YES | '진행중'::character varying
jobs | delivery_completed_date | date | YES |
jobs | project_closed_date | date | YES |
jobs | description | text | YES |
jobs | created_by | character varying | YES |
jobs | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
jobs | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
jobs | is_active | boolean | YES | true
jobs | updated_by | character varying | YES |
jobs | assigned_to | character varying | YES |
jobs | project_type | character varying | NO | '냉동기'::character varying
login_logs | log_id | integer | NO | nextval('login_logs_log_id_seq'::regclass)
login_logs | user_id | integer | YES |
login_logs | login_time | timestamp without time zone | YES | CURRENT_TIMESTAMP
login_logs | ip_address | character varying | YES |
login_logs | user_agent | text | YES |
login_logs | login_status | character varying | YES |
login_logs | failure_reason | character varying | YES |
login_logs | session_duration | integer | YES |
login_logs | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
material_categories | id | integer | NO | nextval('material_categories_id_seq'::regclass)
material_categories | standard_id | integer | YES |
material_categories | category_code | character varying | NO |
material_categories | category_name | character varying | NO |
material_categories | description | text | YES |
material_categories | is_active | boolean | YES | true
material_categories | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
material_categories | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
material_comparison_details | id | integer | NO | nextval('material_comparison_details_id_seq'::regclass)
material_comparison_details | comparison_id | integer | NO |
material_comparison_details | material_hash | character varying | NO |
material_comparison_details | change_type | character varying | NO |
material_comparison_details | description | text | NO |
material_comparison_details | size_spec | character varying | YES |
material_comparison_details | material_grade | character varying | YES |
material_comparison_details | previous_quantity | numeric | YES | 0
material_comparison_details | current_quantity | numeric | YES | 0
material_comparison_details | quantity_diff | numeric | YES | 0
material_comparison_details | additional_purchase_needed | numeric | YES | 0
material_comparison_details | classified_category | character varying | YES |
material_comparison_details | classification_confidence | numeric | YES |
material_comparison_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
material_grades | id | integer | NO | nextval('material_grades_id_seq'::regclass)
material_grades | specification_id | integer | YES |
material_grades | grade_code | character varying | NO |
material_grades | grade_name | character varying | YES |
material_grades | composition | character varying | YES |
material_grades | applications | character varying | YES |
material_grades | temp_max | character varying | YES |
material_grades | temp_range | character varying | YES |
material_grades | yield_strength | character varying | YES |
material_grades | tensile_strength | character varying | YES |
material_grades | corrosion_resistance | character varying | YES |
material_grades | stabilizer | character varying | YES |
material_grades | base_grade | character varying | YES |
material_grades | is_active | boolean | YES | true
material_grades | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
material_grades | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
material_patterns | id | integer | NO | nextval('material_patterns_id_seq'::regclass)
material_patterns | specification_id | integer | YES |
material_patterns | pattern | text | NO |
material_patterns | description | character varying | YES |
material_patterns | priority | integer | YES | 1
material_patterns | is_active | boolean | YES | true
material_patterns | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
material_patterns | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
material_purchase_mapping | id | integer | NO | nextval('material_purchase_mapping_id_seq'::regclass)
material_purchase_mapping | material_id | integer | NO |
material_purchase_mapping | purchase_item_id | integer | NO |
material_purchase_mapping | quantity_ratio | numeric | YES | 1.0
material_purchase_mapping | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
material_purchase_tracking | id | integer | NO | nextval('material_purchase_tracking_id_seq'::regclass)
material_purchase_tracking | material_hash | character varying | NO |
material_purchase_tracking | original_description | text | NO |
material_purchase_tracking | size_spec | character varying | YES |
material_purchase_tracking | material_grade | character varying | YES |
material_purchase_tracking | bom_quantity | numeric | NO |
material_purchase_tracking | confirmed_quantity | numeric | YES |
material_purchase_tracking | purchase_quantity | numeric | YES |
material_purchase_tracking | status | character varying | YES | 'pending'::character varying
material_purchase_tracking | confirmed_by | character varying | YES |
material_purchase_tracking | confirmed_at | timestamp without time zone | YES |
material_purchase_tracking | ordered_by | character varying | YES |
material_purchase_tracking | ordered_at | timestamp without time zone | YES |
material_purchase_tracking | approved_by | character varying | YES |
material_purchase_tracking | approved_at | timestamp without time zone | YES |
material_purchase_tracking | job_no | character varying | YES |
material_purchase_tracking | revision | character varying | YES |
material_purchase_tracking | file_id | integer | YES |
material_purchase_tracking | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
material_purchase_tracking | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
material_revisions_comparison | id | integer | NO | nextval('material_revisions_comparison_id_seq'::regclass)
material_revisions_comparison | job_no | character varying | NO |
material_revisions_comparison | current_revision | character varying | NO |
material_revisions_comparison | previous_revision | character varying | NO |
material_revisions_comparison | current_file_id | integer | NO |
material_revisions_comparison | previous_file_id | integer | NO |
material_revisions_comparison | total_current_items | integer | YES | 0
material_revisions_comparison | total_previous_items | integer | YES | 0
material_revisions_comparison | new_items_count | integer | YES | 0
material_revisions_comparison | modified_items_count | integer | YES | 0
material_revisions_comparison | removed_items_count | integer | YES | 0
material_revisions_comparison | unchanged_items_count | integer | YES | 0
material_revisions_comparison | comparison_details | jsonb | YES |
material_revisions_comparison | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
material_revisions_comparison | created_by | character varying | YES |
material_specifications | id | integer | NO | nextval('material_specifications_id_seq'::regclass)
material_specifications | category_id | integer | YES |
material_specifications | spec_code | character varying | NO |
material_specifications | spec_name | character varying | NO |
material_specifications | description | text | YES |
material_specifications | material_type | character varying | YES |
material_specifications | manufacturing | character varying | YES |
material_specifications | pressure_rating | character varying | YES |
material_specifications | is_active | boolean | YES | true
material_specifications | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
material_specifications | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
material_standards | id | integer | NO | nextval('material_standards_id_seq'::regclass)
material_standards | standard_code | character varying | NO |
material_standards | standard_name | character varying | NO |
material_standards | description | text | YES |
material_standards | country | character varying | YES |
material_standards | is_active | boolean | YES | true
material_standards | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
material_standards | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
material_tubing_mapping | id | integer | NO | nextval('material_tubing_mapping_id_seq'::regclass)
material_tubing_mapping | material_id | integer | YES |
material_tubing_mapping | tubing_product_id | integer | YES |
material_tubing_mapping | confidence_score | numeric | YES |
material_tubing_mapping | mapping_method | character varying | YES |
material_tubing_mapping | mapped_by | character varying | YES |
material_tubing_mapping | mapped_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
material_tubing_mapping | required_length_m | numeric | YES |
material_tubing_mapping | calculated_quantity | numeric | YES |
material_tubing_mapping | is_verified | boolean | YES | false
material_tubing_mapping | verified_by | character varying | YES |
material_tubing_mapping | verified_at | timestamp without time zone | YES |
material_tubing_mapping | notes | text | YES |
material_tubing_mapping | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
materials | id | integer | NO | nextval('materials_id_seq'::regclass)
materials | file_id | integer | YES |
materials | line_number | integer | YES |
materials | original_description | text | NO |
materials | classified_category | character varying | YES |
materials | classified_subcategory | character varying | YES |
materials | material_grade | character varying | YES |
materials | schedule | character varying | YES |
materials | size_spec | character varying | YES |
materials | quantity | numeric | NO |
materials | unit | character varying | NO |
materials | drawing_name | character varying | YES |
materials | area_code | character varying | YES |
materials | line_no | character varying | YES |
materials | classification_confidence | numeric | YES |
materials | classification_details | jsonb | YES |
materials | is_verified | boolean | YES | false
materials | verified_by | character varying | YES |
materials | verified_at | timestamp without time zone | YES |
materials | drawing_reference | character varying | YES |
materials | notes | text | YES |
materials | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
materials | main_nom | character varying | YES |
materials | red_nom | character varying | YES |
materials | full_material_grade | text | YES |
materials | row_number | integer | YES |
materials | length | numeric | YES |
materials | purchase_confirmed | boolean | YES | false
materials | confirmed_quantity | numeric | YES |
materials | purchase_status | character varying | YES |
materials | purchase_confirmed_by | character varying | YES |
materials | purchase_confirmed_at | timestamp without time zone | YES |
materials | revision_status | character varying | YES |
materials | material_hash | character varying | YES |
materials | normalized_description | text | YES |
materials | brand | character varying | YES |
materials | user_requirement | text | YES |
materials | is_active | boolean | YES | true
materials | total_length | numeric | YES |
permissions | permission_id | integer | NO | nextval('permissions_permission_id_seq'::regclass)
permissions | permission_name | character varying | NO |
permissions | description | text | YES |
permissions | module | character varying | YES |
permissions | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
pipe_details | id | integer | NO | nextval('pipe_details_id_seq'::regclass)
pipe_details | material_id | integer | YES |
pipe_details | file_id | integer | YES |
pipe_details | outer_diameter | character varying | YES |
pipe_details | schedule | character varying | YES |
pipe_details | material_spec | character varying | YES |
pipe_details | manufacturing_method | character varying | YES |
pipe_details | end_preparation | character varying | YES |
pipe_details | length_mm | numeric | YES |
pipe_details | classification_confidence | double precision | YES |
pipe_details | additional_info | jsonb | YES |
pipe_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
pipe_details | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
pipe_end_preparations | id | integer | NO | nextval('pipe_end_preparations_id_seq'::regclass)
pipe_end_preparations | material_id | integer | NO |
pipe_end_preparations | file_id | integer | NO |
pipe_end_preparations | end_preparation_type | character varying | YES | 'PBE'::character varying
pipe_end_preparations | end_preparation_code | character varying | YES |
pipe_end_preparations | machining_required | boolean | YES | false
pipe_end_preparations | cutting_note | text | YES |
pipe_end_preparations | original_description | text | NO |
pipe_end_preparations | clean_description | text | NO |
pipe_end_preparations | confidence | double precision | YES | 0.0
pipe_end_preparations | matched_pattern | character varying | YES |
pipe_end_preparations | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
pipe_end_preparations | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
projects | id | integer | NO | nextval('projects_id_seq'::regclass)
projects | official_project_code | character varying | YES |
projects | project_name | character varying | NO |
projects | client_name | character varying | YES |
projects | design_project_code | character varying | YES |
projects | design_project_name | character varying | YES |
projects | is_code_matched | boolean | YES | false
projects | matched_by | character varying | YES |
projects | matched_at | timestamp without time zone | YES |
projects | status | character varying | YES | 'active'::character varying
projects | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
projects | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
projects | description | text | YES |
projects | notes | text | YES |
purchase_confirmations | id | integer | NO | nextval('purchase_confirmations_id_seq'::regclass)
purchase_confirmations | job_no | character varying | NO |
purchase_confirmations | file_id | integer | YES |
purchase_confirmations | bom_name | character varying | NO |
purchase_confirmations | revision | character varying | NO | 'Rev.0'::character varying
purchase_confirmations | confirmed_at | timestamp without time zone | NO |
purchase_confirmations | confirmed_by | character varying | NO |
purchase_confirmations | is_active | boolean | NO | true
purchase_confirmations | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
purchase_confirmations | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
purchase_items | id | integer | NO | nextval('purchase_items_id_seq'::regclass)
purchase_items | item_code | character varying | NO |
purchase_items | category | character varying | NO |
purchase_items | specification | text | NO |
purchase_items | material_spec | character varying | YES |
purchase_items | size_spec | character varying | YES |
purchase_items | unit | character varying | NO |
purchase_items | bom_quantity | numeric | NO |
purchase_items | safety_factor | numeric | YES | 1.10
purchase_items | minimum_order_qty | numeric | YES | 0
purchase_items | order_unit_qty | numeric | YES | 1
purchase_items | calculated_qty | numeric | YES |
purchase_items | cutting_loss | numeric | YES | 0
purchase_items | standard_length | numeric | YES |
purchase_items | pipes_count | integer | YES |
purchase_items | waste_length | numeric | YES |
purchase_items | detailed_spec | jsonb | YES |
purchase_items | preferred_supplier | character varying | YES |
purchase_items | last_unit_price | numeric | YES |
purchase_items | currency | character varying | YES | 'KRW'::character varying
purchase_items | lead_time_days | integer | YES | 30
purchase_items | job_no | character varying | NO |
purchase_items | revision | character varying | YES | 'Rev.0'::character varying
purchase_items | file_id | integer | YES |
purchase_items | is_active | boolean | YES | true
purchase_items | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
purchase_items | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
purchase_items | created_by | character varying | YES |
purchase_items | updated_by | character varying | YES |
purchase_items | approved_by | character varying | YES |
purchase_items | approved_at | timestamp without time zone | YES |
purchase_request_items | item_id | integer | NO | nextval('purchase_request_items_item_id_seq'::regclass)
purchase_request_items | request_id | integer | YES |
purchase_request_items | material_id | integer | YES |
purchase_request_items | description | text | NO |
purchase_request_items | category | character varying | YES |
purchase_request_items | subcategory | character varying | YES |
purchase_request_items | material_grade | character varying | YES |
purchase_request_items | size_spec | character varying | YES |
purchase_request_items | quantity | numeric | NO |
purchase_request_items | unit | character varying | NO |
purchase_request_items | drawing_name | character varying | YES |
purchase_request_items | notes | text | YES |
purchase_request_items | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
purchase_requests | request_id | integer | NO | nextval('purchase_requests_request_id_seq'::regclass)
purchase_requests | request_no | character varying | NO |
purchase_requests | job_no | character varying | NO |
purchase_requests | project_name | character varying | YES |
purchase_requests | requested_by | integer | YES |
purchase_requests | requested_by_username | character varying | YES |
purchase_requests | request_date | timestamp without time zone | YES | CURRENT_TIMESTAMP
purchase_requests | status | character varying | YES | 'pending'::character varying
purchase_requests | total_items | integer | YES | 0
purchase_requests | notes | text | YES |
purchase_requests | approved_by | integer | YES |
purchase_requests | approved_by_username | character varying | YES |
purchase_requests | approved_at | timestamp without time zone | YES |
purchase_requests | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
purchase_requests | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
purchase_requests | file_id | integer | YES |
requirement_types | id | integer | NO | nextval('requirement_types_id_seq'::regclass)
requirement_types | type_code | character varying | NO |
requirement_types | type_name | character varying | NO |
requirement_types | category | character varying | NO |
requirement_types | description | text | YES |
requirement_types | is_active | boolean | YES | true
requirement_types | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
role_permissions | role_permission_id | integer | NO | nextval('role_permissions_role_permission_id_seq'::regclass)
role_permissions | role | character varying | NO |
role_permissions | permission_id | integer | YES |
role_permissions | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
special_material_details | id | integer | NO | nextval('special_material_details_id_seq'::regclass)
special_material_details | material_id | integer | YES |
special_material_details | file_id | integer | YES |
special_material_details | special_type | character varying | YES |
special_material_details | special_subtype | character varying | YES |
special_material_details | material_standard | character varying | YES |
special_material_details | material_grade | character varying | YES |
special_material_details | specifications | text | YES |
special_material_details | dimensions | character varying | YES |
special_material_details | weight_kg | numeric | YES |
special_material_details | classification_confidence | numeric | YES |
special_material_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
special_material_details | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
special_material_grades | id | integer | NO | nextval('special_material_grades_id_seq'::regclass)
special_material_grades | material_id | integer | YES |
special_material_grades | grade_code | character varying | NO |
special_material_grades | composition | character varying | YES |
special_material_grades | applications | character varying | YES |
special_material_grades | temp_max | character varying | YES |
special_material_grades | strength | character varying | YES |
special_material_grades | purity | character varying | YES |
special_material_grades | corrosion | character varying | YES |
special_material_grades | is_active | boolean | YES | true
special_material_grades | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
special_material_grades | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
special_material_patterns | id | integer | NO | nextval('special_material_patterns_id_seq'::regclass)
special_material_patterns | material_id | integer | YES |
special_material_patterns | pattern | text | NO |
special_material_patterns | description | character varying | YES |
special_material_patterns | priority | integer | YES | 1
special_material_patterns | is_active | boolean | YES | true
special_material_patterns | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
special_material_patterns | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
special_materials | id | integer | NO | nextval('special_materials_id_seq'::regclass)
special_materials | material_type | character varying | NO |
special_materials | material_name | character varying | NO |
special_materials | description | text | YES |
special_materials | composition | character varying | YES |
special_materials | applications | text | YES |
special_materials | temp_max | character varying | YES |
special_materials | manufacturing | character varying | YES |
special_materials | is_active | boolean | YES | true
special_materials | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
special_materials | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
support_details | id | integer | NO | nextval('support_details_id_seq'::regclass)
support_details | material_id | integer | YES |
support_details | file_id | integer | YES |
support_details | support_type | character varying | YES |
support_details | support_subtype | character varying | YES |
support_details | load_rating | character varying | YES |
support_details | load_capacity | character varying | YES |
support_details | material_standard | character varying | YES |
support_details | material_grade | character varying | YES |
support_details | pipe_size | character varying | YES |
support_details | length_mm | numeric | YES |
support_details | width_mm | numeric | YES |
support_details | height_mm | numeric | YES |
support_details | classification_confidence | numeric | YES |
support_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
support_details | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
tubing_categories | id | integer | NO | nextval('tubing_categories_id_seq'::regclass)
tubing_categories | category_code | character varying | NO |
tubing_categories | category_name | character varying | NO |
tubing_categories | description | text | YES |
tubing_categories | is_active | boolean | YES | true
tubing_categories | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
tubing_categories | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
tubing_manufacturers | id | integer | NO | nextval('tubing_manufacturers_id_seq'::regclass)
tubing_manufacturers | manufacturer_code | character varying | NO |
tubing_manufacturers | manufacturer_name | character varying | NO |
tubing_manufacturers | country | character varying | YES |
tubing_manufacturers | website | character varying | YES |
tubing_manufacturers | contact_info | jsonb | YES |
tubing_manufacturers | quality_certs | jsonb | YES |
tubing_manufacturers | is_active | boolean | YES | true
tubing_manufacturers | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
tubing_manufacturers | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
tubing_products | id | integer | NO | nextval('tubing_products_id_seq'::regclass)
tubing_products | specification_id | integer | YES |
tubing_products | manufacturer_id | integer | YES |
tubing_products | manufacturer_part_number | character varying | NO |
tubing_products | manufacturer_product_name | character varying | YES |
tubing_products | list_price | numeric | YES |
tubing_products | currency | character varying | YES | 'KRW'::character varying
tubing_products | lead_time_days | integer | YES |
tubing_products | minimum_order_qty | numeric | YES |
tubing_products | standard_packaging_qty | numeric | YES |
tubing_products | availability_status | character varying | YES |
tubing_products | last_price_update | date | YES |
tubing_products | datasheet_url | character varying | YES |
tubing_products | catalog_page | character varying | YES |
tubing_products | notes | text | YES |
tubing_products | is_active | boolean | YES | true
tubing_products | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
tubing_products | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
tubing_specifications | id | integer | NO | nextval('tubing_specifications_id_seq'::regclass)
tubing_specifications | category_id | integer | YES |
tubing_specifications | spec_code | character varying | NO |
tubing_specifications | spec_name | character varying | NO |
tubing_specifications | outer_diameter_mm | numeric | YES |
tubing_specifications | wall_thickness_mm | numeric | YES |
tubing_specifications | inner_diameter_mm | numeric | YES |
tubing_specifications | material_grade | character varying | YES |
tubing_specifications | material_standard | character varying | YES |
tubing_specifications | max_pressure_bar | numeric | YES |
tubing_specifications | max_temperature_c | numeric | YES |
tubing_specifications | min_temperature_c | numeric | YES |
tubing_specifications | standard_length_m | numeric | YES |
tubing_specifications | bend_radius_min_mm | numeric | YES |
tubing_specifications | surface_finish | character varying | YES |
tubing_specifications | hardness | character varying | YES |
tubing_specifications | notes | text | YES |
tubing_specifications | is_active | boolean | YES | true
tubing_specifications | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
tubing_specifications | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
user_activity_logs | id | integer | NO | nextval('user_activity_logs_id_seq'::regclass)
user_activity_logs | user_id | integer | YES |
user_activity_logs | username | character varying | NO |
user_activity_logs | activity_type | character varying | NO |
user_activity_logs | activity_description | text | YES |
user_activity_logs | target_id | integer | YES |
user_activity_logs | target_type | character varying | YES |
user_activity_logs | ip_address | character varying | YES |
user_activity_logs | user_agent | text | YES |
user_activity_logs | metadata | jsonb | YES |
user_activity_logs | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
user_requirements | id | integer | NO | nextval('user_requirements_id_seq'::regclass)
user_requirements | file_id | integer | NO |
user_requirements | material_id | integer | YES |
user_requirements | requirement_type | character varying | NO |
user_requirements | requirement_title | character varying | NO |
user_requirements | requirement_description | text | YES |
user_requirements | requirement_spec | text | YES |
user_requirements | status | character varying | YES | 'PENDING'::character varying
user_requirements | priority | character varying | YES | 'NORMAL'::character varying
user_requirements | assigned_to | character varying | YES |
user_requirements | due_date | date | YES |
user_requirements | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
user_requirements | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
user_sessions | session_id | integer | NO | nextval('user_sessions_session_id_seq'::regclass)
user_sessions | user_id | integer | YES |
user_sessions | refresh_token | character varying | NO |
user_sessions | expires_at | timestamp without time zone | NO |
user_sessions | ip_address | character varying | YES |
user_sessions | user_agent | text | YES |
user_sessions | is_active | boolean | YES | true
user_sessions | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
user_sessions | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
users | user_id | integer | NO | nextval('users_user_id_seq'::regclass)
users | username | character varying | NO |
users | password | character varying | NO |
users | name | character varying | NO |
users | email | character varying | YES |
users | role | character varying | YES | 'user'::character varying
users | access_level | character varying | YES | 'worker'::character varying
users | is_active | boolean | YES | true
users | failed_login_attempts | integer | YES | 0
users | locked_until | timestamp without time zone | YES |
users | department | character varying | YES |
users | position | character varying | YES |
users | phone | character varying | YES |
users | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
users | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
users | last_login_at | timestamp without time zone | YES |
users | status | character varying | YES | 'active'::character varying
valve_details | id | integer | NO | nextval('valve_details_id_seq'::regclass)
valve_details | material_id | integer | YES |
valve_details | file_id | integer | YES |
valve_details | valve_type | character varying | YES |
valve_details | valve_subtype | character varying | YES |
valve_details | actuator_type | character varying | YES |
valve_details | connection_method | character varying | YES |
valve_details | pressure_rating | character varying | YES |
valve_details | pressure_class | character varying | YES |
valve_details | body_material | character varying | YES |
valve_details | trim_material | character varying | YES |
valve_details | size_inches | character varying | YES |
valve_details | fire_safe | boolean | YES |
valve_details | low_temp_service | boolean | YES |
valve_details | special_features | jsonb | YES |
valve_details | classification_confidence | double precision | YES |
valve_details | additional_info | jsonb | YES |
valve_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
valve_details | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
(616 rows)

View File

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

View File

@@ -4,7 +4,7 @@ services:
container_name: tk-mp-nginx-proxy
restart: unless-stopped
ports:
- "80:80"
- "8808:80"
volumes:
- ./nginx-proxy.conf:/etc/nginx/conf.d/default.conf
networks:

View File

@@ -78,13 +78,13 @@ services:
context: ./frontend
dockerfile: Dockerfile
args:
- VITE_API_URL=${VITE_API_URL:-http://localhost:18000}
- VITE_API_URL=${VITE_API_URL:-/api}
container_name: tk-mp-frontend
restart: unless-stopped
ports:
- "${FRONTEND_PORT:-13000}:5173"
- "${FRONTEND_PORT:-13000}:3000"
environment:
- VITE_API_URL=${VITE_API_URL:-http://localhost:18000}
- VITE_API_URL=${VITE_API_URL:-/api}
depends_on:
- backend
networks:

348
frontend/PAGES_GUIDE.md Normal file
View File

@@ -0,0 +1,348 @@
# 프론트엔드 페이지 가이드
이 문서는 TK-MP 프로젝트의 프론트엔드 페이지들의 역할과 기능을 정리한 가이드입니다.
## 📋 목차
- [인증 관련 페이지](#인증-관련-페이지)
- [대시보드 및 메인 페이지](#대시보드-및-메인-페이지)
- [프로젝트 관리 페이지](#프로젝트-관리-페이지)
- [BOM 관리 페이지](#bom-관리-페이지)
- [구매 관리 페이지](#구매-관리-페이지)
- [시스템 관리 페이지](#시스템-관리-페이지)
- [컴포넌트 구조](#컴포넌트-구조)
---
## 인증 관련 페이지
### `LoginPage.jsx`
- **역할**: 사용자 로그인 페이지
- **기능**:
- 사용자 인증 (이메일/비밀번호)
- 로그인 상태 관리
- 인증 실패 시 에러 메시지 표시
- **라우팅**: `/login`
- **접근 권한**: 모든 사용자 (비인증)
---
## 대시보드 및 메인 페이지
### `DashboardPage.jsx`
- **역할**: 메인 대시보드 페이지
- **기능**:
- 프로젝트 선택 드롭다운
- **새로운 3개 BOM 카드**: 📤 BOM Upload, 📊 Revision Management, 📋 BOM Management
- 구매신청 관리 카드
- 관리자 전용 기능 (사용자 관리, 로그 관리)
- 프로젝트 생성/편집/삭제/비활성화
- **라우팅**: `/dashboard`
- **접근 권한**: 인증된 사용자
- **디자인**: 데본씽크 스타일, 글래스모피즘 효과
- **업데이트**: BOM 기능을 3개 전용 페이지로 분리
### `MainPage.jsx`
- **역할**: 초기 랜딩 페이지
- **기능**: 기본 페이지 구조 및 네비게이션
- **라우팅**: `/`
- **접근 권한**: 인증된 사용자
---
## 프로젝트 관리 페이지
### `ProjectsPage.jsx`
- **역할**: 프로젝트 목록 및 관리
- **기능**:
- 프로젝트 목록 조회
- 프로젝트 생성/수정/삭제
- 프로젝트 상태 관리
- **라우팅**: `/projects`
- **접근 권한**: 관리자
### `InactiveProjectsPage.jsx`
- **역할**: 비활성화된 프로젝트 관리
- **기능**:
- 비활성 프로젝트 목록 조회
- 프로젝트 활성화/삭제
- 전체 선택/해제 기능
- **라우팅**: `/inactive-projects`
- **접근 권한**: 관리자
### `JobRegistrationPage.jsx`
- **역할**: 새로운 작업(Job) 등록
- **기능**:
- 작업 정보 입력 및 등록
- 프로젝트 연결
- **라우팅**: `/job-registration`
- **접근 권한**: 관리자
### `JobSelectionPage.jsx`
- **역할**: 작업 선택 페이지
- **기능**:
- 등록된 작업 목록 조회
- 작업 선택 및 이동
- **라우팅**: `/job-selection`
- **접근 권한**: 인증된 사용자
---
## BOM 관리 페이지
### `BOMUploadPage.jsx` ⭐ 신규
- **역할**: BOM 파일 업로드 전용 페이지
- **기능**:
- 드래그 앤 드롭 파일 업로드
- 파일 검증 (형식: .xlsx, .xls, .csv / 최대 50MB)
- 실시간 업로드 진행률 표시
- 자동 BOM 이름 설정
- 업로드 완료 후 BOM 관리 페이지로 자동 이동
- **라우팅**: `/bom-upload`
- **접근 권한**: 인증된 사용자
- **디자인**: 모던 UI, 글래스모피즘 효과
### `BOMRevisionPage.jsx` ⭐ 신규
- **역할**: BOM 리비전 관리 전용 페이지
- **현재 상태**: 기본 구조 완성, 고급 기능 개발 예정
- **기능**:
- BOM 파일 목록 표시
- 리비전 히스토리 개요
- 개발 예정 기능 안내 (타임라인, 비교, 롤백)
- **라우팅**: `/bom-revision`
- **접근 권한**: 인증된 사용자
- **향후 계획**: 📊 리비전 타임라인, 🔍 변경사항 비교, ⏪ 롤백 시스템
### `BOMManagementPage.jsx`
- **역할**: BOM(Bill of Materials) 자재 관리 페이지
- **기능**:
- 카테고리별 자재 조회 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT, SPECIAL, UNCLASSIFIED)
- 자재 선택 및 구매신청 (엑셀 내보내기)
- 구매신청된 자재 비활성화 표시
- 사용자 요구사항 입력 및 저장 (Brand, Additional Request)
- 카테고리별 전용 컴포넌트 구조
- **라우팅**: `/bom-management`
- **접근 권한**: 인증된 사용자
- **특징**: 카테고리별 컴포넌트로 완전 분리된 구조
### `NewMaterialsPage.jsx` (레거시)
- **역할**: 기존 자재 관리 페이지 (현재 백업용)
- **상태**: 사용 중단, `BOMManagementPage`로 대체됨
- **기능**: 자재 분류, 편집, 내보내기 (기존 로직 보존)
### `BOMStatusPage.jsx`
- **역할**: BOM 상태 조회 페이지
- **기능**:
- BOM 파일 상태 확인
- 처리 진행률 표시
- **라우팅**: `/bom-status`
- **접근 권한**: 인증된 사용자
### `_deprecated/BOMWorkspacePage.jsx` (사용 중단)
- **역할**: 기존 BOM 작업 공간 (사용 중단)
- **상태**: `BOMUploadPage``BOMRevisionPage`로 분리됨
- **이유**: 업로드와 리비전 관리 기능을 별도 페이지로 분리하여 사용성 개선
---
## 구매 관리 페이지
### `PurchaseRequestPage.jsx`
- **역할**: 구매신청 관리 페이지
- **기능**:
- 구매신청 목록 조회
- 구매신청 제목 편집 (인라인 편집)
- 원본 파일 정보 표시
- 엑셀 파일 다운로드
- 구매신청 자재 상세 조회
- **라우팅**: `/purchase-requests`
- **접근 권한**: 인증된 사용자
- **특징**: BOM 페이지와 연동된 구매 워크플로우
### `PurchaseBatchPage.jsx`
- **역할**: 구매 배치 처리 페이지
- **기능**:
- 대량 구매 처리
- 배치 작업 관리
- **라우팅**: `/purchase-batch`
- **접근 권한**: 관리자
---
## 시스템 관리 페이지
### `UserManagementPage.jsx`
- **역할**: 사용자 관리 페이지
- **기능**:
- 사용자 목록 조회
- 사용자 생성/수정/삭제
- 권한 관리
- 사용자 상태 관리
- **라우팅**: `/user-management`
- **접근 권한**: 관리자
### `SystemSettingsPage.jsx`
- **역할**: 시스템 설정 페이지
- **기능**:
- 시스템 전반 설정 관리
- 환경 변수 설정
- **라우팅**: `/system-settings`
- **접근 권한**: 관리자
### `SystemSetupPage.jsx`
- **역할**: 시스템 초기 설정 페이지
- **기능**:
- 시스템 초기 구성
- 기본 데이터 설정
- **라우팅**: `/system-setup`
- **접근 권한**: 관리자
### `SystemLogsPage.jsx`
- **역할**: 시스템 로그 조회 페이지
- **기능**:
- 시스템 로그 조회
- 로그 필터링 및 검색
- **라우팅**: `/system-logs`
- **접근 권한**: 관리자
### `LogMonitoringPage.jsx`
- **역할**: 로그 모니터링 페이지
- **기능**:
- 실시간 로그 모니터링
- 로그 분석 및 알림
- **라우팅**: `/log-monitoring`
- **접근 권한**: 관리자
### `AccountSettingsPage.jsx`
- **역할**: 개인 계정 설정 페이지
- **기능**:
- 개인 정보 수정
- 비밀번호 변경
- 계정 설정 관리
- **라우팅**: `/account-settings`
- **접근 권한**: 인증된 사용자
---
## 워크스페이스 페이지
### `ProjectWorkspacePage.jsx`
- **역할**: 프로젝트 작업 공간
- **기능**:
- 프로젝트별 작업 환경
- 파일 관리 및 협업 도구
- **라우팅**: `/project-workspace`
- **접근 권한**: 인증된 사용자
---
## 컴포넌트 구조
### `components/common/`
- **ErrorBoundary.jsx**: 에러 경계 컴포넌트
- **UserMenu.jsx**: 사용자 드롭다운 메뉴
### `components/bom/`
- **shared/**: 공통 BOM 컴포넌트
- `FilterableHeader.jsx`: 필터링 가능한 테이블 헤더
- `MaterialTable.jsx`: 자재 테이블 공통 컴포넌트
- **materials/**: 카테고리별 자재 뷰 컴포넌트
- `PipeMaterialsView.jsx`: 파이프 자재 관리
- `FittingMaterialsView.jsx`: 피팅 자재 관리
- `FlangeMaterialsView.jsx`: 플랜지 자재 관리
- `ValveMaterialsView.jsx`: 밸브 자재 관리
- `GasketMaterialsView.jsx`: 가스켓 자재 관리
- `BoltMaterialsView.jsx`: 볼트 자재 관리
- `SupportMaterialsView.jsx`: 서포트 자재 관리
- `SpecialMaterialsView.jsx`: 특수 제작 자재 관리
#### SPECIAL 카테고리 상세 기능
`SpecialMaterialsView.jsx`는 특수 제작이 필요한 자재들을 관리하는 컴포넌트입니다:
**주요 기능:**
- **자동 타입 분류**: FLANGE, OIL PUMP, COMPRESSOR, VALVE, FITTING, PIPE 등 큰 범주 자동 인식
- **정보 파싱**: 자재 설명을 도면, 항목1-4로 체계적 분리
- **테이블 구조**: `Type | Drawing | Item 1 | Item 2 | Item 3 | Item 4 | Additional Request | Purchase Quantity`
- **엑셀 내보내기**: P열 납기일 규칙 준수, 관리항목 자동 채움
- **저장 기능**: 추가요청사항 저장/편집 (다른 카테고리와 동일)
**처리 예시:**
- `SAE SPECIAL FF, OIL PUMP, ASTM A105` → Type: OIL PUMP, Item1: SAE SPECIAL FF, Item2: OIL PUMP, Item3: ASTM A105
- `FLG SPECIAL FF, COMPRESSOR(N11), ASTM A105` → Type: FLANGE, Item1: FLG SPECIAL FF, Item2: COMPRESSOR(N11), Item3: ASTM A105
**분류 조건:**
- `SPECIAL` 키워드 포함 (단, `SPECIFICATION` 제외)
- 한글 `스페셜` 또는 `SPL` 키워드 포함
### 기타 컴포넌트
- **NavigationMenu.jsx**: 사이드바 네비게이션
- **NavigationBar.jsx**: 상단 네비게이션 바
- **FileUpload.jsx**: 파일 업로드 컴포넌트
- **ProtectedRoute.jsx**: 권한 기반 라우트 보호
---
## 페이지 추가 시 규칙
1. **새 페이지 생성 시 이 문서 업데이트 필수**
2. **페이지 역할과 기능을 명확히 문서화**
3. **라우팅 경로와 접근 권한 명시**
4. **관련 컴포넌트와의 연관성 설명**
5. **디자인 시스템 준수 (데본씽크 스타일, 글래스모피즘)**
---
## 디자인 시스템
### 색상 팔레트
- **Primary**: 블루 그라데이션 (#3b82f6#1d4ed8)
- **Background**: 글래스 효과 (backdrop-filter: blur)
- **Cards**: 20px 둥근 모서리, 그림자 효과
### BOM 카테고리 색상
- **PIPE**: #3b82f6 (파란색)
- **FITTING**: #10b981 (초록색)
- **FLANGE**: #f59e0b (주황색)
- **VALVE**: #ef4444 (빨간색)
- **GASKET**: #8b5cf6 (보라색)
- **BOLT**: #6b7280 (회색)
- **SUPPORT**: #f97316 (주황색)
- **SPECIAL**: #ec4899 (핑크색)
### 반응형 디자인
- **Desktop**: 3-4열 그리드
- **Tablet**: 2열 그리드
- **Mobile**: 1열 그리드
### 타이포그래피
- **Font Family**: Apple 시스템 폰트
- **Weight**: 다양한 weight 활용 (400, 500, 600, 700)
---
*마지막 업데이트: 2024-10-17*
*다음 페이지 추가 시 반드시 이 문서를 업데이트하세요.*
## 최근 업데이트 내역
### 2024-10-17: BOM 페이지 구조 개편 ⭐ 주요 업데이트
- **새로운 페이지 추가**:
- `BOMUploadPage.jsx`: 전용 업로드 페이지 (드래그 앤 드롭, 파일 검증)
- `BOMRevisionPage.jsx`: 리비전 관리 페이지 (기본 구조, 향후 고급 기능 예정)
- **기존 페이지 정리**:
- `BOMWorkspacePage.jsx``_deprecated/` 폴더로 이동 (사용 중단)
- 업로드와 리비전 기능을 별도 페이지로 분리하여 사용성 개선
- **대시보드 개편**:
- BOM 관리를 3개 카드로 분리: 📤 Upload, 📊 Revision, 📋 Management
- 각 기능별 전용 페이지로 명확한 역할 분담
- **라우팅 업데이트**:
- `/bom-upload`: 새 파일 업로드
- `/bom-revision`: 리비전 관리
- `/bom-management`: 자재 관리
### 2024-10-17: SPECIAL 카테고리 추가
- `SpecialMaterialsView.jsx` 컴포넌트 추가
- 특수 제작 자재 관리 기능 구현
- 자동 타입 분류 및 정보 파싱 시스템
- 엑셀 내보내기 규칙 적용 (P열 납기일, 관리항목 자동 채움)
- BOM 카테고리 색상 팔레트에 SPECIAL (#ec4899) 추가

View File

@@ -50,40 +50,95 @@ uvicorn app.main:app --reload
frontend/
├── src/
│ ├── components/
│ │ ├── Dashboard.jsx # 대시보드
│ │ ├── FileUpload.jsx # 파일 업로드
│ │ ├── MaterialList.jsx # 자재 목록
│ │ └── ProjectManager.jsx # 프로젝트 관리
├── App.jsx # 메인 앱
│ ├── main.jsx # 엔트리 포인
└── index.css # 전역 스타일
│ │ ├── common/ # 공통 컴포넌트
│ │ ├── bom/ # BOM 관련 컴포넌트
│ │ ├── materials/ # 카테고리별 자재
│ │ │ └── shared/ # BOM 공통 컴포넌트
│ └── ... # 기타 컴포넌트
│ ├── pages/ # 페이지 컴포넌
│ ├── DashboardPage.jsx # 메인 대시보드
│ │ ├── BOMManagementPage.jsx # BOM 관리
│ │ ├── PurchaseRequestPage.jsx # 구매신청 관리
│ │ └── ... # 기타 페이지들
│ ├── App.jsx # 메인 앱
│ ├── main.jsx # 엔트리 포인트
│ └── index.css # 전역 스타일
├── PAGES_GUIDE.md # 📋 페이지 역할 가이드
├── package.json
└── vite.config.js
```
## 🎯 주요 컴포넌트
## 📋 페이지 가이드
### Dashboard
- 프로젝트 통계 및 현황 표시
- 최근 활동 목록
- 실시간 데이터 업데이트
**모든 페이지의 역할과 기능은 [`PAGES_GUIDE.md`](./PAGES_GUIDE.md)에 상세히 문서화되어 있습니다.**
### FileUpload
- 드래그&드롭 인터페이스
- Excel 파일 검증
- 업로드 진행률 표시
- 배치 파일 처리
### 🔄 페이지 개발 규칙
### MaterialList
- 페이지네이션이 있는 데이터 그리드
- 실시간 검색 및 필터링
- CSV 내보내기
- 정렬 및 컬럼 관리
1. **새 페이지 생성 시 `PAGES_GUIDE.md` 업데이트 필수**
2. **페이지 역할, 기능, 라우팅, 접근 권한을 명확히 문서화**
3. **관련 컴포넌트와의 연관성 설명**
4. **디자인 시스템 준수 (데본씽크 스타일, 글래스모피즘)**
### ProjectManager
- 프로젝트 CRUD 작업
- 카드 형태의 프로젝트 표시
- 모달 기반 편집
### ⚠️ **Docker 배포 시 주의사항**
**프론트엔드 변경사항이 반영되지 않을 때:**
```bash
# 1. 프론트엔드 컨테이너 완전 재빌드 (캐시 문제 해결)
docker-compose stop frontend
docker-compose rm -f frontend
docker-compose build --no-cache frontend
docker-compose up -d frontend
# 2. 배포 후 index 파일 버전 확인
docker exec tk-mp-frontend find /usr/share/nginx/html -name "index-*.js"
# 3. 로컬 빌드 버전과 비교
ls -la frontend/dist/assets/index-*.js
```
**주의:** Docker 컨테이너는 이전 빌드를 캐시할 수 있어 최신 변경사항이 반영되지 않을 수 있습니다.
변경사항이 보이지 않으면 반드시 `--no-cache` 옵션으로 재빌드하세요.
### 🚀 **빠른 배포 명령어**
```bash
# 프론트엔드 빠른 재배포 (한 줄 명령어)
docker-compose stop frontend && docker-compose rm -f frontend && docker-compose build --no-cache frontend && docker-compose up -d frontend
# 전체 시스템 재시작
docker-compose restart
# 특정 서비스만 재시작
docker-compose restart backend
docker-compose restart frontend
```
## 🎯 주요 페이지
### DashboardPage
- 프로젝트 선택 드롭다운
- 프로젝트별 기능 카드 (BOM 관리, 구매신청 관리)
- 관리자 전용 기능 (사용자 관리, 로그 관리)
- 데본씽크 스타일 디자인
### BOMManagementPage
- 카테고리별 자재 관리 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT)
- 구매신청된 자재 비활성화 표시
- 엑셀 내보내기 및 서버 저장
- 사용자 요구사항 입력
### PurchaseRequestPage
- 구매신청 목록 조회 및 관리
- 구매신청 제목 인라인 편집
- 원본 파일 정보 표시
- 엑셀 파일 다운로드
### 카테고리별 자재 뷰 컴포넌트
- 각 자재 카테고리별 전용 뷰 컴포넌트
- 통일된 테이블 형태 UI
- 정렬, 필터링, 전체 선택 기능
- 구매신청된 자재 비활성화 처리
## 📱 반응형 디자인

View File

@@ -34,20 +34,6 @@
"vite": "^4.5.0"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -63,9 +49,9 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
"integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz",
"integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -73,22 +59,22 @@
}
},
"node_modules/@babel/core": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.0",
"@babel/generator": "^7.28.3",
"@babel/helper-compilation-targets": "^7.27.2",
"@babel/helper-module-transforms": "^7.27.3",
"@babel/helpers": "^7.27.6",
"@babel/parser": "^7.28.0",
"@babel/helper-module-transforms": "^7.28.3",
"@babel/helpers": "^7.28.4",
"@babel/parser": "^7.28.4",
"@babel/template": "^7.27.2",
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.28.0",
"@babel/traverse": "^7.28.4",
"@babel/types": "^7.28.4",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -111,13 +97,13 @@
"license": "MIT"
},
"node_modules/@babel/generator": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
"integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.0",
"@babel/types": "^7.28.0",
"@babel/parser": "^7.28.3",
"@babel/types": "^7.28.2",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -166,15 +152,15 @@
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz",
"integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==",
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
"integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1",
"@babel/traverse": "^7.27.3"
"@babel/traverse": "^7.28.3"
},
"engines": {
"node": ">=6.9.0"
@@ -222,26 +208,26 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz",
"integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
"integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.27.2",
"@babel/types": "^7.27.6"
"@babel/types": "^7.28.4"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.0"
"@babel/types": "^7.28.4"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -283,9 +269,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -306,17 +292,17 @@
}
},
"node_modules/@babel/traverse": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz",
"integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.0",
"@babel/generator": "^7.28.3",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.28.0",
"@babel/parser": "^7.28.4",
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.0",
"@babel/types": "^7.28.4",
"debug": "^4.3.1"
},
"engines": {
@@ -324,9 +310,9 @@
}
},
"node_modules/@babel/types": {
"version": "7.28.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz",
"integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -375,9 +361,9 @@
"license": "MIT"
},
"node_modules/@emotion/is-prop-valid": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz",
"integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
"integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
"license": "MIT",
"dependencies": {
"@emotion/memoize": "^0.9.0"
@@ -857,9 +843,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
"integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -958,15 +944,26 @@
"license": "BSD-3-Clause"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.12",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
"integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -977,15 +974,15 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
"integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.29",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
"integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -1079,7 +1076,7 @@
}
}
},
"node_modules/@mui/material/node_modules/@mui/private-theming": {
"node_modules/@mui/private-theming": {
"version": "5.17.1",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz",
"integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==",
@@ -1106,7 +1103,7 @@
}
}
},
"node_modules/@mui/material/node_modules/@mui/styled-engine": {
"node_modules/@mui/styled-engine": {
"version": "5.18.0",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz",
"integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==",
@@ -1139,7 +1136,7 @@
}
}
},
"node_modules/@mui/material/node_modules/@mui/system": {
"node_modules/@mui/system": {
"version": "5.18.0",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz",
"integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==",
@@ -1179,7 +1176,7 @@
}
}
},
"node_modules/@mui/material/node_modules/@mui/types": {
"node_modules/@mui/types": {
"version": "7.2.24",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz",
"integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==",
@@ -1193,7 +1190,7 @@
}
}
},
"node_modules/@mui/material/node_modules/@mui/utils": {
"node_modules/@mui/utils": {
"version": "5.17.1",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz",
"integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==",
@@ -1281,9 +1278,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz",
"integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==",
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
"dev": true,
"license": "MIT"
},
@@ -1323,13 +1320,13 @@
}
},
"node_modules/@types/babel__traverse": {
"version": "7.20.7",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz",
"integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.20.7"
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/parse-json": {
@@ -1345,9 +1342,9 @@
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.23",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
"version": "18.3.26",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -1381,16 +1378,16 @@
"license": "ISC"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz",
"integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==",
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.27.4",
"@babel/core": "^7.28.0",
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@rolldown/pluginutils": "1.0.0-beta.19",
"@rolldown/pluginutils": "1.0.0-beta.27",
"@types/babel__core": "^7.20.5",
"react-refresh": "^0.17.0"
},
@@ -1398,7 +1395,7 @@
"node": "^14.18.0 || >=16.0.0"
},
"peerDependencies": {
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/acorn": {
@@ -1663,13 +1660,13 @@
}
},
"node_modules/axios": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
@@ -1695,6 +1692,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.16",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz",
"integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -1707,9 +1714,9 @@
}
},
"node_modules/browserslist": {
"version": "4.25.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
"integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==",
"version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
"dev": true,
"funding": [
{
@@ -1727,9 +1734,10 @@
],
"license": "MIT",
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
"node-releases": "^2.0.19",
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
"electron-to-chromium": "^1.5.227",
"node-releases": "^2.0.21",
"update-browserslist-db": "^1.1.3"
},
"bin": {
@@ -1798,9 +1806,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001727",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
"version": "1.0.30001750",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz",
"integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==",
"dev": true,
"funding": [
{
@@ -1849,9 +1857,9 @@
}
},
"node_modules/chart.js": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
@@ -1939,15 +1947,6 @@
"node": ">=10"
}
},
"node_modules/cosmiconfig/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@@ -2036,9 +2035,9 @@
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -2142,16 +2141,16 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.182",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.182.tgz",
"integrity": "sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==",
"version": "1.5.237",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
"dev": true,
"license": "ISC"
},
"node_modules/error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.2.1"
@@ -2494,9 +2493,9 @@
}
},
"node_modules/eslint-plugin-react-refresh": {
"version": "0.4.20",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz",
"integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==",
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz",
"integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -2736,9 +2735,9 @@
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
@@ -2772,9 +2771,9 @@
}
},
"node_modules/form-data": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -2858,6 +2857,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generator-function": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
"integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -3353,14 +3362,15 @@
}
},
"node_modules/is-generator-function": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz",
"integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
"integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.3",
"get-proto": "^1.0.0",
"call-bound": "^1.0.4",
"generator-function": "^2.0.0",
"get-proto": "^1.0.1",
"has-tostringtag": "^1.0.2",
"safe-regex-test": "^1.1.0"
},
@@ -3852,9 +3862,9 @@
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"version": "2.0.23",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
"integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==",
"dev": true,
"license": "MIT"
},
@@ -4280,9 +4290,9 @@
}
},
"node_modules/react-is": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz",
"integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==",
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
"integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
"license": "MIT"
},
"node_modules/react-refresh": {
@@ -5308,6 +5318,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,7 @@ import { logApiError } from './utils/errorLogger';
// 환경변수에서 API URL을 읽음 (Vite 기준)
// 프로덕션에서는 nginx 프록시를 통해 /api 경로 사용
const API_BASE_URL = import.meta.env.VITE_API_URL ||
(import.meta.env.DEV ? 'http://localhost:18000' : '/api');
const API_BASE_URL = '/api';
console.log('API Base URL:', API_BASE_URL);
console.log('Environment:', import.meta.env.MODE);

View File

@@ -1,268 +0,0 @@
import React from 'react';
import errorLogger from '../utils/errorLogger';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null
};
}
static getDerivedStateFromError(error) {
// 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트합니다.
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// 오류 정보를 상태에 저장
this.setState({
error: error,
errorInfo: errorInfo
});
// 오류 로깅
errorLogger.logError({
type: 'react_error_boundary',
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
timestamp: new Date().toISOString(),
url: window.location.href,
props: this.props.errorContext || {}
});
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
handleReload = () => {
window.location.reload();
};
handleGoHome = () => {
window.location.href = '/';
};
handleReportError = () => {
const errorReport = {
error: this.state.error?.message,
stack: this.state.error?.stack,
componentStack: this.state.errorInfo?.componentStack,
url: window.location.href,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent
};
// 오류 보고서를 클립보드에 복사
navigator.clipboard.writeText(JSON.stringify(errorReport, null, 2))
.then(() => {
alert('오류 정보가 클립보드에 복사되었습니다.');
})
.catch(() => {
// 클립보드 복사 실패 시 텍스트 영역에 표시
const textarea = document.createElement('textarea');
textarea.value = JSON.stringify(errorReport, null, 2);
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
alert('오류 정보가 클립보드에 복사되었습니다.');
});
};
render() {
if (this.state.hasError) {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f8f9fa',
padding: '20px'
}}>
<div style={{
maxWidth: '600px',
width: '100%',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
padding: '40px',
textAlign: 'center'
}}>
<div style={{
fontSize: '48px',
marginBottom: '20px'
}}>
😵
</div>
<h1 style={{
fontSize: '24px',
fontWeight: '600',
color: '#dc3545',
marginBottom: '16px'
}}>
! 문제가 발생했습니다
</h1>
<p style={{
fontSize: '16px',
color: '#6c757d',
marginBottom: '30px',
lineHeight: '1.5'
}}>
예상치 못한 오류가 발생했습니다. <br />
문제는 자동으로 개발팀에 보고되었습니다.
</p>
<div style={{
display: 'flex',
gap: '12px',
justifyContent: 'center',
flexWrap: 'wrap',
marginBottom: '30px'
}}>
<button
onClick={this.handleReload}
style={{
padding: '12px 24px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
transition: 'background-color 0.2s'
}}
onMouseOver={(e) => e.target.style.backgroundColor = '#0056b3'}
onMouseOut={(e) => e.target.style.backgroundColor = '#007bff'}
>
🔄 페이지 새로고침
</button>
<button
onClick={this.handleGoHome}
style={{
padding: '12px 24px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
transition: 'background-color 0.2s'
}}
onMouseOver={(e) => e.target.style.backgroundColor = '#1e7e34'}
onMouseOut={(e) => e.target.style.backgroundColor = '#28a745'}
>
🏠 홈으로 이동
</button>
<button
onClick={this.handleReportError}
style={{
padding: '12px 24px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
transition: 'background-color 0.2s'
}}
onMouseOver={(e) => e.target.style.backgroundColor = '#545b62'}
onMouseOut={(e) => e.target.style.backgroundColor = '#6c757d'}
>
📋 오류 정보 복사
</button>
</div>
{/* 개발 환경에서만 상세 오류 정보 표시 */}
{process.env.NODE_ENV === 'development' && this.state.error && (
<details style={{
textAlign: 'left',
backgroundColor: '#f8f9fa',
padding: '16px',
borderRadius: '4px',
marginTop: '20px',
fontSize: '12px',
fontFamily: 'monospace'
}}>
<summary style={{
cursor: 'pointer',
fontWeight: '600',
marginBottom: '8px',
color: '#495057'
}}>
개발자 정보 (클릭하여 펼치기)
</summary>
<div style={{ marginBottom: '12px' }}>
<strong>오류 메시지:</strong>
<pre style={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: '4px 0',
color: '#dc3545'
}}>
{this.state.error.message}
</pre>
</div>
<div style={{ marginBottom: '12px' }}>
<strong>스택 트레이스:</strong>
<pre style={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: '4px 0',
fontSize: '11px',
color: '#6c757d'
}}>
{this.state.error.stack}
</pre>
</div>
{this.state.errorInfo?.componentStack && (
<div>
<strong>컴포넌트 스택:</strong>
<pre style={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: '4px 0',
fontSize: '11px',
color: '#6c757d'
}}>
{this.state.errorInfo.componentStack}
</pre>
</div>
)}
</details>
)}
<div style={{
marginTop: '30px',
padding: '16px',
backgroundColor: '#e3f2fd',
borderRadius: '4px',
fontSize: '14px',
color: '#1565c0'
}}>
💡 <strong>도움말:</strong> 문제가 계속 발생하면 페이지를 새로고침하거나
브라우저 캐시를 삭제해보세요.
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -213,7 +213,7 @@
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
overflow: hidden;
animation: dropdownSlide 0.3s ease-out;
z-index: 1000;
z-index: 1050;
}
@keyframes dropdownSlide {

View File

@@ -0,0 +1,3 @@
// BOM Components
export * from './materials';
export * from './shared';

View File

@@ -0,0 +1,742 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader } from '../shared';
const BoltMaterialsView = ({
materials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate,
updateMaterial, // 자재 업데이트 함수
jobNo,
fileId,
user
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [columnFilters, setColumnFilters] = useState({});
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
// 컴포넌트 마운트 시 저장된 데이터 로드
React.useEffect(() => {
const loadSavedData = () => {
const savedRequestsData = {};
materials.forEach(material => {
if (material.user_requirement && material.user_requirement.trim()) {
savedRequestsData[material.id] = material.user_requirement.trim();
}
});
setSavedRequests(savedRequestsData);
};
if (materials && materials.length > 0) {
loadSavedData();
} else {
setSavedRequests({});
}
}, [materials]);
// 추가요구사항 저장 함수
const handleSaveRequest = async (materialId, request) => {
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
try {
await api.patch(`/materials/${materialId}/user-requirement`, {
user_requirement: request.trim()
});
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
if (updateMaterial) {
updateMaterial(materialId, { user_requirement: request.trim() });
}
} catch (error) {
console.error('추가요구사항 저장 실패:', error);
alert('추가요구사항 저장에 실패했습니다.');
} finally {
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
}
};
// 추가요구사항 편집 시작
const handleEditRequest = (materialId, currentRequest) => {
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
};
// 볼트 추가요구사항 추출 함수
const extractBoltAdditionalRequirements = (description) => {
const additionalReqs = [];
// 표면처리 패턴 확인
const surfacePatterns = {
'ELEC.GALV': 'ELEC.GALV',
'ELEC GALV': 'ELEC.GALV',
'GALVANIZED': 'GALVANIZED',
'GALV': 'GALV',
'HOT DIP GALV': 'HDG',
'HDG': 'HDG',
'ZINC PLATED': 'ZINC PLATED',
'ZINC': 'ZINC',
'PLAIN': 'PLAIN'
};
for (const [pattern, treatment] of Object.entries(surfacePatterns)) {
if (description.includes(pattern)) {
additionalReqs.push(treatment);
break; // 첫 번째 매치만 사용
}
}
return additionalReqs.join(', ') || '-';
};
const parseBoltInfo = (material) => {
const qty = Math.round(material.quantity || 0);
// 플랜지당 볼트 세트 수 추출 (예: (8), (4))
const boltDetails = material.bolt_details || {};
let boltsPerFlange = boltDetails.dimensions?.bolts_per_flange || 1;
// 백엔드에서 정보가 없으면 원본 설명에서 직접 추출
if (boltsPerFlange === 1) {
const description = material.original_description || '';
const flangePattern = description.match(/\((\d+)\)/);
if (flangePattern) {
boltsPerFlange = parseInt(flangePattern[1]);
}
}
// 실제 필요한 볼트 수 = 플랜지 수 × 플랜지당 볼트 세트 수
const totalBoltsNeeded = qty * boltsPerFlange;
const safetyQty = Math.ceil(totalBoltsNeeded * 1.05); // 5% 여유율
const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수
// 길이 정보 (bolt_details 우선, 없으면 원본 설명에서 추출)
let boltLength = '-';
if (boltDetails.length && boltDetails.length !== '-') {
boltLength = boltDetails.length;
} else {
// 원본 설명에서 길이 추출
const description = material.original_description || '';
const lengthPatterns = [
/(\d+(?:\.\d+)?)\s*LG/i, // 75 LG, 90.0000 LG
/(\d+(?:\.\d+)?)\s*mm/i, // 50mm
/(\d+(?:\.\d+)?)\s*MM/i, // 50MM
/LG[,\s]*(\d+(?:\.\d+)?)/i // LG, 75 형태
];
for (const pattern of lengthPatterns) {
const match = description.match(pattern);
if (match) {
let lengthValue = match[1];
// 소수점 제거 (145.0000 → 145)
if (lengthValue.includes('.') && lengthValue.endsWith('.0000')) {
lengthValue = lengthValue.split('.')[0];
} else if (lengthValue.includes('.') && /\.0+$/.test(lengthValue)) {
lengthValue = lengthValue.split('.')[0];
}
boltLength = `${lengthValue}mm`;
break;
}
}
}
// 재질 정보 (bolt_details 우선, 없으면 기본 필드 사용)
let boltGrade = '-';
if (boltDetails.material_standard && boltDetails.material_grade) {
// bolt_details에서 완전한 재질 정보 구성
if (boltDetails.material_grade !== 'UNKNOWN' && boltDetails.material_grade !== 'UNCLASSIFIED' && boltDetails.material_grade !== boltDetails.material_standard) {
boltGrade = `${boltDetails.material_standard} ${boltDetails.material_grade}`;
} else {
boltGrade = boltDetails.material_standard;
}
} else if (material.full_material_grade && material.full_material_grade !== '-') {
boltGrade = material.full_material_grade;
} else if (material.material_grade && material.material_grade !== '-') {
boltGrade = material.material_grade;
}
// 볼트 타입 (PSV_BOLT, LT_BOLT 등)
let boltSubtype = 'BOLT_GENERAL';
if (boltDetails.bolt_type && boltDetails.bolt_type !== 'UNKNOWN' && boltDetails.bolt_type !== 'UNCLASSIFIED') {
boltSubtype = boltDetails.bolt_type;
} else {
// 원본 설명에서 특수 볼트 타입 추출
const description = material.original_description || '';
const upperDesc = description.toUpperCase();
if (upperDesc.includes('PSV')) {
boltSubtype = 'PSV_BOLT';
} else if (upperDesc.includes('LT')) {
boltSubtype = 'LT_BOLT';
} else if (upperDesc.includes('CK')) {
boltSubtype = 'CK_BOLT';
}
}
// 압력 등급 추출 (150LB 등)
let boltPressure = '-';
const description = material.original_description || '';
const pressureMatch = description.match(/(\d+)\s*LB/i);
if (pressureMatch) {
boltPressure = `${pressureMatch[1]}LB`;
}
// User Requirements 추출 (ELEC.GALV 등)
const userRequirements = extractBoltAdditionalRequirements(material.original_description || '');
// Purchase Quantity (Quantity + Unit 통합) - 플랜지당 볼트 세트 수 정보 포함
const purchaseQuantity = boltsPerFlange > 1
? `${purchaseQty} SETS (${boltsPerFlange}/flange)`
: `${purchaseQty} SETS`;
return {
type: 'BOLT',
subtype: boltSubtype,
size: material.size_spec || material.main_nom || '-',
pressure: boltPressure, // 압력 등급 (150LB 등)
schedule: boltLength, // 길이 정보
grade: boltGrade,
userRequirements: userRequirements, // User Requirements (ELEC.GALV 등)
additionalReq: '-', // 추가요구사항 (사용자 입력)
purchaseQuantity: purchaseQuantity // 구매수량 (통합)
};
};
// 정렬 처리
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// 필터링된 및 정렬된 자재 목록
const getFilteredAndSortedMaterials = () => {
let filtered = materials.filter(material => {
return Object.entries(columnFilters).every(([key, filterValue]) => {
if (!filterValue) return true;
const info = parseBoltInfo(material);
const value = info[key]?.toString().toLowerCase() || '';
return value.includes(filterValue.toLowerCase());
});
});
if (sortConfig && sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parseBoltInfo(a);
const bInfo = parseBoltInfo(b);
if (!aInfo || !bInfo) return 0;
const aValue = aInfo[sortConfig.key];
const bValue = bInfo[sortConfig.key];
// 값이 없는 경우 처리
if (aValue === undefined && bValue === undefined) return 0;
if (aValue === undefined) return 1;
if (bValue === undefined) return -1;
// 숫자인 경우 숫자로 비교
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
}
// 문자열로 비교
const aStr = String(aValue).toLowerCase();
const bStr = String(bValue).toLowerCase();
if (sortConfig.direction === 'asc') {
return aStr.localeCompare(bStr);
} else {
return bStr.localeCompare(aStr);
}
});
}
return filtered;
};
// 전체 선택/해제 (구매신청된 자재 제외)
const handleSelectAll = () => {
const filteredMaterials = getFilteredAndSortedMaterials();
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
if (selectedMaterials.size === selectableMaterials.length) {
setSelectedMaterials(new Set());
} else {
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
}
};
// 개별 선택 (구매신청된 자재는 선택 불가)
const handleMaterialSelect = (materialId) => {
if (purchasedMaterials.has(materialId)) {
return; // 구매신청된 자재는 선택 불가
}
const newSelected = new Set(selectedMaterials);
if (newSelected.has(materialId)) {
newSelected.delete(materialId);
} else {
newSelected.add(materialId);
}
setSelectedMaterials(newSelected);
};
// 엑셀 내보내기
const handleExportToExcel = async () => {
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
if (selectedMaterialsData.length === 0) {
alert('내보낼 자재를 선택해주세요.');
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const excelFileName = `BOLT_Materials_${timestamp}.xlsx`;
const dataWithRequirements = selectedMaterialsData.map(material => ({
...material,
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
}));
try {
console.log('🔄 볼트 엑셀 내보내기 시작 - 새로운 방식');
// 1. 먼저 클라이언트에서 엑셀 파일 생성
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
category: 'BOLT',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
// 2. 구매신청 생성
const allMaterialIds = selectedMaterialsData.map(m => m.id);
const response = await api.post('/purchase-request/create', {
file_id: fileId,
job_no: jobNo,
category: 'BOLT',
material_ids: allMaterialIds,
materials_data: dataWithRequirements.map(m => ({
material_id: m.id,
description: m.original_description,
category: m.classified_category,
size: m.size_inch || m.size_spec,
schedule: m.schedule,
material_grade: m.material_grade || m.full_material_grade,
quantity: m.quantity,
unit: m.unit,
user_requirement: userRequirements[m.id] || ''
}))
});
if (response.data.success) {
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
// 3. 엑셀 파일을 서버에 업로드
const formData = new FormData();
formData.append('excel_file', excelBlob, excelFileName);
formData.append('request_id', response.data.request_id);
formData.append('category', 'BOLT');
console.log('📤 엑셀 파일 서버 업로드 중...');
await api.post('/purchase-request/upload-excel', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
console.log('✅ 엑셀 파일 서버 업로드 완료');
// 4. 구매된 자재 목록 업데이트 (비활성화)
onPurchasedMaterialsUpdate(allMaterialIds);
console.log('✅ 구매된 자재 목록 업데이트 완료');
// 5. 클라이언트에 파일 다운로드
const url = window.URL.createObjectURL(excelBlob);
const link = document.createElement('a');
link.href = url;
link.download = excelFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} else {
throw new Error(response.data?.message || '구매신청 생성 실패');
}
} catch (error) {
console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패 시에도 클라이언트 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'BOLT',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
}
// 선택 해제
setSelectedMaterials(new Set());
};
const filteredMaterials = getFilteredAndSortedMaterials();
return (
<div style={{ padding: '32px' }}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0'
}}>
Bolt Materials
</h3>
<p style={{
fontSize: '14px',
color: '#64748b',
margin: 0
}}>
{filteredMaterials.length} items {selectedMaterials.size} selected
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={handleSelectAll}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '8px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={handleExportToExcel}
disabled={selectedMaterials.size === 0}
style={{
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #6b7280 0%, #4b5563 100%)' : '#e5e7eb',
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500'
}}
>
Export to Excel ({selectedMaterials.size})
</button>
</div>
</div>
{/* 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'auto',
maxHeight: '600px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
<div style={{ minWidth: '1500px' }}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 200px 200px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
textAlign: 'center'
}}>
<div>
<input
type="checkbox"
checked={(() => {
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
})()}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
</div>
<FilterableHeader
sortKey="subtype"
filterKey="subtype"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Type
</FilterableHeader>
<FilterableHeader
sortKey="size"
filterKey="size"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Size
</FilterableHeader>
<FilterableHeader
sortKey="pressure"
filterKey="pressure"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Pressure
</FilterableHeader>
<FilterableHeader
sortKey="schedule"
filterKey="schedule"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Length
</FilterableHeader>
<FilterableHeader
sortKey="grade"
filterKey="grade"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Material Grade
</FilterableHeader>
<div>User Requirements</div>
<div>Additional Request</div>
<FilterableHeader
sortKey="purchaseQuantity"
filterKey="purchaseQuantity"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Purchase Quantity
</FilterableHeader>
</div>
{/* 데이터 행들 */}
{filteredMaterials.map((material, index) => {
const info = parseBoltInfo(material);
const isSelected = selectedMaterials.has(material.id);
const isPurchased = purchasedMaterials.has(material.id);
return (
<div
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 200px 200px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = 'white';
}
}}
>
<div>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleMaterialSelect(material.id)}
disabled={isPurchased}
style={{
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1
}}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
{info.subtype}
{isPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '500'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.size}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.pressure}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.schedule}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.grade}
</div>
<div style={{
fontSize: '14px',
color: '#1f2937',
textAlign: 'center'
}}>
{info.userRequirements}
</div>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{!editingRequest[material.id] && savedRequests[material.id] ? (
// 저장된 상태 - 요구사항 표시 + 수정 버튼
<>
<div style={{
flex: 1,
padding: '6px 8px',
border: '1px solid #e5e7eb',
borderRadius: '4px',
fontSize: '12px',
textAlign: 'center',
background: '#f9fafb',
color: '#374151'
}}>
{savedRequests[material.id]}
</div>
<button
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
disabled={isPurchased}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: isPurchased ? '#d1d5db' : '#f59e0b',
color: 'white',
fontSize: '10px',
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
Edit
</button>
</>
) : (
// 편집 상태 - 입력 필드 + 저장 버튼
<>
<input
type="text"
value={userRequirements[material.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter additional request..."
disabled={isPurchased}
style={{
flex: 1,
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px',
opacity: isPurchased ? 0.5 : 1,
cursor: isPurchased ? 'not-allowed' : 'text'
}}
/>
<button
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
disabled={isPurchased || savingRequest[material.id]}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: isPurchased ? '#d1d5db' : '#10b981',
color: 'white',
fontSize: '10px',
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
{savingRequest[material.id] ? '...' : 'Save'}
</button>
</>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.purchaseQuantity}
</div>
</div>
);
})}
</div>
</div>
{filteredMaterials.length === 0 && (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
No Bolt Materials Found
</div>
<div style={{ fontSize: '14px' }}>
{Object.keys(columnFilters).some(key => columnFilters[key])
? 'Try adjusting your filters'
: 'No bolt materials available in this BOM'}
</div>
</div>
)}
</div>
);
};
export default BoltMaterialsView;

View File

@@ -0,0 +1,898 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader, MaterialTable } from '../shared';
const FittingMaterialsView = ({
materials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate,
updateMaterial, // 자재 업데이트 함수
fileId,
jobNo,
user,
onNavigate
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [columnFilters, setColumnFilters] = useState({});
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
// 컴포넌트 마운트 시 저장된 데이터 로드
React.useEffect(() => {
const loadSavedData = () => {
const savedRequestsData = {};
materials.forEach(material => {
if (material.user_requirement && material.user_requirement.trim()) {
savedRequestsData[material.id] = material.user_requirement.trim();
}
});
setSavedRequests(savedRequestsData);
};
if (materials && materials.length > 0) {
loadSavedData();
} else {
setSavedRequests({});
}
}, [materials]);
// 추가요구사항 저장 함수
const handleSaveRequest = async (materialId, request) => {
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
try {
await api.patch(`/materials/${materialId}/user-requirement`, {
user_requirement: request.trim()
});
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
if (updateMaterial) {
updateMaterial(materialId, { user_requirement: request.trim() });
}
} catch (error) {
console.error('추가요구사항 저장 실패:', error);
alert('추가요구사항 저장에 실패했습니다.');
} finally {
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
}
};
// 추가요구사항 편집 시작
const handleEditRequest = (materialId, currentRequest) => {
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
};
// 니플 끝단 정보 추출 (기존 로직 복원)
const extractNippleEndInfo = (description) => {
const descUpper = description.toUpperCase();
// 니플 끝단 패턴들 (기존 NewMaterialsPage와 동일)
const endPatterns = {
'PBE': 'PBE', // Plain Both End
'BBE': 'BBE', // Bevel Both End
'POE': 'POE', // Plain One End
'BOE': 'BOE', // Bevel One End
'TOE': 'TOE', // Thread One End
'SW X NPT': 'SW×NPT', // Socket Weld x NPT
'SW X SW': 'SW×SW', // Socket Weld x Socket Weld
'NPT X NPT': 'NPT×NPT', // NPT x NPT
'BOTH END THREADED': 'B.E.T',
'B.E.T': 'B.E.T',
'ONE END THREADED': 'O.E.T',
'O.E.T': 'O.E.T',
'THREADED': 'THD'
};
for (const [pattern, display] of Object.entries(endPatterns)) {
if (descUpper.includes(pattern)) {
return display;
}
}
return '';
};
// 피팅 정보 파싱 (기존 상세 로직 복원)
const parseFittingInfo = (material) => {
const fittingDetails = material.fitting_details || {};
const classificationDetails = material.classification_details || {};
// 개선된 분류기 결과 우선 사용
const fittingTypeInfo = classificationDetails.fitting_type || {};
const scheduleInfo = classificationDetails.schedule_info || {};
// 기존 필드와 새 필드 통합
const fittingType = fittingTypeInfo.type || fittingDetails.fitting_type || '';
const fittingSubtype = fittingTypeInfo.subtype || fittingDetails.fitting_subtype || '';
const mainSchedule = scheduleInfo.main_schedule || fittingDetails.schedule || '';
const redSchedule = scheduleInfo.red_schedule || '';
const hasDifferentSchedules = scheduleInfo.has_different_schedules || false;
const description = material.original_description || '';
// 피팅 타입별 상세 표시
let displayType = '';
// 개선된 분류기 결과 우선 표시
if (fittingType === 'TEE' && fittingSubtype === 'REDUCING') {
displayType = 'TEE REDUCING';
} else if (fittingType === 'REDUCER' && fittingSubtype === 'CONCENTRIC') {
displayType = 'REDUCER CONC';
} else if (fittingType === 'REDUCER' && fittingSubtype === 'ECCENTRIC') {
displayType = 'REDUCER ECC';
} else if (description.toUpperCase().includes('TEE RED')) {
displayType = 'TEE REDUCING';
} else if (description.toUpperCase().includes('RED CONC')) {
displayType = 'REDUCER CONC';
} else if (description.toUpperCase().includes('RED ECC')) {
displayType = 'REDUCER ECC';
} else if (description.toUpperCase().includes('CAP')) {
if (description.includes('NPT(F)')) {
displayType = 'CAP NPT(F)';
} else if (description.includes('SW')) {
displayType = 'CAP SW';
} else if (description.includes('BW')) {
displayType = 'CAP BW';
} else {
displayType = 'CAP';
}
} else if (description.toUpperCase().includes('PLUG')) {
if (description.toUpperCase().includes('HEX')) {
if (description.includes('NPT(M)')) {
displayType = 'HEX PLUG NPT(M)';
} else {
displayType = 'HEX PLUG';
}
} else if (description.includes('NPT(M)')) {
displayType = 'PLUG NPT(M)';
} else if (description.includes('NPT')) {
displayType = 'PLUG NPT';
} else {
displayType = 'PLUG';
}
} else if (fittingType === 'NIPPLE') {
const length = fittingDetails.length_mm || fittingDetails.avg_length_mm;
const endInfo = extractNippleEndInfo(description);
let nippleType = 'NIPPLE';
if (length) nippleType += ` ${length}mm`;
if (endInfo) nippleType += ` ${endInfo}`;
displayType = nippleType;
} else if (fittingType === 'ELBOW') {
let elbowDetails = [];
// 각도 정보 추출
if (fittingSubtype.includes('90DEG') || description.includes('90') || description.includes('90°')) {
elbowDetails.push('90°');
} else if (fittingSubtype.includes('45DEG') || description.includes('45') || description.includes('45°')) {
elbowDetails.push('45°');
}
// 반경 정보 추출 (Long Radius / Short Radius)
if (fittingSubtype.includes('LONG_RADIUS') || description.toUpperCase().includes('LR') || description.toUpperCase().includes('LONG RADIUS')) {
elbowDetails.push('LR');
} else if (fittingSubtype.includes('SHORT_RADIUS') || description.toUpperCase().includes('SR') || description.toUpperCase().includes('SHORT RADIUS')) {
elbowDetails.push('SR');
}
// 연결 방식
if (description.includes('SW')) {
elbowDetails.push('SW');
} else if (description.includes('BW')) {
elbowDetails.push('BW');
}
// 기본값 설정 (각도가 없으면 90도로 가정)
if (!elbowDetails.some(detail => detail.includes('°'))) {
elbowDetails.unshift('90°');
}
displayType = `ELBOW ${elbowDetails.join(' ')}`.trim();
} else if (fittingType === 'TEE') {
// TEE 타입과 연결 방식 상세 표시
let teeDetails = [];
// 등경/축소 타입
if (fittingSubtype === 'EQUAL' || description.toUpperCase().includes('TEE EQ')) {
teeDetails.push('EQ');
} else if (fittingSubtype === 'REDUCING' || description.toUpperCase().includes('TEE RED')) {
teeDetails.push('RED');
}
// 연결 방식
if (description.includes('SW')) {
teeDetails.push('SW');
} else if (description.includes('BW')) {
teeDetails.push('BW');
}
displayType = `TEE ${teeDetails.join(' ')}`.trim();
} else if (fittingType === 'REDUCER') {
const reducerType = fittingSubtype === 'CONCENTRIC' ? 'CONC' : fittingSubtype === 'ECCENTRIC' ? 'ECC' : '';
const sizes = fittingDetails.reduced_size ? `${material.size_spec}×${fittingDetails.reduced_size}` : material.size_spec;
displayType = `RED ${reducerType} ${sizes}`.trim();
} else if (fittingType === 'SWAGE') {
const swageType = fittingSubtype || '';
displayType = `SWAGE ${swageType}`.trim();
} else if (fittingType === 'OLET') {
const oletSubtype = fittingSubtype || '';
let oletDisplayName = '';
// 백엔드 분류기 결과 우선 사용
switch (oletSubtype) {
case 'SOCKOLET':
oletDisplayName = 'SOCK-O-LET';
break;
case 'WELDOLET':
oletDisplayName = 'WELD-O-LET';
break;
case 'ELLOLET':
oletDisplayName = 'ELL-O-LET';
break;
case 'THREADOLET':
oletDisplayName = 'THREAD-O-LET';
break;
case 'ELBOLET':
oletDisplayName = 'ELB-O-LET';
break;
case 'NIPOLET':
oletDisplayName = 'NIP-O-LET';
break;
case 'COUPOLET':
oletDisplayName = 'COUP-O-LET';
break;
default:
// 백엔드 분류가 없으면 description에서 직접 추출
const upperDesc = description.toUpperCase();
if (upperDesc.includes('SOCK-O-LET') || upperDesc.includes('SOCKOLET')) {
oletDisplayName = 'SOCK-O-LET';
} else if (upperDesc.includes('WELD-O-LET') || upperDesc.includes('WELDOLET')) {
oletDisplayName = 'WELD-O-LET';
} else if (upperDesc.includes('ELL-O-LET') || upperDesc.includes('ELLOLET')) {
oletDisplayName = 'ELL-O-LET';
} else if (upperDesc.includes('THREAD-O-LET') || upperDesc.includes('THREADOLET')) {
oletDisplayName = 'THREAD-O-LET';
} else if (upperDesc.includes('ELB-O-LET') || upperDesc.includes('ELBOLET')) {
oletDisplayName = 'ELB-O-LET';
} else if (upperDesc.includes('NIP-O-LET') || upperDesc.includes('NIPOLET')) {
oletDisplayName = 'NIP-O-LET';
} else if (upperDesc.includes('COUP-O-LET') || upperDesc.includes('COUPOLET')) {
oletDisplayName = 'COUP-O-LET';
} else {
oletDisplayName = 'OLET';
}
}
displayType = oletDisplayName;
} else if (!displayType) {
displayType = fittingType || 'FITTING';
}
// 압력 등급과 스케줄 추출 (기존 NewMaterialsPage 로직)
let pressure = '-';
let schedule = '-';
// 압력 등급 찾기 (3000LB, 6000LB 등) - 소켓웰드 피팅에 특히 중요
const pressureMatch = description.match(/(\d+)LB/i);
if (pressureMatch) {
pressure = `${pressureMatch[1]}LB`;
}
// 소켓웰드 피팅의 경우 압력 등급이 더 중요함
if (description.includes('SW') && !pressureMatch) {
// SW 피팅인데 압력이 명시되지 않은 경우 기본값 설정
if (description.includes('3000') || description.includes('3K')) {
pressure = '3000LB';
} else if (description.includes('6000') || description.includes('6K')) {
pressure = '6000LB';
}
}
// 스케줄 표시 (분리 스케줄 지원) - 개선된 로직
// 레듀싱 자재인지 확인
const isReducingFitting = displayType.includes('REDUCING') || displayType.includes('RED') ||
description.toUpperCase().includes('RED') ||
description.toUpperCase().includes('REDUCING');
if (hasDifferentSchedules && mainSchedule && redSchedule) {
schedule = `${mainSchedule}×${redSchedule}`;
} else if (isReducingFitting && mainSchedule && mainSchedule !== 'UNKNOWN' && mainSchedule !== 'UNCLASSIFIED') {
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
schedule = `${mainSchedule}×${mainSchedule}`;
} else if (mainSchedule && mainSchedule !== 'UNKNOWN' && mainSchedule !== 'UNCLASSIFIED') {
schedule = mainSchedule;
} else {
// Description에서 스케줄 추출 - 더 강력한 패턴 매칭
const schedulePatterns = [
/SCH\s*(\d+S?)/i, // SCH 40, SCH 80S
/SCHEDULE\s*(\d+S?)/i, // SCHEDULE 40
/스케줄\s*(\d+S?)/i, // 스케줄 40
/(\d+S?)\s*SCH/i, // 40 SCH (역순)
/SCH\.?\s*(\d+S?)/i, // SCH.40
/SCH\s*(\d+S?)\s*[xX×]\s*SCH\s*(\d+S?)/i // SCH 40 x SCH 80
];
for (const pattern of schedulePatterns) {
const match = description.match(pattern);
if (match) {
if (match.length > 2) {
// 분리 스케줄 패턴 (SCH 40 x SCH 80)
schedule = `SCH ${match[1]}×SCH ${match[2]}`;
} else {
const scheduleNum = match[1];
if (isReducingFitting) {
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
schedule = `SCH ${scheduleNum}×SCH ${scheduleNum}`;
} else {
schedule = `SCH ${scheduleNum}`;
}
}
break;
}
}
// 여전히 찾지 못했다면 더 넓은 패턴 시도
if (schedule === '-') {
const broadPatterns = [
/\b(\d+)\s*LB/i, // 압력 등급에서 유추
/\b(40|80|120|160)\b/i, // 일반적인 스케줄 숫자
/\b(10|20|30|40|60|80|100|120|140|160)\b/i // 모든 표준 스케줄
];
for (const pattern of broadPatterns) {
const match = description.match(pattern);
if (match) {
const num = match[1];
// 압력 등급이 아닌 경우만 스케줄로 간주
if (!description.includes(`${num}LB`)) {
if (isReducingFitting) {
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
schedule = `SCH ${num}×SCH ${num}`;
} else {
schedule = `SCH ${num}`;
}
break;
}
}
}
}
}
return {
type: 'FITTING',
subtype: displayType,
size: material.size_spec || '-',
pressure: pressure,
schedule: schedule,
grade: material.full_material_grade || material.material_grade || '-',
quantity: Math.round(material.quantity || 0),
unit: '개',
isFitting: true
};
};
// 정렬 처리
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// 필터링된 및 정렬된 자재 목록
const getFilteredAndSortedMaterials = () => {
let filtered = materials.filter(material => {
return Object.entries(columnFilters).every(([key, filterValue]) => {
if (!filterValue) return true;
const info = parseFittingInfo(material);
const value = info[key]?.toString().toLowerCase() || '';
return value.includes(filterValue.toLowerCase());
});
});
if (sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parseFittingInfo(a);
const bInfo = parseFittingInfo(b);
const aValue = aInfo[sortConfig.key] || '';
const bValue = bInfo[sortConfig.key] || '';
if (sortConfig.direction === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
}
return filtered;
};
// 전체 선택/해제 (구매신청된 자재 제외)
const handleSelectAll = () => {
const filteredMaterials = getFilteredAndSortedMaterials();
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
if (selectedMaterials.size === selectableMaterials.length) {
setSelectedMaterials(new Set());
} else {
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
}
};
// 개별 선택 (구매신청된 자재는 선택 불가)
const handleMaterialSelect = (materialId) => {
if (purchasedMaterials.has(materialId)) {
return; // 구매신청된 자재는 선택 불가
}
const newSelected = new Set(selectedMaterials);
if (newSelected.has(materialId)) {
newSelected.delete(materialId);
} else {
newSelected.add(materialId);
}
setSelectedMaterials(newSelected);
};
// 엑셀 내보내기
const handleExportToExcel = async () => {
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
if (selectedMaterialsData.length === 0) {
alert('내보낼 자재를 선택해주세요.');
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const excelFileName = `FITTING_Materials_${timestamp}.xlsx`;
const dataWithRequirements = selectedMaterialsData.map(material => ({
...material,
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
}));
try {
console.log('🔄 피팅 엑셀 내보내기 시작 - 새로운 방식');
// 1. 먼저 클라이언트에서 엑셀 파일 생성
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
category: 'FITTING',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
// 2. 구매신청 생성
const allMaterialIds = selectedMaterialsData.map(m => m.id);
const response = await api.post('/purchase-request/create', {
file_id: fileId,
job_no: jobNo,
category: 'FITTING',
material_ids: allMaterialIds,
materials_data: dataWithRequirements.map(m => ({
material_id: m.id,
description: m.original_description,
category: m.classified_category,
size: m.size_inch || m.size_spec,
schedule: m.schedule,
material_grade: m.material_grade || m.full_material_grade,
quantity: m.quantity,
unit: m.unit,
user_requirement: userRequirements[m.id] || ''
}))
});
if (response.data.success) {
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
// 3. 생성된 엑셀 파일을 서버에 업로드
console.log('📤 서버에 엑셀 파일 업로드 중...');
const formData = new FormData();
formData.append('excel_file', excelBlob, excelFileName);
formData.append('request_id', response.data.request_id);
formData.append('category', 'FITTING');
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
if (onPurchasedMaterialsUpdate) {
onPurchasedMaterialsUpdate(allMaterialIds);
}
}
// 4. 클라이언트 다운로드
const url = window.URL.createObjectURL(excelBlob);
const link = document.createElement('a');
link.href = url;
link.download = excelFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} catch (error) {
console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패 시에도 클라이언트 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'FITTING',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
}
// 선택 해제
setSelectedMaterials(new Set());
};
const filteredMaterials = getFilteredAndSortedMaterials();
// 필터 헤더 컴포넌트
const FilterableHeader = ({ sortKey, filterKey, children }) => (
<div className="filterable-header" style={{ position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
onClick={() => handleSort(sortKey)}
style={{ cursor: 'pointer', flex: 1 }}
>
{children}
{sortConfig.key === sortKey && (
<span style={{ marginLeft: '4px' }}>
{sortConfig.direction === 'asc' ? '↑' : '↓'}
</span>
)}
</span>
<button
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px',
fontSize: '12px',
color: '#6b7280'
}}
>
🔍
</button>
</div>
{showFilterDropdown === filterKey && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: '6px',
padding: '8px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
zIndex: 1000,
minWidth: '150px'
}}>
<input
type="text"
placeholder={`Filter ${children}...`}
value={columnFilters[filterKey] || ''}
onChange={(e) => setColumnFilters({
...columnFilters,
[filterKey]: e.target.value
})}
style={{
width: '100%',
padding: '4px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
autoFocus
/>
</div>
)}
</div>
);
return (
<div style={{ padding: '32px' }}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0'
}}>
Fitting Materials
</h3>
<p style={{
fontSize: '14px',
color: '#64748b',
margin: 0
}}>
{filteredMaterials.length} items {selectedMaterials.size} selected
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={handleSelectAll}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '8px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={handleExportToExcel}
disabled={selectedMaterials.size === 0}
style={{
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #10b981 0%, #059669 100%)' : '#e5e7eb',
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500'
}}
>
Export to Excel ({selectedMaterials.size})
</button>
</div>
</div>
{/* 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'auto',
maxHeight: '600px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
<div style={{ minWidth: '1380px' }}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 120px 250px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
textAlign: 'center'
}}>
<div>
<input
type="checkbox"
checked={(() => {
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
})()}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
</div>
<div>Type</div>
<div>Size</div>
<div>Pressure</div>
<div>Schedule</div>
<div>Material Grade</div>
<div>User Requirements</div>
<div>Purchase Quantity</div>
<div>Additional Request</div>
</div>
{/* 데이터 행들 */}
{filteredMaterials.map((material, index) => {
const info = parseFittingInfo(material);
const isSelected = selectedMaterials.has(material.id);
const isPurchased = purchasedMaterials.has(material.id);
return (
<div
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 120px 250px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease',
textAlign: 'center'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = 'white';
}
}}
>
<div>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleMaterialSelect(material.id)}
disabled={isPurchased}
style={{
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1
}}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
{info.subtype}
{isPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '500'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.size}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.pressure}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.schedule}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.grade}
</div>
<div style={{
fontSize: '14px',
color: '#1f2937',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{material.user_requirements?.join(', ') || '-'}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
{info.quantity} {info.unit}
</div>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{!editingRequest[material.id] && savedRequests[material.id] ? (
// 저장된 상태 - 요구사항 표시 + 수정 버튼
<>
<div style={{
flex: 1,
padding: '6px 8px',
border: '1px solid #e5e7eb',
borderRadius: '4px',
fontSize: '12px',
textAlign: 'center',
background: '#f9fafb',
color: '#374151'
}}>
{savedRequests[material.id]}
</div>
<button
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
disabled={isPurchased}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: isPurchased ? '#d1d5db' : '#f59e0b',
color: 'white',
fontSize: '10px',
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
Edit
</button>
</>
) : (
// 편집 상태 - 입력 필드 + 저장 버튼
<>
<input
type="text"
value={userRequirements[material.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter additional request..."
disabled={isPurchased}
style={{
flex: 1,
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px',
opacity: isPurchased ? 0.5 : 1,
cursor: isPurchased ? 'not-allowed' : 'text'
}}
/>
<button
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
disabled={isPurchased || savingRequest[material.id]}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: isPurchased ? '#d1d5db' : '#10b981',
color: 'white',
fontSize: '10px',
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
{savingRequest[material.id] ? '...' : 'Save'}
</button>
</>
)}
</div>
</div>
);
})}
</div>
</div>
{filteredMaterials.length === 0 && (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
No Fitting Materials Found
</div>
<div style={{ fontSize: '14px' }}>
{Object.keys(columnFilters).some(key => columnFilters[key])
? 'Try adjusting your filters'
: 'No fitting materials available in this BOM'}
</div>
</div>
)}
</div>
);
};
export default FittingMaterialsView;

View File

@@ -0,0 +1,722 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader, MaterialTable } from '../shared';
const FlangeMaterialsView = ({
materials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate,
updateMaterial, // 자재 업데이트 함수
fileId,
jobNo,
user,
onNavigate
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [columnFilters, setColumnFilters] = useState({});
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
// 컴포넌트 마운트 시 저장된 데이터 로드
React.useEffect(() => {
const loadSavedData = () => {
const savedRequestsData = {};
materials.forEach(material => {
if (material.user_requirement && material.user_requirement.trim()) {
savedRequestsData[material.id] = material.user_requirement.trim();
}
});
setSavedRequests(savedRequestsData);
};
if (materials && materials.length > 0) {
loadSavedData();
} else {
setSavedRequests({});
}
}, [materials]);
// 추가요구사항 저장 함수
const handleSaveRequest = async (materialId, request) => {
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
try {
await api.patch(`/materials/${materialId}/user-requirement`, {
user_requirement: request.trim()
});
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
if (updateMaterial) {
updateMaterial(materialId, { user_requirement: request.trim() });
}
} catch (error) {
console.error('추가요구사항 저장 실패:', error);
alert('추가요구사항 저장에 실패했습니다.');
} finally {
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
}
};
// 추가요구사항 편집 시작
const handleEditRequest = (materialId, currentRequest) => {
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
};
// 플랜지 정보 파싱
const parseFlangeInfo = (material) => {
const description = material.original_description || '';
const flangeDetails = material.flange_details || {};
const flangeTypeMap = {
'WN': 'WELD NECK FLANGE',
'WELD_NECK': 'WELD NECK FLANGE',
'SO': 'SLIP ON FLANGE',
'SLIP_ON': 'SLIP ON FLANGE',
'SW': 'SOCKET WELD FLANGE',
'SOCKET_WELD': 'SOCKET WELD FLANGE',
'THREADED': 'THREADED FLANGE',
'THD': 'THREADED FLANGE',
'BLIND': 'BLIND FLANGE',
'LAP_JOINT': 'LAP JOINT FLANGE',
'LJ': 'LAP JOINT FLANGE',
'REDUCING': 'REDUCING FLANGE',
'ORIFICE': 'ORIFICE FLANGE',
'SPECTACLE': 'SPECTACLE BLIND',
'SPECTACLE_BLIND': 'SPECTACLE BLIND',
'PADDLE': 'PADDLE BLIND',
'PADDLE_BLIND': 'PADDLE BLIND',
'SPACER': 'SPACER',
'SWIVEL': 'SWIVEL FLANGE',
'DRIP_RING': 'DRIP RING',
'NOZZLE': 'NOZZLE FLANGE'
};
const facingTypeMap = {
'RF': 'RAISED FACE',
'RAISED_FACE': 'RAISED FACE',
'FF': 'FLAT FACE',
'FLAT_FACE': 'FLAT FACE',
'RTJ': 'RING TYPE JOINT',
'RING_TYPE_JOINT': 'RING TYPE JOINT'
};
const rawFlangeType = flangeDetails.flange_type || '';
const rawFacingType = flangeDetails.facing_type || '';
// rawFlangeType에서 facing 정보 분리 (예: "WN RF" -> "WN")
let cleanFlangeType = rawFlangeType;
let extractedFacing = rawFacingType;
// facing 정보가 flange_type에 포함된 경우 분리
if (rawFlangeType.includes(' RF')) {
cleanFlangeType = rawFlangeType.replace(' RF', '').trim();
if (!extractedFacing || extractedFacing === '-') extractedFacing = 'RAISED_FACE';
} else if (rawFlangeType.includes(' FF')) {
cleanFlangeType = rawFlangeType.replace(' FF', '').trim();
if (!extractedFacing || extractedFacing === '-') extractedFacing = 'FLAT_FACE';
} else if (rawFlangeType.includes(' RTJ')) {
cleanFlangeType = rawFlangeType.replace(' RTJ', '').trim();
if (!extractedFacing || extractedFacing === '-') extractedFacing = 'RING_TYPE_JOINT';
}
let displayType = flangeTypeMap[cleanFlangeType] || '-';
let facingType = facingTypeMap[extractedFacing] || '-';
// Description에서 추출
if (displayType === '-') {
const desc = description.toUpperCase();
if (desc.includes('ORIFICE')) {
displayType = 'ORIFICE FLANGE';
} else if (desc.includes('SPECTACLE')) {
displayType = 'SPECTACLE BLIND';
} else if (desc.includes('PADDLE')) {
displayType = 'PADDLE BLIND';
} else if (desc.includes('SPACER')) {
displayType = 'SPACER';
} else if (desc.includes('REDUCING') || desc.includes('RED')) {
displayType = 'REDUCING FLANGE';
} else if (desc.includes('BLIND')) {
displayType = 'BLIND FLANGE';
} else if (desc.includes('WN RF') || desc.includes('WN-RF')) {
displayType = 'WELD NECK FLANGE';
if (facingType === '-') facingType = 'RAISED FACE';
} else if (desc.includes('WN FF') || desc.includes('WN-FF')) {
displayType = 'WELD NECK FLANGE';
if (facingType === '-') facingType = 'FLAT FACE';
} else if (desc.includes('WN RTJ') || desc.includes('WN-RTJ')) {
displayType = 'WELD NECK FLANGE';
if (facingType === '-') facingType = 'RING TYPE JOINT';
} else if (desc.includes('WN')) {
displayType = 'WELD NECK FLANGE';
} else if (desc.includes('SO RF') || desc.includes('SO-RF')) {
displayType = 'SLIP ON FLANGE';
if (facingType === '-') facingType = 'RAISED FACE';
} else if (desc.includes('SO FF') || desc.includes('SO-FF')) {
displayType = 'SLIP ON FLANGE';
if (facingType === '-') facingType = 'FLAT FACE';
} else if (desc.includes('SO')) {
displayType = 'SLIP ON FLANGE';
} else if (desc.includes('SW')) {
displayType = 'SOCKET WELD FLANGE';
} else {
displayType = 'FLANGE';
}
}
if (facingType === '-') {
const desc = description.toUpperCase();
if (desc.includes('RF')) {
facingType = 'RAISED FACE';
} else if (desc.includes('FF')) {
facingType = 'FLAT FACE';
} else if (desc.includes('RTJ')) {
facingType = 'RING TYPE JOINT';
}
}
// 원본 설명에서 스케줄 추출
let schedule = '-';
const upperDesc = description.toUpperCase();
// SCH 40, SCH 80 등의 패턴 찾기
if (upperDesc.includes('SCH')) {
const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
if (schMatch && schMatch[1]) {
schedule = `SCH ${schMatch[1]}`;
}
}
// 압력 등급 추출
let pressure = '-';
const pressureMatch = description.match(/(\d+)LB/i);
if (pressureMatch) {
pressure = `${pressureMatch[1]}LB`;
}
return {
type: 'FLANGE',
subtype: displayType, // 풀네임 플랜지 타입
facing: facingType, // 새로 추가: 끝단처리 정보
size: material.size_spec || '-',
pressure: flangeDetails.pressure_rating || pressure,
schedule: schedule,
grade: material.full_material_grade || material.material_grade || '-',
quantity: Math.round(material.quantity || 0),
unit: '개',
isFlange: true // 플랜지 구분용 플래그
};
};
// 정렬 처리
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// 필터링된 및 정렬된 자재 목록
const getFilteredAndSortedMaterials = () => {
let filtered = materials.filter(material => {
return Object.entries(columnFilters).every(([key, filterValue]) => {
if (!filterValue) return true;
const info = parseFlangeInfo(material);
const value = info[key]?.toString().toLowerCase() || '';
return value.includes(filterValue.toLowerCase());
});
});
if (sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parseFlangeInfo(a);
const bInfo = parseFlangeInfo(b);
const aValue = aInfo[sortConfig.key] || '';
const bValue = bInfo[sortConfig.key] || '';
if (sortConfig.direction === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
}
return filtered;
};
// 전체 선택/해제
const handleSelectAll = () => {
const filteredMaterials = getFilteredAndSortedMaterials();
if (selectedMaterials.size === filteredMaterials.length) {
setSelectedMaterials(new Set());
} else {
setSelectedMaterials(new Set(filteredMaterials.map(m => m.id)));
}
};
// 개별 선택
const handleMaterialSelect = (materialId) => {
const newSelected = new Set(selectedMaterials);
if (newSelected.has(materialId)) {
newSelected.delete(materialId);
} else {
newSelected.add(materialId);
}
setSelectedMaterials(newSelected);
};
// 엑셀 내보내기
const handleExportToExcel = async () => {
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
if (selectedMaterialsData.length === 0) {
alert('내보낼 자재를 선택해주세요.');
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const excelFileName = `FLANGE_Materials_${timestamp}.xlsx`;
const dataWithRequirements = selectedMaterialsData.map(material => ({
...material,
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
}));
try {
console.log('🔄 엑셀 내보내기 시작 - 새로운 방식');
// 1. 먼저 클라이언트에서 엑셀 파일 생성
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
category: 'FLANGE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
// 2. 구매신청 생성
const allMaterialIds = selectedMaterialsData.map(m => m.id);
const response = await api.post('/purchase-request/create', {
file_id: fileId,
job_no: jobNo,
category: 'FLANGE',
material_ids: allMaterialIds,
materials_data: dataWithRequirements.map(m => ({
material_id: m.id,
description: m.original_description,
category: m.classified_category,
size: m.size_inch || m.size_spec,
schedule: m.schedule,
material_grade: m.material_grade || m.full_material_grade,
quantity: m.quantity,
unit: m.unit,
user_requirement: userRequirements[m.id] || ''
}))
});
if (response.data.success) {
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
// 3. 생성된 엑셀 파일을 서버에 업로드
console.log('📤 서버에 엑셀 파일 업로드 중...');
const formData = new FormData();
formData.append('excel_file', excelBlob, excelFileName);
formData.append('request_id', response.data.request_id);
formData.append('category', 'FLANGE');
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
if (onPurchasedMaterialsUpdate) {
onPurchasedMaterialsUpdate(allMaterialIds);
}
}
// 4. 클라이언트 다운로드
const url = window.URL.createObjectURL(excelBlob);
const link = document.createElement('a');
link.href = url;
link.download = excelFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} catch (error) {
console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패 시에도 클라이언트 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'FLANGE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
}
setSelectedMaterials(new Set());
};
const filteredMaterials = getFilteredAndSortedMaterials();
// 필터 헤더 컴포넌트
const FilterableHeader = ({ sortKey, filterKey, children }) => (
<div className="filterable-header" style={{ position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
onClick={() => handleSort(sortKey)}
style={{ cursor: 'pointer', flex: 1 }}
>
{children}
{sortConfig.key === sortKey && (
<span style={{ marginLeft: '4px' }}>
{sortConfig.direction === 'asc' ? '↑' : '↓'}
</span>
)}
</span>
<button
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px',
fontSize: '12px',
color: '#6b7280'
}}
>
🔍
</button>
</div>
{showFilterDropdown === filterKey && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: '6px',
padding: '8px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
zIndex: 1000,
minWidth: '150px'
}}>
<input
type="text"
placeholder={`Filter ${children}...`}
value={columnFilters[filterKey] || ''}
onChange={(e) => setColumnFilters({
...columnFilters,
[filterKey]: e.target.value
})}
style={{
width: '100%',
padding: '4px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
autoFocus
/>
</div>
)}
</div>
);
return (
<div style={{ padding: '32px' }}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0'
}}>
Flange Materials
</h3>
<p style={{
fontSize: '14px',
color: '#64748b',
margin: 0
}}>
{filteredMaterials.length} items {selectedMaterials.size} selected
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={handleSelectAll}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '8px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={handleExportToExcel}
disabled={selectedMaterials.size === 0}
style={{
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' : '#e5e7eb',
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500'
}}
>
Export to Excel ({selectedMaterials.size})
</button>
</div>
</div>
{/* 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'auto',
maxHeight: '600px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
<div style={{ minWidth: '1400px' }}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 250px 150px 120px 120px 100px 150px 180px 250px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
textAlign: 'center'
}}>
<div>
<input
type="checkbox"
checked={(() => {
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
})()}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
</div>
<FilterableHeader sortKey="subtype" filterKey="subtype">Type</FilterableHeader>
<FilterableHeader sortKey="facing" filterKey="facing">Facing</FilterableHeader>
<FilterableHeader sortKey="size" filterKey="size">Size</FilterableHeader>
<FilterableHeader sortKey="pressure" filterKey="pressure">Pressure</FilterableHeader>
<FilterableHeader sortKey="schedule" filterKey="schedule">Schedule</FilterableHeader>
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader>
<div>Purchase Quantity</div>
<div>Additional Request</div>
</div>
{/* 데이터 행들 */}
<div>
{filteredMaterials.map((material, index) => {
const info = parseFlangeInfo(material);
const isSelected = selectedMaterials.has(material.id);
const isPurchased = purchasedMaterials.has(material.id);
return (
<div
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 250px 150px 120px 120px 100px 150px 180px 250px',
gap: '12px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = 'white';
}
}}
>
<div>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleMaterialSelect(material.id)}
disabled={isPurchased}
style={{
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1
}}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500', textAlign: 'center' }}>
{info.subtype}
{isPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '500'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.facing}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.size}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.pressure}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.schedule}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.grade}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'center' }}>
{info.quantity} {info.unit}
</div>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{!editingRequest[material.id] && savedRequests[material.id] ? (
// 저장된 상태 - 요구사항 표시 + 수정 버튼
<>
<div style={{
flex: 1,
padding: '6px 8px',
border: '1px solid #e5e7eb',
borderRadius: '4px',
fontSize: '12px',
textAlign: 'center',
background: '#f9fafb',
color: '#374151'
}}>
{savedRequests[material.id]}
</div>
<button
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
disabled={isPurchased}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: isPurchased ? '#d1d5db' : '#f59e0b',
color: 'white',
fontSize: '10px',
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
Edit
</button>
</>
) : (
// 편집 상태 - 입력 필드 + 저장 버튼
<>
<input
type="text"
value={userRequirements[material.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter additional request..."
disabled={isPurchased}
style={{
flex: 1,
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px',
opacity: isPurchased ? 0.5 : 1,
cursor: isPurchased ? 'not-allowed' : 'text'
}}
/>
<button
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
disabled={isPurchased || savingRequest[material.id]}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: isPurchased ? '#d1d5db' : '#10b981',
color: 'white',
fontSize: '10px',
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
{savingRequest[material.id] ? '...' : 'Save'}
</button>
</>
)}
</div>
</div>
);
})}
</div>
{filteredMaterials.length === 0 && (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
No Flange Materials Found
</div>
<div style={{ fontSize: '14px' }}>
{Object.keys(columnFilters).some(key => columnFilters[key])
? 'Try adjusting your filters'
: 'No flange materials available in this BOM'}
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default FlangeMaterialsView;

View File

@@ -0,0 +1,693 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader } from '../shared';
const GasketMaterialsView = ({
materials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate,
updateMaterial, // 자재 업데이트 함수
fileId,
jobNo,
user
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [columnFilters, setColumnFilters] = useState({});
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
// 컴포넌트 마운트 시 저장된 데이터 로드
React.useEffect(() => {
const loadSavedData = () => {
const savedRequestsData = {};
materials.forEach(material => {
if (material.user_requirement && material.user_requirement.trim()) {
savedRequestsData[material.id] = material.user_requirement.trim();
}
});
setSavedRequests(savedRequestsData);
};
if (materials && materials.length > 0) {
loadSavedData();
} else {
setSavedRequests({});
}
}, [materials]);
// 추가요구사항 저장 함수
const handleSaveRequest = async (materialId, request) => {
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
try {
await api.patch(`/materials/${materialId}/user-requirement`, {
user_requirement: request.trim()
});
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
if (updateMaterial) {
updateMaterial(materialId, { user_requirement: request.trim() });
}
} catch (error) {
console.error('추가요구사항 저장 실패:', error);
alert('추가요구사항 저장에 실패했습니다.');
} finally {
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
}
};
// 추가요구사항 편집 시작
const handleEditRequest = (materialId, currentRequest) => {
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
};
const parseGasketInfo = (material) => {
const qty = Math.round(material.quantity || 0);
const purchaseQty = Math.ceil(qty * 1.05 / 5) * 5; // 5% 여유율 + 5의 배수
const description = material.original_description || '';
// 가스켓 타입 풀네임 매핑
const gasketTypeMap = {
'SWG': 'SPIRAL WOUND GASKET',
'RTJ': 'RING TYPE JOINT',
'FF': 'FULL FACE GASKET',
'RF': 'RAISED FACE GASKET',
'SHEET': 'SHEET GASKET',
'O-RING': 'O-RING GASKET'
};
// 타입 추출 및 풀네임 변환
let gasketType = '-';
const typeMatch = description.match(/\b(SWG|RTJ|FF|RF|SHEET|O-RING)\b/i);
if (typeMatch) {
const shortType = typeMatch[1].toUpperCase();
gasketType = gasketTypeMap[shortType] || shortType;
}
// 크기 정보 추출 (예: 1 1/2")
let size = material.size_spec || material.size_inch || '-';
if (size === '-') {
const sizeMatch = description.match(/(\d+(?:\s+\d+\/\d+)?(?:\.\d+)?)\s*"/);
if (sizeMatch) {
size = sizeMatch[1] + '"';
}
}
// 압력등급 추출
let pressure = '-';
const pressureMatch = description.match(/(\d+LB)/i);
if (pressureMatch) {
pressure = pressureMatch[1];
}
// 구조 정보 추출 (H/F/I/O)
let structure = '-';
if (description.includes('H/F/I/O')) {
structure = 'H/F/I/O';
}
// 재질 상세 정보 추출 (SS304/GRAPHITE/SS304/SS304)
let material_detail = '-';
const materialMatch = description.match(/H\/F\/I\/O\s+([^,]+)/);
if (materialMatch) {
material_detail = materialMatch[1].trim();
// 두께 정보 제거
material_detail = material_detail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim();
}
// 두께 정보 추출
let thickness = '-';
const thicknessMatch = description.match(/(\d+(?:\.\d+)?)\s*mm/i);
if (thicknessMatch) {
thickness = thicknessMatch[1] + 'mm';
}
return {
type: gasketType, // 풀네임으로 표시 (SPIRAL WOUND GASKET)
size: size,
pressure: pressure,
structure: structure, // H/F/I/O
material: material_detail, // SS304/GRAPHITE/SS304/SS304
thickness: thickness,
userRequirements: material.user_requirements?.join(', ') || '-',
purchaseQuantity: purchaseQty,
isGasket: true
};
};
// 정렬 처리
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// 필터링된 및 정렬된 자재 목록
const getFilteredAndSortedMaterials = () => {
let filtered = materials.filter(material => {
return Object.entries(columnFilters).every(([key, filterValue]) => {
if (!filterValue) return true;
const info = parseGasketInfo(material);
const value = info[key]?.toString().toLowerCase() || '';
return value.includes(filterValue.toLowerCase());
});
});
if (sortConfig && sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parseGasketInfo(a);
const bInfo = parseGasketInfo(b);
if (!aInfo || !bInfo) return 0;
const aValue = aInfo[sortConfig.key];
const bValue = bInfo[sortConfig.key];
// 값이 없는 경우 처리
if (aValue === undefined && bValue === undefined) return 0;
if (aValue === undefined) return 1;
if (bValue === undefined) return -1;
// 숫자인 경우 숫자로 비교
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
}
// 문자열로 비교
const aStr = String(aValue).toLowerCase();
const bStr = String(bValue).toLowerCase();
if (sortConfig.direction === 'asc') {
return aStr.localeCompare(bStr);
} else {
return bStr.localeCompare(aStr);
}
});
}
return filtered;
};
// 전체 선택/해제 (구매신청된 자재 제외)
const handleSelectAll = () => {
const filteredMaterials = getFilteredAndSortedMaterials();
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
if (selectedMaterials.size === selectableMaterials.length) {
setSelectedMaterials(new Set());
} else {
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
}
};
// 개별 선택 (구매신청된 자재는 선택 불가)
const handleMaterialSelect = (materialId) => {
if (purchasedMaterials.has(materialId)) {
return; // 구매신청된 자재는 선택 불가
}
const newSelected = new Set(selectedMaterials);
if (newSelected.has(materialId)) {
newSelected.delete(materialId);
} else {
newSelected.add(materialId);
}
setSelectedMaterials(newSelected);
};
// 엑셀 내보내기
const handleExportToExcel = async () => {
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
if (selectedMaterialsData.length === 0) {
alert('내보낼 자재를 선택해주세요.');
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const excelFileName = `GASKET_Materials_${timestamp}.xlsx`;
const dataWithRequirements = selectedMaterialsData.map(material => ({
...material,
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
}));
try {
console.log('🔄 가스켓 엑셀 내보내기 시작 - 새로운 방식');
// 1. 먼저 클라이언트에서 엑셀 파일 생성
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
category: 'GASKET',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
// 2. 구매신청 생성
const allMaterialIds = selectedMaterialsData.map(m => m.id);
const response = await api.post('/purchase-request/create', {
file_id: fileId,
job_no: jobNo,
category: 'GASKET',
material_ids: allMaterialIds,
materials_data: dataWithRequirements.map(m => ({
material_id: m.id,
description: m.original_description,
category: m.classified_category,
size: m.size_inch || m.size_spec,
schedule: m.schedule,
material_grade: m.material_grade || m.full_material_grade,
quantity: m.quantity,
unit: m.unit,
user_requirement: userRequirements[m.id] || ''
}))
});
if (response.data.success) {
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
// 3. 생성된 엑셀 파일을 서버에 업로드
console.log('📤 서버에 엑셀 파일 업로드 중...');
const formData = new FormData();
formData.append('excel_file', excelBlob, excelFileName);
formData.append('request_id', response.data.request_id);
formData.append('category', 'GASKET');
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
if (onPurchasedMaterialsUpdate) {
onPurchasedMaterialsUpdate(allMaterialIds);
}
}
// 4. 클라이언트 다운로드
const url = window.URL.createObjectURL(excelBlob);
const link = document.createElement('a');
link.href = url;
link.download = excelFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} catch (error) {
console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패 시에도 클라이언트 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'GASKET',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
}
};
const filteredMaterials = getFilteredAndSortedMaterials();
return (
<div style={{ padding: '32px' }}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0'
}}>
Gasket Materials
</h3>
<p style={{
fontSize: '14px',
color: '#64748b',
margin: 0
}}>
{filteredMaterials.length} items {selectedMaterials.size} selected
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={handleSelectAll}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '8px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={handleExportToExcel}
disabled={selectedMaterials.size === 0}
style={{
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)' : '#e5e7eb',
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500'
}}
>
Export to Excel ({selectedMaterials.size})
</button>
</div>
</div>
{/* 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'auto',
maxHeight: '600px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
<div style={{ minWidth: '1400px' }}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 250px 120px 100px 120px 200px 100px 180px 200px 120px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
textAlign: 'center'
}}>
<div>
<input
type="checkbox"
checked={(() => {
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
})()}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
</div>
<FilterableHeader
sortKey="type"
filterKey="type"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Type
</FilterableHeader>
<FilterableHeader
sortKey="size"
filterKey="size"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Size
</FilterableHeader>
<FilterableHeader
sortKey="pressure"
filterKey="pressure"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Pressure
</FilterableHeader>
<FilterableHeader
sortKey="structure"
filterKey="structure"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Structure
</FilterableHeader>
<FilterableHeader
sortKey="material"
filterKey="material"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Material
</FilterableHeader>
<FilterableHeader
sortKey="thickness"
filterKey="thickness"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Thickness
</FilterableHeader>
<div>User Requirements</div>
<div>Additional Request</div>
<FilterableHeader
sortKey="purchaseQuantity"
filterKey="purchaseQuantity"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Purchase Quantity
</FilterableHeader>
</div>
{/* 데이터 행들 */}
{filteredMaterials.map((material, index) => {
const info = parseGasketInfo(material);
const isSelected = selectedMaterials.has(material.id);
const isPurchased = purchasedMaterials.has(material.id);
return (
<div
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 250px 120px 100px 120px 200px 100px 180px 200px 120px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = 'white';
}
}}
>
<div>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleMaterialSelect(material.id)}
disabled={isPurchased}
style={{
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1
}}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500', textAlign: 'center' }}>
{info.type}
{isPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '500'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.size}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.pressure}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.structure}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.material}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.thickness}
</div>
<div style={{
fontSize: '14px',
color: '#1f2937',
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{info.userRequirements}
</div>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{!editingRequest[material.id] && savedRequests[material.id] ? (
// 저장된 상태 - 요구사항 표시 + 수정 버튼
<>
<div style={{
flex: 1,
padding: '6px 8px',
border: '1px solid #e5e7eb',
borderRadius: '4px',
fontSize: '12px',
textAlign: 'center',
background: '#f9fafb',
color: '#374151'
}}>
{savedRequests[material.id]}
</div>
<button
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
disabled={isPurchased}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: isPurchased ? '#d1d5db' : '#f59e0b',
color: 'white',
fontSize: '10px',
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
Edit
</button>
</>
) : (
// 편집 상태 - 입력 필드 + 저장 버튼
<>
<input
type="text"
value={userRequirements[material.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter additional request..."
disabled={isPurchased}
style={{
flex: 1,
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px',
opacity: isPurchased ? 0.5 : 1,
cursor: isPurchased ? 'not-allowed' : 'text'
}}
/>
<button
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
disabled={isPurchased || savingRequest[material.id]}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: isPurchased ? '#d1d5db' : '#10b981',
color: 'white',
fontSize: '10px',
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
{savingRequest[material.id] ? '...' : 'Save'}
</button>
</>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center', fontWeight: '500' }}>
{info.purchaseQuantity.toLocaleString()}
</div>
</div>
);
})}
</div>
</div>
{filteredMaterials.length === 0 && (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
No Gasket Materials Found
</div>
<div style={{ fontSize: '14px' }}>
{Object.keys(columnFilters).some(key => columnFilters[key])
? 'Try adjusting your filters'
: 'No gasket materials available in this BOM'}
</div>
</div>
)}
</div>
);
};
export default GasketMaterialsView;

View File

@@ -0,0 +1,792 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader, MaterialTable } from '../shared';
const PipeMaterialsView = ({
materials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate,
updateMaterial, // 자재 업데이트 함수
fileId,
jobNo,
user,
onNavigate
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [columnFilters, setColumnFilters] = useState({});
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
const [brandInputs, setBrandInputs] = useState({}); // 브랜드 입력 상태
const [savingBrand, setSavingBrand] = useState({}); // 브랜드 저장 중 상태
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
const [savedBrands, setSavedBrands] = useState({}); // 저장된 브랜드 상태
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
const [editingBrand, setEditingBrand] = useState({}); // 브랜드 편집 모드
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
// 컴포넌트 마운트 시 저장된 데이터 로드
React.useEffect(() => {
const loadSavedData = () => {
const savedBrandsData = {};
const savedRequestsData = {};
materials.forEach(material => {
// 백엔드에서 가져온 데이터가 있으면 저장된 상태로 설정
if (material.brand && material.brand.trim()) {
savedBrandsData[material.id] = material.brand.trim();
}
if (material.user_requirement && material.user_requirement.trim()) {
savedRequestsData[material.id] = material.user_requirement.trim();
}
});
setSavedBrands(savedBrandsData);
setSavedRequests(savedRequestsData);
};
if (materials && materials.length > 0) {
loadSavedData();
} else {
setSavedBrands({});
setSavedRequests({});
}
}, [materials]);
// 브랜드 저장 함수
const handleSaveBrand = async (materialId, brand) => {
if (!brand.trim()) return;
setSavingBrand(prev => ({ ...prev, [materialId]: true }));
try {
await api.patch(`/materials/${materialId}/brand`, { brand: brand.trim() });
setSavedBrands(prev => ({ ...prev, [materialId]: brand.trim() }));
setEditingBrand(prev => ({ ...prev, [materialId]: false }));
setBrandInputs(prev => ({ ...prev, [materialId]: '' }));
if (updateMaterial) {
updateMaterial(materialId, { brand: brand.trim() });
}
} catch (error) {
console.error('브랜드 저장 실패:', error);
alert('브랜드 저장에 실패했습니다.');
} finally {
setSavingBrand(prev => ({ ...prev, [materialId]: false }));
}
};
// 브랜드 편집 시작
const handleEditBrand = (materialId, currentBrand) => {
setEditingBrand(prev => ({ ...prev, [materialId]: true }));
setBrandInputs(prev => ({ ...prev, [materialId]: currentBrand || '' }));
};
// 추가요구사항 저장 함수
const handleSaveRequest = async (materialId, request) => {
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
try {
await api.patch(`/materials/${materialId}/user-requirement`, {
user_requirement: request.trim()
});
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
if (updateMaterial) {
updateMaterial(materialId, { user_requirement: request.trim() });
}
} catch (error) {
console.error('추가요구사항 저장 실패:', error);
alert('추가요구사항 저장에 실패했습니다.');
} finally {
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
}
};
// 추가요구사항 편집 시작
const handleEditRequest = (materialId, currentRequest) => {
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
};
// 파이프 구매 수량 계산 (백엔드 그룹화 데이터 활용)
const calculatePipePurchase = (material) => {
const pipeDetails = material.pipe_details || {};
// 백엔드에서 이미 그룹화된 데이터 사용
let pipeCount = 1; // 기본값
let totalBomLengthMm = 0;
if (pipeDetails.pipe_count && pipeDetails.total_length_mm) {
// 백엔드에서 그룹화된 데이터 사용
pipeCount = pipeDetails.pipe_count; // 실제 단관 개수
totalBomLengthMm = pipeDetails.total_length_mm; // 이미 합산된 총 길이
} else {
// 개별 파이프 데이터인 경우
pipeCount = material.quantity || 1;
// 길이 정보 우선순위: length_mm > length > pipe_details.length_mm
let singlePipeLengthMm = 0;
if (material.length_mm) {
singlePipeLengthMm = material.length_mm;
} else if (material.length) {
singlePipeLengthMm = material.length * 1000; // m를 mm로 변환
} else if (pipeDetails.length_mm) {
singlePipeLengthMm = pipeDetails.length_mm;
}
totalBomLengthMm = singlePipeLengthMm * pipeCount;
}
// 여유분 포함 계산: 각 단관당 2mm 여유분 추가
const allowancePerPipe = 2; // mm
const totalAllowanceMm = allowancePerPipe * pipeCount;
const totalLengthWithAllowance = totalBomLengthMm + totalAllowanceMm; // mm
// 6,000mm(6m) 표준 길이로 필요한 본수 계산 (올림)
const standardLengthMm = 6000; // mm
const requiredStandardPipes = Math.ceil(totalLengthWithAllowance / standardLengthMm);
return {
pipeCount, // 단관 개수
totalBomLengthMm, // 총 BOM 길이 (mm)
totalLengthWithAllowance, // 여유분 포함 총 길이 (mm)
totalLengthM: totalLengthWithAllowance / 1000, // 총 길이 (m)
requiredStandardPipes, // 필요한 표준 파이프 본수
standardLengthMm,
allowancePerPipe,
totalAllowanceMm,
// 디버깅용 정보
isGrouped: !!(pipeDetails.pipe_count && pipeDetails.total_length_mm)
};
};
// 파이프 정보 파싱 (개선된 로직)
const parsePipeInfo = (material) => {
const calc = calculatePipePurchase(material);
const pipeDetails = material.pipe_details || {};
// User 요구사항 추출 (분류기에서 제공된 정보)
const userRequirements = material.user_requirements || [];
const userReqText = userRequirements.length > 0 ? userRequirements.join(', ') : '-';
return {
// Type 컬럼 제거 (모두 PIPE로 동일)
type: pipeDetails.manufacturing_method || 'SMLS', // Subtype을 Type으로 변경
size: material.size_spec || '-',
schedule: pipeDetails.schedule || material.schedule || '-',
grade: material.full_material_grade || material.material_grade || '-',
userRequirements: userReqText, // User 요구사항
length: calc.totalLengthWithAllowance, // 여유분 포함 총 길이 (mm)
quantity: calc.pipeCount, // 단관 개수
unit: `${calc.requiredStandardPipes}`, // 6m 표준 파이프 필요 본수
details: calc,
isPipe: true
};
};
// 정렬 처리
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// 필터링된 및 정렬된 자재 목록
const getFilteredAndSortedMaterials = () => {
let filtered = materials.filter(material => {
return Object.entries(columnFilters).every(([key, filterValue]) => {
if (!filterValue) return true;
const info = parsePipeInfo(material);
const value = info[key]?.toString().toLowerCase() || '';
return value.includes(filterValue.toLowerCase());
});
});
if (sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parsePipeInfo(a);
const bInfo = parsePipeInfo(b);
const aValue = aInfo[sortConfig.key] || '';
const bValue = bInfo[sortConfig.key] || '';
if (sortConfig.direction === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
}
return filtered;
};
// 전체 선택/해제 (구매된 자재 제외)
const handleSelectAll = () => {
const filteredMaterials = getFilteredAndSortedMaterials();
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
if (selectedMaterials.size === selectableMaterials.length) {
setSelectedMaterials(new Set());
} else {
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
}
};
// 개별 선택 (구매된 자재는 선택 불가)
const handleMaterialSelect = (materialId) => {
if (purchasedMaterials.has(materialId)) {
return; // 구매된 자재는 선택 불가
}
const newSelected = new Set(selectedMaterials);
if (newSelected.has(materialId)) {
newSelected.delete(materialId);
} else {
newSelected.add(materialId);
}
setSelectedMaterials(newSelected);
};
// 엑셀 내보내기
const handleExportToExcel = async () => {
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
if (selectedMaterialsData.length === 0) {
alert('내보낼 자재를 선택해주세요.');
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const excelFileName = `PIPE_Materials_${timestamp}.xlsx`;
// 사용자 요구사항 포함
const dataWithRequirements = selectedMaterialsData.map(material => ({
...material,
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
}));
try {
console.log('🔄 파이프 엑셀 내보내기 시작 - 새로운 방식');
// 1. 먼저 클라이언트에서 엑셀 파일 생성
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
category: 'PIPE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
// 2. 구매신청 생성
const allMaterialIds = selectedMaterialsData.map(m => m.id);
const response = await api.post('/purchase-request/create', {
file_id: fileId,
job_no: jobNo,
category: 'PIPE',
material_ids: allMaterialIds,
materials_data: dataWithRequirements.map(m => ({
material_id: m.id,
description: m.original_description,
category: m.classified_category,
size: m.size_inch || m.size_spec,
schedule: m.schedule,
material_grade: m.material_grade || m.full_material_grade,
quantity: m.quantity,
unit: m.unit,
user_requirement: userRequirements[m.id] || ''
}))
});
if (response.data.success) {
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
// 3. 생성된 엑셀 파일을 서버에 업로드
console.log('📤 서버에 엑셀 파일 업로드 중...');
const formData = new FormData();
formData.append('excel_file', excelBlob, excelFileName);
formData.append('request_id', response.data.request_id);
formData.append('category', 'PIPE');
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
if (onPurchasedMaterialsUpdate) {
onPurchasedMaterialsUpdate(allMaterialIds);
}
}
// 4. 클라이언트 다운로드
const url = window.URL.createObjectURL(excelBlob);
const link = document.createElement('a');
link.href = url;
link.download = excelFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} catch (error) {
console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패 시에도 클라이언트 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'PIPE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
}
// 선택 해제
setSelectedMaterials(new Set());
};
const filteredMaterials = getFilteredAndSortedMaterials();
// 필터 헤더 컴포넌트
const FilterableHeader = ({ sortKey, filterKey, children }) => (
<div className="filterable-header" style={{ position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
onClick={() => handleSort(sortKey)}
style={{ cursor: 'pointer', flex: 1 }}
>
{children}
{sortConfig.key === sortKey && (
<span style={{ marginLeft: '4px' }}>
{sortConfig.direction === 'asc' ? '↑' : '↓'}
</span>
)}
</span>
<button
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px',
fontSize: '12px',
color: '#6b7280'
}}
>
🔍
</button>
</div>
{showFilterDropdown === filterKey && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: '6px',
padding: '8px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
zIndex: 1000,
minWidth: '150px'
}}>
<input
type="text"
placeholder={`Filter ${children}...`}
value={columnFilters[filterKey] || ''}
onChange={(e) => setColumnFilters({
...columnFilters,
[filterKey]: e.target.value
})}
style={{
width: '100%',
padding: '4px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
autoFocus
/>
</div>
)}
</div>
);
return (
<div style={{ padding: '32px' }}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0'
}}>
Pipe Materials
</h3>
<p style={{
fontSize: '14px',
color: '#64748b',
margin: 0
}}>
{filteredMaterials.length} items {selectedMaterials.size} selected
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={handleSelectAll}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '8px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={handleExportToExcel}
disabled={selectedMaterials.size === 0}
style={{
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)' : '#e5e7eb',
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500'
}}
>
Export to Excel ({selectedMaterials.size})
</button>
</div>
</div>
{/* 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'auto',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
maxHeight: '600px'
}}>
{/* 테이블 내용 - 헤더와 본문이 함께 스크롤 */}
<div style={{
minWidth: '1200px'
}}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 120px 120px 120px 150px 200px 120px 100px 120px 300px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
textAlign: 'center'
}}>
<div>
<input
type="checkbox"
checked={(() => {
const filteredMaterials = getFilteredAndSortedMaterials();
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
})()}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
</div>
<FilterableHeader
sortKey="type"
filterKey="type"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Type
</FilterableHeader>
<FilterableHeader
sortKey="size"
filterKey="size"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Size
</FilterableHeader>
<FilterableHeader
sortKey="schedule"
filterKey="schedule"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Schedule
</FilterableHeader>
<FilterableHeader
sortKey="grade"
filterKey="grade"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Material Grade
</FilterableHeader>
<FilterableHeader
sortKey="userRequirements"
filterKey="userRequirements"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
User Requirements
</FilterableHeader>
<FilterableHeader
sortKey="length"
filterKey="length"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Length (MM)
</FilterableHeader>
<FilterableHeader
sortKey="quantity"
filterKey="quantity"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Quantity (EA)
</FilterableHeader>
<div>Purchase Unit</div>
<div>Additional Request</div>
</div>
{/* 데이터 행들 */}
<div>
{filteredMaterials.map((material, index) => {
const info = parsePipeInfo(material);
const isSelected = selectedMaterials.has(material.id);
const isPurchased = purchasedMaterials.has(material.id);
return (
<div
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 120px 120px 120px 150px 200px 120px 100px 120px 300px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease',
textAlign: 'center'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = 'white';
}
}}
>
<div>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleMaterialSelect(material.id)}
disabled={isPurchased}
style={{
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1
}}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
{info.type}
{isPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '500'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.size}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.schedule}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.grade}
</div>
<div style={{
fontSize: '14px',
color: '#1f2937',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{info.userRequirements}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{Math.round(info.length).toLocaleString()}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
{info.quantity}
</div>
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>
{info.unit}
</div>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{!editingRequest[material.id] && savedRequests[material.id] ? (
// 저장된 상태 - 요구사항 표시 + 수정 버튼
<>
<div style={{
flex: 1,
padding: '6px',
border: '1px solid #e5e7eb',
borderRadius: '4px',
fontSize: '11px',
textAlign: 'center',
background: '#f9fafb',
color: '#374151'
}}>
{savedRequests[material.id]}
</div>
<button
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
disabled={isPurchased}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: isPurchased ? '#d1d5db' : '#f59e0b',
color: 'white',
fontSize: '10px',
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
Edit
</button>
</>
) : (
// 편집 상태 - 입력 필드 + 저장 버튼
<>
<input
type="text"
value={userRequirements[material.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter additional request..."
disabled={isPurchased}
style={{
flex: 1,
padding: '6px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '11px',
opacity: isPurchased ? 0.5 : 1,
cursor: isPurchased ? 'not-allowed' : 'text'
}}
/>
<button
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
disabled={isPurchased || savingRequest[material.id]}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: isPurchased ? '#d1d5db' : '#10b981',
color: 'white',
fontSize: '10px',
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
{savingRequest[material.id] ? '...' : 'Save'}
</button>
</>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
{filteredMaterials.length === 0 && (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
No Pipe Materials Found
</div>
<div style={{ fontSize: '14px' }}>
{Object.keys(columnFilters).some(key => columnFilters[key])
? 'Try adjusting your filters'
: 'No pipe materials available in this BOM'}
</div>
</div>
)}
</div>
);
};
export default PipeMaterialsView;

View File

@@ -0,0 +1,628 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader } from '../shared';
const SpecialMaterialsView = ({
materials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate,
updateMaterial, // 자재 업데이트 함수
jobNo,
fileId,
user,
onNavigate
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [columnFilters, setColumnFilters] = useState({});
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
// 컴포넌트 마운트 시 저장된 데이터 로드
React.useEffect(() => {
const loadSavedData = () => {
const savedRequestsData = {};
materials.forEach(material => {
if (material.user_requirement && material.user_requirement.trim()) {
savedRequestsData[material.id] = material.user_requirement.trim();
}
});
setSavedRequests(savedRequestsData);
};
if (materials && materials.length > 0) {
loadSavedData();
} else {
setSavedRequests({});
}
}, [materials]);
// 추가요구사항 저장 함수
const handleSaveRequest = async (materialId, request) => {
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
try {
await api.patch(`/materials/${materialId}/user-requirement`, {
user_requirement: request.trim()
});
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
if (updateMaterial) {
updateMaterial(materialId, { user_requirement: request.trim() });
}
} catch (error) {
console.error('추가요구사항 저장 실패:', error);
alert('추가요구사항 저장에 실패했습니다.');
} finally {
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
}
};
// 추가요구사항 편집 시작
const handleEditRequest = (materialId, currentRequest) => {
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
};
// SPECIAL 자재 정보 파싱
const parseSpecialInfo = (material) => {
const description = material.original_description || '';
const qty = Math.round(material.quantity || 0);
// Type 추출 (큰 범주: 우선순위 기반 분류)
let type = 'SPECIAL';
const descUpper = description.toUpperCase();
// 우선순위 1: 주요 장비 타입 (OIL PUMP, COMPRESSOR 등이 FLG보다 우선)
if (descUpper.includes('OIL PUMP') || (descUpper.includes('PUMP') && !descUpper.includes('FITTING'))) {
type = 'OIL PUMP';
} else if (descUpper.includes('COMPRESSOR')) {
type = 'COMPRESSOR';
} else if (descUpper.includes('VALVE') && !descUpper.includes('FLG')) {
type = 'VALVE';
}
// 우선순위 2: 구조물/부품 타입
else if (descUpper.includes('FLG') || descUpper.includes('FLANGE')) {
// FLG가 있어도 주요 장비가 함께 있으면 장비 타입 우선
if (descUpper.includes('OIL PUMP') || descUpper.includes('COMPRESSOR')) {
if (descUpper.includes('OIL PUMP')) {
type = 'OIL PUMP';
} else if (descUpper.includes('COMPRESSOR')) {
type = 'COMPRESSOR';
}
} else {
type = 'FLANGE';
}
} else if (descUpper.includes('FITTING')) {
type = 'FITTING';
} else if (descUpper.includes('PIPE')) {
type = 'PIPE';
}
// 도면 정보 (drawing_name 또는 line_no에서 추출)
const drawing = material.drawing_name || material.line_no || '-';
// 설명을 항목별로 분리 (콤마, 세미콜론, 파이프 등으로 구분)
const parts = description
.split(/[,;|\/]/)
.map(part => part.trim())
.filter(part => part.length > 0);
// 최대 4개 항목으로 제한
const detail1 = parts[0] || '-';
const detail2 = parts[1] || '-';
const detail3 = parts[2] || '-';
const detail4 = parts[3] || '-';
return {
type,
drawing,
detail1,
detail2,
detail3,
detail4,
quantity: qty,
originalQuantity: qty,
purchaseQuantity: qty,
isSpecial: true
};
};
// 정렬 처리
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// 필터링 및 정렬된 자료
const getFilteredAndSortedMaterials = () => {
let filtered = materials.filter(material => {
const info = parseSpecialInfo(material);
// 컬럼 필터 적용
for (const [key, filterValue] of Object.entries(columnFilters)) {
if (filterValue && filterValue.trim()) {
const materialValue = String(info[key] || '').toLowerCase();
const filter = filterValue.toLowerCase();
if (!materialValue.includes(filter)) {
return false;
}
}
}
return true;
});
// 정렬 적용
if (sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parseSpecialInfo(a);
const bInfo = parseSpecialInfo(b);
const aValue = aInfo[sortConfig.key];
const bValue = bInfo[sortConfig.key];
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
}
return filtered;
};
const filteredMaterials = getFilteredAndSortedMaterials();
// 전체 선택/해제
const handleSelectAll = () => {
const selectableMaterials = filteredMaterials.filter(material =>
!purchasedMaterials.has(material.id)
);
const allSelected = selectableMaterials.every(material =>
selectedMaterials.has(material.id)
);
if (allSelected) {
// 전체 해제
const newSelected = new Set(selectedMaterials);
selectableMaterials.forEach(material => {
newSelected.delete(material.id);
});
setSelectedMaterials(newSelected);
} else {
// 전체 선택
const newSelected = new Set(selectedMaterials);
selectableMaterials.forEach(material => {
newSelected.add(material.id);
});
setSelectedMaterials(newSelected);
}
};
// 개별 선택/해제
const handleMaterialSelect = (materialId) => {
const newSelected = new Set(selectedMaterials);
if (newSelected.has(materialId)) {
newSelected.delete(materialId);
} else {
newSelected.add(materialId);
}
setSelectedMaterials(newSelected);
};
// 엑셀 내보내기
const handleExportToExcel = async () => {
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
if (selectedMaterialsData.length === 0) {
alert('내보낼 자재를 선택해주세요.');
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const excelFileName = `SPECIAL_Materials_${timestamp}.xlsx`;
const dataWithRequirements = selectedMaterialsData.map(material => ({
...material,
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
}));
try {
console.log('🔄 스페셜 엑셀 내보내기 시작 - 새로운 방식');
// 1. 먼저 클라이언트에서 엑셀 파일 생성
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
category: 'SPECIAL',
filename: excelFileName,
user: user?.username || 'unknown'
});
// 2. 구매신청 생성
console.log('📝 구매신청 생성 중...');
const purchaseResponse = await api.post('/purchase-request/create', {
materials_data: dataWithRequirements,
file_id: fileId,
job_no: jobNo
});
console.log('✅ 구매신청 생성 완료:', purchaseResponse.data);
// 3. 엑셀 파일을 서버에 업로드
console.log('📤 엑셀 파일 서버 업로드 중...');
const formData = new FormData();
formData.append('excel_file', excelBlob, excelFileName);
formData.append('request_id', purchaseResponse.data.request_id);
formData.append('filename', excelFileName);
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
// 4. 구매신청된 자재들을 비활성화
const purchasedIds = selectedMaterialsData.map(m => m.id);
onPurchasedMaterialsUpdate(purchasedIds);
// 5. 선택 해제
setSelectedMaterials(new Set());
// 6. 클라이언트에서도 다운로드
const url = window.URL.createObjectURL(excelBlob);
const link = document.createElement('a');
link.href = url;
link.download = excelFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
console.log('✅ 스페셜 엑셀 내보내기 완료');
alert(`스페셜 자재 엑셀 파일이 생성되었습니다.\n파일명: ${excelFileName}`);
// 구매신청 관리 페이지로 이동
if (onNavigate) {
onNavigate('purchase-requests');
}
} catch (error) {
console.error('❌ 스페셜 엑셀 내보내기 실패:', error);
alert('엑셀 내보내기 중 오류가 발생했습니다: ' + (error.response?.data?.detail || error.message));
}
};
const allSelected = filteredMaterials.length > 0 &&
filteredMaterials.filter(material => !purchasedMaterials.has(material.id))
.every(material => selectedMaterials.has(material.id));
return (
<div style={{ padding: '24px' }}>
{/* 헤더 */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px',
padding: '20px',
background: 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)',
borderRadius: '12px',
color: 'white'
}}>
<div>
<h2 style={{ margin: 0, fontSize: '24px', fontWeight: '700' }}>
Special Items
</h2>
<p style={{ margin: '8px 0 0 0', opacity: 0.9 }}>
특수 제작 품목 관리 ({filteredMaterials.length})
</p>
</div>
<button
onClick={handleExportToExcel}
disabled={selectedMaterials.size === 0}
style={{
padding: '12px 24px',
background: selectedMaterials.size > 0 ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.1)',
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: '8px',
color: 'white',
fontWeight: '600',
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
transition: 'all 0.2s ease',
backdropFilter: 'blur(10px)'
}}
>
구매신청 ({selectedMaterials.size})
</button>
</div>
{/* 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'auto',
maxHeight: '600px',
border: '1px solid #e5e7eb',
minWidth: '1400px'
}}>
<div style={{ minWidth: '1400px' }}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 150px 200px 200px 200px 200px 200px 250px 150px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '2px solid #e2e8f0',
fontWeight: '600',
fontSize: '14px',
color: '#1f2937',
textAlign: 'center'
}}>
<div>
<input
type="checkbox"
checked={allSelected}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
</div>
<FilterableHeader
sortKey="type"
filterKey="type"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Type
</FilterableHeader>
<FilterableHeader
sortKey="drawing"
filterKey="drawing"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Drawing
</FilterableHeader>
<FilterableHeader
sortKey="detail1"
filterKey="detail1"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Detail 1
</FilterableHeader>
<FilterableHeader
sortKey="detail2"
filterKey="detail2"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Detail 2
</FilterableHeader>
<FilterableHeader
sortKey="detail3"
filterKey="detail3"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Detail 3
</FilterableHeader>
<FilterableHeader
sortKey="detail4"
filterKey="detail4"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Detail 4
</FilterableHeader>
<div>Additional Request</div>
<div>Purchase Quantity</div>
</div>
{/* 데이터 행들 */}
<div>
{filteredMaterials.map((material, index) => {
const info = parseSpecialInfo(material);
const isSelected = selectedMaterials.has(material.id);
const isPurchased = purchasedMaterials.has(material.id);
return (
<div
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 150px 200px 200px 200px 200px 200px 250px 150px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease',
textAlign: 'center'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = 'white';
}
}}
>
<div>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleMaterialSelect(material.id)}
disabled={isPurchased}
style={{
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1
}}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
{info.type}
{isPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '600'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.drawing}</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail1}</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail2}</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail3}</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail4}</div>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{!editingRequest[material.id] && savedRequests[material.id] ? (
// 저장된 상태 - 요구사항 표시 + 수정 버튼
<>
<div style={{
flex: 1,
padding: '6px 8px',
border: '1px solid #e5e7eb',
borderRadius: '4px',
fontSize: '12px',
textAlign: 'center',
background: '#f9fafb',
color: '#374151'
}}>
{savedRequests[material.id]}
</div>
<button
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
disabled={isPurchased}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: isPurchased ? '#d1d5db' : '#f59e0b',
color: 'white',
fontSize: '10px',
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
Edit
</button>
</>
) : (
// 편집 상태 - 입력 필드 + 저장 버튼
<>
<input
type="text"
value={userRequirements[material.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter additional request..."
disabled={isPurchased}
style={{
flex: 1,
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px',
opacity: isPurchased ? 0.5 : 1,
cursor: isPurchased ? 'not-allowed' : 'text'
}}
/>
<button
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
disabled={isPurchased || savingRequest[material.id]}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: isPurchased ? '#d1d5db' : '#10b981',
color: 'white',
fontSize: '10px',
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
{savingRequest[material.id] ? '...' : 'Save'}
</button>
</>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.purchaseQuantity}
</div>
</div>
);
})}
</div>
</div>
</div>
{filteredMaterials.length === 0 && (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#6b7280',
background: 'white',
borderRadius: '12px',
border: '1px solid #e5e7eb',
marginTop: '20px'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📋</div>
<h3 style={{ margin: '0 0 8px 0', color: '#374151' }}>스페셜 자재가 없습니다</h3>
<p style={{ margin: 0 }}>특수 제작이 필요한 자재가 발견되면 여기에 표시됩니다.</p>
</div>
)}
</div>
);
};
export default SpecialMaterialsView;

View File

@@ -0,0 +1,687 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader } from '../shared';
const SupportMaterialsView = ({
materials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate,
updateMaterial, // 자재 업데이트 함수
jobNo,
fileId,
user
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [columnFilters, setColumnFilters] = useState({});
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
// 컴포넌트 마운트 시 저장된 데이터 로드
React.useEffect(() => {
const loadSavedData = () => {
const savedRequestsData = {};
materials.forEach(material => {
if (material.user_requirement && material.user_requirement.trim()) {
savedRequestsData[material.id] = material.user_requirement.trim();
}
});
setSavedRequests(savedRequestsData);
};
if (materials && materials.length > 0) {
loadSavedData();
} else {
setSavedRequests({});
}
}, [materials]);
// 추가요구사항 저장 함수
const handleSaveRequest = async (materialId, request) => {
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
try {
await api.patch(`/materials/${materialId}/user-requirement`, {
user_requirement: request.trim()
});
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
if (updateMaterial) {
updateMaterial(materialId, { user_requirement: request.trim() });
}
} catch (error) {
console.error('추가요구사항 저장 실패:', error);
alert('추가요구사항 저장에 실패했습니다.');
} finally {
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
}
};
// 추가요구사항 편집 시작
const handleEditRequest = (materialId, currentRequest) => {
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
};
const parseSupportInfo = (material) => {
const desc = material.original_description || '';
const descUpper = desc.toUpperCase();
// 서포트 타입 분류 (백엔드 분류기와 동일한 로직)
let supportType = 'U-BOLT'; // 기본값
if (descUpper.includes('URETHANE') || descUpper.includes('BLOCK SHOE') || descUpper.includes('우레탄')) {
supportType = 'URETHANE BLOCK SHOE';
} else if (descUpper.includes('CLAMP') || descUpper.includes('클램프')) {
// 클램프 타입 상세 분류 (CL-1, CL-2, CL-3 등)
const clampMatch = desc.match(/CL[-\s]*(\d+)/i);
if (clampMatch) {
supportType = `CLAMP CL-${clampMatch[1]}`;
} else {
supportType = 'CLAMP CL-1'; // 기본값
}
} else if (descUpper.includes('HANGER') || descUpper.includes('행거')) {
supportType = 'HANGER';
} else if (descUpper.includes('SPRING') || descUpper.includes('스프링')) {
supportType = 'SPRING HANGER';
} else if (descUpper.includes('GUIDE') || descUpper.includes('가이드')) {
supportType = 'GUIDE';
} else if (descUpper.includes('ANCHOR') || descUpper.includes('앵커')) {
supportType = 'ANCHOR';
}
// User Requirements 추출 (분류기에서 제공된 것 우선)
const userRequirements = material.user_requirements || [];
// 구매 수량 계산 (서포트는 취합된 숫자 그대로)
const qty = Math.round(material.quantity || 0);
const purchaseQty = qty;
// Material Grade 처리 - 우레탄 블럭슈의 경우 두께 정보 포함
let materialGrade = material.full_material_grade || material.material_grade || '-';
if (supportType === 'URETHANE BLOCK SHOE') {
// 두께 정보 추출 (40t, 27t 등 - 여기서 t는 thickness를 의미)
const thicknessMatch = desc.match(/(\d+)\s*[tT]/);
if (thicknessMatch) {
const thickness = `${thicknessMatch[1]}t`;
if (materialGrade === '-' || !materialGrade) {
materialGrade = thickness;
} else if (!materialGrade.includes(thickness)) {
materialGrade = `${materialGrade} ${thickness}`;
}
}
}
return {
type: supportType,
size: material.main_nom || material.size_inch || material.size_spec || '-',
grade: materialGrade,
userRequirements: userRequirements.join(', ') || '-',
additionalReq: '-',
purchaseQuantity: `${purchaseQty} EA`,
originalQuantity: qty,
isSupport: true
};
};
// 동일한 서포트 항목 합산
const consolidateSupportMaterials = (materials) => {
const consolidated = {};
materials.forEach(material => {
const info = parseSupportInfo(material);
const key = `${info.type}|${info.size}|${info.grade}`;
if (!consolidated[key]) {
consolidated[key] = {
...material,
// Material Grade 정보를 parsedInfo에서 가져와서 설정
material_grade: info.grade,
full_material_grade: info.grade,
consolidatedQuantity: info.originalQuantity,
consolidatedIds: [material.id],
parsedInfo: info
};
} else {
consolidated[key].consolidatedQuantity += info.originalQuantity;
consolidated[key].consolidatedIds.push(material.id);
}
});
// 합산된 수량으로 구매 수량 재계산 (서포트는 취합된 숫자 그대로)
return Object.values(consolidated).map(item => {
const purchaseQty = item.consolidatedQuantity;
return {
...item,
parsedInfo: {
...item.parsedInfo,
originalQuantity: item.consolidatedQuantity,
purchaseQuantity: `${purchaseQty} EA`
}
};
});
};
// 정렬 처리
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// 필터링된 및 정렬된 자재 목록
const getFilteredAndSortedMaterials = () => {
// 먼저 합산 처리
let consolidated = consolidateSupportMaterials(materials);
// 필터링
let filtered = consolidated.filter(material => {
return Object.entries(columnFilters).every(([key, filterValue]) => {
if (!filterValue) return true;
const info = material.parsedInfo;
const value = info[key]?.toString().toLowerCase() || '';
return value.includes(filterValue.toLowerCase());
});
});
// 정렬
if (sortConfig && sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = a.parsedInfo;
const bInfo = b.parsedInfo;
if (!aInfo || !bInfo) return 0;
const aValue = aInfo[sortConfig.key];
const bValue = bInfo[sortConfig.key];
// 값이 없는 경우 처리
if (aValue === undefined && bValue === undefined) return 0;
if (aValue === undefined) return 1;
if (bValue === undefined) return -1;
// 숫자인 경우 숫자로 비교
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
}
// 문자열로 비교
const aStr = String(aValue).toLowerCase();
const bStr = String(bValue).toLowerCase();
if (sortConfig.direction === 'asc') {
return aStr.localeCompare(bStr);
} else {
return bStr.localeCompare(aStr);
}
});
}
return filtered;
};
// 전체 선택/해제 (구매신청된 자재 제외)
const handleSelectAll = () => {
const filteredMaterials = getFilteredAndSortedMaterials();
const selectableMaterials = filteredMaterials.filter(material =>
!material.consolidatedIds.some(id => purchasedMaterials.has(id))
);
if (selectedMaterials.size === selectableMaterials.flatMap(m => m.consolidatedIds).length) {
setSelectedMaterials(new Set());
} else {
const allIds = selectableMaterials.flatMap(m => m.consolidatedIds);
setSelectedMaterials(new Set(allIds));
}
};
// 개별 선택 (구매신청된 자재는 선택 불가)
const handleMaterialSelect = (consolidatedMaterial) => {
const hasAnyPurchased = consolidatedMaterial.consolidatedIds.some(id => purchasedMaterials.has(id));
if (hasAnyPurchased) {
return; // 구매신청된 자재가 포함된 경우 선택 불가
}
const newSelected = new Set(selectedMaterials);
const allSelected = consolidatedMaterial.consolidatedIds.every(id => newSelected.has(id));
if (allSelected) {
// 모두 선택된 경우 모두 해제
consolidatedMaterial.consolidatedIds.forEach(id => newSelected.delete(id));
} else {
// 일부 또는 전체 미선택인 경우 모두 선택
consolidatedMaterial.consolidatedIds.forEach(id => newSelected.add(id));
}
setSelectedMaterials(newSelected);
};
// 엑셀 내보내기
const handleExportToExcel = async () => {
// 선택된 합산 자료 가져오기
const filteredMaterials = getFilteredAndSortedMaterials();
const selectedConsolidatedMaterials = filteredMaterials.filter(consolidatedMaterial =>
consolidatedMaterial.consolidatedIds.some(id => selectedMaterials.has(id))
);
if (selectedConsolidatedMaterials.length === 0) {
alert('내보낼 자재를 선택해주세요.');
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const excelFileName = `SUPPORT_Materials_${timestamp}.xlsx`;
// 합산된 자료를 엑셀 형태로 변환
const dataWithRequirements = selectedConsolidatedMaterials.map(consolidatedMaterial => ({
...consolidatedMaterial,
// 합산된 수량으로 덮어쓰기
quantity: consolidatedMaterial.consolidatedQuantity,
// 사용자 요구사항은 대표 ID 기준 (저장된 데이터 우선)
user_requirement: savedRequests[consolidatedMaterial.id] || userRequirements[consolidatedMaterial.id] || ''
}));
try {
console.log('🔄 서포트 엑셀 내보내기 시작 - 새로운 방식');
// 1. 먼저 클라이언트에서 엑셀 파일 생성
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
category: 'SUPPORT',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
// 2. 구매신청 생성
const allMaterialIds = selectedConsolidatedMaterials.flatMap(cm => cm.consolidatedIds);
const response = await api.post('/purchase-request/create', {
file_id: fileId,
job_no: jobNo,
category: 'SUPPORT',
material_ids: allMaterialIds,
materials_data: dataWithRequirements.map(m => ({
material_id: m.id,
description: m.original_description,
category: m.classified_category,
size: m.size_inch || m.size_spec,
schedule: m.schedule,
material_grade: m.material_grade || m.full_material_grade,
quantity: m.quantity, // 이미 합산된 수량
unit: m.unit,
user_requirement: userRequirements[m.id] || ''
}))
});
if (response.data.success) {
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
// 3. 엑셀 파일을 서버에 업로드
const formData = new FormData();
formData.append('excel_file', excelBlob, excelFileName);
formData.append('request_id', response.data.request_id);
formData.append('category', 'SUPPORT');
console.log('📤 엑셀 파일 서버 업로드 중...');
await api.post('/purchase-request/upload-excel', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
console.log('✅ 엑셀 파일 서버 업로드 완료');
// 4. 구매된 자재 목록 업데이트 (비활성화)
onPurchasedMaterialsUpdate(allMaterialIds);
console.log('✅ 구매된 자재 목록 업데이트 완료');
// 5. 클라이언트에 파일 다운로드
const url = window.URL.createObjectURL(excelBlob);
const link = document.createElement('a');
link.href = url;
link.download = excelFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} else {
throw new Error(response.data?.message || '구매신청 생성 실패');
}
} catch (error) {
console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패 시에도 클라이언트 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'SUPPORT',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
}
// 선택 해제
setSelectedMaterials(new Set());
};
const filteredMaterials = getFilteredAndSortedMaterials();
return (
<div style={{ padding: '32px' }}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0'
}}>
Support Materials
</h3>
<p style={{
fontSize: '14px',
color: '#64748b',
margin: 0
}}>
{filteredMaterials.length} items {selectedMaterials.size} selected
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={handleSelectAll}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '8px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={handleExportToExcel}
disabled={selectedMaterials.size === 0}
style={{
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)' : '#e5e7eb',
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500'
}}
>
Export to Excel ({selectedMaterials.size})
</button>
</div>
</div>
{/* 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'auto',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
maxHeight: '600px'
}}>
<div style={{ minWidth: '1200px' }}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 150px 180px 200px 200px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
textAlign: 'center'
}}>
<div>
<input
type="checkbox"
checked={(() => {
const selectableMaterials = filteredMaterials.filter(material =>
!material.consolidatedIds.some(id => purchasedMaterials.has(id))
);
return selectedMaterials.size === selectableMaterials.flatMap(m => m.consolidatedIds).length && selectableMaterials.length > 0;
})()}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
</div>
<FilterableHeader
sortKey="type"
filterKey="type"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Type
</FilterableHeader>
<FilterableHeader
sortKey="size"
filterKey="size"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Size
</FilterableHeader>
<FilterableHeader
sortKey="grade"
filterKey="grade"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Material Grade
</FilterableHeader>
<div>User Requirements</div>
<div>Additional Request</div>
<FilterableHeader
sortKey="purchaseQuantity"
filterKey="purchaseQuantity"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Purchase Quantity
</FilterableHeader>
</div>
{/* 데이터 행들 */}
{filteredMaterials.map((consolidatedMaterial, index) => {
const info = consolidatedMaterial.parsedInfo;
const hasAnyPurchased = consolidatedMaterial.consolidatedIds.some(id => purchasedMaterials.has(id));
const allSelected = consolidatedMaterial.consolidatedIds.every(id => selectedMaterials.has(id));
return (
<div
key={`consolidated-${index}`}
style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 150px 180px 200px 200px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: allSelected ? '#eff6ff' : (hasAnyPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease',
textAlign: 'center'
}}
onMouseEnter={(e) => {
if (!allSelected && !hasAnyPurchased) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!allSelected && !hasAnyPurchased) {
e.target.style.background = 'white';
}
}}
>
<div>
<input
type="checkbox"
checked={allSelected}
onChange={() => handleMaterialSelect(consolidatedMaterial)}
disabled={hasAnyPurchased}
style={{
cursor: hasAnyPurchased ? 'not-allowed' : 'pointer',
opacity: hasAnyPurchased ? 0.5 : 1
}}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
{info.type}
{hasAnyPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '500'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.size}</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.grade}</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.userRequirements}</div>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{!editingRequest[consolidatedMaterial.id] && savedRequests[consolidatedMaterial.id] ? (
// 저장된 상태 - 요구사항 표시 + 수정 버튼
<>
<div style={{
flex: 1,
padding: '8px',
border: '1px solid #e5e7eb',
borderRadius: '4px',
fontSize: '12px',
textAlign: 'center',
background: '#f9fafb',
color: '#374151'
}}>
{savedRequests[consolidatedMaterial.id]}
</div>
<button
onClick={() => handleEditRequest(consolidatedMaterial.id, savedRequests[consolidatedMaterial.id])}
disabled={hasAnyPurchased}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: hasAnyPurchased ? '#d1d5db' : '#f59e0b',
color: 'white',
fontSize: '10px',
cursor: hasAnyPurchased ? 'not-allowed' : 'pointer',
opacity: hasAnyPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
Edit
</button>
</>
) : (
// 편집 상태 - 입력 필드 + 저장 버튼
<>
<input
type="text"
value={userRequirements[consolidatedMaterial.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[consolidatedMaterial.id]: e.target.value
})}
placeholder="Enter additional request..."
disabled={hasAnyPurchased}
style={{
flex: 1,
padding: '8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px',
opacity: hasAnyPurchased ? 0.5 : 1,
cursor: hasAnyPurchased ? 'not-allowed' : 'text'
}}
/>
<button
onClick={() => handleSaveRequest(consolidatedMaterial.id, userRequirements[consolidatedMaterial.id] || '')}
disabled={hasAnyPurchased || savingRequest[consolidatedMaterial.id]}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: hasAnyPurchased ? '#d1d5db' : '#10b981',
color: 'white',
fontSize: '10px',
cursor: hasAnyPurchased || savingRequest[consolidatedMaterial.id] ? 'not-allowed' : 'pointer',
opacity: hasAnyPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
{savingRequest[consolidatedMaterial.id] ? '...' : 'Save'}
</button>
</>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.purchaseQuantity}</div>
</div>
);
})}
</div>
</div>
{filteredMaterials.length === 0 && (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
No Support Materials Found
</div>
<div style={{ fontSize: '14px' }}>
{Object.keys(columnFilters).some(key => columnFilters[key])
? 'Try adjusting your filters'
: 'No support materials available in this BOM'}
</div>
</div>
)}
</div>
);
};
export default SupportMaterialsView;

View File

@@ -0,0 +1,561 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader } from '../shared';
const UnclassifiedMaterialsView = ({
materials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate,
updateMaterial, // 자재 업데이트 함수
jobNo,
fileId,
user,
onNavigate
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [columnFilters, setColumnFilters] = useState({});
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
// 컴포넌트 마운트 시 저장된 데이터 로드
React.useEffect(() => {
const loadSavedData = () => {
const savedRequestsData = {};
materials.forEach(material => {
if (material.user_requirement && material.user_requirement.trim()) {
savedRequestsData[material.id] = material.user_requirement.trim();
}
});
setSavedRequests(savedRequestsData);
};
if (materials && materials.length > 0) {
loadSavedData();
} else {
setSavedRequests({});
}
}, [materials]);
// 추가요구사항 저장 함수
const handleSaveRequest = async (materialId, request) => {
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
try {
await api.patch(`/materials/${materialId}/user-requirement`, {
user_requirement: request.trim()
});
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
if (updateMaterial) {
updateMaterial(materialId, { user_requirement: request.trim() });
}
} catch (error) {
console.error('추가요구사항 저장 실패:', error);
alert('추가요구사항 저장에 실패했습니다.');
} finally {
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
}
};
// 추가요구사항 편집 시작
const handleEditRequest = (materialId, currentRequest) => {
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
};
// 미분류 자재 정보 파싱 (원본 그대로 표시)
const parseUnclassifiedInfo = (material) => {
const description = material.original_description || material.description || '';
const qty = Math.round(material.quantity || 0);
return {
description: description || '-',
size: material.main_nom || material.size_spec || '-',
drawing: material.drawing_name || material.line_no || '-',
lineNo: material.line_no || '-',
quantity: qty,
originalQuantity: qty,
purchaseQuantity: qty,
isUnclassified: true
};
};
// 정렬 처리
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// 필터링 및 정렬된 자료
const getFilteredAndSortedMaterials = () => {
let filtered = materials.filter(material => {
const info = parseUnclassifiedInfo(material);
// 컬럼 필터 적용
for (const [key, filterValue] of Object.entries(columnFilters)) {
if (filterValue && filterValue.trim()) {
const materialValue = String(info[key] || '').toLowerCase();
const filter = filterValue.toLowerCase();
if (!materialValue.includes(filter)) {
return false;
}
}
}
return true;
});
// 정렬 적용
if (sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parseUnclassifiedInfo(a);
const bInfo = parseUnclassifiedInfo(b);
const aValue = aInfo[sortConfig.key];
const bValue = bInfo[sortConfig.key];
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
}
return filtered;
};
const filteredMaterials = getFilteredAndSortedMaterials();
// 전체 선택/해제
const handleSelectAll = () => {
const selectableMaterials = filteredMaterials.filter(material =>
!purchasedMaterials.has(material.id)
);
const allSelected = selectableMaterials.every(material =>
selectedMaterials.has(material.id)
);
if (allSelected) {
// 전체 해제
const newSelected = new Set(selectedMaterials);
selectableMaterials.forEach(material => {
newSelected.delete(material.id);
});
setSelectedMaterials(newSelected);
} else {
// 전체 선택
const newSelected = new Set(selectedMaterials);
selectableMaterials.forEach(material => {
newSelected.add(material.id);
});
setSelectedMaterials(newSelected);
}
};
// 개별 선택/해제
const handleMaterialSelect = (materialId) => {
const newSelected = new Set(selectedMaterials);
if (newSelected.has(materialId)) {
newSelected.delete(materialId);
} else {
newSelected.add(materialId);
}
setSelectedMaterials(newSelected);
};
// 엑셀 내보내기
const handleExportToExcel = async () => {
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
if (selectedMaterialsData.length === 0) {
alert('내보낼 자재를 선택해주세요.');
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const excelFileName = `UNCLASSIFIED_Materials_${timestamp}.xlsx`;
const dataWithRequirements = selectedMaterialsData.map(material => ({
...material,
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
}));
try {
console.log('🔄 미분류 엑셀 내보내기 시작 - 새로운 방식');
// 1. 먼저 클라이언트에서 엑셀 파일 생성
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
category: 'UNCLASSIFIED',
filename: excelFileName,
user: user?.username || 'unknown'
});
// 2. 구매신청 생성
console.log('📝 구매신청 생성 중...');
const purchaseResponse = await api.post('/purchase-request/create', {
materials_data: dataWithRequirements,
file_id: fileId,
job_no: jobNo
});
console.log('✅ 구매신청 생성 완료:', purchaseResponse.data);
// 3. 엑셀 파일을 서버에 업로드
console.log('📤 엑셀 파일 서버 업로드 중...');
const formData = new FormData();
formData.append('excel_file', excelBlob, excelFileName);
formData.append('request_id', purchaseResponse.data.request_id);
formData.append('filename', excelFileName);
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
// 4. 구매신청된 자재들을 비활성화
const purchasedIds = selectedMaterialsData.map(m => m.id);
onPurchasedMaterialsUpdate(purchasedIds);
// 5. 선택 해제
setSelectedMaterials(new Set());
// 6. 클라이언트에서도 다운로드
const url = window.URL.createObjectURL(excelBlob);
const link = document.createElement('a');
link.href = url;
link.download = excelFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
console.log('✅ 미분류 엑셀 내보내기 완료');
alert(`미분류 자재 엑셀 파일이 생성되었습니다.\n파일명: ${excelFileName}`);
// 구매신청 관리 페이지로 이동
if (onNavigate) {
onNavigate('purchase-requests');
}
} catch (error) {
console.error('❌ 미분류 엑셀 내보내기 실패:', error);
alert('엑셀 내보내기 중 오류가 발생했습니다: ' + (error.response?.data?.detail || error.message));
}
};
const allSelected = filteredMaterials.length > 0 &&
filteredMaterials.filter(material => !purchasedMaterials.has(material.id))
.every(material => selectedMaterials.has(material.id));
return (
<div style={{ padding: '24px' }}>
{/* 헤더 */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px',
padding: '20px',
background: 'linear-gradient(135deg, #64748b 0%, #475569 100%)',
borderRadius: '12px',
color: 'white'
}}>
<div>
<h2 style={{ margin: 0, fontSize: '24px', fontWeight: '700' }}>
Unclassified Materials
</h2>
<p style={{ margin: '8px 0 0 0', opacity: 0.9 }}>
분류되지 않은 자재 관리 ({filteredMaterials.length})
</p>
</div>
<button
onClick={handleExportToExcel}
disabled={selectedMaterials.size === 0}
style={{
padding: '12px 24px',
background: selectedMaterials.size > 0 ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.1)',
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: '8px',
color: 'white',
fontWeight: '600',
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
transition: 'all 0.2s ease',
backdropFilter: 'blur(10px)'
}}
>
구매신청 ({selectedMaterials.size})
</button>
</div>
{/* 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'auto',
maxHeight: '600px',
border: '1px solid #e5e7eb',
minWidth: '1200px'
}}>
<div style={{ minWidth: '1200px' }}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 400px 150px 200px 150px 250px 150px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '2px solid #e2e8f0',
fontWeight: '600',
fontSize: '14px',
color: '#1f2937',
textAlign: 'center'
}}>
<div>
<input
type="checkbox"
checked={allSelected}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
</div>
<FilterableHeader
sortKey="description"
filterKey="description"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Description
</FilterableHeader>
<FilterableHeader
sortKey="size"
filterKey="size"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Size
</FilterableHeader>
<FilterableHeader
sortKey="drawing"
filterKey="drawing"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Drawing
</FilterableHeader>
<FilterableHeader
sortKey="lineNo"
filterKey="lineNo"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Line No
</FilterableHeader>
<div>Additional Request</div>
<div>Purchase Quantity</div>
</div>
{/* 데이터 행들 */}
<div>
{filteredMaterials.map((material, index) => {
const info = parseUnclassifiedInfo(material);
const isSelected = selectedMaterials.has(material.id);
const isPurchased = purchasedMaterials.has(material.id);
return (
<div
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 400px 150px 200px 150px 250px 150px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease',
textAlign: 'center'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = 'white';
}
}}
>
<div>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleMaterialSelect(material.id)}
disabled={isPurchased}
style={{
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1
}}
/>
</div>
<div style={{
fontSize: '14px',
color: '#1f2937',
textAlign: 'left',
paddingLeft: '8px',
wordBreak: 'break-word'
}}>
{info.description}
{isPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '600'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.size}</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.drawing}</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.lineNo}</div>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{!editingRequest[material.id] && savedRequests[material.id] ? (
// 저장된 상태 - 요구사항 표시 + 수정 버튼
<>
<div style={{
flex: 1,
padding: '6px 8px',
border: '1px solid #e5e7eb',
borderRadius: '4px',
fontSize: '12px',
textAlign: 'center',
background: '#f9fafb',
color: '#374151'
}}>
{savedRequests[material.id]}
</div>
<button
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
disabled={isPurchased}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: isPurchased ? '#d1d5db' : '#f59e0b',
color: 'white',
fontSize: '10px',
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
Edit
</button>
</>
) : (
// 편집 상태 - 입력 필드 + 저장 버튼
<>
<input
type="text"
value={userRequirements[material.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter additional request..."
disabled={isPurchased}
style={{
flex: 1,
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px',
opacity: isPurchased ? 0.5 : 1,
cursor: isPurchased ? 'not-allowed' : 'text'
}}
/>
<button
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
disabled={isPurchased || savingRequest[material.id]}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: isPurchased ? '#d1d5db' : '#10b981',
color: 'white',
fontSize: '10px',
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
{savingRequest[material.id] ? '...' : 'Save'}
</button>
</>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.purchaseQuantity}
</div>
</div>
);
})}
</div>
</div>
</div>
{filteredMaterials.length === 0 && (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#6b7280',
background: 'white',
borderRadius: '12px',
border: '1px solid #e5e7eb',
marginTop: '20px'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<h3 style={{ margin: '0 0 8px 0', color: '#374151' }}>미분류 자재가 없습니다</h3>
<p style={{ margin: 0 }}>분류되지 않은 자재가 발견되면 여기에 표시됩니다.</p>
</div>
)}
</div>
);
};
export default UnclassifiedMaterialsView;

View File

@@ -0,0 +1,840 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader } from '../shared';
const ValveMaterialsView = ({
materials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate,
updateMaterial, // 자재 업데이트 함수
fileId,
jobNo,
user
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [columnFilters, setColumnFilters] = useState({});
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
const [brandInputs, setBrandInputs] = useState({}); // 브랜드 입력 상태
const [savingBrand, setSavingBrand] = useState({}); // 브랜드 저장 중 상태
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
const [savedBrands, setSavedBrands] = useState({}); // 저장된 브랜드 상태
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
const [editingBrand, setEditingBrand] = useState({}); // 브랜드 편집 모드
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
// 컴포넌트 마운트 시 저장된 데이터 로드
React.useEffect(() => {
const loadSavedData = () => {
const savedBrandsData = {};
const savedRequestsData = {};
console.log('🔍 ValveMaterialsView useEffect 트리거됨:', materials.length, '개 자재');
console.log('🔍 현재 materials 배열:', materials.map(m => ({id: m.id, brand: m.brand, user_requirement: m.user_requirement})));
materials.forEach(material => {
// 백엔드에서 가져온 데이터가 있으면 저장된 상태로 설정
if (material.brand && material.brand.trim()) {
savedBrandsData[material.id] = material.brand.trim();
console.log('✅ 브랜드 로드됨:', material.id, '→', material.brand);
}
if (material.user_requirement && material.user_requirement.trim()) {
savedRequestsData[material.id] = material.user_requirement.trim();
console.log('✅ 요구사항 로드됨:', material.id, '→', material.user_requirement);
}
});
console.log('💾 최종 저장된 브랜드:', savedBrandsData);
console.log('💾 최종 저장된 요구사항:', savedRequestsData);
// 상태 업데이트를 즉시 반영하기 위해 setTimeout 사용
setSavedBrands(savedBrandsData);
setSavedRequests(savedRequestsData);
// 상태 업데이트 후 강제 리렌더링 확인
setTimeout(() => {
console.log('🔄 상태 업데이트 후 확인 - savedBrands:', savedBrandsData);
}, 100);
};
console.log('🔄 ValveMaterialsView useEffect 실행 - materials 길이:', materials?.length || 0);
if (materials && materials.length > 0) {
loadSavedData();
} else {
console.log('⚠️ materials가 비어있거나 undefined');
// 빈 상태로 초기화
setSavedBrands({});
setSavedRequests({});
}
}, [materials]);
const parseValveInfo = (material) => {
const valveDetails = material.valve_details || {};
const description = material.original_description || '';
const descUpper = description.toUpperCase();
// 1. 벨브 타입 파싱 (한글명으로 표시)
let valveType = '';
if (descUpper.includes('SIGHT GLASS') || descUpper.includes('사이트글라스')) {
valveType = 'SIGHT GLASS';
} else if (descUpper.includes('STRAINER') || descUpper.includes('스트레이너')) {
valveType = 'STRAINER';
} else if (descUpper.includes('GATE') || descUpper.includes('게이트')) {
valveType = 'GATE VALVE';
} else if (descUpper.includes('BALL') || descUpper.includes('볼')) {
valveType = 'BALL VALVE';
} else if (descUpper.includes('CHECK') || descUpper.includes('체크')) {
valveType = 'CHECK VALVE';
} else if (descUpper.includes('GLOBE') || descUpper.includes('글로브')) {
valveType = 'GLOBE VALVE';
} else if (descUpper.includes('BUTTERFLY') || descUpper.includes('버터플라이')) {
valveType = 'BUTTERFLY VALVE';
} else if (descUpper.includes('NEEDLE') || descUpper.includes('니들')) {
valveType = 'NEEDLE VALVE';
} else if (descUpper.includes('RELIEF') || descUpper.includes('릴리프')) {
valveType = 'RELIEF VALVE';
} else {
valveType = 'VALVE';
}
// 2. 사이즈 정보
const size = material.main_nom || material.size_inch || material.size_spec || '-';
// 3. 압력 등급
const pressure = material.pressure_rating ||
(descUpper.match(/(\d+)\s*LB/) ? descUpper.match(/(\d+)\s*LB/)[0] : '-');
// 4. 브랜드 (사용자 입력 가능)
const brand = '-'; // 기본값, 사용자가 입력할 수 있도록
// 5. 추가 정보 추출 (3-WAY, DOUL PLATE, DOUBLE DISC 등)
let additionalInfo = '';
const additionalPatterns = [
'3-WAY', '3WAY', 'THREE WAY',
'DOUL PLATE', 'DOUBLE PLATE', 'DUAL PLATE',
'DOUBLE DISC', 'DUAL DISC',
'SWING', 'LIFT', 'TILTING',
'WAFER', 'LUG', 'FLANGED',
'FULL BORE', 'REDUCED BORE',
'FIRE SAFE', 'ANTI STATIC'
];
for (const pattern of additionalPatterns) {
if (descUpper.includes(pattern)) {
if (additionalInfo) {
additionalInfo += ', ';
}
additionalInfo += pattern;
}
}
if (!additionalInfo) {
additionalInfo = '-';
}
// 6. 연결 방식 (투입구/Connection Type)
let connectionType = '';
if (descUpper.includes('SW X THRD') || descUpper.includes('SW×THRD')) {
connectionType = 'SW×THRD';
} else if (descUpper.includes('FLG') || descUpper.includes('FLANGE')) {
connectionType = 'FLG';
} else if (descUpper.includes('SW') || descUpper.includes('SOCKET')) {
connectionType = 'SW';
} else if (descUpper.includes('THRD') || descUpper.includes('THREAD')) {
connectionType = 'THRD';
} else if (descUpper.includes('BW') || descUpper.includes('BUTT WELD')) {
connectionType = 'BW';
} else {
connectionType = '-';
}
// 7. 구매 수량 계산 (기본 수량 그대로)
const qty = Math.round(material.quantity || 0);
const purchaseQuantity = `${qty} EA`;
return {
type: valveType, // 벨브 종류만 (GATE VALVE, BALL VALVE 등)
size: size,
pressure: pressure,
brand: brand, // 브랜드 (사용자 입력 가능)
additionalInfo: additionalInfo, // 추가 정보 (3-WAY, DOUL PLATE 등)
connection: connectionType, // 투입구/연결방식 (SW, FLG 등)
additionalRequest: '-', // 추가 요구사항 (기존 User Requirement)
purchaseQuantity: purchaseQuantity,
originalQuantity: qty,
isValve: true
};
};
// 정렬 처리
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// 필터링된 및 정렬된 자재 목록
const getFilteredAndSortedMaterials = () => {
let filtered = materials.filter(material => {
return Object.entries(columnFilters).every(([key, filterValue]) => {
if (!filterValue) return true;
const info = parseValveInfo(material);
const value = info[key]?.toString().toLowerCase() || '';
return value.includes(filterValue.toLowerCase());
});
});
if (sortConfig && sortConfig.key) {
filtered.sort((a, b) => {
const aInfo = parseValveInfo(a);
const bInfo = parseValveInfo(b);
if (!aInfo || !bInfo) return 0;
const aValue = aInfo[sortConfig.key];
const bValue = bInfo[sortConfig.key];
// 값이 없는 경우 처리
if (aValue === undefined && bValue === undefined) return 0;
if (aValue === undefined) return 1;
if (bValue === undefined) return -1;
// 숫자인 경우 숫자로 비교
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
}
// 문자열로 비교
const aStr = String(aValue).toLowerCase();
const bStr = String(bValue).toLowerCase();
if (sortConfig.direction === 'asc') {
return aStr.localeCompare(bStr);
} else {
return bStr.localeCompare(aStr);
}
});
}
return filtered;
};
// 전체 선택/해제 (구매신청된 자재 제외)
// 브랜드 저장 함수
const handleSaveBrand = async (materialId, brand) => {
if (!brand.trim()) return;
setSavingBrand(prev => ({ ...prev, [materialId]: true }));
try {
await api.patch(`/materials/${materialId}/brand`, { brand: brand.trim() });
// 성공 시 저장된 상태로 전환
setSavedBrands(prev => ({ ...prev, [materialId]: brand.trim() }));
setEditingBrand(prev => ({ ...prev, [materialId]: false }));
setBrandInputs(prev => ({ ...prev, [materialId]: '' })); // 입력 필드 초기화
// materials 배열도 업데이트 (카테고리 변경 시 데이터 유지를 위해)
if (updateMaterial) {
updateMaterial(materialId, { brand: brand.trim() });
console.log('✅ materials 배열 업데이트 완료:', materialId, '→', brand.trim());
}
} catch (error) {
console.error('브랜드 저장 실패:', error);
alert('브랜드 저장에 실패했습니다.');
} finally {
setSavingBrand(prev => ({ ...prev, [materialId]: false }));
}
};
// 브랜드 편집 시작
const handleEditBrand = (materialId, currentBrand) => {
setEditingBrand(prev => ({ ...prev, [materialId]: true }));
setBrandInputs(prev => ({ ...prev, [materialId]: currentBrand || '' }));
};
// 추가요구사항 저장 함수
const handleSaveRequest = async (materialId, request) => {
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
try {
await api.patch(`/materials/${materialId}/user-requirement`, {
user_requirement: request.trim()
});
// 성공 시 저장된 상태로 전환
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
setUserRequirements(prev => ({ ...prev, [materialId]: '' })); // 입력 필드 초기화
// materials 배열도 업데이트 (카테고리 변경 시 데이터 유지를 위해)
if (updateMaterial) {
updateMaterial(materialId, { user_requirement: request.trim() });
console.log('✅ materials 배열 업데이트 완료:', materialId, '→', request.trim());
}
} catch (error) {
console.error('추가요구사항 저장 실패:', error);
alert('추가요구사항 저장에 실패했습니다.');
} finally {
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
}
};
// 추가요구사항 편집 시작
const handleEditRequest = (materialId, currentRequest) => {
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
};
const handleSelectAll = () => {
const filteredMaterials = getFilteredAndSortedMaterials();
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
if (selectedMaterials.size === selectableMaterials.length) {
setSelectedMaterials(new Set());
} else {
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
}
};
// 개별 선택 (구매신청된 자재는 선택 불가)
const handleMaterialSelect = (materialId) => {
if (purchasedMaterials.has(materialId)) {
return; // 구매신청된 자재는 선택 불가
}
const newSelected = new Set(selectedMaterials);
if (newSelected.has(materialId)) {
newSelected.delete(materialId);
} else {
newSelected.add(materialId);
}
setSelectedMaterials(newSelected);
};
// 엑셀 내보내기
const handleExportToExcel = async () => {
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
if (selectedMaterialsData.length === 0) {
alert('내보낼 자재를 선택해주세요.');
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const excelFileName = `VALVE_Materials_${timestamp}.xlsx`;
const dataWithRequirements = selectedMaterialsData.map(material => ({
...material,
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
}));
try {
console.log('🔄 밸브 엑셀 내보내기 시작 - 새로운 방식');
// 1. 먼저 클라이언트에서 엑셀 파일 생성
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
category: 'VALVE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
// 2. 구매신청 생성
const allMaterialIds = selectedMaterialsData.map(m => m.id);
const response = await api.post('/purchase-request/create', {
file_id: fileId,
job_no: jobNo,
category: 'VALVE',
material_ids: allMaterialIds,
materials_data: dataWithRequirements.map(m => ({
material_id: m.id,
description: m.original_description,
category: m.classified_category,
size: m.size_inch || m.size_spec,
schedule: m.schedule,
material_grade: m.material_grade || m.full_material_grade,
quantity: m.quantity,
unit: m.unit,
user_requirement: userRequirements[m.id] || ''
}))
});
if (response.data.success) {
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
// 3. 생성된 엑셀 파일을 서버에 업로드
console.log('📤 서버에 엑셀 파일 업로드 중...');
const formData = new FormData();
formData.append('excel_file', excelBlob, excelFileName);
formData.append('request_id', response.data.request_id);
formData.append('category', 'VALVE');
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
if (onPurchasedMaterialsUpdate) {
onPurchasedMaterialsUpdate(allMaterialIds);
}
}
// 4. 클라이언트 다운로드
const url = window.URL.createObjectURL(excelBlob);
const link = document.createElement('a');
link.href = url;
link.download = excelFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} catch (error) {
console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패 시에도 클라이언트 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'VALVE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
}
};
const filteredMaterials = getFilteredAndSortedMaterials();
return (
<div style={{ padding: '32px' }}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h3 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0'
}}>
Valve Materials
</h3>
<p style={{
fontSize: '14px',
color: '#64748b',
margin: 0
}}>
{filteredMaterials.length} items {selectedMaterials.size} selected
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={handleSelectAll}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '8px',
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
</button>
<button
onClick={handleExportToExcel}
disabled={selectedMaterials.size === 0}
style={{
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' : '#e5e7eb',
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '8px',
padding: '10px 16px',
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500'
}}
>
Export to Excel ({selectedMaterials.size})
</button>
</div>
</div>
{/* 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'auto',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
maxHeight: '600px'
}}>
<div style={{ minWidth: '1600px' }}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 120px 150px 180px 180px 150px 200px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
textAlign: 'center'
}}>
<div>
<input
type="checkbox"
checked={(() => {
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
})()}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
</div>
<FilterableHeader
sortKey="type"
filterKey="type"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Type
</FilterableHeader>
<FilterableHeader
sortKey="size"
filterKey="size"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Size
</FilterableHeader>
<FilterableHeader
sortKey="pressure"
filterKey="pressure"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Pressure
</FilterableHeader>
<div>Brand</div>
<FilterableHeader
sortKey="additionalInfo"
filterKey="additionalInfo"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Additional Info
</FilterableHeader>
<FilterableHeader
sortKey="connection"
filterKey="connection"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Connection
</FilterableHeader>
<div>Additional Request</div>
<FilterableHeader
sortKey="purchaseQuantity"
filterKey="purchaseQuantity"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Purchase Quantity
</FilterableHeader>
</div>
{/* 데이터 행들 */}
{filteredMaterials.map((material, index) => {
const info = parseValveInfo(material);
const isSelected = selectedMaterials.has(material.id);
const isPurchased = purchasedMaterials.has(material.id);
return (
<div
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 120px 150px 180px 180px 150px 200px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease',
textAlign: 'center'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isPurchased) {
e.target.style.background = 'white';
}
}}
>
<div>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleMaterialSelect(material.id)}
disabled={isPurchased}
style={{
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1
}}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
{info.type}
{isPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '500'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.size}</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.pressure}</div>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{(() => {
// 디버깅: 렌더링 시점의 상태 확인
const hasEditingBrand = !!editingBrand[material.id];
const hasSavedBrand = !!savedBrands[material.id];
const shouldShowSaved = !hasEditingBrand && hasSavedBrand;
if (material.id === 11789) { // 테스트 자재만 로그
console.log(`🎨 UI 렌더링 - ID ${material.id}:`, {
editingBrand: hasEditingBrand,
savedBrandExists: hasSavedBrand,
savedBrandValue: savedBrands[material.id],
shouldShowSaved: shouldShowSaved,
allSavedBrands: Object.keys(savedBrands),
renderingMode: shouldShowSaved ? 'SAVED_VIEW' : 'INPUT_VIEW'
});
}
// 명시적으로 boolean 반환
return shouldShowSaved ? true : false;
})() ? (
// 저장된 상태 - 브랜드 표시 + 수정 버튼
<>
<div style={{
flex: 1,
padding: '6px',
border: '1px solid #e5e7eb',
borderRadius: '4px',
fontSize: '11px',
textAlign: 'center',
background: '#f9fafb',
color: '#374151'
}}>
{savedBrands[material.id]}
</div>
<button
onClick={() => handleEditBrand(material.id, savedBrands[material.id])}
disabled={isPurchased}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: isPurchased ? '#d1d5db' : '#f59e0b',
color: 'white',
fontSize: '10px',
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
Edit
</button>
</>
) : (
// 편집 상태 - 입력 필드 + 저장 버튼
<>
<input
type="text"
value={brandInputs[material.id] || ''}
onChange={(e) => setBrandInputs({
...brandInputs,
[material.id]: e.target.value
})}
placeholder="Enter brand..."
disabled={isPurchased}
style={{
flex: 1,
padding: '6px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '11px',
textAlign: 'center',
opacity: isPurchased ? 0.5 : 1,
cursor: isPurchased ? 'not-allowed' : 'text'
}}
/>
<button
onClick={() => handleSaveBrand(material.id, brandInputs[material.id] || '')}
disabled={isPurchased || savingBrand[material.id] || !brandInputs[material.id]?.trim()}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: isPurchased ? '#d1d5db' : '#3b82f6',
color: 'white',
fontSize: '10px',
cursor: isPurchased || savingBrand[material.id] ? 'not-allowed' : 'pointer',
opacity: isPurchased || !brandInputs[material.id]?.trim() ? 0.5 : 1,
minWidth: '40px'
}}
>
{savingBrand[material.id] ? '...' : 'Save'}
</button>
</>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.additionalInfo}</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.connection}</div>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{!editingRequest[material.id] && savedRequests[material.id] ? (
// 저장된 상태 - 요구사항 표시 + 수정 버튼
<>
<div style={{
flex: 1,
padding: '6px',
border: '1px solid #e5e7eb',
borderRadius: '4px',
fontSize: '11px',
background: '#f9fafb',
color: '#374151'
}}>
{savedRequests[material.id]}
</div>
<button
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
disabled={isPurchased}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: isPurchased ? '#d1d5db' : '#f59e0b',
color: 'white',
fontSize: '10px',
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
Edit
</button>
</>
) : (
// 편집 상태 - 입력 필드 + 저장 버튼
<>
<input
type="text"
value={userRequirements[material.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter additional request..."
disabled={isPurchased}
style={{
flex: 1,
padding: '6px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '11px',
opacity: isPurchased ? 0.5 : 1,
cursor: isPurchased ? 'not-allowed' : 'text'
}}
/>
<button
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
disabled={isPurchased || savingRequest[material.id]}
style={{
padding: '6px 8px',
border: 'none',
borderRadius: '4px',
background: isPurchased ? '#d1d5db' : '#10b981',
color: 'white',
fontSize: '10px',
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1,
minWidth: '40px'
}}
>
{savingRequest[material.id] ? '...' : 'Save'}
</button>
</>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.purchaseQuantity}</div>
</div>
);
})}
</div>
</div>
{filteredMaterials.length === 0 && (
<div style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
No Valve Materials Found
</div>
<div style={{ fontSize: '14px' }}>
{Object.keys(columnFilters).some(key => columnFilters[key])
? 'Try adjusting your filters'
: 'No valve materials available in this BOM'}
</div>
</div>
)}
</div>
);
};
export default ValveMaterialsView;

View File

@@ -0,0 +1,10 @@
// BOM Materials Components
export { default as PipeMaterialsView } from './PipeMaterialsView';
export { default as FittingMaterialsView } from './FittingMaterialsView';
export { default as FlangeMaterialsView } from './FlangeMaterialsView';
export { default as ValveMaterialsView } from './ValveMaterialsView';
export { default as GasketMaterialsView } from './GasketMaterialsView';
export { default as BoltMaterialsView } from './BoltMaterialsView';
export { default as SupportMaterialsView } from './SupportMaterialsView';
export { default as SpecialMaterialsView } from './SpecialMaterialsView';
export { default as UnclassifiedMaterialsView } from './UnclassifiedMaterialsView';

View File

@@ -0,0 +1,78 @@
import React from 'react';
const FilterableHeader = ({
sortKey,
filterKey,
children,
sortConfig,
onSort,
columnFilters,
onFilterChange,
showFilterDropdown,
setShowFilterDropdown
}) => {
return (
<div className="filterable-header" style={{ position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
onClick={() => onSort(sortKey)}
style={{ cursor: 'pointer', flex: 1 }}
>
{children}
{sortConfig && sortConfig.key === sortKey && (
<span style={{ marginLeft: '4px' }}>
{sortConfig.direction === 'asc' ? '↑' : '↓'}
</span>
)}
</span>
<button
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px',
fontSize: '12px',
color: '#6b7280'
}}
>
🔍
</button>
</div>
{showFilterDropdown === filterKey && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: '6px',
padding: '8px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
zIndex: 1000,
minWidth: '150px'
}}>
<input
type="text"
placeholder={`Filter ${children}...`}
value={columnFilters[filterKey] || ''}
onChange={(e) => onFilterChange({
...columnFilters,
[filterKey]: e.target.value
})}
style={{
width: '100%',
padding: '4px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
autoFocus
/>
</div>
)}
</div>
);
};
export default FilterableHeader;

View File

@@ -0,0 +1,161 @@
import React from 'react';
const MaterialTable = ({
children,
className = '',
style = {}
}) => {
return (
<div
className={`material-table ${className}`}
style={{
background: 'white',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
...style
}}
>
{children}
</div>
);
};
const MaterialTableHeader = ({
children,
gridColumns,
className = ''
}) => {
return (
<div
className={`material-table-header ${className}`}
style={{
display: 'grid',
gridTemplateColumns: gridColumns,
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151'
}}
>
{children}
</div>
);
};
const MaterialTableBody = ({
children,
maxHeight = '600px',
className = ''
}) => {
return (
<div
className={`material-table-body ${className}`}
style={{
maxHeight,
overflowY: 'auto'
}}
>
{children}
</div>
);
};
const MaterialTableRow = ({
children,
gridColumns,
isSelected = false,
isPurchased = false,
isLast = false,
onClick,
className = ''
}) => {
return (
<div
className={`material-table-row ${className}`}
onClick={onClick}
style={{
display: 'grid',
gridTemplateColumns: gridColumns,
gap: '16px',
padding: '16px',
borderBottom: !isLast ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease',
cursor: onClick ? 'pointer' : 'default'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased && !onClick) {
e.target.style.background = '#f8fafc';
}
}}
onMouseLeave={(e) => {
if (!isSelected && !isPurchased && !onClick) {
e.target.style.background = 'white';
}
}}
>
{children}
</div>
);
};
const MaterialTableCell = ({
children,
align = 'left',
fontWeight = 'normal',
color = '#1f2937',
className = ''
}) => {
return (
<div
className={`material-table-cell ${className}`}
style={{
fontSize: '14px',
color,
fontWeight,
textAlign: align
}}
>
{children}
</div>
);
};
const MaterialTableEmpty = ({
icon = '📦',
title = 'No Materials Found',
message = 'No materials available',
className = ''
}) => {
return (
<div
className={`material-table-empty ${className}`}
style={{
textAlign: 'center',
padding: '60px 20px',
color: '#64748b'
}}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>{icon}</div>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
{title}
</div>
<div style={{ fontSize: '14px' }}>
{message}
</div>
</div>
);
};
// 복합 컴포넌트로 export
MaterialTable.Header = MaterialTableHeader;
MaterialTable.Body = MaterialTableBody;
MaterialTable.Row = MaterialTableRow;
MaterialTable.Cell = MaterialTableCell;
MaterialTable.Empty = MaterialTableEmpty;
export default MaterialTable;

View File

@@ -0,0 +1,3 @@
// BOM Shared Components
export { default as FilterableHeader } from './FilterableHeader';
export { default as MaterialTable } from './MaterialTable';

View File

@@ -0,0 +1,536 @@
import React, { useState, useEffect } from 'react';
import api from '../../../api';
const BOMFilesTab = ({
selectedProject,
user,
bomFiles,
setBomFiles,
selectedBOM,
onBOMSelect,
refreshTrigger
}) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [revisionDialog, setRevisionDialog] = useState({ open: false, file: null });
const [groupedFiles, setGroupedFiles] = useState({});
// BOM 파일 목록 로드 함수
const loadBOMFiles = async () => {
if (!selectedProject) return;
try {
setLoading(true);
setError('');
const projectCode = selectedProject.official_project_code || selectedProject.job_no;
const encodedProjectCode = encodeURIComponent(projectCode);
const response = await api.get(`/files/project/${encodedProjectCode}`);
const files = response.data || [];
setBomFiles(files);
// BOM 이름별로 그룹화
const groups = groupFilesByBOM(files);
setGroupedFiles(groups);
} catch (err) {
console.error('BOM 파일 로드 실패:', err);
setError('BOM 파일을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
// BOM 파일 목록 로드
useEffect(() => {
loadBOMFiles();
}, [selectedProject, refreshTrigger, setBomFiles]);
// 파일을 BOM 이름별로 그룹화
const groupFilesByBOM = (fileList) => {
const groups = {};
fileList.forEach(file => {
const bomName = file.bom_name || file.original_filename;
if (!groups[bomName]) {
groups[bomName] = [];
}
groups[bomName].push(file);
});
// 각 그룹 내에서 리비전 번호로 정렬
Object.keys(groups).forEach(bomName => {
groups[bomName].sort((a, b) => {
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
return revB - revA; // 최신 리비전이 위로
});
});
return groups;
};
// BOM 선택 처리
const handleBOMClick = (bomFile) => {
if (onBOMSelect) {
onBOMSelect(bomFile);
}
};
// 파일 삭제
const handleDeleteFile = async (fileId, bomName) => {
if (!window.confirm(`이 파일을 삭제하시겠습니까?`)) {
return;
}
try {
await api.delete(`/files/delete/${fileId}`);
// 파일 목록 새로고침
const projectCode = selectedProject.official_project_code || selectedProject.job_no;
const encodedProjectCode = encodeURIComponent(projectCode);
const response = await api.get(`/files/project/${encodedProjectCode}`);
const files = response.data || [];
setBomFiles(files);
setGroupedFiles(groupFilesByBOM(files));
} catch (err) {
console.error('파일 삭제 실패:', err);
setError('파일 삭제에 실패했습니다.');
}
};
// 리비전 업로드
const handleRevisionUpload = (parentFile) => {
setRevisionDialog({
open: true,
file: parentFile
});
};
// 리비전 업로드 성공 핸들러
const handleRevisionUploadSuccess = () => {
setRevisionDialog({ open: false, file: null });
// BOM 파일 목록 새로고침
loadBOMFiles();
};
// 파일 업로드 처리
const handleFileUpload = async (file) => {
if (!file || !revisionDialog.file) return;
try {
const formData = new FormData();
formData.append('file', file);
formData.append('job_no', selectedProject.job_no);
formData.append('parent_file_id', revisionDialog.file.id);
formData.append('bom_name', revisionDialog.file.bom_name || revisionDialog.file.original_filename);
const response = await api.post('/files/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
if (response.data.success) {
alert(`리비전 업로드 성공! ${response.data.revision}`);
handleRevisionUploadSuccess();
} else {
alert(response.data.message || '리비전 업로드에 실패했습니다.');
}
} catch (error) {
console.error('리비전 업로드 실패:', error);
alert('리비전 업로드에 실패했습니다.');
}
};
// 날짜 포맷팅
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
try {
return new Date(dateString).toLocaleDateString('ko-KR');
} catch {
return dateString;
}
};
if (loading) {
return (
<div style={{
padding: '60px',
textAlign: 'center',
color: '#6b7280'
}}>
<div style={{ fontSize: '24px', marginBottom: '16px' }}></div>
<div>Loading BOM files...</div>
</div>
);
}
if (error) {
return (
<div style={{
padding: '40px',
textAlign: 'center'
}}>
<div style={{
background: '#fee2e2',
border: '1px solid #fecaca',
borderRadius: '8px',
padding: '16px',
color: '#dc2626'
}}>
<div style={{ fontSize: '20px', marginBottom: '8px' }}></div>
{error}
</div>
</div>
);
}
if (bomFiles.length === 0) {
return (
<div style={{
padding: '60px',
textAlign: 'center',
color: '#6b7280'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📄</div>
<h3 style={{ fontSize: '20px', fontWeight: '600', marginBottom: '8px' }}>
No BOM Files Found
</h3>
<p style={{ fontSize: '16px', margin: 0 }}>
Upload your first BOM file using the Upload tab
</p>
</div>
);
}
return (
<div style={{ padding: '40px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '32px'
}}>
<div>
<h2 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0'
}}>
BOM Files & Revisions
</h2>
<p style={{
fontSize: '16px',
color: '#64748b',
margin: 0
}}>
Select a BOM file to manage its materials
</p>
</div>
<div style={{
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
padding: '12px 20px',
borderRadius: '12px',
fontSize: '14px',
fontWeight: '600',
color: '#1d4ed8'
}}>
{Object.keys(groupedFiles).length} BOM Groups {bomFiles.length} Total Files
</div>
</div>
{/* BOM 파일 그룹 목록 */}
<div style={{ display: 'grid', gap: '24px' }}>
{Object.entries(groupedFiles).map(([bomName, files]) => {
const latestFile = files[0]; // 최신 리비전
const isSelected = selectedBOM?.id === latestFile.id;
return (
<div key={bomName} style={{
background: isSelected ? '#eff6ff' : 'white',
border: isSelected ? '2px solid #3b82f6' : '1px solid #e5e7eb',
borderRadius: '16px',
padding: '24px',
transition: 'all 0.2s ease',
cursor: 'pointer'
}}
onClick={() => handleBOMClick(latestFile)}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '16px'
}}>
<div style={{ flex: 1 }}>
<h3 style={{
fontSize: '20px',
fontWeight: '600',
color: isSelected ? '#1d4ed8' : '#374151',
margin: '0 0 8px 0',
display: 'flex',
alignItems: 'center',
gap: '12px'
}}>
<span style={{ fontSize: '24px' }}>📋</span>
{bomName}
{isSelected && (
<span style={{
background: '#3b82f6',
color: 'white',
fontSize: '12px',
padding: '4px 8px',
borderRadius: '6px',
fontWeight: '500'
}}>
SELECTED
</span>
)}
</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '12px',
fontSize: '14px',
color: '#6b7280'
}}>
<div>
<span style={{ fontWeight: '500' }}>Latest:</span> {latestFile.revision || 'Rev.0'}
</div>
<div>
<span style={{ fontWeight: '500' }}>Revisions:</span> {Math.max(0, files.length - 1)}
</div>
<div>
<span style={{ fontWeight: '500' }}>Updated:</span> {formatDate(latestFile.upload_date)}
</div>
<div>
<span style={{ fontWeight: '500' }}>Size:</span> {latestFile.file_size ? `${Math.round(latestFile.file_size / 1024)} KB` : 'N/A'}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', marginLeft: '16px' }}>
<button
onClick={(e) => {
e.stopPropagation();
handleRevisionUpload(latestFile);
}}
style={{
padding: '8px 12px',
background: 'white',
color: '#f59e0b',
border: '1px solid #f59e0b',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '500',
transition: 'all 0.2s ease'
}}
>
📝 New Revision
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteFile(latestFile.id, bomName);
}}
style={{
padding: '8px 12px',
background: '#fee2e2',
color: '#dc2626',
border: '1px solid #fecaca',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '500'
}}
>
🗑 Delete
</button>
</div>
</div>
{/* 리비전 히스토리 */}
{files.length > 1 && (
<div style={{
background: '#f8fafc',
borderRadius: '8px',
padding: '12px',
marginTop: '16px'
}}>
<h4 style={{
fontSize: '14px',
fontWeight: '600',
color: '#374151',
margin: '0 0 8px 0'
}}>
Revision History
</h4>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{files.map((file, index) => (
<div key={file.id} style={{
background: index === 0 ? '#dbeafe' : 'white',
color: index === 0 ? '#1d4ed8' : '#6b7280',
padding: '4px 8px',
borderRadius: '6px',
fontSize: '12px',
fontWeight: '500',
border: '1px solid #e5e7eb'
}}>
{file.revision || 'Rev.0'}
{index === 0 && ' (Latest)'}
</div>
))}
</div>
</div>
)}
{/* 선택 안내 */}
{!isSelected && (
<div style={{
marginTop: '16px',
padding: '12px',
background: 'rgba(59, 130, 246, 0.05)',
borderRadius: '8px',
textAlign: 'center',
fontSize: '14px',
color: '#3b82f6',
fontWeight: '500'
}}>
Click to select this BOM for material management
</div>
)}
</div>
);
})}
</div>
{/* 향후 기능 안내 */}
<div style={{
marginTop: '40px',
padding: '24px',
background: '#f8fafc',
borderRadius: '12px',
border: '1px solid #e2e8f0'
}}>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#374151',
marginBottom: '16px'
}}>
🚧 Coming Soon: Advanced Revision Features
</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '16px'
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', marginBottom: '8px' }}>📊</div>
<div style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
Visual Timeline
</div>
<div style={{ fontSize: '12px', color: '#6b7280' }}>
Interactive revision history
</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', marginBottom: '8px' }}>🔍</div>
<div style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
Diff Comparison
</div>
<div style={{ fontSize: '12px', color: '#6b7280' }}>
Compare changes between revisions
</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', marginBottom: '8px' }}></div>
<div style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
Rollback System
</div>
<div style={{ fontSize: '12px', color: '#6b7280' }}>
Restore previous versions
</div>
</div>
</div>
</div>
{/* 리비전 업로드 다이얼로그 */}
{revisionDialog.open && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
maxWidth: '500px',
width: '90%',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
}}>
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600' }}>
📝 리비전 업로드: {revisionDialog.file?.original_filename || 'BOM 파일'}
</h3>
<div style={{ marginBottom: '16px', fontSize: '14px', color: '#6b7280' }}>
새로운 리비전 파일을 선택해주세요.
</div>
<input
type="file"
accept=".csv,.xlsx,.xls"
onChange={(e) => {
const file = e.target.files[0];
if (file) {
handleFileUpload(file);
}
}}
style={{
width: '100%',
marginBottom: '16px',
padding: '8px',
border: '2px dashed #d1d5db',
borderRadius: '8px'
}}
/>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={() => setRevisionDialog({ open: false, file: null })}
style={{
padding: '8px 16px',
background: '#f3f4f6',
color: '#374151',
border: 'none',
borderRadius: '6px',
cursor: 'pointer'
}}
>
취소
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default BOMFilesTab;

View File

@@ -0,0 +1,105 @@
import React from 'react';
import BOMManagementPage from '../../../pages/BOMManagementPage';
const BOMMaterialsTab = ({
selectedProject,
user,
selectedBOM,
onNavigate
}) => {
// BOMManagementPage에 필요한 props 구성
const bomManagementProps = {
onNavigate,
user,
selectedProject,
fileId: selectedBOM?.id,
jobNo: selectedBOM?.job_no || selectedProject?.official_project_code || selectedProject?.job_no,
bomName: selectedBOM?.bom_name || selectedBOM?.original_filename,
revision: selectedBOM?.revision || 'Rev.0',
filename: selectedBOM?.original_filename
};
return (
<div style={{
background: 'white',
minHeight: '600px'
}}>
{/* 헤더 정보 */}
<div style={{
padding: '24px 40px',
borderBottom: '1px solid #e5e7eb',
background: '#f8fafc'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h2 style={{
fontSize: '24px',
fontWeight: '700',
color: '#0f172a',
margin: '0 0 8px 0'
}}>
Material Management
</h2>
<p style={{
fontSize: '16px',
color: '#64748b',
margin: 0
}}>
BOM: {selectedBOM?.bom_name || selectedBOM?.original_filename} {selectedBOM?.revision || 'Rev.0'}
</p>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
gap: '12px',
fontSize: '12px'
}}>
<div style={{
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
padding: '8px 12px',
borderRadius: '8px',
textAlign: 'center'
}}>
<div style={{ fontSize: '14px', fontWeight: '600', color: '#1d4ed8' }}>
{selectedBOM?.id || 'N/A'}
</div>
<div style={{ color: '#1d4ed8', fontWeight: '500' }}>
File ID
</div>
</div>
<div style={{
background: 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)',
padding: '8px 12px',
borderRadius: '8px',
textAlign: 'center'
}}>
<div style={{ fontSize: '14px', fontWeight: '600', color: '#059669' }}>
{selectedBOM?.revision || 'Rev.0'}
</div>
<div style={{ color: '#059669', fontWeight: '500' }}>
Revision
</div>
</div>
</div>
</div>
</div>
{/* BOM 관리 페이지 임베드 */}
<div style={{
background: 'white',
// BOMManagementPage의 기본 패딩을 제거하기 위한 스타일 오버라이드
'& > div': {
padding: '0 !important',
background: 'transparent !important',
minHeight: 'auto !important'
}
}}>
<BOMManagementPage {...bomManagementProps} />
</div>
</div>
);
};
export default BOMMaterialsTab;

View File

@@ -0,0 +1,494 @@
import React, { useState, useRef, useCallback } from 'react';
import api from '../../../api';
const BOMUploadTab = ({
selectedProject,
user,
onUploadSuccess,
onNavigate
}) => {
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [selectedFiles, setSelectedFiles] = useState([]);
const [bomName, setBomName] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const fileInputRef = useRef(null);
// 파일 검증
const validateFile = (file) => {
const allowedTypes = [
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/csv'
];
const maxSize = 50 * 1024 * 1024; // 50MB
if (!allowedTypes.includes(file.type) && !file.name.match(/\.(xlsx|xls|csv)$/i)) {
return '지원되지 않는 파일 형식입니다. Excel 또는 CSV 파일만 업로드 가능합니다.';
}
if (file.size > maxSize) {
return '파일 크기가 너무 큽니다. 50MB 이하의 파일만 업로드 가능합니다.';
}
return null;
};
// 파일 선택 처리
const handleFileSelect = useCallback((files) => {
const fileList = Array.from(files);
const validFiles = [];
const errors = [];
fileList.forEach(file => {
const error = validateFile(file);
if (error) {
errors.push(`${file.name}: ${error}`);
} else {
validFiles.push(file);
}
});
if (errors.length > 0) {
setError(errors.join('\n'));
return;
}
setSelectedFiles(validFiles);
setError('');
// 첫 번째 파일명을 기본 BOM 이름으로 설정 (확장자 제거)
if (validFiles.length > 0 && !bomName) {
const fileName = validFiles[0].name;
const nameWithoutExt = fileName.replace(/\.[^/.]+$/, '');
setBomName(nameWithoutExt);
}
}, [bomName]);
// 드래그 앤 드롭 처리
const handleDragOver = useCallback((e) => {
e.preventDefault();
setDragOver(true);
}, []);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
setDragOver(false);
}, []);
const handleDrop = useCallback((e) => {
e.preventDefault();
setDragOver(false);
handleFileSelect(e.dataTransfer.files);
}, [handleFileSelect]);
// 파일 선택 버튼 클릭
const handleFileButtonClick = () => {
fileInputRef.current?.click();
};
// 파일 업로드
const handleUpload = async () => {
if (selectedFiles.length === 0) {
setError('업로드할 파일을 선택해주세요.');
return;
}
if (!bomName.trim()) {
setError('BOM 이름을 입력해주세요.');
return;
}
if (!selectedProject) {
setError('프로젝트를 선택해주세요.');
return;
}
try {
setUploading(true);
setUploadProgress(0);
setError('');
setSuccess('');
let uploadedFile = null;
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
const formData = new FormData();
formData.append('file', file);
formData.append('job_no', selectedProject.official_project_code || selectedProject.job_no);
formData.append('bom_name', bomName.trim());
formData.append('revision', 'Rev.0'); // 새 업로드는 항상 Rev.0
const response = await api.post('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
const progress = Math.round(
((i * 100) + (progressEvent.loaded * 100) / progressEvent.total) / selectedFiles.length
);
setUploadProgress(progress);
}
});
if (!response.data?.success) {
throw new Error(response.data?.message || '업로드 실패');
}
// 첫 번째 파일의 정보를 저장
if (i === 0) {
uploadedFile = {
id: response.data.file_id,
bom_name: bomName.trim(),
revision: 'Rev.0',
job_no: selectedProject.official_project_code || selectedProject.job_no,
original_filename: file.name
};
}
}
setSuccess(`${selectedFiles.length}개 파일이 성공적으로 업로드되었습니다!`);
// 업로드 성공 즉시 콜백 호출 (파일 목록 새로고침)
if (onUploadSuccess) {
onUploadSuccess(uploadedFile);
}
// 파일 초기화
setSelectedFiles([]);
setBomName('');
} catch (err) {
console.error('업로드 실패:', err);
setError(`업로드 실패: ${err.response?.data?.detail || err.message}`);
} finally {
setUploading(false);
setUploadProgress(0);
}
};
// 파일 제거
const removeFile = (index) => {
const newFiles = selectedFiles.filter((_, i) => i !== index);
setSelectedFiles(newFiles);
if (newFiles.length === 0) {
setBomName('');
}
};
// 파일 크기 포맷팅
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
return (
<div style={{ padding: '40px' }}>
{/* BOM 이름 입력 */}
<div style={{ marginBottom: '32px' }}>
<label style={{
display: 'block',
fontSize: '16px',
fontWeight: '600',
color: '#374151',
marginBottom: '8px'
}}>
BOM Name
</label>
<input
type="text"
value={bomName}
onChange={(e) => setBomName(e.target.value)}
placeholder="Enter BOM name..."
style={{
width: '100%',
padding: '12px 16px',
border: '2px solid #e5e7eb',
borderRadius: '12px',
fontSize: '16px',
transition: 'border-color 0.2s ease',
outline: 'none'
}}
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
onBlur={(e) => e.target.style.borderColor = '#e5e7eb'}
/>
</div>
{/* 파일 드롭 영역 */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
border: `3px dashed ${dragOver ? '#3b82f6' : '#d1d5db'}`,
borderRadius: '16px',
padding: '60px 40px',
textAlign: 'center',
background: dragOver ? '#eff6ff' : '#f9fafb',
transition: 'all 0.3s ease',
cursor: 'pointer',
marginBottom: '24px'
}}
onClick={handleFileButtonClick}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
{dragOver ? '📁' : '📄'}
</div>
<h3 style={{
fontSize: '20px',
fontWeight: '600',
color: '#374151',
margin: '0 0 8px 0'
}}>
{dragOver ? 'Drop files here' : 'Upload BOM Files'}
</h3>
<p style={{
fontSize: '16px',
color: '#6b7280',
margin: '0 0 16px 0'
}}>
Drag and drop your Excel or CSV files here, or click to browse
</p>
<div style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
padding: '8px 16px',
background: 'rgba(59, 130, 246, 0.1)',
borderRadius: '8px',
fontSize: '14px',
color: '#3b82f6'
}}>
<span>📋</span>
Supported: .xlsx, .xls, .csv (Max 50MB)
</div>
</div>
{/* 숨겨진 파일 입력 */}
<input
ref={fileInputRef}
type="file"
multiple
accept=".xlsx,.xls,.csv"
onChange={(e) => handleFileSelect(e.target.files)}
style={{ display: 'none' }}
/>
{/* 선택된 파일 목록 */}
{selectedFiles.length > 0 && (
<div style={{ marginBottom: '24px' }}>
<h4 style={{
fontSize: '18px',
fontWeight: '600',
color: '#374151',
marginBottom: '16px'
}}>
Selected Files ({selectedFiles.length})
</h4>
<div style={{
background: '#f8fafc',
borderRadius: '12px',
padding: '16px'
}}>
{selectedFiles.map((file, index) => (
<div key={index} style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px 16px',
background: 'white',
borderRadius: '8px',
marginBottom: index < selectedFiles.length - 1 ? '8px' : '0',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '20px' }}>📄</span>
<div>
<div style={{ fontWeight: '500', color: '#374151' }}>
{file.name}
</div>
<div style={{ fontSize: '14px', color: '#6b7280' }}>
{formatFileSize(file.size)}
</div>
</div>
</div>
<button
onClick={() => removeFile(index)}
style={{
background: '#fee2e2',
color: '#dc2626',
border: 'none',
borderRadius: '6px',
padding: '6px 12px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '500'
}}
>
Remove
</button>
</div>
))}
</div>
</div>
)}
{/* 업로드 진행률 */}
{uploading && (
<div style={{ marginBottom: '24px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px'
}}>
<span style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
Uploading...
</span>
<span style={{ fontSize: '14px', fontWeight: '500', color: '#3b82f6' }}>
{uploadProgress}%
</span>
</div>
<div style={{
width: '100%',
height: '8px',
background: '#e5e7eb',
borderRadius: '4px',
overflow: 'hidden'
}}>
<div style={{
width: `${uploadProgress}%`,
height: '100%',
background: 'linear-gradient(90deg, #3b82f6 0%, #1d4ed8 100%)',
transition: 'width 0.3s ease'
}} />
</div>
</div>
)}
{/* 에러 메시지 */}
{error && (
<div style={{
background: '#fee2e2',
border: '1px solid #fecaca',
borderRadius: '8px',
padding: '12px 16px',
marginBottom: '24px'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '16px' }}></span>
<div style={{ fontSize: '14px', color: '#dc2626', whiteSpace: 'pre-line' }}>
{error}
</div>
</div>
</div>
)}
{/* 성공 메시지 */}
{success && (
<div style={{
background: '#dcfce7',
border: '1px solid #bbf7d0',
borderRadius: '8px',
padding: '12px 16px',
marginBottom: '24px'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '16px' }}></span>
<div style={{ fontSize: '14px', color: '#059669' }}>
{success}
</div>
</div>
</div>
)}
{/* 업로드 버튼 */}
<div style={{ display: 'flex', gap: '16px', justifyContent: 'flex-end' }}>
<button
onClick={handleUpload}
disabled={uploading || selectedFiles.length === 0 || !bomName.trim()}
style={{
padding: '12px 32px',
background: (uploading || selectedFiles.length === 0 || !bomName.trim())
? '#d1d5db'
: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
color: 'white',
border: 'none',
borderRadius: '12px',
cursor: (uploading || selectedFiles.length === 0 || !bomName.trim())
? 'not-allowed'
: 'pointer',
fontSize: '16px',
fontWeight: '600',
transition: 'all 0.2s ease'
}}
>
{uploading ? 'Uploading...' : 'Upload BOM'}
</button>
</div>
{/* 가이드 정보 */}
<div style={{
marginTop: '40px',
padding: '24px',
background: '#f8fafc',
borderRadius: '12px',
border: '1px solid #e2e8f0'
}}>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#374151',
marginBottom: '16px'
}}>
📋 Upload Guidelines
</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '20px'
}}>
<div>
<h4 style={{ fontSize: '14px', fontWeight: '600', color: '#059669', marginBottom: '8px' }}>
Supported Formats
</h4>
<ul style={{ margin: 0, paddingLeft: '16px', color: '#6b7280', fontSize: '14px' }}>
<li>Excel files (.xlsx, .xls)</li>
<li>CSV files (.csv)</li>
<li>Maximum file size: 50MB</li>
</ul>
</div>
<div>
<h4 style={{ fontSize: '14px', fontWeight: '600', color: '#3b82f6', marginBottom: '8px' }}>
📊 Required Columns
</h4>
<ul style={{ margin: 0, paddingLeft: '16px', color: '#6b7280', fontSize: '14px' }}>
<li>Description (자재명/품명)</li>
<li>Quantity (수량)</li>
<li>Size information (사이즈)</li>
</ul>
</div>
<div>
<h4 style={{ fontSize: '14px', fontWeight: '600', color: '#f59e0b', marginBottom: '8px' }}>
Auto Processing
</h4>
<ul style={{ margin: 0, paddingLeft: '16px', color: '#6b7280', fontSize: '14px' }}>
<li>Automatic material classification</li>
<li>WELD GAP items excluded</li>
<li>Ready for material management</li>
</ul>
</div>
</div>
</div>
</div>
);
};
export default BOMUploadTab;

View File

@@ -0,0 +1,163 @@
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
this.setState({
error: error,
errorInfo: errorInfo
});
// 에러 로깅
console.error('ErrorBoundary caught an error:', error, errorInfo);
// 에러 컨텍스트 정보 로깅
if (this.props.errorContext) {
console.error('Error context:', this.props.errorContext);
}
}
render() {
if (this.state.hasError) {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
padding: '40px'
}}>
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
padding: '40px',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
border: '1px solid rgba(255, 255, 255, 0.2)',
textAlign: 'center',
maxWidth: '600px'
}}>
<div style={{ fontSize: '64px', marginBottom: '24px' }}></div>
<h2 style={{
fontSize: '28px',
fontWeight: '700',
color: '#dc2626',
margin: '0 0 16px 0',
letterSpacing: '-0.025em'
}}>
Something went wrong
</h2>
<p style={{
fontSize: '16px',
color: '#64748b',
marginBottom: '32px',
lineHeight: '1.6'
}}>
An unexpected error occurred. Please try refreshing the page or contact support if the problem persists.
</p>
<div style={{ display: 'flex', gap: '16px', justifyContent: 'center' }}>
<button
onClick={() => window.location.reload()}
style={{
background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
color: 'white',
border: 'none',
borderRadius: '12px',
padding: '12px 24px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.target.style.transform = 'translateY(-2px)';
e.target.style.boxShadow = '0 8px 25px 0 rgba(59, 130, 246, 0.5)';
}}
onMouseLeave={(e) => {
e.target.style.transform = 'translateY(0)';
e.target.style.boxShadow = 'none';
}}
>
Refresh Page
</button>
<button
onClick={() => this.setState({ hasError: false, error: null, errorInfo: null })}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '12px',
padding: '12px 24px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.target.style.background = '#f9fafb';
e.target.style.borderColor = '#9ca3af';
}}
onMouseLeave={(e) => {
e.target.style.background = 'white';
e.target.style.borderColor = '#d1d5db';
}}
>
Try Again
</button>
</div>
{/* 개발 환경에서만 에러 상세 정보 표시 */}
{process.env.NODE_ENV === 'development' && this.state.error && (
<details style={{
marginTop: '32px',
textAlign: 'left',
background: '#f8fafc',
padding: '16px',
borderRadius: '8px',
border: '1px solid #e2e8f0'
}}>
<summary style={{
cursor: 'pointer',
fontWeight: '600',
color: '#374151',
marginBottom: '8px'
}}>
Error Details (Development)
</summary>
<pre style={{
fontSize: '12px',
color: '#dc2626',
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}>
{this.state.error && this.state.error.toString()}
<br />
{this.state.errorInfo.componentStack}
</pre>
</details>
)}
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,219 @@
import React, { useState } from 'react';
const UserMenu = ({ user, onNavigate, onLogout }) => {
const [showUserMenu, setShowUserMenu] = useState(false);
return (
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowUserMenu(!showUserMenu)}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
background: '#f8f9fa',
border: '1px solid #e9ecef',
borderRadius: '8px',
padding: '8px 12px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
color: '#495057',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.target.style.background = '#e9ecef';
e.target.style.borderColor = '#dee2e6';
}}
onMouseLeave={(e) => {
e.target.style.background = '#f8f9fa';
e.target.style.borderColor = '#e9ecef';
}}
>
<div style={{
width: '32px',
height: '32px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '14px',
fontWeight: '600'
}}>
{(user?.name || user?.username || 'U').charAt(0).toUpperCase()}
</div>
<div style={{ textAlign: 'left' }}>
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
{user?.name || user?.username}
</div>
<div style={{ fontSize: '12px', color: '#6c757d' }}>
{user?.role === 'system' ? '시스템 관리자' :
user?.role === 'admin' ? '관리자' : '사용자'}
</div>
</div>
<div style={{
fontSize: '12px',
color: '#6c757d',
transform: showUserMenu ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease'
}}>
</div>
</button>
{/* 드롭다운 메뉴 */}
{showUserMenu && (
<div style={{
position: 'absolute',
top: '100%',
right: 0,
marginTop: '8px',
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: '8px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
zIndex: 1050,
minWidth: '200px'
}}>
<div style={{ padding: '8px 0' }}>
<div style={{ padding: '8px 16px', borderBottom: '1px solid #f1f3f4' }}>
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
{user?.name || user?.username}
</div>
<div style={{ fontSize: '12px', color: '#6c757d' }}>
{user?.email || '이메일 없음'}
</div>
</div>
<button
onClick={() => {
onNavigate('account-settings');
setShowUserMenu(false);
}}
style={{
width: '100%',
padding: '12px 16px',
border: 'none',
background: 'none',
textAlign: 'left',
cursor: 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '8px',
':hover': { background: '#f8f9fa' }
}}
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
onMouseLeave={(e) => e.target.style.background = 'none'}
>
계정 설정
</button>
{user?.role === 'admin' && (
<>
<button
onClick={() => {
onNavigate('user-management');
setShowUserMenu(false);
}}
style={{
width: '100%',
padding: '12px 16px',
border: 'none',
background: 'none',
textAlign: 'left',
cursor: 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
onMouseLeave={(e) => e.target.style.background = 'none'}
>
👥 사용자 관리
</button>
<button
onClick={() => {
onNavigate('system-settings');
setShowUserMenu(false);
}}
style={{
width: '100%',
padding: '12px 16px',
border: 'none',
background: 'none',
textAlign: 'left',
cursor: 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
onMouseLeave={(e) => e.target.style.background = 'none'}
>
🔧 시스템 설정
</button>
<button
onClick={() => {
onNavigate('system-logs');
setShowUserMenu(false);
}}
style={{
width: '100%',
padding: '12px 16px',
border: 'none',
background: 'none',
textAlign: 'left',
cursor: 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
onMouseLeave={(e) => e.target.style.background = 'none'}
>
📊 시스템 로그
</button>
</>
)}
<div style={{ borderTop: '1px solid #f1f3f4', marginTop: '4px' }}>
<button
onClick={() => {
onLogout();
setShowUserMenu(false);
}}
style={{
width: '100%',
padding: '12px 16px',
border: 'none',
background: 'none',
textAlign: 'left',
cursor: 'pointer',
fontSize: '14px',
color: '#dc3545',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
onMouseLeave={(e) => e.target.style.background = 'none'}
>
🚪 로그아웃
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default UserMenu;

View File

@@ -0,0 +1,3 @@
// Common Components
export { default as UserMenu } from './UserMenu';
export { default as ErrorBoundary } from './ErrorBoundary';

View File

@@ -0,0 +1,541 @@
/**
* 리비전 관리 패널 컴포넌트
* BOM 관리 페이지에 통합되어 리비전 기능을 제공
*/
import React, { useState, useEffect } from 'react';
import { useRevisionManagement } from '../../hooks/useRevisionManagement';
const RevisionManagementPanel = ({
jobNo,
currentFileId,
previousFileId,
onRevisionComplete,
onRevisionCancel
}) => {
const {
loading,
error,
currentSession,
sessionStatus,
createRevisionSession,
getSessionStatus,
compareCategory,
getSessionChanges,
processRevisionAction,
completeSession,
cancelSession,
getRevisionSummary,
getSupportedCategories,
clearError
} = useRevisionManagement();
const [categories, setCategories] = useState([]);
const [selectedCategory, setSelectedCategory] = useState(null);
const [categoryChanges, setCategoryChanges] = useState({});
const [revisionSummary, setRevisionSummary] = useState(null);
const [processingActions, setProcessingActions] = useState(new Set());
// 컴포넌트 초기화
useEffect(() => {
initializeRevisionPanel();
}, [currentFileId, previousFileId]);
// 세션 상태 모니터링
useEffect(() => {
if (currentSession?.session_id) {
const interval = setInterval(() => {
refreshSessionStatus();
}, 5000); // 5초마다 상태 갱신
return () => clearInterval(interval);
}
}, [currentSession]);
const initializeRevisionPanel = async () => {
try {
// 지원 카테고리 로드
const categoriesResult = await getSupportedCategories();
if (categoriesResult.success) {
setCategories(categoriesResult.data);
}
// 리비전 세션 생성
if (currentFileId && previousFileId) {
const sessionResult = await createRevisionSession(jobNo, currentFileId, previousFileId);
if (sessionResult.success) {
console.log('✅ 리비전 세션 생성 완료');
await refreshSessionStatus();
}
}
} catch (error) {
console.error('리비전 패널 초기화 실패:', error);
}
};
const refreshSessionStatus = async () => {
if (currentSession?.session_id) {
try {
await getSessionStatus(currentSession.session_id);
await loadRevisionSummary();
} catch (error) {
console.error('세션 상태 갱신 실패:', error);
}
}
};
const loadRevisionSummary = async () => {
if (currentSession?.session_id) {
try {
const summaryResult = await getRevisionSummary(currentSession.session_id);
if (summaryResult.success) {
setRevisionSummary(summaryResult.data);
}
} catch (error) {
console.error('리비전 요약 로드 실패:', error);
}
}
};
const handleCategoryCompare = async (category) => {
if (!currentSession?.session_id) return;
try {
const result = await compareCategory(currentSession.session_id, category);
if (result.success) {
// 변경사항 로드
const changesResult = await getSessionChanges(currentSession.session_id, category);
if (changesResult.success) {
setCategoryChanges(prev => ({
...prev,
[category]: changesResult.data.changes
}));
}
await refreshSessionStatus();
}
} catch (error) {
console.error(`카테고리 ${category} 비교 실패:`, error);
}
};
const handleActionProcess = async (changeId, action, notes = '') => {
setProcessingActions(prev => new Set(prev).add(changeId));
try {
const result = await processRevisionAction(changeId, action, notes);
if (result.success) {
// 해당 카테고리 변경사항 새로고침
if (selectedCategory) {
const changesResult = await getSessionChanges(currentSession.session_id, selectedCategory);
if (changesResult.success) {
setCategoryChanges(prev => ({
...prev,
[selectedCategory]: changesResult.data.changes
}));
}
}
await refreshSessionStatus();
}
} catch (error) {
console.error('액션 처리 실패:', error);
} finally {
setProcessingActions(prev => {
const newSet = new Set(prev);
newSet.delete(changeId);
return newSet;
});
}
};
const handleCompleteRevision = async () => {
if (!currentSession?.session_id) return;
try {
const result = await completeSession(currentSession.session_id);
if (result.success) {
onRevisionComplete?.(result.data);
}
} catch (error) {
console.error('리비전 완료 실패:', error);
}
};
const handleCancelRevision = async (reason = '') => {
if (!currentSession?.session_id) return;
try {
const result = await cancelSession(currentSession.session_id, reason);
if (result.success) {
onRevisionCancel?.(result.data);
}
} catch (error) {
console.error('리비전 취소 실패:', error);
}
};
const getActionColor = (action) => {
const colors = {
'new_material': '#10b981',
'additional_purchase': '#f59e0b',
'inventory_transfer': '#8b5cf6',
'purchase_cancel': '#ef4444',
'quantity_update': '#3b82f6',
'maintain': '#6b7280'
};
return colors[action] || '#6b7280';
};
const getActionLabel = (action) => {
const labels = {
'new_material': '신규 자재',
'additional_purchase': '추가 구매',
'inventory_transfer': '재고 이관',
'purchase_cancel': '구매 취소',
'quantity_update': '수량 업데이트',
'maintain': '유지'
};
return labels[action] || action;
};
if (!currentSession) {
return (
<div style={{
padding: '20px',
textAlign: 'center',
background: '#f8fafc',
borderRadius: '12px',
border: '2px dashed #cbd5e1'
}}>
<div style={{ fontSize: '18px', color: '#64748b', marginBottom: '8px' }}>
🔄 리비전 세션 초기화 ...
</div>
<div style={{ fontSize: '14px', color: '#94a3b8' }}>
자재 비교를 위한 세션을 준비하고 있습니다.
</div>
</div>
);
}
return (
<div style={{
background: 'white',
borderRadius: '16px',
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.1)',
border: '1px solid rgba(255, 255, 255, 0.2)',
overflow: 'hidden'
}}>
{/* 헤더 */}
<div style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
padding: '20px 24px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div>
<h3 style={{ margin: 0, fontSize: '20px', fontWeight: '600' }}>
📊 리비전 관리
</h3>
<p style={{ margin: '4px 0 0 0', fontSize: '14px', opacity: 0.9 }}>
Job: {jobNo} | 세션 ID: {currentSession.session_id}
</p>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handleCompleteRevision}
disabled={loading}
style={{
background: '#10b981',
color: 'white',
border: 'none',
borderRadius: '8px',
padding: '8px 16px',
fontSize: '14px',
fontWeight: '500',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1
}}
>
완료
</button>
<button
onClick={() => handleCancelRevision('사용자 요청')}
disabled={loading}
style={{
background: '#ef4444',
color: 'white',
border: 'none',
borderRadius: '8px',
padding: '8px 16px',
fontSize: '14px',
fontWeight: '500',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1
}}
>
취소
</button>
</div>
</div>
{/* 에러 메시지 */}
{error && (
<div style={{
background: '#fef2f2',
border: '1px solid #fecaca',
color: '#dc2626',
padding: '12px 24px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<span> {error}</span>
<button
onClick={clearError}
style={{
background: 'none',
border: 'none',
color: '#dc2626',
cursor: 'pointer',
fontSize: '16px'
}}
>
</button>
</div>
)}
{/* 진행 상황 */}
{sessionStatus && (
<div style={{ padding: '20px 24px', borderBottom: '1px solid #e2e8f0' }}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
gap: '16px',
marginBottom: '16px'
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', fontWeight: '700', color: '#10b981' }}>
{sessionStatus.session_info.added_count || 0}
</div>
<div style={{ fontSize: '12px', color: '#64748b' }}>추가</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', fontWeight: '700', color: '#ef4444' }}>
{sessionStatus.session_info.removed_count || 0}
</div>
<div style={{ fontSize: '12px', color: '#64748b' }}>제거</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', fontWeight: '700', color: '#f59e0b' }}>
{sessionStatus.session_info.changed_count || 0}
</div>
<div style={{ fontSize: '12px', color: '#64748b' }}>변경</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', fontWeight: '700', color: '#6b7280' }}>
{sessionStatus.session_info.unchanged_count || 0}
</div>
<div style={{ fontSize: '12px', color: '#64748b' }}>유지</div>
</div>
</div>
{/* 진행률 바 */}
<div style={{
background: '#f1f5f9',
borderRadius: '8px',
height: '8px',
overflow: 'hidden'
}}>
<div
style={{
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 100%)',
height: '100%',
width: `${sessionStatus.progress_percentage || 0}%`,
transition: 'width 0.3s ease'
}}
/>
</div>
<div style={{
textAlign: 'center',
fontSize: '12px',
color: '#64748b',
marginTop: '4px'
}}>
진행률: {Math.round(sessionStatus.progress_percentage || 0)}%
</div>
</div>
)}
{/* 카테고리 탭 */}
<div style={{ padding: '20px 24px' }}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
gap: '8px',
marginBottom: '20px'
}}>
{categories.map(category => {
const hasChanges = revisionSummary?.category_summaries?.[category.key];
const isActive = selectedCategory === category.key;
return (
<button
key={category.key}
onClick={() => {
setSelectedCategory(category.key);
if (!categoryChanges[category.key]) {
handleCategoryCompare(category.key);
}
}}
style={{
background: isActive
? 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)'
: hasChanges
? 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)'
: 'white',
color: isActive ? 'white' : hasChanges ? '#92400e' : '#64748b',
border: isActive ? 'none' : '1px solid #e2e8f0',
borderRadius: '8px',
padding: '8px 12px',
fontSize: '12px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease',
position: 'relative'
}}
>
{category.name}
{hasChanges && (
<span style={{
position: 'absolute',
top: '-4px',
right: '-4px',
background: '#ef4444',
color: 'white',
borderRadius: '50%',
width: '16px',
height: '16px',
fontSize: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
{hasChanges.total_changes}
</span>
)}
</button>
);
})}
</div>
{/* 선택된 카테고리의 변경사항 */}
{selectedCategory && categoryChanges[selectedCategory] && (
<div style={{
background: '#f8fafc',
borderRadius: '12px',
padding: '16px',
maxHeight: '400px',
overflowY: 'auto'
}}>
<h4 style={{
margin: '0 0 16px 0',
fontSize: '16px',
fontWeight: '600',
color: '#1e293b'
}}>
{categories.find(c => c.key === selectedCategory)?.name} 변경사항
</h4>
{categoryChanges[selectedCategory].map((change, index) => (
<div
key={change.id || index}
style={{
background: 'white',
borderRadius: '8px',
padding: '12px',
marginBottom: '8px',
border: '1px solid #e2e8f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<div style={{ flex: 1 }}>
<div style={{
fontSize: '14px',
fontWeight: '500',
color: '#1e293b',
marginBottom: '4px'
}}>
{change.material_description}
</div>
<div style={{
fontSize: '12px',
color: '#64748b',
display: 'flex',
gap: '12px'
}}>
<span>이전: {change.previous_quantity || 0}</span>
<span>현재: {change.current_quantity || 0}</span>
<span>차이: {change.quantity_difference > 0 ? '+' : ''}{change.quantity_difference}</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
style={{
background: getActionColor(change.revision_action),
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '11px',
fontWeight: '500'
}}
>
{getActionLabel(change.revision_action)}
</span>
{change.action_status === 'pending' && (
<button
onClick={() => handleActionProcess(change.id, change.revision_action)}
disabled={processingActions.has(change.id)}
style={{
background: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '4px',
padding: '4px 8px',
fontSize: '11px',
cursor: processingActions.has(change.id) ? 'not-allowed' : 'pointer',
opacity: processingActions.has(change.id) ? 0.6 : 1
}}
>
{processingActions.has(change.id) ? '처리중...' : '처리'}
</button>
)}
{change.action_status === 'completed' && (
<span style={{
color: '#10b981',
fontSize: '11px',
fontWeight: '500'
}}>
완료
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default RevisionManagementPanel;

View File

@@ -0,0 +1,318 @@
/**
* 리비전 관리 훅
* - 리비전 세션 생성, 관리, 완료
* - 자재 비교 및 변경사항 처리
* - 리비전 히스토리 조회
*/
import { useState, useCallback } from 'react';
import api from '../api';
export const useRevisionManagement = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [currentSession, setCurrentSession] = useState(null);
const [sessionStatus, setSessionStatus] = useState(null);
const [revisionHistory, setRevisionHistory] = useState([]);
// 에러 처리 헬퍼
const handleError = useCallback((error, defaultMessage) => {
console.error(defaultMessage, error);
const errorMessage = error.response?.data?.detail || error.message || defaultMessage;
setError(errorMessage);
return { success: false, error: errorMessage };
}, []);
// 리비전 세션 생성
const createRevisionSession = useCallback(async (jobNo, currentFileId, previousFileId) => {
setLoading(true);
setError(null);
try {
const response = await api.post('/revision-management/sessions', {
job_no: jobNo,
current_file_id: currentFileId,
previous_file_id: previousFileId
});
if (response.data.success) {
setCurrentSession(response.data.data);
console.log('✅ 리비전 세션 생성 완료:', response.data.data);
return { success: true, data: response.data.data };
} else {
return handleError(new Error(response.data.message), '리비전 세션 생성 실패');
}
} catch (error) {
return handleError(error, '리비전 세션 생성 중 오류 발생');
} finally {
setLoading(false);
}
}, [handleError]);
// 세션 상태 조회
const getSessionStatus = useCallback(async (sessionId) => {
setLoading(true);
setError(null);
try {
const response = await api.get(`/revision-management/sessions/${sessionId}`);
if (response.data.success) {
setSessionStatus(response.data.data);
console.log('✅ 세션 상태 조회 완료:', response.data.data);
return { success: true, data: response.data.data };
} else {
return handleError(new Error(response.data.message), '세션 상태 조회 실패');
}
} catch (error) {
return handleError(error, '세션 상태 조회 중 오류 발생');
} finally {
setLoading(false);
}
}, [handleError]);
// 카테고리별 자재 비교
const compareCategory = useCallback(async (sessionId, category) => {
setLoading(true);
setError(null);
try {
const response = await api.post(`/revision-management/sessions/${sessionId}/compare/${category}`);
if (response.data.success) {
console.log(`✅ 카테고리 ${category} 비교 완료:`, response.data.data);
return { success: true, data: response.data.data };
} else {
return handleError(new Error(response.data.message), `카테고리 ${category} 비교 실패`);
}
} catch (error) {
return handleError(error, `카테고리 ${category} 비교 중 오류 발생`);
} finally {
setLoading(false);
}
}, [handleError]);
// 세션 변경사항 조회
const getSessionChanges = useCallback(async (sessionId, category = null) => {
setLoading(true);
setError(null);
try {
const params = category ? { category } : {};
const response = await api.get(`/revision-management/sessions/${sessionId}/changes`, { params });
if (response.data.success) {
console.log('✅ 세션 변경사항 조회 완료:', response.data.data);
return { success: true, data: response.data.data };
} else {
return handleError(new Error(response.data.message), '세션 변경사항 조회 실패');
}
} catch (error) {
return handleError(error, '세션 변경사항 조회 중 오류 발생');
} finally {
setLoading(false);
}
}, [handleError]);
// 리비전 액션 처리
const processRevisionAction = useCallback(async (changeId, action, notes = null) => {
setLoading(true);
setError(null);
try {
const response = await api.post(`/revision-management/changes/${changeId}/process`, {
action,
notes
});
if (response.data.success) {
console.log('✅ 리비전 액션 처리 완료:', response.data.data);
return { success: true, data: response.data.data };
} else {
return handleError(new Error(response.data.message), '리비전 액션 처리 실패');
}
} catch (error) {
return handleError(error, '리비전 액션 처리 중 오류 발생');
} finally {
setLoading(false);
}
}, [handleError]);
// 세션 완료
const completeSession = useCallback(async (sessionId) => {
setLoading(true);
setError(null);
try {
const response = await api.post(`/revision-management/sessions/${sessionId}/complete`);
if (response.data.success) {
setCurrentSession(null);
setSessionStatus(null);
console.log('✅ 리비전 세션 완료:', response.data.data);
return { success: true, data: response.data.data };
} else {
return handleError(new Error(response.data.message), '리비전 세션 완료 실패');
}
} catch (error) {
return handleError(error, '리비전 세션 완료 중 오류 발생');
} finally {
setLoading(false);
}
}, [handleError]);
// 세션 취소
const cancelSession = useCallback(async (sessionId, reason = null) => {
setLoading(true);
setError(null);
try {
const params = reason ? { reason } : {};
const response = await api.post(`/revision-management/sessions/${sessionId}/cancel`, null, { params });
if (response.data.success) {
setCurrentSession(null);
setSessionStatus(null);
console.log('✅ 리비전 세션 취소:', response.data.data);
return { success: true, data: response.data.data };
} else {
return handleError(new Error(response.data.message), '리비전 세션 취소 실패');
}
} catch (error) {
return handleError(error, '리비전 세션 취소 중 오류 발생');
} finally {
setLoading(false);
}
}, [handleError]);
// 리비전 히스토리 조회
const getRevisionHistory = useCallback(async (jobNo) => {
setLoading(true);
setError(null);
try {
const response = await api.get(`/revision-management/history/${jobNo}`);
if (response.data.success) {
setRevisionHistory(response.data.data.history);
console.log('✅ 리비전 히스토리 조회 완료:', response.data.data);
return { success: true, data: response.data.data };
} else {
return handleError(new Error(response.data.message), '리비전 히스토리 조회 실패');
}
} catch (error) {
return handleError(error, '리비전 히스토리 조회 중 오류 발생');
} finally {
setLoading(false);
}
}, [handleError]);
// 리비전 요약 조회
const getRevisionSummary = useCallback(async (sessionId) => {
setLoading(true);
setError(null);
try {
const response = await api.get(`/revision-management/sessions/${sessionId}/summary`);
if (response.data.success) {
console.log('✅ 리비전 요약 조회 완료:', response.data.data);
return { success: true, data: response.data.data };
} else {
return handleError(new Error(response.data.message), '리비전 요약 조회 실패');
}
} catch (error) {
return handleError(error, '리비전 요약 조회 중 오류 발생');
} finally {
setLoading(false);
}
}, [handleError]);
// 지원 카테고리 조회
const getSupportedCategories = useCallback(async () => {
try {
const response = await api.get('/revision-management/categories');
if (response.data.success) {
return { success: true, data: response.data.data.categories };
}
} catch (error) {
console.warn('지원 카테고리 조회 실패:', error);
}
// 기본 카테고리 반환
return {
success: true,
data: [
{ 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: "분류되지 않은 자재" }
]
};
}, []);
// 리비전 액션 목록 조회
const getRevisionActions = useCallback(async () => {
try {
const response = await api.get('/revision-management/actions');
if (response.data.success) {
return { success: true, data: response.data.data.actions };
}
} catch (error) {
console.warn('리비전 액션 조회 실패:', error);
}
// 기본 액션 반환
return {
success: true,
data: [
{ 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: "변경사항 없음" }
]
};
}, []);
// 상태 초기화
const resetState = useCallback(() => {
setCurrentSession(null);
setSessionStatus(null);
setRevisionHistory([]);
setError(null);
setLoading(false);
}, []);
return {
// 상태
loading,
error,
currentSession,
sessionStatus,
revisionHistory,
// 액션
createRevisionSession,
getSessionStatus,
compareCategory,
getSessionChanges,
processRevisionAction,
completeSession,
cancelSession,
getRevisionHistory,
getRevisionSummary,
getSupportedCategories,
getRevisionActions,
resetState,
// 유틸리티
clearError: () => setError(null)
};
};

View File

@@ -0,0 +1,184 @@
/* BOM Management Page Styles */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.bom-management-page {
padding: 40px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
min-height: 100vh;
}
.bom-header-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 32px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
border: 1px solid rgba(255, 255, 255, 0.2);
margin-bottom: 40px;
}
.bom-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.bom-stat-card {
padding: 20px;
border-radius: 12px;
text-align: center;
transition: transform 0.2s ease;
}
.bom-stat-card:hover {
transform: translateY(-2px);
}
.bom-stat-number {
font-size: 32px;
font-weight: 700;
margin-bottom: 4px;
}
.bom-stat-label {
font-size: 14px;
font-weight: 500;
}
.bom-category-tabs {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 24px 32px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
border: 1px solid rgba(255, 255, 255, 0.2);
margin-bottom: 40px;
}
.bom-category-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 16px;
}
.bom-category-button {
border-radius: 12px;
padding: 16px 12px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s ease;
text-align: center;
border: none;
outline: none;
}
.bom-category-button:hover {
transform: translateY(-1px);
}
.bom-category-icon {
font-size: 20px;
margin-bottom: 8px;
}
.bom-category-count {
font-size: 12px;
opacity: 0.8;
font-weight: 500;
}
.bom-content-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
border: 1px solid rgba(255, 255, 255, 0.2);
overflow: hidden;
}
.bom-loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
}
.bom-loading-spinner {
width: 60px;
height: 60px;
border: 4px solid #e2e8f0;
border-top: 4px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
.bom-loading-text {
font-size: 18px;
color: #64748b;
font-weight: 600;
}
.bom-error {
padding: 60px;
text-align: center;
color: #dc2626;
}
.bom-error-icon {
font-size: 48px;
margin-bottom: 16px;
}
.bom-error-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.bom-error-message {
font-size: 14px;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.bom-management-page {
padding: 20px;
}
.bom-header-card {
padding: 24px;
}
.bom-stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.bom-category-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.bom-category-button {
padding: 12px 8px;
font-size: 12px;
}
}
@media (max-width: 480px) {
.bom-stats-grid {
grid-template-columns: 1fr;
}
.bom-category-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,613 @@
import React, { useState, useEffect } from 'react';
import { fetchMaterials } from '../api';
import api from '../api';
import {
PipeMaterialsView,
FittingMaterialsView,
FlangeMaterialsView,
ValveMaterialsView,
GasketMaterialsView,
BoltMaterialsView,
SupportMaterialsView,
SpecialMaterialsView,
UnclassifiedMaterialsView
} from '../components/bom';
import RevisionManagementPanel from '../components/revision/RevisionManagementPanel';
import './BOMManagementPage.css';
const BOMManagementPage = ({
onNavigate,
selectedProject,
fileId,
jobNo,
bomName,
revision,
filename,
user
}) => {
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedCategory, setSelectedCategory] = useState('PIPE');
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
const [exportHistory, setExportHistory] = useState([]);
const [availableRevisions, setAvailableRevisions] = useState([]);
const [currentRevision, setCurrentRevision] = useState(revision || 'Rev.0');
const [userRequirements, setUserRequirements] = useState({});
const [purchasedMaterials, setPurchasedMaterials] = useState(new Set());
const [error, setError] = useState(null);
// 리비전 관련 상태
const [isRevisionMode, setIsRevisionMode] = useState(false);
const [previousFileId, setPreviousFileId] = useState(null);
const [showRevisionPanel, setShowRevisionPanel] = useState(false);
// 자재 업데이트 함수 (브랜드, 사용자 요구사항 등)
const updateMaterial = (materialId, updates) => {
setMaterials(prevMaterials =>
prevMaterials.map(material =>
material.id === materialId
? { ...material, ...updates }
: material
)
);
};
// 카테고리 정의
const categories = [
{ key: 'PIPE', label: 'Pipes', color: '#3b82f6' },
{ key: 'FITTING', label: 'Fittings', color: '#10b981' },
{ key: 'FLANGE', label: 'Flanges', color: '#f59e0b' },
{ key: 'VALVE', label: 'Valves', color: '#ef4444' },
{ key: 'GASKET', label: 'Gaskets', color: '#8b5cf6' },
{ key: 'BOLT', label: 'Bolts', color: '#6b7280' },
{ key: 'SUPPORT', label: 'Supports', color: '#f97316' },
{ key: 'SPECIAL', label: 'Special Items', color: '#ec4899' },
{ key: 'UNCLASSIFIED', label: 'Unclassified', color: '#64748b' }
];
// 자료 로드 함수들
const loadMaterials = async (id) => {
try {
setLoading(true);
console.log('🔍 자재 데이터 로딩 중...', {
file_id: id,
selectedProject: selectedProject?.job_no || selectedProject?.official_project_code,
jobNo
});
// 구매신청된 자재 먼저 확인
const projectJobNo = selectedProject?.job_no || selectedProject?.official_project_code || jobNo;
await loadPurchasedMaterials(projectJobNo);
const response = await fetchMaterials({
file_id: parseInt(id),
limit: 10000,
exclude_requested: false,
job_no: projectJobNo
});
if (response.data?.materials) {
const materialsData = response.data.materials;
console.log(`${materialsData.length}개 원본 자재 로드 완료`);
setMaterials(materialsData);
setError(null);
} else {
console.warn('⚠️ 자재 데이터가 없습니다:', response.data);
setMaterials([]);
}
} catch (error) {
console.error('자재 로드 실패:', error);
setError('자재 로드에 실패했습니다.');
} finally {
setLoading(false);
}
};
const loadAvailableRevisions = async () => {
try {
const response = await api.get('/files/', {
params: { job_no: jobNo }
});
const allFiles = Array.isArray(response.data) ? response.data : response.data?.files || [];
const sameBomFiles = allFiles.filter(file =>
(file.bom_name || file.original_filename) === bomName
);
sameBomFiles.sort((a, b) => {
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
return revB - revA;
});
setAvailableRevisions(sameBomFiles);
} catch (error) {
console.error('리비전 목록 조회 실패:', error);
}
};
const loadPurchasedMaterials = async (jobNo) => {
try {
// 새로운 API로 구매신청된 자재 ID 목록 조회
const response = await api.get('/purchase-request/requested-materials', {
params: {
job_no: jobNo,
file_id: fileId
}
});
if (response.data?.requested_material_ids) {
const purchasedIds = new Set(response.data.requested_material_ids);
setPurchasedMaterials(purchasedIds);
console.log(`${purchasedIds.size}개 구매신청된 자재 ID 로드 완료`);
}
} catch (error) {
console.error('구매신청 자재 조회 실패:', error);
}
};
const loadUserRequirements = async (fileId) => {
try {
const response = await api.get(`/files/${fileId}/user-requirements`);
if (response.data?.requirements) {
const reqMap = {};
response.data.requirements.forEach(req => {
reqMap[req.material_id] = req.requirement;
});
setUserRequirements(reqMap);
}
} catch (error) {
console.error('사용자 요구사항 로드 실패:', error);
}
};
// 초기 로드
useEffect(() => {
if (fileId) {
loadMaterials(fileId);
loadAvailableRevisions();
loadUserRequirements(fileId);
checkRevisionMode(); // 리비전 모드 확인
}
}, [fileId]);
// 리비전 모드 확인
const checkRevisionMode = async () => {
try {
// 현재 job_no의 모든 파일 목록 확인
const response = await api.get(`/files/list?job_no=${jobNo}`);
const files = response.data.files || [];
if (files.length > 1) {
// 파일들을 업로드 날짜순으로 정렬
const sortedFiles = files.sort((a, b) => new Date(a.upload_date) - new Date(b.upload_date));
// 현재 파일의 인덱스 찾기
const currentIndex = sortedFiles.findIndex(file => file.id === parseInt(fileId));
if (currentIndex > 0) {
// 이전 파일이 있으면 리비전 모드 활성화
const previousFile = sortedFiles[currentIndex - 1];
setIsRevisionMode(true);
setPreviousFileId(previousFile.id);
console.log('✅ 리비전 모드 활성화:', {
currentFileId: fileId,
previousFileId: previousFile.id,
currentRevision: revision,
previousRevision: previousFile.revision
});
}
}
} catch (error) {
console.error('리비전 모드 확인 실패:', error);
}
};
// 리비전 관리 핸들러
const handleRevisionComplete = (revisionData) => {
console.log('✅ 리비전 완료:', revisionData);
setShowRevisionPanel(false);
setIsRevisionMode(false);
// 자재 목록 새로고침
loadMaterials(fileId);
// 성공 메시지 표시
alert('리비전 처리가 완료되었습니다!');
};
const handleRevisionCancel = (cancelData) => {
console.log('❌ 리비전 취소:', cancelData);
setShowRevisionPanel(false);
// 취소 메시지 표시
alert('리비전 처리가 취소되었습니다.');
};
// 자재 로드 후 선택된 카테고리가 유효한지 확인
useEffect(() => {
if (materials.length > 0) {
const availableCategories = categories.filter(category => {
const count = getCategoryMaterials(category.key).length;
return count > 0;
});
// 현재 선택된 카테고리에 자재가 없으면 첫 번째 유효한 카테고리로 전환
const currentCategoryHasMaterials = getCategoryMaterials(selectedCategory).length > 0;
if (!currentCategoryHasMaterials && availableCategories.length > 0) {
setSelectedCategory(availableCategories[0].key);
}
}
}, [materials, selectedCategory]);
// 카테고리별 자재 필터링
const getCategoryMaterials = (category) => {
return materials.filter(material =>
material.classified_category === category ||
material.category === category
);
};
// 카테고리별 컴포넌트 렌더링
const renderCategoryView = () => {
const categoryMaterials = getCategoryMaterials(selectedCategory);
const commonProps = {
materials: categoryMaterials,
selectedMaterials,
setSelectedMaterials,
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate: (materialIds) => {
setPurchasedMaterials(prev => {
const newSet = new Set(prev);
materialIds.forEach(id => newSet.add(id));
console.log(`📦 구매신청 자재 추가: 기존 ${prev.size}개 → 신규 ${newSet.size}`);
return newSet;
});
},
updateMaterial, // 자재 업데이트 함수 추가
fileId,
jobNo,
user,
onNavigate
};
switch (selectedCategory) {
case 'PIPE':
return <PipeMaterialsView {...commonProps} />;
case 'FITTING':
return <FittingMaterialsView {...commonProps} />;
case 'FLANGE':
return <FlangeMaterialsView {...commonProps} />;
case 'VALVE':
return <ValveMaterialsView {...commonProps} />;
case 'GASKET':
return <GasketMaterialsView {...commonProps} />;
case 'BOLT':
return <BoltMaterialsView {...commonProps} />;
case 'SUPPORT':
return <SupportMaterialsView {...commonProps} />;
case 'SPECIAL':
return <SpecialMaterialsView {...commonProps} />;
case 'UNCLASSIFIED':
return <UnclassifiedMaterialsView {...commonProps} />;
default:
return <div>카테고리를 선택해주세요.</div>;
}
};
if (loading) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)'
}}>
<div style={{ textAlign: 'center' }}>
<div style={{
width: '60px',
height: '60px',
border: '4px solid #e2e8f0',
borderTop: '4px solid #3b82f6',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 20px'
}}></div>
<div style={{ fontSize: '18px', color: '#64748b', fontWeight: '600' }}>
Loading Materials...
</div>
</div>
</div>
);
}
return (
<div style={{
padding: '40px',
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
minHeight: '100vh'
}}>
{/* 헤더 섹션 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
padding: '32px',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
border: '1px solid rgba(255, 255, 255, 0.2)',
marginBottom: '40px'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '28px' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<h2 style={{
fontSize: '28px',
fontWeight: '700',
color: '#0f172a',
margin: 0,
letterSpacing: '-0.025em'
}}>
BOM Materials Management
</h2>
{isRevisionMode && (
<div style={{
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
color: 'white',
padding: '6px 12px',
borderRadius: '8px',
fontSize: '12px',
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: '0.05em',
boxShadow: '0 4px 6px -1px rgba(245, 158, 11, 0.3)'
}}>
📊 Revision Mode
</div>
)}
</div>
<p style={{
fontSize: '16px',
color: '#64748b',
margin: 0,
fontWeight: '400'
}}>
{bomName} - {currentRevision} | Project: {selectedProject?.job_name || jobNo}
</p>
</div>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
{isRevisionMode && (
<button
onClick={() => setShowRevisionPanel(!showRevisionPanel)}
style={{
background: showRevisionPanel
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
color: 'white',
border: 'none',
borderRadius: '12px',
padding: '12px 20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.2s ease',
letterSpacing: '0.025em',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
}}
>
{showRevisionPanel ? '📊 Hide Revision Panel' : '🔄 Manage Revision'}
</button>
)}
<button
onClick={() => onNavigate('dashboard')}
style={{
background: 'white',
color: '#6b7280',
border: '1px solid #d1d5db',
borderRadius: '12px',
padding: '12px 20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.2s ease',
letterSpacing: '0.025em'
}}
onMouseEnter={(e) => {
e.target.style.background = '#f9fafb';
e.target.style.borderColor = '#9ca3af';
}}
onMouseLeave={(e) => {
e.target.style.background = 'white';
e.target.style.borderColor = '#d1d5db';
}}
>
Back to Dashboard
</button>
</div>
</div>
{/* 통계 정보 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '20px',
marginBottom: '32px'
}}>
<div style={{
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#1d4ed8', marginBottom: '4px' }}>
{materials.length}
</div>
<div style={{ fontSize: '14px', color: '#1d4ed8', fontWeight: '500' }}>
Total Materials
</div>
</div>
<div style={{
background: 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#059669', marginBottom: '4px' }}>
{getCategoryMaterials(selectedCategory).length}
</div>
<div style={{ fontSize: '14px', color: '#059669', fontWeight: '500' }}>
{selectedCategory} Items
</div>
</div>
<div style={{
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#92400e', marginBottom: '4px' }}>
{selectedMaterials.size}
</div>
<div style={{ fontSize: '14px', color: '#92400e', fontWeight: '500' }}>
Selected
</div>
</div>
<div style={{
background: 'linear-gradient(135deg, #fee2e2 0%, #fecaca 100%)',
padding: '20px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#dc2626', marginBottom: '4px' }}>
{purchasedMaterials.size}
</div>
<div style={{ fontSize: '14px', color: '#dc2626', fontWeight: '500' }}>
Purchased
</div>
</div>
</div>
</div>
{/* 카테고리 탭 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
padding: '24px 32px',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
border: '1px solid rgba(255, 255, 255, 0.2)',
marginBottom: '40px'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: '16px'
}}>
{categories
.filter((category) => {
const count = getCategoryMaterials(category.key).length;
return count > 0; // 0개인 카테고리는 숨김
})
.map((category) => {
const isActive = selectedCategory === category.key;
const count = getCategoryMaterials(category.key).length;
return (
<button
key={category.key}
onClick={() => setSelectedCategory(category.key)}
style={{
background: isActive
? `linear-gradient(135deg, ${category.color} 0%, ${category.color}dd 100%)`
: 'white',
color: isActive ? 'white' : '#64748b',
border: isActive ? 'none' : '1px solid #e2e8f0',
borderRadius: '12px',
padding: '16px 12px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
transition: 'all 0.2s ease',
textAlign: 'center',
boxShadow: isActive ? `0 4px 14px 0 ${category.color}39` : '0 2px 8px rgba(0,0,0,0.05)'
}}
onMouseEnter={(e) => {
if (!isActive) {
e.target.style.background = '#f8fafc';
e.target.style.borderColor = '#cbd5e1';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.target.style.background = 'white';
e.target.style.borderColor = '#e2e8f0';
}
}}
>
<div style={{ marginBottom: '4px' }}>
{category.label}
</div>
<div style={{
fontSize: '12px',
opacity: 0.8,
fontWeight: '500'
}}>
{count} items
</div>
</button>
);
})}
</div>
</div>
{/* 카테고리별 컨텐츠 */}
<div style={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '20px',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
border: '1px solid rgba(255, 255, 255, 0.2)',
overflow: 'hidden'
}}>
{error ? (
<div style={{
padding: '60px',
textAlign: 'center',
color: '#dc2626'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
Error Loading Materials
</div>
<div style={{ fontSize: '14px' }}>
{error}
</div>
</div>
) : (
renderCategoryView()
)}
</div>
{/* 리비전 관리 패널 */}
{isRevisionMode && showRevisionPanel && (
<div style={{ marginTop: '40px' }}>
<RevisionManagementPanel
jobNo={jobNo}
currentFileId={parseInt(fileId)}
previousFileId={previousFileId}
onRevisionComplete={handleRevisionComplete}
onRevisionCancel={handleRevisionCancel}
/>
</div>
)}
</div>
);
};
export default BOMManagementPage;

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