🔧 완전한 스키마 자동화 시스템 구축
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
✨ 주요 기능: - 완전한 데이터베이스 스키마 분석 및 자동 마이그레이션 시스템 - 44개 테이블 완전 지원 (운영 서버 43개 + 1개 추가) - 누락된 테이블/컬럼 자동 감지 및 생성 🔧 해결된 스키마 문제: - users.status 컬럼 누락 → 자동 추가 - files 테이블 4개 컬럼 누락 → 자동 추가 - materials 테이블 22개 컬럼 누락 → 자동 추가 - support_details, purchase_requests, purchase_request_items 테이블 누락 → 자동 생성 - material_purchase_tracking.description, purchase_status 컬럼 누락 → 자동 추가 🚀 자동화 도구: - schema_analyzer.py: 코드와 DB 스키마 비교 분석 - auto_migrator.py: 자동 마이그레이션 실행 - docker_migrator.py: Docker 환경용 간편 마이그레이션 - schema_monitor.py: 실시간 스키마 모니터링 📋 리비전 관리 시스템: - 8개 카테고리별 리비전 페이지 구현 - PIPE Cutting Plan 관리 시스템 - PIPE Issue Management 시스템 - 완전한 리비전 비교 및 추적 기능 🎯 사용법: docker exec tk-mp-backend python3 scripts/docker_migrator.py 앞으로 스키마 문제가 발생하면 위 명령 하나로 자동 해결!
This commit is contained in:
255
PIPE_DATABASE_TABLES.md
Normal file
255
PIPE_DATABASE_TABLES.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# 🗄️ PIPE 관리 시스템 데이터베이스 테이블 가이드
|
||||
|
||||
## 📋 테이블 개요
|
||||
|
||||
PIPE 관리 시스템은 **7개의 전용 테이블**로 구성되어 있으며, 각각 고유한 역할을 담당합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **1. pipe_cutting_plans**
|
||||
**용도**: PIPE Cutting Plan의 단관 정보 저장
|
||||
|
||||
### **주요 컬럼**
|
||||
- `job_no`: 작업 번호
|
||||
- `area`: 구역 정보 (#01, #02 등)
|
||||
- `drawing_name`: 도면명 (P&ID-001)
|
||||
- `line_no`: 라인번호 (LINE-A-001)
|
||||
- `material_grade`: 재질 (A106 GR.B)
|
||||
- `schedule_spec`: 스케줄 (SCH40, SCH80)
|
||||
- `nominal_size`: 호칭 크기 (4", 6")
|
||||
- `length_mm`: 길이 (mm 단위)
|
||||
- `end_preparation`: 끝단 가공 (무개선, 한개선, 양개선)
|
||||
|
||||
### **사용 시점**
|
||||
- Cutting Plan 작성 시 단관 정보 저장
|
||||
- 구역별 도면 할당 완료 후
|
||||
- 라인번호 입력 완료 후
|
||||
|
||||
---
|
||||
|
||||
## 🔄 **2. pipe_revision_comparisons**
|
||||
**용도**: PIPE 리비전 비교 결과 및 통계 저장
|
||||
|
||||
### **주요 컬럼**
|
||||
- `job_no`: 작업 번호
|
||||
- `current_file_id`: 현재 파일 ID
|
||||
- `previous_cutting_plan_id`: 이전 Cutting Plan ID
|
||||
- `total_drawings`: 전체 도면 수
|
||||
- `changed_drawings`: 변경된 도면 수
|
||||
- `total_segments`: 전체 단관 수
|
||||
- `added_segments`: 추가된 단관 수
|
||||
- `removed_segments`: 삭제된 단관 수
|
||||
- `modified_segments`: 수정된 단관 수
|
||||
|
||||
### **사용 시점**
|
||||
- 새로운 BOM 업로드 시 (Cutting Plan 작성 후)
|
||||
- 기존 Cutting Plan과 신규 BOM 비교 시
|
||||
- 리비전 변경사항 분석 시
|
||||
|
||||
---
|
||||
|
||||
## 📝 **3. pipe_revision_changes**
|
||||
**용도**: PIPE 리비전 변경사항 상세 정보 저장
|
||||
|
||||
### **주요 컬럼**
|
||||
- `comparison_id`: 비교 결과 ID (pipe_revision_comparisons 참조)
|
||||
- `drawing_name`: 도면명
|
||||
- `change_type`: 변경 유형 (added, removed, modified, unchanged)
|
||||
- `old_*`: 이전 데이터 (라인번호, 재질, 길이 등)
|
||||
- `new_*`: 새로운 데이터 (라인번호, 재질, 길이 등)
|
||||
- `change_reason`: 변경 사유
|
||||
|
||||
### **사용 시점**
|
||||
- 리비전 비교 수행 시 각 단관별 변경사항 기록
|
||||
- 변경사항 상세 분석 시
|
||||
- 리비전 이력 추적 시
|
||||
|
||||
---
|
||||
|
||||
## 📸 **4. pipe_issue_snapshots**
|
||||
**용도**: 이슈 관리용 스냅샷 메타데이터 저장
|
||||
|
||||
### **주요 컬럼**
|
||||
- `job_no`: 작업 번호
|
||||
- `snapshot_name`: 스냅샷 이름
|
||||
- `is_active`: 활성 상태
|
||||
- `is_locked`: 잠금 상태 (이슈 관리 시작 시 true)
|
||||
- `total_segments`: 총 단관 수
|
||||
- `total_drawings`: 총 도면 수
|
||||
- `created_at`: 생성 시간
|
||||
- `locked_at`: 잠금 시간
|
||||
|
||||
### **사용 시점**
|
||||
- Cutting Plan 확정 시 자동 생성
|
||||
- 이슈 관리 시작 시 잠금
|
||||
- 리비전 보호 활성화 시
|
||||
|
||||
---
|
||||
|
||||
## 🔒 **5. pipe_issue_segments**
|
||||
**용도**: 스냅샷된 단관 정보 저장 (고정 데이터)
|
||||
|
||||
### **주요 컬럼**
|
||||
- `snapshot_id`: 스냅샷 ID (pipe_issue_snapshots 참조)
|
||||
- `area`: 구역 정보
|
||||
- `drawing_name`: 도면명
|
||||
- `line_no`: 라인번호
|
||||
- `material_grade`: 재질
|
||||
- `length_mm`: 길이
|
||||
- `end_preparation`: 끝단 가공
|
||||
- `original_cutting_plan_id`: 원본 Cutting Plan ID
|
||||
|
||||
### **사용 시점**
|
||||
- Cutting Plan 확정 시 현재 데이터 복사
|
||||
- 이슈 관리 페이지에서 기준 데이터로 사용
|
||||
- 리비전과 무관하게 고정된 데이터 제공
|
||||
|
||||
---
|
||||
|
||||
## 📋 **6. pipe_drawing_issues**
|
||||
**용도**: 도면 전반적인 이슈 저장
|
||||
|
||||
### **주요 컬럼**
|
||||
- `snapshot_id`: 스냅샷 ID (pipe_issue_snapshots 참조)
|
||||
- `area`: 구역 정보
|
||||
- `drawing_name`: 도면명
|
||||
- `issue_description`: 이슈 설명 (자유 텍스트)
|
||||
- `severity`: 심각도 (low, medium, high, critical)
|
||||
- `status`: 상태 (open, in_progress, resolved)
|
||||
- `resolution_notes`: 해결 방법
|
||||
- `reported_by`: 보고자
|
||||
|
||||
### **사용 시점**
|
||||
- 현장에서 도면 전체에 대한 문제 발견 시
|
||||
- 배관 간섭, 라우팅 변경 등 전반적 이슈 기록
|
||||
- 설계 변경 요청 시
|
||||
|
||||
### **예시**
|
||||
```
|
||||
구역: #01
|
||||
도면: P&ID-001
|
||||
이슈: "도면 A 전체적으로 배관 간섭이 심함. 현장 여건상 일부 루트 변경 필요"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **7. pipe_segment_issues**
|
||||
**용도**: 개별 단관별 이슈 저장
|
||||
|
||||
### **주요 컬럼**
|
||||
- `snapshot_id`: 스냅샷 ID (pipe_issue_snapshots 참조)
|
||||
- `segment_id`: 단관 ID (pipe_issue_segments 참조)
|
||||
- `issue_description`: 이슈 설명
|
||||
- `issue_type`: 이슈 유형 (cutting, installation, material, routing, other)
|
||||
- `length_change`: 길이 변경량 (+/- mm)
|
||||
- `new_length`: 최종 길이
|
||||
- `material_change`: 재질 변경 정보
|
||||
- `severity`: 심각도
|
||||
- `status`: 상태
|
||||
|
||||
### **사용 시점**
|
||||
- 개별 단관에서 문제 발견 시
|
||||
- 현장 절단, 설치 문제 등 구체적 이슈 기록
|
||||
- 단관별 수정사항 추적
|
||||
|
||||
### **예시**
|
||||
```
|
||||
구역: #01
|
||||
도면: P&ID-001
|
||||
라인번호: LINE-A-001
|
||||
이슈: "설치가 힘들어 30mm 절단함"
|
||||
길이 변경: -30mm
|
||||
최종 길이: 1470mm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 **테이블 관계도**
|
||||
|
||||
```
|
||||
pipe_cutting_plans (단관 정보)
|
||||
↓
|
||||
pipe_revision_comparisons (리비전 비교)
|
||||
↓
|
||||
pipe_revision_changes (변경사항 상세)
|
||||
|
||||
pipe_cutting_plans (단관 정보)
|
||||
↓ (확정 시 스냅샷)
|
||||
pipe_issue_snapshots (스냅샷 메타)
|
||||
↓
|
||||
pipe_issue_segments (고정된 단관 정보)
|
||||
↓
|
||||
pipe_segment_issues (단관별 이슈)
|
||||
|
||||
pipe_issue_snapshots (스냅샷 메타)
|
||||
↓
|
||||
pipe_drawing_issues (도면별 이슈)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **데이터 흐름**
|
||||
|
||||
### **1. Cutting Plan 작성**
|
||||
```
|
||||
BOM 업로드 → PIPE 데이터 추출 → pipe_cutting_plans 저장
|
||||
```
|
||||
|
||||
### **2. 리비전 발생**
|
||||
```
|
||||
새 BOM 업로드 → 기존 데이터 비교 → pipe_revision_comparisons + pipe_revision_changes 저장
|
||||
```
|
||||
|
||||
### **3. Cutting Plan 확정**
|
||||
```
|
||||
확정 버튼 클릭 → pipe_issue_snapshots 생성 → pipe_issue_segments 복사 (고정)
|
||||
```
|
||||
|
||||
### **4. 이슈 관리**
|
||||
```
|
||||
현장 이슈 발생 → pipe_drawing_issues (도면별) 또는 pipe_segment_issues (단관별) 저장
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 **리비전 보호 메커니즘**
|
||||
|
||||
### **확정 전**
|
||||
- `pipe_cutting_plans`: 리비전 시 변경됨 ❌
|
||||
- Excel 내보내기: 현재 데이터 기준 (변동 가능)
|
||||
|
||||
### **확정 후**
|
||||
- `pipe_issue_snapshots`: 잠금 상태 🔒
|
||||
- `pipe_issue_segments`: 고정된 데이터 ✅
|
||||
- Excel 내보내기: 스냅샷 데이터 기준 (고정)
|
||||
- 이슈 관리: 고정된 데이터 기준으로 진행
|
||||
|
||||
---
|
||||
|
||||
## 📊 **자동 마이그레이션**
|
||||
|
||||
모든 PIPE 관련 테이블은 `backend/scripts/analyze_and_fix_schema.py`에 포함되어 있어 **자동으로 생성**됩니다.
|
||||
|
||||
### **마이그레이션 포함 사항**
|
||||
- ✅ 7개 테이블 자동 생성
|
||||
- ✅ 모든 인덱스 자동 생성
|
||||
- ✅ 외래키 제약조건 설정
|
||||
- ✅ 기본값 및 제약조건 설정
|
||||
|
||||
### **배포 시 자동 실행**
|
||||
```bash
|
||||
# Docker 컨테이너 시작 시 자동 실행
|
||||
./start.sh → analyze_and_fix_schema.py → PIPE 테이블 생성
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎉 **핵심 장점**
|
||||
|
||||
1. **완전한 리비전 보호**: 확정 후 데이터 변경 불가
|
||||
2. **체계적 이슈 관리**: 도면별/단관별 구분 관리
|
||||
3. **자동 마이그레이션**: 배포 시 자동 테이블 생성
|
||||
4. **성능 최적화**: 모든 주요 컬럼에 인덱스 설정
|
||||
5. **데이터 무결성**: 외래키 제약조건으로 관계 보장
|
||||
|
||||
이제 **완벽한 PIPE 관리 시스템**의 데이터베이스 기반이 구축되었습니다! 🚀
|
||||
1123
PIPE_DEVELOPMENT_GUIDE.md
Normal file
1123
PIPE_DEVELOPMENT_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
212
SCHEMA_ANALYSIS.md
Normal file
212
SCHEMA_ANALYSIS.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# TK-MP-Project 데이터베이스 스키마 분석
|
||||
|
||||
## 1. 코드에서 정의된 테이블 (SQLAlchemy 모델)
|
||||
|
||||
### 인증 관련 테이블 (backend/app/auth/models.py)
|
||||
- `users` - 사용자 정보
|
||||
- `login_logs` - 로그인 기록
|
||||
- `user_sessions` - 사용자 세션
|
||||
- `permissions` - 권한 정보
|
||||
- `role_permissions` - 역할별 권한
|
||||
|
||||
### 메인 테이블 (backend/app/models.py)
|
||||
- `projects` - 프로젝트 정보
|
||||
- `files` - 업로드된 파일 정보
|
||||
- `materials` - 자재 정보
|
||||
- `material_standards` - 자재 표준 규격
|
||||
- `material_categories` - 자재 카테고리
|
||||
- `material_specifications` - 자재 사양
|
||||
- `material_grades` - 자재 등급
|
||||
- `material_patterns` - 자재 패턴
|
||||
- `special_materials` - 특수 자재
|
||||
- `special_material_grades` - 특수 자재 등급
|
||||
- `special_material_patterns` - 특수 자재 패턴
|
||||
- `pipe_details` - 파이프 상세 정보
|
||||
- `requirement_types` - 요구사항 타입
|
||||
- `user_requirements` - 사용자 요구사항
|
||||
- `tubing_categories` - 튜빙 카테고리
|
||||
- `tubing_specifications` - 튜빙 사양
|
||||
- `tubing_manufacturers` - 튜빙 제조사
|
||||
- `tubing_products` - 튜빙 제품
|
||||
- `material_tubing_mapping` - 자재-튜빙 매핑
|
||||
|
||||
## 2. 현재 DB에 존재하는 테이블
|
||||
|
||||
### 존재하는 테이블 (40개)
|
||||
- bolt_details
|
||||
- confirmed_purchase_items
|
||||
- files
|
||||
- fitting_details
|
||||
- flange_details
|
||||
- gasket_details
|
||||
- instrument_details
|
||||
- jobs
|
||||
- login_logs
|
||||
- material_categories
|
||||
- material_comparison_details
|
||||
- material_grades
|
||||
- material_patterns
|
||||
- material_purchase_mapping
|
||||
- material_purchase_tracking
|
||||
- material_revisions_comparison
|
||||
- material_specifications
|
||||
- material_standards
|
||||
- material_tubing_mapping
|
||||
- materials
|
||||
- permissions
|
||||
- pipe_details
|
||||
- pipe_end_preparations
|
||||
- projects
|
||||
- purchase_confirmations
|
||||
- purchase_items
|
||||
- requirement_types
|
||||
- role_permissions
|
||||
- special_material_grades
|
||||
- special_material_patterns
|
||||
- special_materials
|
||||
- tubing_categories
|
||||
- tubing_manufacturers
|
||||
- tubing_products
|
||||
- tubing_specifications
|
||||
- user_activity_logs
|
||||
- user_requirements
|
||||
- user_sessions
|
||||
- users
|
||||
- valve_details
|
||||
|
||||
## 3. 누락된 테이블 (코드에는 있지만 DB에는 없음)
|
||||
|
||||
**없음** - 모든 코드 정의 테이블이 DB에 존재함
|
||||
|
||||
## 4. 추가 테이블 (DB에는 있지만 코드에는 정의되지 않음)
|
||||
|
||||
- `bolt_details` - 볼트 상세 정보
|
||||
- `confirmed_purchase_items` - 구매 확정 항목
|
||||
- `fitting_details` - 피팅 상세 정보
|
||||
- `flange_details` - 플랜지 상세 정보
|
||||
- `gasket_details` - 가스켓 상세 정보
|
||||
- `instrument_details` - 계기 상세 정보
|
||||
- `jobs` - 작업 정보
|
||||
- `material_comparison_details` - 자재 비교 상세
|
||||
- `material_purchase_mapping` - 자재 구매 매핑
|
||||
- `material_purchase_tracking` - 자재 구매 추적
|
||||
- `material_revisions_comparison` - 자재 리비전 비교
|
||||
- `pipe_end_preparations` - 파이프 끝단 가공
|
||||
- `purchase_confirmations` - 구매 확정
|
||||
- `purchase_items` - 구매 항목
|
||||
- `user_activity_logs` - 사용자 활동 로그
|
||||
- `valve_details` - 밸브 상세 정보
|
||||
|
||||
## 5. 컬럼 누락 문제
|
||||
|
||||
### users 테이블 문제 (✅ 해결됨)
|
||||
**오류**: `column users.status does not exist`
|
||||
- **해결**: `status` 컬럼 (VARCHAR(20), default='active') 추가 완료
|
||||
|
||||
### files 테이블 문제 (✅ 해결됨)
|
||||
**오류**: `column "job_no" of relation "files" does not exist`
|
||||
|
||||
**누락된 컬럼들**:
|
||||
- `job_no` (VARCHAR(100)) - 작업 번호
|
||||
- `bom_name` (VARCHAR(255)) - BOM 이름
|
||||
- `description` (TEXT) - 파일 설명
|
||||
- `parsed_count` (INTEGER) - 파싱된 자재 개수
|
||||
|
||||
**해결**: 모든 누락 컬럼 추가 완료
|
||||
|
||||
### materials 테이블 문제 (✅ 해결됨)
|
||||
**오류**: `column "main_nom" of relation "materials" does not exist`
|
||||
|
||||
**대규모 누락된 컬럼들 (22개)**:
|
||||
- 사이즈 정보: `main_nom`, `red_nom`, `row_number`
|
||||
- 재질 정보: `full_material_grade`, `standard`, `grade`, `subcategory`
|
||||
- 사용자 입력: `brand`, `user_requirement`
|
||||
- 메타데이터: `material_hash`, `classified_by`, `updated_by`, `revision_status`
|
||||
- 추가 필드들: `length`, `total_length`, `is_active`, `purchase_confirmed` 등
|
||||
|
||||
**해결**: 테스팅 서버 기준으로 모든 누락 컬럼 추가 완료 (22개 → 44개 컬럼)
|
||||
|
||||
## 6. 해결 방안
|
||||
|
||||
### 즉시 해결 필요
|
||||
1. `users` 테이블에 `status` 컬럼 추가
|
||||
2. 기존 사용자 데이터의 `status` 값을 'active'로 설정
|
||||
|
||||
### 장기 해결 방안
|
||||
1. 모든 상세 테이블들에 대한 SQLAlchemy 모델 정의 추가
|
||||
2. 자동 마이그레이션 시스템 구축
|
||||
3. 스키마 검증 시스템 구축
|
||||
|
||||
## 7. 마이그레이션 스크립트 필요사항
|
||||
|
||||
```sql
|
||||
-- 즉시 필요한 수정 (✅ 완료됨)
|
||||
ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'active';
|
||||
UPDATE users SET status = 'active' WHERE status IS NULL;
|
||||
|
||||
-- files 테이블 누락 컬럼 추가 (✅ 완료됨)
|
||||
ALTER TABLE files ADD COLUMN job_no VARCHAR(100);
|
||||
ALTER TABLE files ADD COLUMN bom_name VARCHAR(255);
|
||||
ALTER TABLE files ADD COLUMN description TEXT;
|
||||
ALTER TABLE files ADD COLUMN parsed_count INTEGER;
|
||||
```
|
||||
|
||||
## 8. 구축된 자동화 도구들
|
||||
|
||||
### 스키마 분석기 (`backend/scripts/schema_analyzer.py`)
|
||||
- 코드와 DB 스키마를 자동으로 비교 분석
|
||||
- 누락된 테이블/컬럼을 자동으로 감지
|
||||
- 마이그레이션 SQL을 자동 생성
|
||||
|
||||
### 자동 마이그레이션 도구 (`backend/scripts/auto_migrator.py`)
|
||||
- 분석 결과를 바탕으로 자동으로 DB 업데이트
|
||||
- 트랜잭션 기반 안전한 마이그레이션
|
||||
- 마이그레이션 로그 자동 생성
|
||||
|
||||
### Docker 환경 마이그레이션 (`backend/scripts/docker_migrator.py`)
|
||||
- Docker 컨테이너 내에서 실행 가능한 간단한 마이그레이션 도구
|
||||
- 중요 테이블 존재 여부 자동 확인
|
||||
- 즉시 해결 필요한 스키마 문제 자동 수정
|
||||
|
||||
### 스키마 모니터링 시스템 (`backend/scripts/schema_monitor.py`)
|
||||
- 코드 변경사항을 실시간으로 감지
|
||||
- 스키마 불일치 발생 시 자동 알림
|
||||
- 배포 준비 상태 자동 검증
|
||||
|
||||
## 9. 사용법
|
||||
|
||||
### 즉시 스키마 체크 및 수정
|
||||
```bash
|
||||
docker exec tk-mp-backend python3 scripts/docker_migrator.py
|
||||
```
|
||||
|
||||
### 전체 스키마 분석 (로컬)
|
||||
```bash
|
||||
cd backend/scripts
|
||||
python3 schema_analyzer.py
|
||||
```
|
||||
|
||||
### 자동 마이그레이션 실행 (로컬)
|
||||
```bash
|
||||
cd backend/scripts
|
||||
python3 auto_migrator.py
|
||||
```
|
||||
|
||||
### 배포 준비 상태 검증
|
||||
```bash
|
||||
cd backend/scripts
|
||||
python3 schema_monitor.py --mode validate
|
||||
```
|
||||
|
||||
### 배포용 마이그레이션 생성
|
||||
```bash
|
||||
cd backend/scripts
|
||||
python3 schema_monitor.py --mode deploy
|
||||
```
|
||||
|
||||
## 10. 지속적인 관리 방안
|
||||
|
||||
1. **개발 중**: `schema_monitor.py --mode monitor`로 실시간 모니터링
|
||||
2. **배포 전**: `schema_monitor.py --mode validate`로 준비 상태 확인
|
||||
3. **배포 시**: `schema_monitor.py --mode deploy`로 안전한 마이그레이션 스크립트 생성
|
||||
4. **운영 중**: 정기적으로 `docker_migrator.py` 실행하여 스키마 동기화 확인
|
||||
409
backend/app/routers/enhanced_revision.py
Normal file
409
backend/app/routers/enhanced_revision.py
Normal file
@@ -0,0 +1,409 @@
|
||||
"""
|
||||
강화된 리비전 관리 API 엔드포인트
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from ..database import get_db
|
||||
from ..auth.middleware import get_current_user
|
||||
from ..services.enhanced_revision_service import EnhancedRevisionService
|
||||
from ..auth.models import User
|
||||
from ..models import RevisionComparison, RevisionChangeLog
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/enhanced-revision", tags=["Enhanced Revision Management"])
|
||||
|
||||
|
||||
@router.post("/compare-revisions")
|
||||
async def compare_revisions_enhanced(
|
||||
job_no: str,
|
||||
current_file_id: int,
|
||||
previous_file_id: Optional[int] = None,
|
||||
save_comparison: bool = True,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
강화된 리비전 비교 수행
|
||||
|
||||
Args:
|
||||
job_no: 작업 번호
|
||||
current_file_id: 현재 파일 ID
|
||||
previous_file_id: 이전 파일 ID (None이면 자동 탐지)
|
||||
save_comparison: 비교 결과 저장 여부
|
||||
"""
|
||||
|
||||
try:
|
||||
revision_service = EnhancedRevisionService(db)
|
||||
|
||||
# 리비전 비교 수행
|
||||
comparison_result = revision_service.compare_revisions_with_purchase_status(
|
||||
job_no=job_no,
|
||||
current_file_id=current_file_id,
|
||||
previous_file_id=previous_file_id
|
||||
)
|
||||
|
||||
# 비교 결과 저장 (옵션)
|
||||
if save_comparison:
|
||||
comparison_record = RevisionComparison(
|
||||
job_no=job_no,
|
||||
current_file_id=current_file_id,
|
||||
previous_file_id=previous_file_id,
|
||||
comparison_result=comparison_result,
|
||||
summary_stats=comparison_result.get("summary", {}),
|
||||
created_by=current_user.username,
|
||||
is_applied=False
|
||||
)
|
||||
|
||||
db.add(comparison_record)
|
||||
db.commit()
|
||||
db.refresh(comparison_record)
|
||||
|
||||
comparison_result["comparison_id"] = comparison_record.id
|
||||
|
||||
logger.info(f"Enhanced revision comparison completed for job {job_no}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": comparison_result,
|
||||
"message": "강화된 리비전 비교가 완료되었습니다."
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Enhanced revision comparison failed: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"리비전 비교 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/apply-revision-changes/{comparison_id}")
|
||||
async def apply_revision_changes(
|
||||
comparison_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
리비전 변경사항을 실제 DB에 적용
|
||||
|
||||
Args:
|
||||
comparison_id: 비교 결과 ID
|
||||
"""
|
||||
|
||||
try:
|
||||
# 비교 결과 조회
|
||||
comparison = db.query(RevisionComparison).filter(
|
||||
RevisionComparison.id == comparison_id
|
||||
).first()
|
||||
|
||||
if not comparison:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="비교 결과를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
if comparison.is_applied:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미 적용된 비교 결과입니다."
|
||||
)
|
||||
|
||||
revision_service = EnhancedRevisionService(db)
|
||||
|
||||
# 변경사항 적용
|
||||
apply_result = revision_service.apply_revision_changes(
|
||||
comparison_result=comparison.comparison_result,
|
||||
current_file_id=comparison.current_file_id
|
||||
)
|
||||
|
||||
if apply_result["success"]:
|
||||
# 적용 완료 표시
|
||||
comparison.is_applied = True
|
||||
comparison.applied_at = datetime.utcnow()
|
||||
comparison.applied_by = current_user.username
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Revision changes applied for comparison {comparison_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": apply_result,
|
||||
"message": "리비전 변경사항이 성공적으로 적용되었습니다."
|
||||
}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=apply_result.get("message", "변경사항 적용 중 오류가 발생했습니다.")
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply revision changes: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"변경사항 적용 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/comparison-history/{job_no}")
|
||||
async def get_comparison_history(
|
||||
job_no: str,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
작업별 리비전 비교 이력 조회
|
||||
|
||||
Args:
|
||||
job_no: 작업 번호
|
||||
limit: 조회 개수 제한
|
||||
offset: 조회 시작 위치
|
||||
"""
|
||||
|
||||
try:
|
||||
comparisons = db.query(RevisionComparison).filter(
|
||||
RevisionComparison.job_no == job_no
|
||||
).order_by(
|
||||
RevisionComparison.comparison_date.desc()
|
||||
).offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for comp in comparisons:
|
||||
result.append({
|
||||
"id": comp.id,
|
||||
"job_no": comp.job_no,
|
||||
"current_file_id": comp.current_file_id,
|
||||
"previous_file_id": comp.previous_file_id,
|
||||
"comparison_date": comp.comparison_date.isoformat(),
|
||||
"summary_stats": comp.summary_stats,
|
||||
"created_by": comp.created_by,
|
||||
"is_applied": comp.is_applied,
|
||||
"applied_at": comp.applied_at.isoformat() if comp.applied_at else None,
|
||||
"applied_by": comp.applied_by
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result,
|
||||
"total": len(result),
|
||||
"message": "리비전 비교 이력을 조회했습니다."
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get comparison history: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"비교 이력 조회 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/comparison-details/{comparison_id}")
|
||||
async def get_comparison_details(
|
||||
comparison_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
특정 비교 결과의 상세 정보 조회
|
||||
|
||||
Args:
|
||||
comparison_id: 비교 결과 ID
|
||||
"""
|
||||
|
||||
try:
|
||||
comparison = db.query(RevisionComparison).filter(
|
||||
RevisionComparison.id == comparison_id
|
||||
).first()
|
||||
|
||||
if not comparison:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="비교 결과를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 변경 로그도 함께 조회
|
||||
change_logs = db.query(RevisionChangeLog).filter(
|
||||
RevisionChangeLog.comparison_id == comparison_id
|
||||
).all()
|
||||
|
||||
result = {
|
||||
"comparison": {
|
||||
"id": comparison.id,
|
||||
"job_no": comparison.job_no,
|
||||
"current_file_id": comparison.current_file_id,
|
||||
"previous_file_id": comparison.previous_file_id,
|
||||
"comparison_date": comparison.comparison_date.isoformat(),
|
||||
"comparison_result": comparison.comparison_result,
|
||||
"summary_stats": comparison.summary_stats,
|
||||
"created_by": comparison.created_by,
|
||||
"is_applied": comparison.is_applied,
|
||||
"applied_at": comparison.applied_at.isoformat() if comparison.applied_at else None,
|
||||
"applied_by": comparison.applied_by
|
||||
},
|
||||
"change_logs": [
|
||||
{
|
||||
"id": log.id,
|
||||
"material_id": log.material_id,
|
||||
"change_type": log.change_type,
|
||||
"previous_data": log.previous_data,
|
||||
"current_data": log.current_data,
|
||||
"action_taken": log.action_taken,
|
||||
"notes": log.notes,
|
||||
"created_at": log.created_at.isoformat()
|
||||
}
|
||||
for log in change_logs
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result,
|
||||
"message": "비교 결과 상세 정보를 조회했습니다."
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get comparison details: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"비교 결과 조회 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pipe-length-summary/{file_id}")
|
||||
async def get_pipe_length_summary(
|
||||
file_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
PIPE 자재 길이 요약 정보 조회
|
||||
|
||||
Args:
|
||||
file_id: 파일 ID
|
||||
"""
|
||||
|
||||
try:
|
||||
revision_service = EnhancedRevisionService(db)
|
||||
|
||||
# PIPE 자재만 조회하여 도면-라인넘버별 길이 집계
|
||||
pipe_materials = revision_service._get_materials_with_purchase_status(file_id)
|
||||
|
||||
pipe_summary = {}
|
||||
for key, material in pipe_materials.items():
|
||||
if material.get('classified_category') == 'PIPE':
|
||||
drawing_line = f"{material.get('drawing_name', 'Unknown')} - {material.get('line_no', 'Unknown')}"
|
||||
|
||||
if drawing_line not in pipe_summary:
|
||||
pipe_summary[drawing_line] = {
|
||||
"drawing_name": material.get('drawing_name'),
|
||||
"line_no": material.get('line_no'),
|
||||
"material_grade": material.get('material_grade'),
|
||||
"schedule": material.get('schedule'),
|
||||
"nominal_size": material.get('main_nom'),
|
||||
"total_length": 0,
|
||||
"segment_count": 0,
|
||||
"purchase_status": "mixed"
|
||||
}
|
||||
|
||||
pipe_summary[drawing_line]["total_length"] += material.get('total_length', 0)
|
||||
pipe_summary[drawing_line]["segment_count"] += 1
|
||||
|
||||
# 구매 상태 확인
|
||||
if material.get('purchase_confirmed'):
|
||||
if pipe_summary[drawing_line]["purchase_status"] == "mixed":
|
||||
pipe_summary[drawing_line]["purchase_status"] = "purchased"
|
||||
else:
|
||||
if pipe_summary[drawing_line]["purchase_status"] == "purchased":
|
||||
pipe_summary[drawing_line]["purchase_status"] = "mixed"
|
||||
elif pipe_summary[drawing_line]["purchase_status"] != "mixed":
|
||||
pipe_summary[drawing_line]["purchase_status"] = "pending"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"file_id": file_id,
|
||||
"pipe_lines": list(pipe_summary.values()),
|
||||
"total_lines": len(pipe_summary),
|
||||
"total_length": sum(line["total_length"] for line in pipe_summary.values())
|
||||
},
|
||||
"message": "PIPE 자재 길이 요약을 조회했습니다."
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get pipe length summary: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"PIPE 길이 요약 조회 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/recalculate-pipe-lengths/{file_id}")
|
||||
async def recalculate_pipe_lengths(
|
||||
file_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
PIPE 자재 길이 재계산 및 업데이트
|
||||
|
||||
Args:
|
||||
file_id: 파일 ID
|
||||
"""
|
||||
|
||||
try:
|
||||
revision_service = EnhancedRevisionService(db)
|
||||
|
||||
# PIPE 자재 조회 및 길이 재계산
|
||||
pipe_materials = revision_service._get_materials_with_purchase_status(file_id)
|
||||
|
||||
updated_count = 0
|
||||
for key, material in pipe_materials.items():
|
||||
if material.get('classified_category') == 'PIPE':
|
||||
# total_length 업데이트
|
||||
total_length = material.get('total_length', 0)
|
||||
|
||||
update_query = """
|
||||
UPDATE materials
|
||||
SET total_length = :total_length,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :material_id
|
||||
"""
|
||||
|
||||
revision_service.db_service.execute_query(update_query, {
|
||||
"total_length": total_length,
|
||||
"material_id": material["id"]
|
||||
})
|
||||
|
||||
updated_count += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Recalculated pipe lengths for {updated_count} materials in file {file_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"file_id": file_id,
|
||||
"updated_count": updated_count
|
||||
},
|
||||
"message": f"PIPE 자재 {updated_count}개의 길이를 재계산했습니다."
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to recalculate pipe lengths: {e}")
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"PIPE 길이 재계산 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
224
backend/app/routers/pipe_excel.py
Normal file
224
backend/app/routers/pipe_excel.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
PIPE 스냅샷 Excel 내보내기 API 라우터
|
||||
|
||||
확정된 Cutting Plan의 고정된 Excel 내보내기 기능
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from io import BytesIO
|
||||
|
||||
from ..database import get_db
|
||||
from ..services.pipe_snapshot_excel_service import get_pipe_snapshot_excel_service, PipeSnapshotExcelService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/pipe-excel", tags=["pipe-excel"])
|
||||
|
||||
|
||||
@router.get("/export-finalized/{job_no}")
|
||||
async def export_finalized_cutting_plan(
|
||||
job_no: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
확정된 Cutting Plan Excel 내보내기
|
||||
- 스냅샷 데이터 기준으로 고정된 Excel 생성
|
||||
- 리비전과 무관하게 동일한 데이터 제공
|
||||
"""
|
||||
try:
|
||||
service = get_pipe_snapshot_excel_service(db)
|
||||
result = service.export_finalized_cutting_plan(job_no)
|
||||
|
||||
if not result["success"]:
|
||||
if "확정된 Cutting Plan이 없습니다" in result["message"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=result["message"]
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result["message"]
|
||||
)
|
||||
|
||||
# Excel 파일 스트리밍 응답
|
||||
excel_buffer = result["excel_buffer"]
|
||||
filename = result["filename"]
|
||||
|
||||
# 파일 다운로드를 위한 헤더 설정
|
||||
headers = {
|
||||
'Content-Disposition': f'attachment; filename="{filename}"',
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
}
|
||||
|
||||
return StreamingResponse(
|
||||
BytesIO(excel_buffer.getvalue()),
|
||||
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
headers=headers
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to export finalized cutting plan: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"확정된 Excel 내보내기 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/check-finalization/{job_no}")
|
||||
async def check_finalization_status(
|
||||
job_no: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Cutting Plan 확정 상태 확인
|
||||
- 확정된 Excel 내보내기 가능 여부 확인
|
||||
"""
|
||||
try:
|
||||
service = get_pipe_snapshot_excel_service(db)
|
||||
result = service.check_finalization_status(job_no)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check finalization status: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"확정 상태 확인 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/preview-finalized/{job_no}")
|
||||
async def preview_finalized_data(
|
||||
job_no: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
확정된 데이터 미리보기
|
||||
- Excel 생성 전 데이터 확인용
|
||||
"""
|
||||
try:
|
||||
from ..services.pipe_issue_snapshot_service import get_pipe_issue_snapshot_service
|
||||
|
||||
# 스냅샷 상태 확인
|
||||
snapshot_service = get_pipe_issue_snapshot_service(db)
|
||||
snapshot_info = snapshot_service.get_snapshot_info(job_no)
|
||||
|
||||
if not snapshot_info["has_snapshot"] or not snapshot_info["is_locked"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="확정된 Cutting Plan이 없습니다."
|
||||
)
|
||||
|
||||
# 스냅샷 데이터 조회
|
||||
snapshot_id = snapshot_info["snapshot_id"]
|
||||
segments = snapshot_service.get_snapshot_segments(snapshot_id)
|
||||
|
||||
# 구역별/도면별 통계
|
||||
area_stats = {}
|
||||
drawing_stats = {}
|
||||
material_stats = {}
|
||||
|
||||
for segment in segments:
|
||||
# 구역별 통계
|
||||
area = segment.get("area", "미할당")
|
||||
if area not in area_stats:
|
||||
area_stats[area] = {"count": 0, "total_length": 0}
|
||||
area_stats[area]["count"] += 1
|
||||
area_stats[area]["total_length"] += segment.get("length_mm", 0)
|
||||
|
||||
# 도면별 통계
|
||||
drawing = segment.get("drawing_name", "UNKNOWN")
|
||||
if drawing not in drawing_stats:
|
||||
drawing_stats[drawing] = {"count": 0, "total_length": 0}
|
||||
drawing_stats[drawing]["count"] += 1
|
||||
drawing_stats[drawing]["total_length"] += segment.get("length_mm", 0)
|
||||
|
||||
# 재질별 통계
|
||||
material = segment.get("material_grade", "UNKNOWN")
|
||||
if material not in material_stats:
|
||||
material_stats[material] = {"count": 0, "total_length": 0}
|
||||
material_stats[material]["count"] += 1
|
||||
material_stats[material]["total_length"] += segment.get("length_mm", 0)
|
||||
|
||||
return {
|
||||
"job_no": job_no,
|
||||
"snapshot_info": snapshot_info,
|
||||
"preview_data": {
|
||||
"total_segments": len(segments),
|
||||
"area_stats": area_stats,
|
||||
"drawing_stats": drawing_stats,
|
||||
"material_stats": material_stats
|
||||
},
|
||||
"sample_segments": segments[:10] if segments else [], # 처음 10개만 미리보기
|
||||
"can_export": True,
|
||||
"message": "확정된 데이터 미리보기가 준비되었습니다."
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to preview finalized data: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"데이터 미리보기 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/export-temp/{job_no}")
|
||||
async def export_temp_cutting_plan(
|
||||
job_no: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
임시 Cutting Plan Excel 내보내기 (구현 예정)
|
||||
- 현재 작업 중인 데이터 기준
|
||||
- 리비전 시 변경될 수 있는 데이터
|
||||
"""
|
||||
try:
|
||||
# TODO: 임시 Excel 내보내기 구현
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="임시 Excel 내보내기 기능은 구현 예정입니다."
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to export temp cutting plan: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"임시 Excel 내보내기 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/download-history/{job_no}")
|
||||
async def get_download_history(
|
||||
job_no: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Excel 다운로드 이력 조회 (구현 예정)
|
||||
- 확정된 Excel 다운로드 기록
|
||||
- 다운로드 시간 및 사용자 추적
|
||||
"""
|
||||
try:
|
||||
# TODO: 다운로드 이력 추적 구현
|
||||
return {
|
||||
"job_no": job_no,
|
||||
"download_history": [],
|
||||
"message": "다운로드 이력 추적 기능은 구현 예정입니다."
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get download history: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"다운로드 이력 조회 실패: {str(e)}"
|
||||
)
|
||||
535
backend/app/routers/pipe_issue.py
Normal file
535
backend/app/routers/pipe_issue.py
Normal file
@@ -0,0 +1,535 @@
|
||||
"""
|
||||
PIPE 이슈 관리 API 라우터
|
||||
|
||||
스냅샷 기반 도면별/단관별 이슈 관리 기능
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import (
|
||||
PipeIssueSnapshot, PipeIssueSegment,
|
||||
PipeDrawingIssue, PipeSegmentIssue
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/pipe-issue", tags=["pipe-issue"])
|
||||
|
||||
|
||||
# Pydantic 모델들
|
||||
class CreateDrawingIssueRequest(BaseModel):
|
||||
snapshot_id: int
|
||||
area: str
|
||||
drawing_name: str
|
||||
issue_description: str
|
||||
severity: str = 'medium' # low, medium, high, critical
|
||||
reported_by: Optional[str] = 'user'
|
||||
|
||||
|
||||
class CreateSegmentIssueRequest(BaseModel):
|
||||
snapshot_id: int
|
||||
segment_id: int
|
||||
issue_description: str
|
||||
issue_type: Optional[str] = 'other' # cutting, installation, material, routing, other
|
||||
length_change: Optional[float] = None
|
||||
new_length: Optional[float] = None
|
||||
material_change: Optional[str] = None
|
||||
severity: str = 'medium'
|
||||
reported_by: Optional[str] = 'user'
|
||||
|
||||
|
||||
class UpdateIssueStatusRequest(BaseModel):
|
||||
status: str # open, in_progress, resolved
|
||||
resolution_notes: Optional[str] = None
|
||||
resolved_by: Optional[str] = None
|
||||
|
||||
|
||||
class DrawingIssueResponse(BaseModel):
|
||||
id: int
|
||||
snapshot_id: int
|
||||
area: str
|
||||
drawing_name: str
|
||||
issue_description: str
|
||||
severity: str
|
||||
status: str
|
||||
resolution_notes: Optional[str] = None
|
||||
resolved_by: Optional[str] = None
|
||||
resolved_at: Optional[str] = None
|
||||
reported_by: Optional[str] = None
|
||||
reported_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class SegmentIssueResponse(BaseModel):
|
||||
id: int
|
||||
snapshot_id: int
|
||||
segment_id: int
|
||||
issue_description: str
|
||||
issue_type: Optional[str] = None
|
||||
length_change: Optional[float] = None
|
||||
new_length: Optional[float] = None
|
||||
material_change: Optional[str] = None
|
||||
severity: str
|
||||
status: str
|
||||
resolution_notes: Optional[str] = None
|
||||
resolved_by: Optional[str] = None
|
||||
resolved_at: Optional[str] = None
|
||||
reported_by: Optional[str] = None
|
||||
reported_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
@router.get("/snapshots/{job_no}")
|
||||
async def get_job_snapshots(
|
||||
job_no: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""작업의 활성 스냅샷 조회"""
|
||||
try:
|
||||
snapshots = db.query(PipeIssueSnapshot).filter(
|
||||
PipeIssueSnapshot.job_no == job_no,
|
||||
PipeIssueSnapshot.is_active == True
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for snapshot in snapshots:
|
||||
result.append({
|
||||
"snapshot_id": snapshot.id,
|
||||
"snapshot_name": snapshot.snapshot_name,
|
||||
"is_locked": snapshot.is_locked,
|
||||
"total_segments": snapshot.total_segments,
|
||||
"total_drawings": snapshot.total_drawings,
|
||||
"created_at": snapshot.created_at.isoformat() if snapshot.created_at else None,
|
||||
"locked_at": snapshot.locked_at.isoformat() if snapshot.locked_at else None
|
||||
})
|
||||
|
||||
return {
|
||||
"job_no": job_no,
|
||||
"snapshots": result,
|
||||
"total_count": len(result)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get job snapshots: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"스냅샷 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/drawing-issues/{snapshot_id}")
|
||||
async def get_drawing_issues(
|
||||
snapshot_id: int,
|
||||
area: Optional[str] = None,
|
||||
drawing_name: Optional[str] = None,
|
||||
status_filter: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""도면 이슈 목록 조회"""
|
||||
try:
|
||||
query = db.query(PipeDrawingIssue).filter(
|
||||
PipeDrawingIssue.snapshot_id == snapshot_id
|
||||
)
|
||||
|
||||
if area:
|
||||
query = query.filter(PipeDrawingIssue.area == area)
|
||||
|
||||
if drawing_name:
|
||||
query = query.filter(PipeDrawingIssue.drawing_name == drawing_name)
|
||||
|
||||
if status_filter:
|
||||
query = query.filter(PipeDrawingIssue.status == status_filter)
|
||||
|
||||
issues = query.order_by(PipeDrawingIssue.reported_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for issue in issues:
|
||||
result.append(DrawingIssueResponse(
|
||||
id=issue.id,
|
||||
snapshot_id=issue.snapshot_id,
|
||||
area=issue.area,
|
||||
drawing_name=issue.drawing_name,
|
||||
issue_description=issue.issue_description,
|
||||
severity=issue.severity,
|
||||
status=issue.status,
|
||||
resolution_notes=issue.resolution_notes,
|
||||
resolved_by=issue.resolved_by,
|
||||
resolved_at=issue.resolved_at.isoformat() if issue.resolved_at else None,
|
||||
reported_by=issue.reported_by,
|
||||
reported_at=issue.reported_at.isoformat() if issue.reported_at else '',
|
||||
updated_at=issue.updated_at.isoformat() if issue.updated_at else ''
|
||||
))
|
||||
|
||||
return {
|
||||
"snapshot_id": snapshot_id,
|
||||
"issues": result,
|
||||
"total_count": len(result)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get drawing issues: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"도면 이슈 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/drawing-issues", response_model=DrawingIssueResponse)
|
||||
async def create_drawing_issue(
|
||||
request: CreateDrawingIssueRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""도면 이슈 생성"""
|
||||
try:
|
||||
# 스냅샷 존재 확인
|
||||
snapshot = db.query(PipeIssueSnapshot).filter(
|
||||
PipeIssueSnapshot.id == request.snapshot_id
|
||||
).first()
|
||||
|
||||
if not snapshot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="스냅샷을 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 이슈 생성
|
||||
issue = PipeDrawingIssue(
|
||||
snapshot_id=request.snapshot_id,
|
||||
area=request.area,
|
||||
drawing_name=request.drawing_name,
|
||||
issue_description=request.issue_description,
|
||||
severity=request.severity,
|
||||
reported_by=request.reported_by
|
||||
)
|
||||
|
||||
db.add(issue)
|
||||
db.commit()
|
||||
db.refresh(issue)
|
||||
|
||||
return DrawingIssueResponse(
|
||||
id=issue.id,
|
||||
snapshot_id=issue.snapshot_id,
|
||||
area=issue.area,
|
||||
drawing_name=issue.drawing_name,
|
||||
issue_description=issue.issue_description,
|
||||
severity=issue.severity,
|
||||
status=issue.status,
|
||||
resolution_notes=issue.resolution_notes,
|
||||
resolved_by=issue.resolved_by,
|
||||
resolved_at=issue.resolved_at.isoformat() if issue.resolved_at else None,
|
||||
reported_by=issue.reported_by,
|
||||
reported_at=issue.reported_at.isoformat() if issue.reported_at else '',
|
||||
updated_at=issue.updated_at.isoformat() if issue.updated_at else ''
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to create drawing issue: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"도면 이슈 생성 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/segment-issues/{snapshot_id}")
|
||||
async def get_segment_issues(
|
||||
snapshot_id: int,
|
||||
segment_id: Optional[int] = None,
|
||||
status_filter: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""단관 이슈 목록 조회"""
|
||||
try:
|
||||
query = db.query(PipeSegmentIssue).filter(
|
||||
PipeSegmentIssue.snapshot_id == snapshot_id
|
||||
)
|
||||
|
||||
if segment_id:
|
||||
query = query.filter(PipeSegmentIssue.segment_id == segment_id)
|
||||
|
||||
if status_filter:
|
||||
query = query.filter(PipeSegmentIssue.status == status_filter)
|
||||
|
||||
issues = query.order_by(PipeSegmentIssue.reported_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for issue in issues:
|
||||
result.append(SegmentIssueResponse(
|
||||
id=issue.id,
|
||||
snapshot_id=issue.snapshot_id,
|
||||
segment_id=issue.segment_id,
|
||||
issue_description=issue.issue_description,
|
||||
issue_type=issue.issue_type,
|
||||
length_change=float(issue.length_change) if issue.length_change else None,
|
||||
new_length=float(issue.new_length) if issue.new_length else None,
|
||||
material_change=issue.material_change,
|
||||
severity=issue.severity,
|
||||
status=issue.status,
|
||||
resolution_notes=issue.resolution_notes,
|
||||
resolved_by=issue.resolved_by,
|
||||
resolved_at=issue.resolved_at.isoformat() if issue.resolved_at else None,
|
||||
reported_by=issue.reported_by,
|
||||
reported_at=issue.reported_at.isoformat() if issue.reported_at else '',
|
||||
updated_at=issue.updated_at.isoformat() if issue.updated_at else ''
|
||||
))
|
||||
|
||||
return {
|
||||
"snapshot_id": snapshot_id,
|
||||
"issues": result,
|
||||
"total_count": len(result)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get segment issues: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"단관 이슈 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/segment-issues", response_model=SegmentIssueResponse)
|
||||
async def create_segment_issue(
|
||||
request: CreateSegmentIssueRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""단관 이슈 생성"""
|
||||
try:
|
||||
# 단관 존재 확인
|
||||
segment = db.query(PipeIssueSegment).filter(
|
||||
PipeIssueSegment.id == request.segment_id
|
||||
).first()
|
||||
|
||||
if not segment:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="단관을 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 이슈 생성
|
||||
issue = PipeSegmentIssue(
|
||||
snapshot_id=request.snapshot_id,
|
||||
segment_id=request.segment_id,
|
||||
issue_description=request.issue_description,
|
||||
issue_type=request.issue_type,
|
||||
length_change=request.length_change,
|
||||
new_length=request.new_length,
|
||||
material_change=request.material_change,
|
||||
severity=request.severity,
|
||||
reported_by=request.reported_by
|
||||
)
|
||||
|
||||
db.add(issue)
|
||||
db.commit()
|
||||
db.refresh(issue)
|
||||
|
||||
return SegmentIssueResponse(
|
||||
id=issue.id,
|
||||
snapshot_id=issue.snapshot_id,
|
||||
segment_id=issue.segment_id,
|
||||
issue_description=issue.issue_description,
|
||||
issue_type=issue.issue_type,
|
||||
length_change=float(issue.length_change) if issue.length_change else None,
|
||||
new_length=float(issue.new_length) if issue.new_length else None,
|
||||
material_change=issue.material_change,
|
||||
severity=issue.severity,
|
||||
status=issue.status,
|
||||
resolution_notes=issue.resolution_notes,
|
||||
resolved_by=issue.resolved_by,
|
||||
resolved_at=issue.resolved_at.isoformat() if issue.resolved_at else None,
|
||||
reported_by=issue.reported_by,
|
||||
reported_at=issue.reported_at.isoformat() if issue.reported_at else '',
|
||||
updated_at=issue.updated_at.isoformat() if issue.updated_at else ''
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to create segment issue: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"단관 이슈 생성 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/drawing-issues/{issue_id}/status")
|
||||
async def update_drawing_issue_status(
|
||||
issue_id: int,
|
||||
request: UpdateIssueStatusRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""도면 이슈 상태 업데이트"""
|
||||
try:
|
||||
issue = db.query(PipeDrawingIssue).filter(
|
||||
PipeDrawingIssue.id == issue_id
|
||||
).first()
|
||||
|
||||
if not issue:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="이슈를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 상태 업데이트
|
||||
issue.status = request.status
|
||||
if request.resolution_notes:
|
||||
issue.resolution_notes = request.resolution_notes
|
||||
if request.resolved_by:
|
||||
issue.resolved_by = request.resolved_by
|
||||
|
||||
if request.status == 'resolved':
|
||||
issue.resolved_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": "이슈 상태가 업데이트되었습니다.", "issue_id": issue_id}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to update drawing issue status: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"이슈 상태 업데이트 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/segment-issues/{issue_id}/status")
|
||||
async def update_segment_issue_status(
|
||||
issue_id: int,
|
||||
request: UpdateIssueStatusRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""단관 이슈 상태 업데이트"""
|
||||
try:
|
||||
issue = db.query(PipeSegmentIssue).filter(
|
||||
PipeSegmentIssue.id == issue_id
|
||||
).first()
|
||||
|
||||
if not issue:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="이슈를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 상태 업데이트
|
||||
issue.status = request.status
|
||||
if request.resolution_notes:
|
||||
issue.resolution_notes = request.resolution_notes
|
||||
if request.resolved_by:
|
||||
issue.resolved_by = request.resolved_by
|
||||
|
||||
if request.status == 'resolved':
|
||||
issue.resolved_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": "이슈 상태가 업데이트되었습니다.", "issue_id": issue_id}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to update segment issue status: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"이슈 상태 업데이트 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/report/{snapshot_id}")
|
||||
async def generate_issue_report(
|
||||
snapshot_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""이슈 리포트 생성"""
|
||||
try:
|
||||
# 스냅샷 정보
|
||||
snapshot = db.query(PipeIssueSnapshot).filter(
|
||||
PipeIssueSnapshot.id == snapshot_id
|
||||
).first()
|
||||
|
||||
if not snapshot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="스냅샷을 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 도면 이슈 통계
|
||||
drawing_issues = db.query(PipeDrawingIssue).filter(
|
||||
PipeDrawingIssue.snapshot_id == snapshot_id
|
||||
).all()
|
||||
|
||||
# 단관 이슈 통계
|
||||
segment_issues = db.query(PipeSegmentIssue).filter(
|
||||
PipeSegmentIssue.snapshot_id == snapshot_id
|
||||
).all()
|
||||
|
||||
# 통계 계산
|
||||
drawing_stats = {
|
||||
"total": len(drawing_issues),
|
||||
"by_status": {},
|
||||
"by_severity": {},
|
||||
"by_area": {}
|
||||
}
|
||||
|
||||
for issue in drawing_issues:
|
||||
# 상태별
|
||||
status = issue.status
|
||||
drawing_stats["by_status"][status] = drawing_stats["by_status"].get(status, 0) + 1
|
||||
|
||||
# 심각도별
|
||||
severity = issue.severity
|
||||
drawing_stats["by_severity"][severity] = drawing_stats["by_severity"].get(severity, 0) + 1
|
||||
|
||||
# 구역별
|
||||
area = issue.area
|
||||
drawing_stats["by_area"][area] = drawing_stats["by_area"].get(area, 0) + 1
|
||||
|
||||
segment_stats = {
|
||||
"total": len(segment_issues),
|
||||
"by_status": {},
|
||||
"by_severity": {},
|
||||
"by_type": {}
|
||||
}
|
||||
|
||||
for issue in segment_issues:
|
||||
# 상태별
|
||||
status = issue.status
|
||||
segment_stats["by_status"][status] = segment_stats["by_status"].get(status, 0) + 1
|
||||
|
||||
# 심각도별
|
||||
severity = issue.severity
|
||||
segment_stats["by_severity"][severity] = segment_stats["by_severity"].get(severity, 0) + 1
|
||||
|
||||
# 유형별
|
||||
issue_type = issue.issue_type or 'other'
|
||||
segment_stats["by_type"][issue_type] = segment_stats["by_type"].get(issue_type, 0) + 1
|
||||
|
||||
return {
|
||||
"snapshot_id": snapshot_id,
|
||||
"snapshot_name": snapshot.snapshot_name,
|
||||
"job_no": snapshot.job_no,
|
||||
"report_generated_at": datetime.utcnow().isoformat(),
|
||||
"drawing_issues": drawing_stats,
|
||||
"segment_issues": segment_stats,
|
||||
"total_issues": len(drawing_issues) + len(segment_issues)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate issue report: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"이슈 리포트 생성 실패: {str(e)}"
|
||||
)
|
||||
435
backend/app/routers/pipe_revision.py
Normal file
435
backend/app/routers/pipe_revision.py
Normal file
@@ -0,0 +1,435 @@
|
||||
"""
|
||||
PIPE 리비전 관리 API 라우터
|
||||
|
||||
Cutting Plan 전/후 리비전 처리 및 비교 기능
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..database import get_db
|
||||
from ..services.pipe_revision_service import get_pipe_revision_service, PipeRevisionService
|
||||
from ..models import PipeRevisionComparison, PipeRevisionChange
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/pipe-revision", tags=["pipe-revision"])
|
||||
|
||||
|
||||
# Pydantic 모델들
|
||||
class RevisionStatusRequest(BaseModel):
|
||||
job_no: str
|
||||
new_file_id: int
|
||||
|
||||
|
||||
class PreRevisionRequest(BaseModel):
|
||||
job_no: str
|
||||
new_file_id: int
|
||||
|
||||
|
||||
class PostRevisionRequest(BaseModel):
|
||||
job_no: str
|
||||
new_file_id: int
|
||||
|
||||
|
||||
class RevisionStatusResponse(BaseModel):
|
||||
revision_type: str
|
||||
requires_action: bool
|
||||
message: str
|
||||
previous_file_id: Optional[int] = None
|
||||
|
||||
|
||||
class PreRevisionResponse(BaseModel):
|
||||
status: str
|
||||
revision_type: str
|
||||
deleted_items: int
|
||||
new_pipe_materials: int
|
||||
message: str
|
||||
next_action: str
|
||||
pipe_materials: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class PostRevisionResponse(BaseModel):
|
||||
status: str
|
||||
revision_type: str
|
||||
comparison_id: int
|
||||
summary: Dict[str, Any]
|
||||
changed_drawings: List[Dict[str, Any]]
|
||||
unchanged_drawings: List[Dict[str, Any]]
|
||||
message: str
|
||||
next_action: str
|
||||
|
||||
|
||||
@router.post("/check-status", response_model=RevisionStatusResponse)
|
||||
async def check_revision_status(
|
||||
request: RevisionStatusRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
PIPE 리비전 상태 확인
|
||||
- 리비전 유형 판단 (pre/post cutting plan)
|
||||
- 필요한 처리 방식 결정
|
||||
"""
|
||||
try:
|
||||
service = get_pipe_revision_service(db)
|
||||
result = service.check_revision_status(request.job_no, request.new_file_id)
|
||||
|
||||
return RevisionStatusResponse(**result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check pipe revision status: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"리비전 상태 확인 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/handle-pre-cutting-plan", response_model=PreRevisionResponse)
|
||||
async def handle_pre_cutting_plan_revision(
|
||||
request: PreRevisionRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Cutting Plan 작성 전 리비전 처리
|
||||
- 기존 PIPE 데이터 삭제
|
||||
- 새 BOM 데이터로 초기화
|
||||
"""
|
||||
try:
|
||||
service = get_pipe_revision_service(db)
|
||||
result = service.handle_pre_cutting_plan_revision(request.job_no, request.new_file_id)
|
||||
|
||||
if result["status"] != "success":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result["message"]
|
||||
)
|
||||
|
||||
return PreRevisionResponse(**result)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to handle pre-cutting-plan revision: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Cutting Plan 작성 전 리비전 처리 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/handle-post-cutting-plan", response_model=PostRevisionResponse)
|
||||
async def handle_post_cutting_plan_revision(
|
||||
request: PostRevisionRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Cutting Plan 작성 후 리비전 처리
|
||||
- 기존 Cutting Plan과 신규 BOM 비교
|
||||
- 변경사항 상세 분석
|
||||
"""
|
||||
try:
|
||||
service = get_pipe_revision_service(db)
|
||||
result = service.handle_post_cutting_plan_revision(request.job_no, request.new_file_id)
|
||||
|
||||
if result["status"] != "success":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result["message"]
|
||||
)
|
||||
|
||||
return PostRevisionResponse(**result)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to handle post-cutting-plan revision: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Cutting Plan 작성 후 리비전 처리 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/comparison/{comparison_id}")
|
||||
async def get_revision_comparison(
|
||||
comparison_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
리비전 비교 결과 상세 조회
|
||||
"""
|
||||
try:
|
||||
# 비교 결과 조회
|
||||
comparison = db.query(PipeRevisionComparison).filter(
|
||||
PipeRevisionComparison.id == comparison_id
|
||||
).first()
|
||||
|
||||
if not comparison:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="비교 결과를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 상세 변경사항 조회
|
||||
changes = db.query(PipeRevisionChange).filter(
|
||||
PipeRevisionChange.comparison_id == comparison_id
|
||||
).all()
|
||||
|
||||
# 도면별로 변경사항 그룹화
|
||||
changes_by_drawing = {}
|
||||
for change in changes:
|
||||
drawing = change.drawing_name
|
||||
if drawing not in changes_by_drawing:
|
||||
changes_by_drawing[drawing] = []
|
||||
|
||||
changes_by_drawing[drawing].append({
|
||||
"id": change.id,
|
||||
"change_type": change.change_type,
|
||||
"old_data": {
|
||||
"line_no": change.old_line_no,
|
||||
"material_grade": change.old_material_grade,
|
||||
"schedule_spec": change.old_schedule_spec,
|
||||
"nominal_size": change.old_nominal_size,
|
||||
"length_mm": change.old_length_mm,
|
||||
"end_preparation": change.old_end_preparation
|
||||
} if change.old_line_no else None,
|
||||
"new_data": {
|
||||
"line_no": change.new_line_no,
|
||||
"material_grade": change.new_material_grade,
|
||||
"schedule_spec": change.new_schedule_spec,
|
||||
"nominal_size": change.new_nominal_size,
|
||||
"length_mm": change.new_length_mm,
|
||||
"end_preparation": change.new_end_preparation
|
||||
} if change.new_line_no else None,
|
||||
"change_reason": change.change_reason
|
||||
})
|
||||
|
||||
return {
|
||||
"comparison_id": comparison.id,
|
||||
"job_no": comparison.job_no,
|
||||
"comparison_date": comparison.comparison_date,
|
||||
"summary": {
|
||||
"total_drawings": comparison.total_drawings,
|
||||
"changed_drawings": comparison.changed_drawings,
|
||||
"unchanged_drawings": comparison.unchanged_drawings,
|
||||
"total_segments": comparison.total_segments,
|
||||
"added_segments": comparison.added_segments,
|
||||
"removed_segments": comparison.removed_segments,
|
||||
"modified_segments": comparison.modified_segments,
|
||||
"unchanged_segments": comparison.unchanged_segments
|
||||
},
|
||||
"changes_by_drawing": changes_by_drawing,
|
||||
"is_applied": comparison.is_applied,
|
||||
"applied_at": comparison.applied_at,
|
||||
"applied_by": comparison.applied_by
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get revision comparison: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"리비전 비교 결과 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/comparison/{comparison_id}/apply")
|
||||
async def apply_revision_changes(
|
||||
comparison_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
리비전 변경사항 적용
|
||||
"""
|
||||
try:
|
||||
# 비교 결과 조회
|
||||
comparison = db.query(PipeRevisionComparison).filter(
|
||||
PipeRevisionComparison.id == comparison_id
|
||||
).first()
|
||||
|
||||
if not comparison:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="비교 결과를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
if comparison.is_applied:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미 적용된 리비전입니다."
|
||||
)
|
||||
|
||||
# 적용 상태 업데이트
|
||||
comparison.is_applied = True
|
||||
comparison.applied_at = datetime.utcnow()
|
||||
comparison.applied_by = "system" # 추후 사용자 정보로 변경
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "리비전 변경사항이 적용되었습니다.",
|
||||
"comparison_id": comparison_id,
|
||||
"applied_at": comparison.applied_at
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to apply revision changes: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"리비전 변경사항 적용 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/job/{job_no}/history")
|
||||
async def get_revision_history(
|
||||
job_no: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
작업별 PIPE 리비전 이력 조회
|
||||
"""
|
||||
try:
|
||||
comparisons = db.query(PipeRevisionComparison).filter(
|
||||
PipeRevisionComparison.job_no == job_no
|
||||
).order_by(PipeRevisionComparison.comparison_date.desc()).all()
|
||||
|
||||
history = []
|
||||
for comp in comparisons:
|
||||
history.append({
|
||||
"comparison_id": comp.id,
|
||||
"comparison_date": comp.comparison_date,
|
||||
"summary": {
|
||||
"total_drawings": comp.total_drawings,
|
||||
"changed_drawings": comp.changed_drawings,
|
||||
"total_segments": comp.total_segments,
|
||||
"added_segments": comp.added_segments,
|
||||
"removed_segments": comp.removed_segments,
|
||||
"modified_segments": comp.modified_segments
|
||||
},
|
||||
"is_applied": comp.is_applied,
|
||||
"applied_at": comp.applied_at,
|
||||
"applied_by": comp.applied_by,
|
||||
"created_by": comp.created_by
|
||||
})
|
||||
|
||||
return {
|
||||
"job_no": job_no,
|
||||
"total_revisions": len(history),
|
||||
"history": history
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get revision history: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"리비전 이력 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/comparison/{comparison_id}/purchase-impact")
|
||||
async def get_purchase_impact(
|
||||
comparison_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
리비전이 구매량에 미치는 영향 분석
|
||||
"""
|
||||
try:
|
||||
# 비교 결과 조회
|
||||
comparison = db.query(PipeRevisionComparison).filter(
|
||||
PipeRevisionComparison.id == comparison_id
|
||||
).first()
|
||||
|
||||
if not comparison:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="비교 결과를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 변경사항 조회
|
||||
changes = db.query(PipeRevisionChange).filter(
|
||||
PipeRevisionChange.comparison_id == comparison_id
|
||||
).all()
|
||||
|
||||
# 재질별 길이 변화 계산
|
||||
material_impacts = {}
|
||||
|
||||
for change in changes:
|
||||
# 재질 정보 추출
|
||||
material = None
|
||||
length_change = 0
|
||||
|
||||
if change.change_type == "added" and change.new_material_grade:
|
||||
material = change.new_material_grade
|
||||
length_change = change.new_length_mm or 0
|
||||
elif change.change_type == "removed" and change.old_material_grade:
|
||||
material = change.old_material_grade
|
||||
length_change = -(change.old_length_mm or 0)
|
||||
elif change.change_type == "modified":
|
||||
# 재질이 같은 경우만 길이 변화 계산
|
||||
if (change.old_material_grade == change.new_material_grade and
|
||||
change.old_material_grade):
|
||||
material = change.old_material_grade
|
||||
old_length = change.old_length_mm or 0
|
||||
new_length = change.new_length_mm or 0
|
||||
length_change = new_length - old_length
|
||||
|
||||
if material and length_change != 0:
|
||||
if material not in material_impacts:
|
||||
material_impacts[material] = {
|
||||
"material": material,
|
||||
"total_length_change": 0,
|
||||
"change_count": 0
|
||||
}
|
||||
|
||||
material_impacts[material]["total_length_change"] += length_change
|
||||
material_impacts[material]["change_count"] += 1
|
||||
|
||||
# 결과 정리
|
||||
impact_summary = []
|
||||
for material, impact in material_impacts.items():
|
||||
length_change_m = impact["total_length_change"] / 1000 # mm to m
|
||||
impact_summary.append({
|
||||
"material": material,
|
||||
"length_change_mm": impact["total_length_change"],
|
||||
"length_change_m": round(length_change_m, 3),
|
||||
"change_count": impact["change_count"],
|
||||
"impact_type": "increase" if length_change_m > 0 else "decrease",
|
||||
"requires_additional_purchase": length_change_m > 0
|
||||
})
|
||||
|
||||
# 전체 영향 요약
|
||||
total_additional_purchase = sum(
|
||||
impact["length_change_m"] for impact in impact_summary
|
||||
if impact["requires_additional_purchase"]
|
||||
)
|
||||
|
||||
return {
|
||||
"comparison_id": comparison_id,
|
||||
"job_no": comparison.job_no,
|
||||
"material_impacts": impact_summary,
|
||||
"summary": {
|
||||
"total_materials_affected": len(impact_summary),
|
||||
"materials_requiring_additional_purchase": len([
|
||||
i for i in impact_summary if i["requires_additional_purchase"]
|
||||
]),
|
||||
"total_additional_purchase_needed": total_additional_purchase > 0,
|
||||
"total_additional_length_m": round(total_additional_purchase, 3)
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get purchase impact: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"구매 영향 분석 실패: {str(e)}"
|
||||
)
|
||||
338
backend/app/routers/pipe_snapshot.py
Normal file
338
backend/app/routers/pipe_snapshot.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
PIPE 스냅샷 관리 API 라우터
|
||||
|
||||
Cutting Plan 확정, 스냅샷 생성, 이슈 관리 준비 등의 기능 제공
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..database import get_db
|
||||
from ..services.pipe_issue_snapshot_service import get_pipe_issue_snapshot_service, PipeIssueSnapshotService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/pipe-snapshot", tags=["pipe-snapshot"])
|
||||
|
||||
|
||||
# Pydantic 모델들
|
||||
class FinalizeCuttingPlanRequest(BaseModel):
|
||||
job_no: str
|
||||
created_by: Optional[str] = "system"
|
||||
|
||||
|
||||
class SnapshotStatusResponse(BaseModel):
|
||||
has_snapshot: bool
|
||||
snapshot_id: Optional[int] = None
|
||||
snapshot_name: Optional[str] = None
|
||||
is_locked: bool = False
|
||||
created_at: Optional[str] = None
|
||||
created_by: Optional[str] = None
|
||||
locked_at: Optional[str] = None
|
||||
locked_by: Optional[str] = None
|
||||
total_segments: int = 0
|
||||
total_drawings: int = 0
|
||||
drawing_issues_count: int = 0
|
||||
segment_issues_count: int = 0
|
||||
can_start_issue_management: bool = False
|
||||
message: str
|
||||
|
||||
|
||||
class SnapshotSegmentsResponse(BaseModel):
|
||||
snapshot_id: int
|
||||
segments: List[Dict[str, Any]]
|
||||
total_count: int
|
||||
areas: List[str]
|
||||
drawings: List[str]
|
||||
|
||||
|
||||
class FinalizeCuttingPlanResponse(BaseModel):
|
||||
success: bool
|
||||
snapshot_id: Optional[int] = None
|
||||
snapshot_name: Optional[str] = None
|
||||
total_segments: int = 0
|
||||
total_drawings: int = 0
|
||||
is_locked: bool = False
|
||||
locked_at: Optional[str] = None
|
||||
message: str
|
||||
next_action: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/finalize-cutting-plan", response_model=FinalizeCuttingPlanResponse)
|
||||
async def finalize_cutting_plan(
|
||||
request: FinalizeCuttingPlanRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Cutting Plan 확정 및 스냅샷 생성
|
||||
- 현재 단관 데이터를 스냅샷으로 고정
|
||||
- 이슈 관리 시작 가능 상태로 변경
|
||||
"""
|
||||
try:
|
||||
service = get_pipe_issue_snapshot_service(db)
|
||||
result = service.create_and_lock_snapshot_on_finalize(
|
||||
request.job_no,
|
||||
request.created_by
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result["message"]
|
||||
)
|
||||
|
||||
return FinalizeCuttingPlanResponse(**result)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to finalize cutting plan: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Cutting Plan 확정 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status/{job_no}", response_model=SnapshotStatusResponse)
|
||||
async def get_snapshot_status(
|
||||
job_no: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
작업의 스냅샷 상태 조회
|
||||
- 스냅샷 존재 여부
|
||||
- 잠금 상태
|
||||
- 이슈 관리 가능 여부
|
||||
"""
|
||||
try:
|
||||
service = get_pipe_issue_snapshot_service(db)
|
||||
result = service.get_snapshot_info(job_no)
|
||||
|
||||
return SnapshotStatusResponse(**result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get snapshot status: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"스냅샷 상태 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/segments/{snapshot_id}", response_model=SnapshotSegmentsResponse)
|
||||
async def get_snapshot_segments(
|
||||
snapshot_id: int,
|
||||
area: Optional[str] = None,
|
||||
drawing_name: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
스냅샷된 단관 정보 조회
|
||||
- 구역별/도면별 필터링 가능
|
||||
- 이슈 관리 페이지에서 사용
|
||||
"""
|
||||
try:
|
||||
service = get_pipe_issue_snapshot_service(db)
|
||||
|
||||
# 단관 데이터 조회
|
||||
segments = service.get_snapshot_segments(snapshot_id, area, drawing_name)
|
||||
|
||||
# 사용 가능한 구역/도면 목록
|
||||
areas = service.get_available_areas(snapshot_id)
|
||||
drawings = service.get_available_drawings(snapshot_id, area)
|
||||
|
||||
return SnapshotSegmentsResponse(
|
||||
snapshot_id=snapshot_id,
|
||||
segments=segments,
|
||||
total_count=len(segments),
|
||||
areas=areas,
|
||||
drawings=drawings
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get snapshot segments: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"스냅샷 단관 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/areas/{snapshot_id}")
|
||||
async def get_available_areas(
|
||||
snapshot_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""스냅샷의 사용 가능한 구역 목록 조회"""
|
||||
try:
|
||||
service = get_pipe_issue_snapshot_service(db)
|
||||
areas = service.get_available_areas(snapshot_id)
|
||||
|
||||
return {
|
||||
"snapshot_id": snapshot_id,
|
||||
"areas": areas,
|
||||
"total_count": len(areas)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get available areas: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"구역 목록 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/drawings/{snapshot_id}")
|
||||
async def get_available_drawings(
|
||||
snapshot_id: int,
|
||||
area: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""스냅샷의 사용 가능한 도면 목록 조회"""
|
||||
try:
|
||||
service = get_pipe_issue_snapshot_service(db)
|
||||
drawings = service.get_available_drawings(snapshot_id, area)
|
||||
|
||||
return {
|
||||
"snapshot_id": snapshot_id,
|
||||
"area": area,
|
||||
"drawings": drawings,
|
||||
"total_count": len(drawings)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get available drawings: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"도면 목록 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/revision-protection/{job_no}")
|
||||
async def check_revision_protection(
|
||||
job_no: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
리비전 보호 상태 확인
|
||||
- 잠긴 스냅샷이 있으면 리비전 영향 받지 않음
|
||||
"""
|
||||
try:
|
||||
service = get_pipe_issue_snapshot_service(db)
|
||||
result = service.check_revision_protection(job_no)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check revision protection: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"리비전 보호 상태 확인 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/snapshot/{snapshot_id}")
|
||||
async def delete_snapshot(
|
||||
snapshot_id: int,
|
||||
force: bool = False,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
스냅샷 삭제 (개발/테스트용)
|
||||
- force=True: 강제 삭제 (이슈 데이터 포함)
|
||||
- force=False: 이슈가 없는 경우만 삭제
|
||||
"""
|
||||
try:
|
||||
from ..models import PipeIssueSnapshot, PipeDrawingIssue, PipeSegmentIssue
|
||||
|
||||
# 스냅샷 존재 확인
|
||||
snapshot = db.query(PipeIssueSnapshot).filter(
|
||||
PipeIssueSnapshot.id == snapshot_id
|
||||
).first()
|
||||
|
||||
if not snapshot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="스냅샷을 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 이슈 데이터 확인
|
||||
drawing_issues_count = db.query(PipeDrawingIssue).filter(
|
||||
PipeDrawingIssue.snapshot_id == snapshot_id
|
||||
).count()
|
||||
|
||||
segment_issues_count = db.query(PipeSegmentIssue).filter(
|
||||
PipeSegmentIssue.snapshot_id == snapshot_id
|
||||
).count()
|
||||
|
||||
total_issues = drawing_issues_count + segment_issues_count
|
||||
|
||||
if total_issues > 0 and not force:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"이슈 데이터가 {total_issues}개 있습니다. force=true로 강제 삭제하거나 이슈를 먼저 정리해주세요."
|
||||
)
|
||||
|
||||
# 스냅샷 삭제 (CASCADE로 관련 데이터 자동 삭제)
|
||||
db.delete(snapshot)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"스냅샷 '{snapshot.snapshot_name}'이 삭제되었습니다.",
|
||||
"deleted_snapshot_id": snapshot_id,
|
||||
"deleted_issues_count": total_issues
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to delete snapshot: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"스냅샷 삭제 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/job/{job_no}/history")
|
||||
async def get_snapshot_history(
|
||||
job_no: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""작업별 스냅샷 이력 조회"""
|
||||
try:
|
||||
from ..models import PipeIssueSnapshot
|
||||
|
||||
snapshots = db.query(PipeIssueSnapshot).filter(
|
||||
PipeIssueSnapshot.job_no == job_no
|
||||
).order_by(PipeIssueSnapshot.created_at.desc()).all()
|
||||
|
||||
history = []
|
||||
for snapshot in snapshots:
|
||||
history.append({
|
||||
"snapshot_id": snapshot.id,
|
||||
"snapshot_name": snapshot.snapshot_name,
|
||||
"is_active": snapshot.is_active,
|
||||
"is_locked": snapshot.is_locked,
|
||||
"created_at": snapshot.created_at.isoformat() if snapshot.created_at else None,
|
||||
"created_by": snapshot.created_by,
|
||||
"locked_at": snapshot.locked_at.isoformat() if snapshot.locked_at else None,
|
||||
"locked_by": snapshot.locked_by,
|
||||
"total_segments": snapshot.total_segments,
|
||||
"total_drawings": snapshot.total_drawings
|
||||
})
|
||||
|
||||
return {
|
||||
"job_no": job_no,
|
||||
"total_snapshots": len(history),
|
||||
"history": history
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get snapshot history: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"스냅샷 이력 조회 실패: {str(e)}"
|
||||
)
|
||||
97
backend/app/routers/revision_comparison.py
Normal file
97
backend/app/routers/revision_comparison.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
리비전 비교 API 엔드포인트
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from ..database import get_db
|
||||
from ..auth.middleware import get_current_user
|
||||
from ..services.revision_comparison_service import RevisionComparisonService
|
||||
from ..auth.models import User
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/revision-comparison", tags=["Revision Comparison"])
|
||||
|
||||
|
||||
@router.post("/compare")
|
||||
async def compare_revisions(
|
||||
current_file_id: int = Query(..., description="현재 파일 ID"),
|
||||
previous_file_id: int = Query(..., description="이전 파일 ID"),
|
||||
category_filter: Optional[str] = Query(None, description="카테고리 필터"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
두 리비전 간 자재 비교
|
||||
"""
|
||||
|
||||
try:
|
||||
comparison_service = RevisionComparisonService(db)
|
||||
|
||||
comparison_result = comparison_service.compare_revisions(
|
||||
current_file_id=current_file_id,
|
||||
previous_file_id=previous_file_id,
|
||||
category_filter=category_filter
|
||||
)
|
||||
|
||||
logger.info(f"Revision comparison completed: {current_file_id} vs {previous_file_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": comparison_result,
|
||||
"message": "리비전 비교 완료"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to compare revisions: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"리비전 비교 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/category/{current_file_id}/{previous_file_id}/{category}")
|
||||
async def get_category_comparison(
|
||||
current_file_id: int,
|
||||
previous_file_id: int,
|
||||
category: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
특정 카테고리의 리비전 비교
|
||||
"""
|
||||
|
||||
if category == 'PIPE':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="PIPE 카테고리는 별도 처리가 필요합니다."
|
||||
)
|
||||
|
||||
try:
|
||||
comparison_service = RevisionComparisonService(db)
|
||||
|
||||
comparison_result = comparison_service.get_category_comparison(
|
||||
current_file_id=current_file_id,
|
||||
previous_file_id=previous_file_id,
|
||||
category=category
|
||||
)
|
||||
|
||||
logger.info(f"Category comparison completed: {category} ({current_file_id} vs {previous_file_id})")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": comparison_result,
|
||||
"message": f"{category} 카테고리 비교 완료"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to compare category {category}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"카테고리 비교 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
199
backend/app/routers/revision_material.py
Normal file
199
backend/app/routers/revision_material.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
리비전 자재 처리 API 엔드포인트
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Dict, Any
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..database import get_db
|
||||
from ..auth.middleware import get_current_user
|
||||
from ..services.revision_material_service import RevisionMaterialService
|
||||
from ..auth.models import User
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/revision-material", tags=["Revision Material"])
|
||||
|
||||
|
||||
class ProcessingResultRequest(BaseModel):
|
||||
processing_results: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class MaterialProcessRequest(BaseModel):
|
||||
action: str
|
||||
additional_data: Dict[str, Any] = {}
|
||||
|
||||
|
||||
@router.get("/category/{file_id}/{category}")
|
||||
async def get_category_materials(
|
||||
file_id: int,
|
||||
category: str,
|
||||
include_processing_info: bool = True,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
리비전 페이지용 카테고리별 자재 조회
|
||||
"""
|
||||
|
||||
if category == 'PIPE':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="PIPE 카테고리는 별도 처리가 필요합니다."
|
||||
)
|
||||
|
||||
try:
|
||||
material_service = RevisionMaterialService(db)
|
||||
|
||||
materials = material_service.get_category_materials_for_revision(
|
||||
file_id=file_id,
|
||||
category=category,
|
||||
include_processing_info=include_processing_info
|
||||
)
|
||||
|
||||
# 처리 정보 요약
|
||||
processing_info = {
|
||||
"total_materials": len(materials),
|
||||
"by_status": {},
|
||||
"by_priority": {"high": 0, "medium": 0, "low": 0}
|
||||
}
|
||||
|
||||
for material in materials:
|
||||
proc_info = material.get('processing_info', {})
|
||||
status = proc_info.get('display_status', 'UNKNOWN')
|
||||
priority = proc_info.get('priority', 'medium')
|
||||
|
||||
processing_info["by_status"][status] = processing_info["by_status"].get(status, 0) + 1
|
||||
processing_info["by_priority"][priority] += 1
|
||||
|
||||
logger.info(f"Retrieved {len(materials)} materials for category {category} in file {file_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"materials": materials,
|
||||
"processing_info": processing_info
|
||||
},
|
||||
"message": f"{category} 카테고리 자재 조회 완료"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get category materials: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"카테고리 자재 조회 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/process/{material_id}")
|
||||
async def process_material(
|
||||
material_id: int,
|
||||
request: MaterialProcessRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
개별 자재 처리
|
||||
"""
|
||||
|
||||
try:
|
||||
material_service = RevisionMaterialService(db)
|
||||
|
||||
# 자재 정보 조회
|
||||
material_query = """
|
||||
SELECT * FROM materials WHERE id = :material_id
|
||||
"""
|
||||
|
||||
result = material_service.db_service.execute_query(
|
||||
material_query, {"material_id": material_id}
|
||||
)
|
||||
material = result.fetchone()
|
||||
|
||||
if not material:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="자재를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
material_dict = dict(material._mapping)
|
||||
|
||||
# 액션에 따른 처리
|
||||
if request.action == "new_material":
|
||||
processing_result = material_service.process_new_material(
|
||||
material_dict,
|
||||
material_dict.get('classified_category', 'UNKNOWN')
|
||||
)
|
||||
elif request.action == "remove_material":
|
||||
processing_result = material_service.process_removed_material(
|
||||
material_dict,
|
||||
material_dict.get('classified_category', 'UNKNOWN')
|
||||
)
|
||||
else:
|
||||
# 기본 처리 (구매 상태별)
|
||||
# 이전 자재 정보가 필요한 경우 additional_data에서 가져옴
|
||||
prev_material = request.additional_data.get('previous_material', material_dict)
|
||||
|
||||
processing_result = material_service.process_material_by_purchase_status(
|
||||
prev_material,
|
||||
material_dict,
|
||||
material_dict.get('classified_category', 'UNKNOWN')
|
||||
)
|
||||
|
||||
# 처리 결과 적용
|
||||
apply_result = material_service.apply_material_processing_results([processing_result])
|
||||
|
||||
logger.info(f"Processed material {material_id} with action {request.action}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"processing_result": processing_result,
|
||||
"apply_result": apply_result
|
||||
},
|
||||
"message": "자재 처리 완료"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process material {material_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"자재 처리 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/apply-results")
|
||||
async def apply_processing_results(
|
||||
request: ProcessingResultRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
자재 처리 결과를 일괄 적용
|
||||
"""
|
||||
|
||||
try:
|
||||
material_service = RevisionMaterialService(db)
|
||||
|
||||
apply_result = material_service.apply_material_processing_results(
|
||||
request.processing_results
|
||||
)
|
||||
|
||||
logger.info(f"Applied {len(request.processing_results)} processing results")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": apply_result,
|
||||
"message": "처리 결과 적용 완료"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply processing results: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"처리 결과 적용 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
130
backend/app/routers/revision_redirect.py
Normal file
130
backend/app/routers/revision_redirect.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
리비전 리다이렉트 API 엔드포인트
|
||||
BOM 페이지 접근 시 리비전 페이지로 리다이렉트 필요성 판단
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from ..database import get_db
|
||||
from ..auth.middleware import get_current_user
|
||||
from ..services.revision_logic_service import RevisionLogicService
|
||||
from ..auth.models import User
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/revision-redirect", tags=["Revision Redirect"])
|
||||
|
||||
|
||||
@router.get("/check/{job_no}/{file_id}")
|
||||
async def check_revision_redirect(
|
||||
job_no: str,
|
||||
file_id: int,
|
||||
previous_file_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
리비전 페이지 리다이렉트 필요성 확인
|
||||
|
||||
Args:
|
||||
job_no: 작업 번호
|
||||
file_id: 현재 파일 ID
|
||||
previous_file_id: 이전 파일 ID (선택사항)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"should_redirect": bool,
|
||||
"reason": str,
|
||||
"redirect_url": str,
|
||||
"processing_summary": dict
|
||||
}
|
||||
"""
|
||||
|
||||
try:
|
||||
revision_service = RevisionLogicService(db)
|
||||
|
||||
# 리다이렉트 필요성 판단
|
||||
should_redirect, reason = revision_service.should_redirect_to_revision_page(
|
||||
job_no=job_no,
|
||||
current_file_id=file_id,
|
||||
previous_file_id=previous_file_id
|
||||
)
|
||||
|
||||
result = {
|
||||
"should_redirect": should_redirect,
|
||||
"reason": reason,
|
||||
"redirect_url": f"/enhanced-revision?job_no={job_no}¤t_file_id={file_id}",
|
||||
"processing_summary": None
|
||||
}
|
||||
|
||||
# 상세 처리 결과도 함께 반환 (필요시)
|
||||
if should_redirect:
|
||||
processing_result = revision_service.process_revision_by_purchase_status(
|
||||
job_no=job_no,
|
||||
current_file_id=file_id,
|
||||
previous_file_id=previous_file_id
|
||||
)
|
||||
result["processing_summary"] = processing_result["summary"]
|
||||
|
||||
logger.info(f"Revision redirect check for {job_no}/{file_id}: {should_redirect}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result,
|
||||
"message": "리비전 리다이렉트 확인 완료"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check revision redirect: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"리비전 리다이렉트 확인 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/process-revision/{job_no}/{file_id}")
|
||||
async def process_revision_logic(
|
||||
job_no: str,
|
||||
file_id: int,
|
||||
previous_file_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
리비전 로직 처리 및 결과 반환
|
||||
|
||||
Args:
|
||||
job_no: 작업 번호
|
||||
file_id: 현재 파일 ID
|
||||
previous_file_id: 이전 파일 ID (선택사항)
|
||||
|
||||
Returns:
|
||||
전체 리비전 처리 결과
|
||||
"""
|
||||
|
||||
try:
|
||||
revision_service = RevisionLogicService(db)
|
||||
|
||||
processing_result = revision_service.process_revision_by_purchase_status(
|
||||
job_no=job_no,
|
||||
current_file_id=file_id,
|
||||
previous_file_id=previous_file_id
|
||||
)
|
||||
|
||||
logger.info(f"Revision processing completed for {job_no}/{file_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": processing_result,
|
||||
"message": "리비전 처리 완료"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process revision logic: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"리비전 처리 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
258
backend/app/routers/revision_status.py
Normal file
258
backend/app/routers/revision_status.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
리비전 상태 관리 API 엔드포인트
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..database import get_db
|
||||
from ..auth.middleware import get_current_user
|
||||
from ..services.revision_status_service import RevisionStatusService
|
||||
from ..auth.models import User
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/revision-status", tags=["Revision Status"])
|
||||
|
||||
|
||||
class CreateComparisonRequest(BaseModel):
|
||||
job_no: str
|
||||
current_file_id: int
|
||||
previous_file_id: int
|
||||
comparison_result: dict
|
||||
|
||||
|
||||
class RejectComparisonRequest(BaseModel):
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@router.get("/{job_no}/{file_id}")
|
||||
async def get_revision_status(
|
||||
job_no: str,
|
||||
file_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
리비전 상태 조회
|
||||
"""
|
||||
|
||||
try:
|
||||
status_service = RevisionStatusService(db)
|
||||
|
||||
revision_status = status_service.get_revision_status(job_no, file_id)
|
||||
|
||||
if "error" in revision_status:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=revision_status["error"]
|
||||
)
|
||||
|
||||
logger.info(f"Retrieved revision status for {job_no}/{file_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": revision_status,
|
||||
"message": "리비전 상태 조회 완료"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get revision status: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"리비전 상태 조회 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history/{job_no}")
|
||||
async def get_revision_history(
|
||||
job_no: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
작업의 전체 리비전 히스토리 조회
|
||||
"""
|
||||
|
||||
try:
|
||||
status_service = RevisionStatusService(db)
|
||||
|
||||
history = status_service.get_revision_history(job_no)
|
||||
|
||||
logger.info(f"Retrieved revision history for {job_no}: {len(history)} revisions")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": history,
|
||||
"message": "리비전 히스토리 조회 완료"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get revision history: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"리비전 히스토리 조회 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/create-comparison")
|
||||
async def create_comparison_record(
|
||||
request: CreateComparisonRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
리비전 비교 기록 생성
|
||||
"""
|
||||
|
||||
try:
|
||||
status_service = RevisionStatusService(db)
|
||||
|
||||
comparison_id = status_service.create_revision_comparison_record(
|
||||
job_no=request.job_no,
|
||||
current_file_id=request.current_file_id,
|
||||
previous_file_id=request.previous_file_id,
|
||||
comparison_result=request.comparison_result,
|
||||
created_by=current_user.username
|
||||
)
|
||||
|
||||
logger.info(f"Created revision comparison record: {comparison_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"comparison_id": comparison_id},
|
||||
"message": "리비전 비교 기록 생성 완료"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create comparison record: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"비교 기록 생성 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/apply-comparison/{comparison_id}")
|
||||
async def apply_comparison(
|
||||
comparison_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
리비전 비교 결과 적용
|
||||
"""
|
||||
|
||||
try:
|
||||
status_service = RevisionStatusService(db)
|
||||
|
||||
apply_result = status_service.apply_revision_comparison(
|
||||
comparison_id=comparison_id,
|
||||
applied_by=current_user.username
|
||||
)
|
||||
|
||||
if not apply_result["success"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=apply_result["error"]
|
||||
)
|
||||
|
||||
logger.info(f"Applied revision comparison: {comparison_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": apply_result,
|
||||
"message": "리비전 비교 결과 적용 완료"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply comparison {comparison_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"비교 결과 적용 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pending")
|
||||
async def get_pending_revisions(
|
||||
job_no: Optional[str] = Query(None, description="특정 작업의 대기 중인 리비전만 조회"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
대기 중인 리비전 목록 조회
|
||||
"""
|
||||
|
||||
try:
|
||||
status_service = RevisionStatusService(db)
|
||||
|
||||
pending_revisions = status_service.get_pending_revisions(job_no)
|
||||
|
||||
logger.info(f"Retrieved {len(pending_revisions)} pending revisions")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": pending_revisions,
|
||||
"message": "대기 중인 리비전 조회 완료"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get pending revisions: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"대기 중인 리비전 조회 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reject-comparison/{comparison_id}")
|
||||
async def reject_comparison(
|
||||
comparison_id: int,
|
||||
request: RejectComparisonRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
리비전 비교 결과 거부
|
||||
"""
|
||||
|
||||
try:
|
||||
# 비교 기록을 거부 상태로 업데이트
|
||||
update_query = """
|
||||
UPDATE revision_comparisons
|
||||
SET is_applied = false,
|
||||
notes = CONCAT(COALESCE(notes, ''), '\n거부됨: ', :reason, ' (by ', :rejected_by, ')')
|
||||
WHERE id = :comparison_id
|
||||
"""
|
||||
|
||||
from ..services.database_service import DatabaseService
|
||||
db_service = DatabaseService(db)
|
||||
|
||||
db_service.execute_query(update_query, {
|
||||
"comparison_id": comparison_id,
|
||||
"reason": request.reason or "사유 없음",
|
||||
"rejected_by": current_user.username
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Rejected revision comparison: {comparison_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"comparison_id": comparison_id, "reason": request.reason},
|
||||
"message": "리비전 비교 결과 거부 완료"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reject comparison {comparison_id}: {e}")
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"비교 결과 거부 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
488
backend/app/services/enhanced_revision_service.py
Normal file
488
backend/app/services/enhanced_revision_service.py
Normal file
@@ -0,0 +1,488 @@
|
||||
"""
|
||||
강화된 리비전 관리 서비스
|
||||
구매 상태 기반 리비전 비교 및 처리
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, and_, or_
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from decimal import Decimal
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
|
||||
from ..models import Material, File
|
||||
from ..utils.logger import get_logger
|
||||
from .database_service import DatabaseService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class EnhancedRevisionService:
|
||||
"""강화된 리비전 관리 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.db_service = DatabaseService(db)
|
||||
|
||||
def compare_revisions_with_purchase_status(
|
||||
self,
|
||||
job_no: str,
|
||||
current_file_id: int,
|
||||
previous_file_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
구매 상태를 고려한 리비전 비교
|
||||
|
||||
Args:
|
||||
job_no: 작업 번호
|
||||
current_file_id: 현재 파일 ID
|
||||
previous_file_id: 이전 파일 ID (None이면 자동 탐지)
|
||||
|
||||
Returns:
|
||||
비교 결과 딕셔너리
|
||||
"""
|
||||
|
||||
if not previous_file_id:
|
||||
previous_file_id = self._get_previous_file_id(job_no, current_file_id)
|
||||
|
||||
if not previous_file_id:
|
||||
return self._handle_first_revision(current_file_id)
|
||||
|
||||
# 이전 리비전 자재 조회 (구매 상태 포함)
|
||||
previous_materials = self._get_materials_with_purchase_status(previous_file_id)
|
||||
|
||||
# 현재 리비전 자재 조회
|
||||
current_materials = self._get_materials_with_purchase_status(current_file_id)
|
||||
|
||||
# 자재별 비교 수행
|
||||
comparison_result = self._perform_detailed_comparison(
|
||||
previous_materials, current_materials, job_no
|
||||
)
|
||||
|
||||
return comparison_result
|
||||
|
||||
def _get_materials_with_purchase_status(self, file_id: int) -> Dict[str, Dict]:
|
||||
"""파일의 자재를 구매 상태와 함께 조회"""
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
m.id, m.original_description, m.classified_category,
|
||||
m.material_grade, m.schedule, m.size_spec, m.main_nom, m.red_nom,
|
||||
m.quantity, m.unit, m.length, m.drawing_name, m.line_no,
|
||||
m.purchase_confirmed, m.confirmed_quantity, m.purchase_status,
|
||||
m.material_hash, m.revision_status,
|
||||
-- PIPE 자재 특별 키 생성
|
||||
CASE
|
||||
WHEN m.classified_category = 'PIPE' THEN
|
||||
CONCAT(m.drawing_name, '|', m.line_no, '|', COALESCE(m.length, 0))
|
||||
ELSE
|
||||
m.material_hash
|
||||
END as comparison_key
|
||||
FROM materials m
|
||||
WHERE m.file_id = :file_id
|
||||
ORDER BY m.line_number
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {"file_id": file_id})
|
||||
materials = {}
|
||||
|
||||
for row in result.fetchall():
|
||||
row_dict = dict(row._mapping)
|
||||
comparison_key = row_dict['comparison_key']
|
||||
|
||||
# PIPE 자재의 경우 도면-라인넘버별로 길이 합산
|
||||
if row_dict['classified_category'] == 'PIPE':
|
||||
if comparison_key in materials:
|
||||
# 기존 자재에 길이 합산
|
||||
materials[comparison_key]['quantity'] += row_dict['quantity']
|
||||
materials[comparison_key]['total_length'] = (
|
||||
materials[comparison_key].get('total_length', 0) +
|
||||
(row_dict['length'] or 0) * row_dict['quantity']
|
||||
)
|
||||
else:
|
||||
row_dict['total_length'] = (row_dict['length'] or 0) * row_dict['quantity']
|
||||
materials[comparison_key] = row_dict
|
||||
else:
|
||||
materials[comparison_key] = row_dict
|
||||
|
||||
return materials
|
||||
|
||||
def _perform_detailed_comparison(
|
||||
self,
|
||||
previous_materials: Dict[str, Dict],
|
||||
current_materials: Dict[str, Dict],
|
||||
job_no: str
|
||||
) -> Dict[str, Any]:
|
||||
"""상세 비교 수행"""
|
||||
|
||||
result = {
|
||||
"job_no": job_no,
|
||||
"comparison_date": datetime.now().isoformat(),
|
||||
"summary": {
|
||||
"total_previous": len(previous_materials),
|
||||
"total_current": len(current_materials),
|
||||
"purchased_maintained": 0,
|
||||
"purchased_increased": 0,
|
||||
"purchased_decreased": 0,
|
||||
"unpurchased_maintained": 0,
|
||||
"unpurchased_increased": 0,
|
||||
"unpurchased_decreased": 0,
|
||||
"new_materials": 0,
|
||||
"deleted_materials": 0
|
||||
},
|
||||
"changes": {
|
||||
"purchased_materials": {
|
||||
"maintained": [],
|
||||
"additional_purchase_needed": [],
|
||||
"excess_inventory": []
|
||||
},
|
||||
"unpurchased_materials": {
|
||||
"maintained": [],
|
||||
"quantity_updated": [],
|
||||
"quantity_reduced": []
|
||||
},
|
||||
"new_materials": [],
|
||||
"deleted_materials": []
|
||||
}
|
||||
}
|
||||
|
||||
# 이전 자재 기준으로 비교
|
||||
for key, prev_material in previous_materials.items():
|
||||
if key in current_materials:
|
||||
curr_material = current_materials[key]
|
||||
change_info = self._analyze_material_change(prev_material, curr_material)
|
||||
|
||||
if prev_material.get('purchase_confirmed', False):
|
||||
# 구매 완료된 자재 처리
|
||||
self._process_purchased_material_change(result, change_info, prev_material, curr_material)
|
||||
else:
|
||||
# 구매 미완료 자재 처리
|
||||
self._process_unpurchased_material_change(result, change_info, prev_material, curr_material)
|
||||
else:
|
||||
# 삭제된 자재
|
||||
result["changes"]["deleted_materials"].append({
|
||||
"material": prev_material,
|
||||
"reason": "removed_from_new_revision"
|
||||
})
|
||||
result["summary"]["deleted_materials"] += 1
|
||||
|
||||
# 신규 자재 처리
|
||||
for key, curr_material in current_materials.items():
|
||||
if key not in previous_materials:
|
||||
result["changes"]["new_materials"].append({
|
||||
"material": curr_material,
|
||||
"action": "new_material_added"
|
||||
})
|
||||
result["summary"]["new_materials"] += 1
|
||||
|
||||
return result
|
||||
|
||||
def _analyze_material_change(self, prev_material: Dict, curr_material: Dict) -> Dict:
|
||||
"""자재 변경 사항 분석"""
|
||||
|
||||
prev_qty = float(prev_material.get('quantity', 0))
|
||||
curr_qty = float(curr_material.get('quantity', 0))
|
||||
|
||||
# PIPE 자재의 경우 총 길이로 비교
|
||||
if prev_material.get('classified_category') == 'PIPE':
|
||||
prev_total = prev_material.get('total_length', 0)
|
||||
curr_total = curr_material.get('total_length', 0)
|
||||
|
||||
return {
|
||||
"quantity_change": curr_qty - prev_qty,
|
||||
"length_change": curr_total - prev_total,
|
||||
"change_type": "length_based" if abs(curr_total - prev_total) > 0.01 else "no_change"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"quantity_change": curr_qty - prev_qty,
|
||||
"change_type": "increased" if curr_qty > prev_qty else "decreased" if curr_qty < prev_qty else "no_change"
|
||||
}
|
||||
|
||||
def _process_purchased_material_change(
|
||||
self,
|
||||
result: Dict,
|
||||
change_info: Dict,
|
||||
prev_material: Dict,
|
||||
curr_material: Dict
|
||||
):
|
||||
"""구매 완료 자재 변경 처리"""
|
||||
|
||||
if change_info["change_type"] == "no_change":
|
||||
result["changes"]["purchased_materials"]["maintained"].append({
|
||||
"material": curr_material,
|
||||
"action": "maintain_inventory"
|
||||
})
|
||||
result["summary"]["purchased_maintained"] += 1
|
||||
|
||||
elif change_info["change_type"] == "increased":
|
||||
additional_qty = change_info["quantity_change"]
|
||||
result["changes"]["purchased_materials"]["additional_purchase_needed"].append({
|
||||
"material": curr_material,
|
||||
"previous_quantity": prev_material.get('quantity'),
|
||||
"current_quantity": curr_material.get('quantity'),
|
||||
"additional_needed": additional_qty,
|
||||
"action": "additional_purchase_required"
|
||||
})
|
||||
result["summary"]["purchased_increased"] += 1
|
||||
|
||||
else: # decreased
|
||||
excess_qty = abs(change_info["quantity_change"])
|
||||
result["changes"]["purchased_materials"]["excess_inventory"].append({
|
||||
"material": curr_material,
|
||||
"previous_quantity": prev_material.get('quantity'),
|
||||
"current_quantity": curr_material.get('quantity'),
|
||||
"excess_quantity": excess_qty,
|
||||
"action": "mark_as_excess_inventory"
|
||||
})
|
||||
result["summary"]["purchased_decreased"] += 1
|
||||
|
||||
def _process_unpurchased_material_change(
|
||||
self,
|
||||
result: Dict,
|
||||
change_info: Dict,
|
||||
prev_material: Dict,
|
||||
curr_material: Dict
|
||||
):
|
||||
"""구매 미완료 자재 변경 처리"""
|
||||
|
||||
if change_info["change_type"] == "no_change":
|
||||
result["changes"]["unpurchased_materials"]["maintained"].append({
|
||||
"material": curr_material,
|
||||
"action": "maintain_purchase_pending"
|
||||
})
|
||||
result["summary"]["unpurchased_maintained"] += 1
|
||||
|
||||
elif change_info["change_type"] == "increased":
|
||||
result["changes"]["unpurchased_materials"]["quantity_updated"].append({
|
||||
"material": curr_material,
|
||||
"previous_quantity": prev_material.get('quantity'),
|
||||
"current_quantity": curr_material.get('quantity'),
|
||||
"quantity_change": change_info["quantity_change"],
|
||||
"action": "update_purchase_quantity"
|
||||
})
|
||||
result["summary"]["unpurchased_increased"] += 1
|
||||
|
||||
else: # decreased
|
||||
result["changes"]["unpurchased_materials"]["quantity_reduced"].append({
|
||||
"material": curr_material,
|
||||
"previous_quantity": prev_material.get('quantity'),
|
||||
"current_quantity": curr_material.get('quantity'),
|
||||
"quantity_change": change_info["quantity_change"],
|
||||
"action": "reduce_purchase_quantity"
|
||||
})
|
||||
result["summary"]["unpurchased_decreased"] += 1
|
||||
|
||||
def _get_previous_file_id(self, job_no: str, current_file_id: int) -> Optional[int]:
|
||||
"""이전 파일 ID 자동 탐지"""
|
||||
|
||||
query = """
|
||||
SELECT id, revision
|
||||
FROM files
|
||||
WHERE job_no = :job_no AND id != :current_file_id AND is_active = true
|
||||
ORDER BY upload_date DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {
|
||||
"job_no": job_no,
|
||||
"current_file_id": current_file_id
|
||||
})
|
||||
|
||||
row = result.fetchone()
|
||||
return row.id if row else None
|
||||
|
||||
def _handle_first_revision(self, current_file_id: int) -> Dict[str, Any]:
|
||||
"""첫 번째 리비전 처리"""
|
||||
|
||||
materials = self._get_materials_with_purchase_status(current_file_id)
|
||||
|
||||
return {
|
||||
"job_no": None,
|
||||
"comparison_date": datetime.now().isoformat(),
|
||||
"is_first_revision": True,
|
||||
"summary": {
|
||||
"total_materials": len(materials),
|
||||
"all_new": True
|
||||
},
|
||||
"changes": {
|
||||
"new_materials": [{"material": mat, "action": "first_revision"} for mat in materials.values()]
|
||||
}
|
||||
}
|
||||
|
||||
def apply_revision_changes(self, comparison_result: Dict, current_file_id: int) -> Dict[str, Any]:
|
||||
"""리비전 변경사항을 DB에 적용"""
|
||||
|
||||
try:
|
||||
# 각 변경사항별로 DB 업데이트
|
||||
updates_applied = {
|
||||
"purchased_materials": 0,
|
||||
"unpurchased_materials": 0,
|
||||
"new_materials": 0,
|
||||
"deleted_materials": 0
|
||||
}
|
||||
|
||||
changes = comparison_result.get("changes", {})
|
||||
|
||||
# 구매 완료 자재 처리
|
||||
purchased = changes.get("purchased_materials", {})
|
||||
for category, materials in purchased.items():
|
||||
for item in materials:
|
||||
material = item["material"]
|
||||
action = item["action"]
|
||||
|
||||
if action == "additional_purchase_required":
|
||||
self._mark_additional_purchase_needed(material, item)
|
||||
elif action == "mark_as_excess_inventory":
|
||||
self._mark_excess_inventory(material, item)
|
||||
|
||||
updates_applied["purchased_materials"] += 1
|
||||
|
||||
# 구매 미완료 자재 처리
|
||||
unpurchased = changes.get("unpurchased_materials", {})
|
||||
for category, materials in unpurchased.items():
|
||||
for item in materials:
|
||||
material = item["material"]
|
||||
action = item["action"]
|
||||
|
||||
if action == "update_purchase_quantity":
|
||||
self._update_purchase_quantity(material, item)
|
||||
elif action == "reduce_purchase_quantity":
|
||||
self._reduce_purchase_quantity(material, item)
|
||||
|
||||
updates_applied["unpurchased_materials"] += 1
|
||||
|
||||
# 신규 자재 처리
|
||||
for item in changes.get("new_materials", []):
|
||||
self._mark_new_material(item["material"])
|
||||
updates_applied["new_materials"] += 1
|
||||
|
||||
# 삭제된 자재 처리
|
||||
for item in changes.get("deleted_materials", []):
|
||||
self._mark_deleted_material(item["material"])
|
||||
updates_applied["deleted_materials"] += 1
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"updates_applied": updates_applied,
|
||||
"message": "리비전 변경사항이 성공적으로 적용되었습니다."
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to apply revision changes: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": "리비전 변경사항 적용 중 오류가 발생했습니다."
|
||||
}
|
||||
|
||||
def _mark_additional_purchase_needed(self, material: Dict, change_info: Dict):
|
||||
"""추가 구매 필요 표시"""
|
||||
|
||||
update_query = """
|
||||
UPDATE materials
|
||||
SET revision_status = 'additional_purchase_needed',
|
||||
notes = CONCAT(COALESCE(notes, ''),
|
||||
'\n추가 구매 필요: ', :additional_qty, ' ', unit)
|
||||
WHERE id = :material_id
|
||||
"""
|
||||
|
||||
self.db_service.execute_query(update_query, {
|
||||
"material_id": material["id"],
|
||||
"additional_qty": change_info["additional_needed"]
|
||||
})
|
||||
|
||||
def _mark_excess_inventory(self, material: Dict, change_info: Dict):
|
||||
"""잉여 재고 표시"""
|
||||
|
||||
update_query = """
|
||||
UPDATE materials
|
||||
SET revision_status = 'excess_inventory',
|
||||
notes = CONCAT(COALESCE(notes, ''),
|
||||
'\n잉여 재고: ', :excess_qty, ' ', unit)
|
||||
WHERE id = :material_id
|
||||
"""
|
||||
|
||||
self.db_service.execute_query(update_query, {
|
||||
"material_id": material["id"],
|
||||
"excess_qty": change_info["excess_quantity"]
|
||||
})
|
||||
|
||||
def _update_purchase_quantity(self, material: Dict, change_info: Dict):
|
||||
"""구매 수량 업데이트"""
|
||||
|
||||
update_query = """
|
||||
UPDATE materials
|
||||
SET quantity = :new_quantity,
|
||||
revision_status = 'quantity_updated',
|
||||
notes = CONCAT(COALESCE(notes, ''),
|
||||
'\n수량 변경: ', :prev_qty, ' → ', :new_qty)
|
||||
WHERE id = :material_id
|
||||
"""
|
||||
|
||||
self.db_service.execute_query(update_query, {
|
||||
"material_id": material["id"],
|
||||
"new_quantity": change_info["current_quantity"],
|
||||
"prev_qty": change_info["previous_quantity"],
|
||||
"new_qty": change_info["current_quantity"]
|
||||
})
|
||||
|
||||
def _reduce_purchase_quantity(self, material: Dict, change_info: Dict):
|
||||
"""구매 수량 감소"""
|
||||
|
||||
if change_info["current_quantity"] <= 0:
|
||||
# 수량이 0 이하면 삭제 표시
|
||||
update_query = """
|
||||
UPDATE materials
|
||||
SET revision_status = 'deleted',
|
||||
is_active = false,
|
||||
notes = CONCAT(COALESCE(notes, ''), '\n리비전에서 삭제됨')
|
||||
WHERE id = :material_id
|
||||
"""
|
||||
else:
|
||||
# 수량만 감소
|
||||
update_query = """
|
||||
UPDATE materials
|
||||
SET quantity = :new_quantity,
|
||||
revision_status = 'quantity_reduced',
|
||||
notes = CONCAT(COALESCE(notes, ''),
|
||||
'\n수량 감소: ', :prev_qty, ' → ', :new_qty)
|
||||
WHERE id = :material_id
|
||||
"""
|
||||
|
||||
self.db_service.execute_query(update_query, {
|
||||
"material_id": material["id"],
|
||||
"new_quantity": change_info.get("current_quantity", 0),
|
||||
"prev_qty": change_info.get("previous_quantity", 0),
|
||||
"new_qty": change_info.get("current_quantity", 0)
|
||||
})
|
||||
|
||||
def _mark_new_material(self, material: Dict):
|
||||
"""신규 자재 표시"""
|
||||
|
||||
update_query = """
|
||||
UPDATE materials
|
||||
SET revision_status = 'new_in_revision'
|
||||
WHERE id = :material_id
|
||||
"""
|
||||
|
||||
self.db_service.execute_query(update_query, {
|
||||
"material_id": material["id"]
|
||||
})
|
||||
|
||||
def _mark_deleted_material(self, material: Dict):
|
||||
"""삭제된 자재 표시 (이전 리비전에서)"""
|
||||
|
||||
update_query = """
|
||||
UPDATE materials
|
||||
SET revision_status = 'removed_in_new_revision',
|
||||
notes = CONCAT(COALESCE(notes, ''), '\n신규 리비전에서 제거됨')
|
||||
WHERE id = :material_id
|
||||
"""
|
||||
|
||||
self.db_service.execute_query(update_query, {
|
||||
"material_id": material["id"]
|
||||
})
|
||||
396
backend/app/services/pipe_data_extraction_service.py
Normal file
396
backend/app/services/pipe_data_extraction_service.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
PIPE 데이터 추출 서비스
|
||||
|
||||
BOM 파일에서 PIPE 자재의 도면-라인번호-길이 정보를 추출하고 처리
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import Material, File
|
||||
from ..utils.pipe_utils import (
|
||||
PipeConstants, PipeDataExtractor, PipeValidator,
|
||||
PipeFormatter, PipeLogger
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PipeDataExtractionService:
|
||||
"""PIPE 데이터 추출 및 처리 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def extract_pipe_data_from_file(self, file_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
파일에서 PIPE 데이터 추출
|
||||
|
||||
Args:
|
||||
file_id: 파일 ID
|
||||
|
||||
Returns:
|
||||
추출된 PIPE 데이터 정보
|
||||
"""
|
||||
try:
|
||||
# 1. 파일 정보 확인
|
||||
file_info = self.db.query(File).filter(File.id == file_id).first()
|
||||
if not file_info:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "파일을 찾을 수 없습니다."
|
||||
}
|
||||
|
||||
# 2. PIPE 자재 조회
|
||||
pipe_materials = self._get_pipe_materials_from_file(file_id)
|
||||
if not pipe_materials:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "PIPE 자재가 없습니다."
|
||||
}
|
||||
|
||||
# 3. 데이터 추출 및 정제
|
||||
extracted_data = []
|
||||
extraction_stats = {
|
||||
"total_materials": len(pipe_materials),
|
||||
"successful_extractions": 0,
|
||||
"failed_extractions": 0,
|
||||
"unique_drawings": set(),
|
||||
"unique_line_numbers": set(),
|
||||
"total_length": 0
|
||||
}
|
||||
|
||||
for material in pipe_materials:
|
||||
extracted_item = self._extract_pipe_item_data(material)
|
||||
if extracted_item["success"]:
|
||||
extracted_data.append(extracted_item["data"])
|
||||
extraction_stats["successful_extractions"] += 1
|
||||
extraction_stats["unique_drawings"].add(extracted_item["data"]["drawing_name"])
|
||||
if extracted_item["data"]["line_no"]:
|
||||
extraction_stats["unique_line_numbers"].add(extracted_item["data"]["line_no"])
|
||||
extraction_stats["total_length"] += extracted_item["data"]["length_mm"]
|
||||
else:
|
||||
extraction_stats["failed_extractions"] += 1
|
||||
logger.warning(f"Failed to extract data from material {material.id}: {extracted_item['message']}")
|
||||
|
||||
# 4. 통계 정리
|
||||
extraction_stats["unique_drawings"] = len(extraction_stats["unique_drawings"])
|
||||
extraction_stats["unique_line_numbers"] = len(extraction_stats["unique_line_numbers"])
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"file_id": file_id,
|
||||
"file_name": file_info.original_filename,
|
||||
"job_no": file_info.job_no,
|
||||
"extracted_data": extracted_data,
|
||||
"extraction_stats": extraction_stats,
|
||||
"message": f"PIPE 데이터 추출 완료: {extraction_stats['successful_extractions']}개 성공, {extraction_stats['failed_extractions']}개 실패"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to extract pipe data from file {file_id}: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"PIPE 데이터 추출 실패: {str(e)}"
|
||||
}
|
||||
|
||||
def _get_pipe_materials_from_file(self, file_id: int) -> List[Material]:
|
||||
"""파일에서 PIPE 자재 조회"""
|
||||
return self.db.query(Material).filter(
|
||||
Material.file_id == file_id,
|
||||
Material.classified_category == 'PIPE',
|
||||
Material.is_active == True
|
||||
).all()
|
||||
|
||||
def _extract_pipe_item_data(self, material: Material) -> Dict[str, Any]:
|
||||
"""개별 PIPE 자재에서 데이터 추출"""
|
||||
try:
|
||||
# 기본 정보
|
||||
data = {
|
||||
"material_id": material.id,
|
||||
"drawing_name": self._extract_drawing_name(material),
|
||||
"line_no": self._extract_line_number(material),
|
||||
"material_grade": self._extract_material_grade(material),
|
||||
"schedule_spec": self._extract_schedule_spec(material),
|
||||
"nominal_size": self._extract_nominal_size(material),
|
||||
"length_mm": self._extract_length(material),
|
||||
"end_preparation": self._extract_end_preparation(material),
|
||||
"quantity": int(material.quantity or 1),
|
||||
"description": material.description or "",
|
||||
"original_description": material.description or ""
|
||||
}
|
||||
|
||||
# 데이터 검증
|
||||
validation_result = self._validate_extracted_data(data)
|
||||
if not validation_result["valid"]:
|
||||
return {
|
||||
"success": False,
|
||||
"message": validation_result["message"],
|
||||
"data": data
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": data,
|
||||
"message": "데이터 추출 성공"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to extract data from material {material.id}: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"데이터 추출 실패: {str(e)}",
|
||||
"data": {}
|
||||
}
|
||||
|
||||
def _extract_drawing_name(self, material: Material) -> str:
|
||||
"""도면명 추출"""
|
||||
# 1. drawing_name 필드 우선
|
||||
if material.drawing_name:
|
||||
return material.drawing_name.strip()
|
||||
|
||||
# 2. description에서 추출 시도
|
||||
if material.description:
|
||||
# 일반적인 도면명 패턴 (P&ID-001, DWG-A-001 등)
|
||||
drawing_patterns = [
|
||||
r'(P&ID[-_]\w+)',
|
||||
r'(DWG[-_]\w+[-_]\w+)',
|
||||
r'(DRAWING[-_]\w+)',
|
||||
r'([A-Z]+[-_]\d+[-_]\w+)',
|
||||
r'([A-Z]+\d+[A-Z]*)'
|
||||
]
|
||||
|
||||
for pattern in drawing_patterns:
|
||||
match = re.search(pattern, material.description.upper())
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return "UNKNOWN_DRAWING"
|
||||
|
||||
def _extract_line_number(self, material: Material) -> str:
|
||||
"""라인번호 추출"""
|
||||
# 1. line_no 필드 우선
|
||||
if material.line_no:
|
||||
return material.line_no.strip()
|
||||
|
||||
# 2. description에서 추출 시도
|
||||
if material.description:
|
||||
# 라인번호 패턴 (LINE-001, L-001, 1001 등)
|
||||
line_patterns = [
|
||||
r'LINE[-_]?(\w+)',
|
||||
r'L[-_]?(\d+[A-Z]*)',
|
||||
r'(\d{3,4}[A-Z]*)', # 3-4자리 숫자 + 선택적 문자
|
||||
r'([A-Z]\d+[A-Z]*)' # 문자+숫자+선택적문자
|
||||
]
|
||||
|
||||
for pattern in line_patterns:
|
||||
match = re.search(pattern, material.description.upper())
|
||||
if match:
|
||||
return f"LINE-{match.group(1)}"
|
||||
|
||||
return "" # 라인번호는 필수가 아님
|
||||
|
||||
def _extract_material_grade(self, material: Material) -> str:
|
||||
"""재질 추출"""
|
||||
# 1. full_material_grade 필드 우선
|
||||
if material.full_material_grade:
|
||||
return material.full_material_grade.strip()
|
||||
|
||||
# 2. description에서 추출 시도
|
||||
if material.description:
|
||||
# 일반적인 재질 패턴
|
||||
material_patterns = [
|
||||
r'(A\d+\s*GR\.?\s*[A-Z])', # A106 GR.B
|
||||
r'(A\d+)', # A106
|
||||
r'(SS\d+[A-Z]*)', # SS316L
|
||||
r'(CS|CARBON\s*STEEL)', # Carbon Steel
|
||||
r'(SS|STAINLESS\s*STEEL)' # Stainless Steel
|
||||
]
|
||||
|
||||
for pattern in material_patterns:
|
||||
match = re.search(pattern, material.description.upper())
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
return "UNKNOWN"
|
||||
|
||||
def _extract_schedule_spec(self, material: Material) -> str:
|
||||
"""스케줄/규격 추출"""
|
||||
if material.description:
|
||||
# 스케줄 패턴 (SCH40, SCH80, STD, XS 등)
|
||||
schedule_patterns = [
|
||||
r'(SCH\s*\d+[A-Z]*)',
|
||||
r'(STD|STANDARD)',
|
||||
r'(XS|EXTRA\s*STRONG)',
|
||||
r'(XXS|DOUBLE\s*EXTRA\s*STRONG)',
|
||||
r'(\d+\.?\d*\s*MM)', # 두께 (mm)
|
||||
r'(\d+\.?\d*"?\s*THK)' # 두께 (THK)
|
||||
]
|
||||
|
||||
for pattern in schedule_patterns:
|
||||
match = re.search(pattern, material.description.upper())
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_nominal_size(self, material: Material) -> str:
|
||||
"""호칭 크기 추출"""
|
||||
# 1. main_nom 필드 우선
|
||||
if material.main_nom:
|
||||
return material.main_nom.strip()
|
||||
|
||||
# 2. description에서 추출 시도
|
||||
if material.description:
|
||||
# 호칭 크기 패턴 (4", 6", 100A 등)
|
||||
size_patterns = [
|
||||
r'(\d+\.?\d*")', # 4", 6.5"
|
||||
r'(\d+\.?\d*\s*INCH)', # 4 INCH
|
||||
r'(\d+A)', # 100A
|
||||
r'(DN\s*\d+)', # DN100
|
||||
r'(\d+\.?\d*\s*MM)' # 100MM (직경)
|
||||
]
|
||||
|
||||
for pattern in size_patterns:
|
||||
match = re.search(pattern, material.description.upper())
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_length(self, material: Material) -> float:
|
||||
"""길이 추출 (mm 단위)"""
|
||||
# 1. length 필드 우선
|
||||
if material.length and material.length > 0:
|
||||
return float(material.length)
|
||||
|
||||
# 2. total_length 필드
|
||||
if material.total_length and material.total_length > 0:
|
||||
return float(material.total_length)
|
||||
|
||||
# 3. description에서 추출 시도
|
||||
if material.description:
|
||||
# 길이 패턴
|
||||
length_patterns = [
|
||||
r'(\d+\.?\d*)\s*MM', # 1500MM
|
||||
r'(\d+\.?\d*)\s*M(?!\w)', # 1.5M (단, MM이 아닌)
|
||||
r'(\d+\.?\d*)\s*METER', # 1.5 METER
|
||||
r'L\s*=?\s*(\d+\.?\d*)', # L=1500
|
||||
r'LENGTH\s*:?\s*(\d+\.?\d*)' # LENGTH: 1500
|
||||
]
|
||||
|
||||
for pattern in length_patterns:
|
||||
match = re.search(pattern, material.description.upper())
|
||||
if match:
|
||||
length_value = float(match.group(1))
|
||||
# 단위 변환 (M -> MM)
|
||||
if 'M' in pattern and 'MM' not in pattern:
|
||||
length_value *= 1000
|
||||
return length_value
|
||||
|
||||
# 기본값: 6000mm (6m)
|
||||
return 6000.0
|
||||
|
||||
def _extract_end_preparation(self, material: Material) -> str:
|
||||
"""끝단 가공 정보 추출"""
|
||||
if material.description:
|
||||
desc_upper = material.description.upper()
|
||||
|
||||
# 끝단 가공 패턴
|
||||
if any(keyword in desc_upper for keyword in ['DOUBLE BEVEL', '양개선', 'DBE']):
|
||||
return '양개선'
|
||||
elif any(keyword in desc_upper for keyword in ['SINGLE BEVEL', '한개선', 'SBE']):
|
||||
return '한개선'
|
||||
elif any(keyword in desc_upper for keyword in ['PLAIN', '무개선', 'PE']):
|
||||
return '무개선'
|
||||
|
||||
return '무개선' # 기본값
|
||||
|
||||
def _validate_extracted_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""추출된 데이터 검증"""
|
||||
errors = []
|
||||
|
||||
# 필수 필드 검증
|
||||
if not data.get("drawing_name") or data["drawing_name"] == "UNKNOWN_DRAWING":
|
||||
errors.append("도면명을 추출할 수 없습니다")
|
||||
|
||||
if data.get("length_mm", 0) <= 0:
|
||||
errors.append("유효한 길이 정보가 없습니다")
|
||||
|
||||
if not data.get("material_grade") or data["material_grade"] == "UNKNOWN":
|
||||
errors.append("재질 정보를 추출할 수 없습니다")
|
||||
|
||||
# 경고 (오류는 아님)
|
||||
warnings = []
|
||||
if not data.get("line_no"):
|
||||
warnings.append("라인번호가 없습니다")
|
||||
|
||||
if not data.get("nominal_size"):
|
||||
warnings.append("호칭 크기가 없습니다")
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"message": "; ".join(errors) if errors else "검증 통과"
|
||||
}
|
||||
|
||||
def get_extraction_summary(self, file_id: int) -> Dict[str, Any]:
|
||||
"""파일의 PIPE 데이터 추출 요약 정보"""
|
||||
try:
|
||||
extraction_result = self.extract_pipe_data_from_file(file_id)
|
||||
|
||||
if not extraction_result["success"]:
|
||||
return extraction_result
|
||||
|
||||
# 요약 통계 생성
|
||||
extracted_data = extraction_result["extracted_data"]
|
||||
|
||||
# 도면별 통계
|
||||
drawing_stats = {}
|
||||
for item in extracted_data:
|
||||
drawing = item["drawing_name"]
|
||||
if drawing not in drawing_stats:
|
||||
drawing_stats[drawing] = {
|
||||
"count": 0,
|
||||
"total_length": 0,
|
||||
"line_numbers": set(),
|
||||
"materials": set()
|
||||
}
|
||||
|
||||
drawing_stats[drawing]["count"] += 1
|
||||
drawing_stats[drawing]["total_length"] += item["length_mm"]
|
||||
if item["line_no"]:
|
||||
drawing_stats[drawing]["line_numbers"].add(item["line_no"])
|
||||
drawing_stats[drawing]["materials"].add(item["material_grade"])
|
||||
|
||||
# set을 list로 변환
|
||||
for drawing in drawing_stats:
|
||||
drawing_stats[drawing]["line_numbers"] = list(drawing_stats[drawing]["line_numbers"])
|
||||
drawing_stats[drawing]["materials"] = list(drawing_stats[drawing]["materials"])
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"file_id": file_id,
|
||||
"extraction_stats": extraction_result["extraction_stats"],
|
||||
"drawing_stats": drawing_stats,
|
||||
"ready_for_cutting_plan": extraction_result["extraction_stats"]["successful_extractions"] > 0
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get extraction summary: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"추출 요약 생성 실패: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def get_pipe_data_extraction_service(db: Session = None) -> PipeDataExtractionService:
|
||||
"""PipeDataExtractionService 인스턴스 생성"""
|
||||
if db is None:
|
||||
db = next(get_db())
|
||||
return PipeDataExtractionService(db)
|
||||
362
backend/app/services/pipe_issue_snapshot_service.py
Normal file
362
backend/app/services/pipe_issue_snapshot_service.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
PIPE 이슈 관리용 스냅샷 시스템
|
||||
|
||||
단관 관리 DB의 특정 시점 데이터를 고정하여
|
||||
이후 리비전이 발생해도 이슈 관리에 영향을 주지 않도록 함
|
||||
"""
|
||||
|
||||
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 ..database import get_db
|
||||
from ..models import (
|
||||
PipeCuttingPlan, PipeIssueSnapshot, PipeIssueSegment,
|
||||
PipeDrawingIssue, PipeSegmentIssue
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PipeIssueSnapshotService:
|
||||
"""PIPE 이슈 관리용 스냅샷 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create_and_lock_snapshot_on_finalize(self, job_no: str, created_by: str = "system") -> Dict[str, Any]:
|
||||
"""
|
||||
Cutting Plan 확정 시 스냅샷 생성 및 즉시 잠금
|
||||
|
||||
Args:
|
||||
job_no: 작업 번호
|
||||
created_by: 생성자
|
||||
|
||||
Returns:
|
||||
생성된 스냅샷 정보
|
||||
"""
|
||||
try:
|
||||
# 1. 기존 활성 스냅샷 확인
|
||||
existing_snapshot = self.db.query(PipeIssueSnapshot).filter(
|
||||
and_(
|
||||
PipeIssueSnapshot.job_no == job_no,
|
||||
PipeIssueSnapshot.is_active == True
|
||||
)
|
||||
).first()
|
||||
|
||||
if existing_snapshot:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"이미 확정된 Cutting Plan이 존재합니다: {existing_snapshot.snapshot_name}",
|
||||
"existing_snapshot_id": existing_snapshot.id,
|
||||
"can_manage_issues": True
|
||||
}
|
||||
|
||||
# 2. 현재 단관 데이터 조회
|
||||
current_segments = self._get_current_cutting_plan_data(job_no)
|
||||
if not current_segments:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "확정할 Cutting Plan 데이터가 없습니다."
|
||||
}
|
||||
|
||||
# 3. 자동 스냅샷 이름 생성
|
||||
snapshot_name = f"Cutting Plan 확정 - {datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
# 4. 스냅샷 레코드 생성 (즉시 잠금 상태)
|
||||
snapshot = PipeIssueSnapshot(
|
||||
job_no=job_no,
|
||||
snapshot_name=snapshot_name,
|
||||
created_by=created_by,
|
||||
is_locked=True, # 확정과 동시에 잠금
|
||||
locked_at=datetime.utcnow(),
|
||||
locked_by=created_by,
|
||||
total_segments=len(current_segments),
|
||||
total_drawings=len(set(seg["drawing_name"] for seg in current_segments))
|
||||
)
|
||||
|
||||
self.db.add(snapshot)
|
||||
self.db.flush() # ID 생성을 위해
|
||||
|
||||
# 4. 단관 데이터 스냅샷 저장
|
||||
snapshot_segments = []
|
||||
for segment_data in current_segments:
|
||||
segment = PipeIssueSegment(
|
||||
snapshot_id=snapshot.id,
|
||||
area=segment_data.get("area"),
|
||||
drawing_name=segment_data["drawing_name"],
|
||||
line_no=segment_data["line_no"],
|
||||
material_grade=segment_data.get("material_grade"),
|
||||
schedule_spec=segment_data.get("schedule_spec"),
|
||||
nominal_size=segment_data.get("nominal_size"),
|
||||
length_mm=segment_data["length_mm"],
|
||||
end_preparation=segment_data.get("end_preparation", "무개선"),
|
||||
original_cutting_plan_id=segment_data.get("original_id")
|
||||
)
|
||||
snapshot_segments.append(segment)
|
||||
|
||||
self.db.add_all(snapshot_segments)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Created snapshot {snapshot.id} for job {job_no} with {len(snapshot_segments)} segments")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"snapshot_id": snapshot.id,
|
||||
"snapshot_name": snapshot_name,
|
||||
"total_segments": len(snapshot_segments),
|
||||
"total_drawings": snapshot.total_drawings,
|
||||
"is_locked": True,
|
||||
"locked_at": snapshot.locked_at,
|
||||
"message": f"Cutting Plan이 확정되었습니다! 이제 이슈 관리를 시작할 수 있습니다.",
|
||||
"next_action": "start_issue_management"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to create snapshot: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"스냅샷 생성 실패: {str(e)}"
|
||||
}
|
||||
|
||||
def lock_snapshot(self, snapshot_id: int, locked_by: str = "system") -> Dict[str, Any]:
|
||||
"""
|
||||
스냅샷 잠금 (이슈 등록 시작)
|
||||
잠금 후에는 더 이상 리비전 영향을 받지 않음
|
||||
"""
|
||||
try:
|
||||
snapshot = self.db.query(PipeIssueSnapshot).filter(
|
||||
PipeIssueSnapshot.id == snapshot_id
|
||||
).first()
|
||||
|
||||
if not snapshot:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "스냅샷을 찾을 수 없습니다."
|
||||
}
|
||||
|
||||
if snapshot.is_locked:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"이미 잠긴 스냅샷입니다. (잠금자: {snapshot.locked_by})"
|
||||
}
|
||||
|
||||
# 스냅샷 잠금
|
||||
snapshot.is_locked = True
|
||||
snapshot.locked_at = datetime.utcnow()
|
||||
snapshot.locked_by = locked_by
|
||||
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Locked snapshot {snapshot_id} by {locked_by}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"스냅샷 '{snapshot.snapshot_name}'이 잠금되었습니다. 이제 이슈 관리를 시작할 수 있습니다.",
|
||||
"locked_at": snapshot.locked_at
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to lock snapshot: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"스냅샷 잠금 실패: {str(e)}"
|
||||
}
|
||||
|
||||
def get_snapshot_info(self, job_no: str) -> Dict[str, Any]:
|
||||
"""작업의 스냅샷 정보 조회"""
|
||||
try:
|
||||
snapshot = self.db.query(PipeIssueSnapshot).filter(
|
||||
and_(
|
||||
PipeIssueSnapshot.job_no == job_no,
|
||||
PipeIssueSnapshot.is_active == True
|
||||
)
|
||||
).first()
|
||||
|
||||
if not snapshot:
|
||||
return {
|
||||
"has_snapshot": False,
|
||||
"message": "생성된 스냅샷이 없습니다."
|
||||
}
|
||||
|
||||
# 이슈 통계 조회
|
||||
drawing_issues_count = self.db.query(PipeDrawingIssue).filter(
|
||||
PipeDrawingIssue.snapshot_id == snapshot.id
|
||||
).count()
|
||||
|
||||
segment_issues_count = self.db.query(PipeSegmentIssue).filter(
|
||||
PipeSegmentIssue.snapshot_id == snapshot.id
|
||||
).count()
|
||||
|
||||
return {
|
||||
"has_snapshot": True,
|
||||
"snapshot_id": snapshot.id,
|
||||
"snapshot_name": snapshot.snapshot_name,
|
||||
"is_locked": snapshot.is_locked,
|
||||
"created_at": snapshot.created_at,
|
||||
"created_by": snapshot.created_by,
|
||||
"locked_at": snapshot.locked_at,
|
||||
"locked_by": snapshot.locked_by,
|
||||
"total_segments": snapshot.total_segments,
|
||||
"total_drawings": snapshot.total_drawings,
|
||||
"drawing_issues_count": drawing_issues_count,
|
||||
"segment_issues_count": segment_issues_count,
|
||||
"can_start_issue_management": not snapshot.is_locked,
|
||||
"message": "잠긴 스냅샷 - 이슈 관리 진행 중" if snapshot.is_locked else "스냅샷 준비 완료"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get snapshot info: {e}")
|
||||
return {
|
||||
"has_snapshot": False,
|
||||
"message": f"스냅샷 정보 조회 실패: {str(e)}"
|
||||
}
|
||||
|
||||
def get_snapshot_segments(self, snapshot_id: int, area: str = None, drawing_name: str = None) -> List[Dict[str, Any]]:
|
||||
"""스냅샷된 단관 데이터 조회"""
|
||||
try:
|
||||
query = self.db.query(PipeIssueSegment).filter(
|
||||
PipeIssueSegment.snapshot_id == snapshot_id
|
||||
)
|
||||
|
||||
if area:
|
||||
query = query.filter(PipeIssueSegment.area == area)
|
||||
|
||||
if drawing_name:
|
||||
query = query.filter(PipeIssueSegment.drawing_name == drawing_name)
|
||||
|
||||
segments = query.order_by(
|
||||
PipeIssueSegment.area,
|
||||
PipeIssueSegment.drawing_name,
|
||||
PipeIssueSegment.line_no
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for segment in segments:
|
||||
result.append({
|
||||
"id": segment.id,
|
||||
"area": segment.area,
|
||||
"drawing_name": segment.drawing_name,
|
||||
"line_no": segment.line_no,
|
||||
"material_grade": segment.material_grade,
|
||||
"schedule_spec": segment.schedule_spec,
|
||||
"nominal_size": segment.nominal_size,
|
||||
"length_mm": float(segment.length_mm) if segment.length_mm else 0,
|
||||
"end_preparation": segment.end_preparation,
|
||||
"material_info": f"{segment.material_grade or ''} {segment.schedule_spec or ''} {segment.nominal_size or ''}".strip()
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get snapshot segments: {e}")
|
||||
return []
|
||||
|
||||
def get_available_areas(self, snapshot_id: int) -> List[str]:
|
||||
"""스냅샷의 사용 가능한 구역 목록"""
|
||||
try:
|
||||
result = self.db.query(PipeIssueSegment.area).filter(
|
||||
and_(
|
||||
PipeIssueSegment.snapshot_id == snapshot_id,
|
||||
PipeIssueSegment.area.isnot(None)
|
||||
)
|
||||
).distinct().all()
|
||||
|
||||
areas = [row.area for row in result if row.area]
|
||||
return sorted(areas)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get available areas: {e}")
|
||||
return []
|
||||
|
||||
def get_available_drawings(self, snapshot_id: int, area: str = None) -> List[str]:
|
||||
"""스냅샷의 사용 가능한 도면 목록"""
|
||||
try:
|
||||
query = self.db.query(PipeIssueSegment.drawing_name).filter(
|
||||
PipeIssueSegment.snapshot_id == snapshot_id
|
||||
)
|
||||
|
||||
if area:
|
||||
query = query.filter(PipeIssueSegment.area == area)
|
||||
|
||||
result = query.distinct().all()
|
||||
drawings = [row.drawing_name for row in result if row.drawing_name]
|
||||
return sorted(drawings)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get available drawings: {e}")
|
||||
return []
|
||||
|
||||
def _get_current_cutting_plan_data(self, job_no: str) -> List[Dict[str, Any]]:
|
||||
"""현재 단관 관리 DB에서 데이터 조회"""
|
||||
try:
|
||||
cutting_plans = self.db.query(PipeCuttingPlan).filter(
|
||||
PipeCuttingPlan.job_no == job_no
|
||||
).all()
|
||||
|
||||
segments = []
|
||||
for plan in cutting_plans:
|
||||
segments.append({
|
||||
"original_id": plan.id,
|
||||
"area": plan.area,
|
||||
"drawing_name": plan.drawing_name,
|
||||
"line_no": plan.line_no,
|
||||
"material_grade": plan.material_grade,
|
||||
"schedule_spec": plan.schedule_spec,
|
||||
"nominal_size": plan.nominal_size,
|
||||
"length_mm": float(plan.length_mm) if plan.length_mm else 0,
|
||||
"end_preparation": plan.end_preparation or "무개선"
|
||||
})
|
||||
|
||||
return segments
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get current cutting plan data: {e}")
|
||||
return []
|
||||
|
||||
def check_revision_protection(self, job_no: str) -> Dict[str, Any]:
|
||||
"""
|
||||
리비전 보호 상태 확인
|
||||
잠긴 스냅샷이 있으면 더 이상 리비전 영향을 받지 않음
|
||||
"""
|
||||
try:
|
||||
snapshot = self.db.query(PipeIssueSnapshot).filter(
|
||||
and_(
|
||||
PipeIssueSnapshot.job_no == job_no,
|
||||
PipeIssueSnapshot.is_active == True,
|
||||
PipeIssueSnapshot.is_locked == True
|
||||
)
|
||||
).first()
|
||||
|
||||
if snapshot:
|
||||
return {
|
||||
"is_protected": True,
|
||||
"snapshot_id": snapshot.id,
|
||||
"snapshot_name": snapshot.snapshot_name,
|
||||
"locked_at": snapshot.locked_at,
|
||||
"locked_by": snapshot.locked_by,
|
||||
"message": f"이슈 관리가 진행 중입니다. 스냅샷 '{snapshot.snapshot_name}'이 보호되고 있습니다."
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"is_protected": False,
|
||||
"message": "리비전 보호가 활성화되지 않았습니다."
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check revision protection: {e}")
|
||||
return {
|
||||
"is_protected": False,
|
||||
"message": f"리비전 보호 상태 확인 실패: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def get_pipe_issue_snapshot_service(db: Session = None) -> PipeIssueSnapshotService:
|
||||
"""PipeIssueSnapshotService 인스턴스 생성"""
|
||||
if db is None:
|
||||
db = next(get_db())
|
||||
return PipeIssueSnapshotService(db)
|
||||
541
backend/app/services/pipe_revision_service.py
Normal file
541
backend/app/services/pipe_revision_service.py
Normal file
@@ -0,0 +1,541 @@
|
||||
"""
|
||||
PIPE 전용 리비전 관리 서비스
|
||||
|
||||
Cutting Plan 작성 전/후에 따른 차별화된 리비전 처리 로직
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, and_, or_
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import (
|
||||
File, Material, PipeCuttingPlan, PipeRevisionComparison,
|
||||
PipeRevisionChange, PipeLengthCalculation
|
||||
)
|
||||
from ..utils.pipe_utils import (
|
||||
PipeConstants, PipeDataExtractor, PipeCalculator,
|
||||
PipeComparator, PipeValidator, PipeLogger
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PipeRevisionService:
|
||||
"""PIPE 전용 리비전 관리 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def check_revision_status(self, job_no: str, new_file_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
리비전 상태 확인 및 처리 방식 결정
|
||||
|
||||
Returns:
|
||||
- revision_type: 'no_revision', 'pre_cutting_plan', 'post_cutting_plan'
|
||||
- requires_action: 처리가 필요한지 여부
|
||||
- message: 사용자에게 표시할 메시지
|
||||
"""
|
||||
try:
|
||||
# 기존 파일 확인
|
||||
previous_file = self._get_previous_file(job_no, new_file_id)
|
||||
if not previous_file:
|
||||
return {
|
||||
"revision_type": "no_revision",
|
||||
"requires_action": False,
|
||||
"message": "첫 번째 BOM 파일입니다. 새로운 Cutting Plan을 작성해주세요."
|
||||
}
|
||||
|
||||
# Cutting Plan 존재 여부 확인
|
||||
has_cutting_plan = self._has_existing_cutting_plan(job_no)
|
||||
|
||||
if not has_cutting_plan:
|
||||
# Cutting Plan 작성 전 리비전
|
||||
return {
|
||||
"revision_type": "pre_cutting_plan",
|
||||
"requires_action": True,
|
||||
"previous_file_id": previous_file.id,
|
||||
"message": "Cutting Plan 작성 전 리비전이 감지되었습니다. 새로운 BOM으로 Cutting Plan을 작성해주세요."
|
||||
}
|
||||
else:
|
||||
# Cutting Plan 작성 후 리비전
|
||||
return {
|
||||
"revision_type": "post_cutting_plan",
|
||||
"requires_action": True,
|
||||
"previous_file_id": previous_file.id,
|
||||
"message": "기존 Cutting Plan이 있는 상태에서 리비전이 감지되었습니다. 변경사항을 비교 검토해주세요."
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check pipe revision status: {e}")
|
||||
return {
|
||||
"revision_type": "error",
|
||||
"requires_action": False,
|
||||
"message": f"리비전 상태 확인 중 오류가 발생했습니다: {str(e)}"
|
||||
}
|
||||
|
||||
def handle_pre_cutting_plan_revision(self, job_no: str, new_file_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Cutting Plan 작성 전 리비전 처리
|
||||
- 기존 PIPE 관련 데이터 전체 삭제
|
||||
- 새 BOM 파일로 초기화
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Processing pre-cutting-plan revision for job {job_no}")
|
||||
|
||||
# 1. 기존 PIPE 관련 데이터 삭제
|
||||
deleted_count = self._delete_existing_pipe_data(job_no)
|
||||
|
||||
# 2. 새 BOM에서 PIPE 데이터 추출
|
||||
pipe_materials = self._extract_pipe_materials_from_bom(new_file_id)
|
||||
|
||||
# 3. 처리 결과 반환
|
||||
return {
|
||||
"status": "success",
|
||||
"revision_type": "pre_cutting_plan",
|
||||
"deleted_items": deleted_count,
|
||||
"new_pipe_materials": len(pipe_materials),
|
||||
"message": f"기존 PIPE 데이터 {deleted_count}건이 삭제되었습니다. 새로운 Cutting Plan을 작성해주세요.",
|
||||
"next_action": "create_new_cutting_plan",
|
||||
"pipe_materials": pipe_materials
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to handle pre-cutting-plan revision: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Cutting Plan 작성 전 리비전 처리 실패: {str(e)}"
|
||||
}
|
||||
|
||||
def handle_post_cutting_plan_revision(self, job_no: str, new_file_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Cutting Plan 작성 후 리비전 처리
|
||||
- 기존 Cutting Plan과 신규 BOM 비교
|
||||
- 변경사항 상세 분석
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Processing post-cutting-plan revision for job {job_no}")
|
||||
|
||||
# 1. 기존 Cutting Plan 조회
|
||||
existing_plan = self._get_existing_cutting_plan_data(job_no)
|
||||
if not existing_plan:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "기존 Cutting Plan을 찾을 수 없습니다."
|
||||
}
|
||||
|
||||
# 2. 새 BOM에서 PIPE 데이터 추출
|
||||
new_pipe_data = self._extract_pipe_materials_from_bom(new_file_id)
|
||||
|
||||
# 3. 도면별 비교 수행
|
||||
comparison_result = self._compare_pipe_data_by_drawing(existing_plan, new_pipe_data)
|
||||
|
||||
# 4. 비교 결과 저장
|
||||
comparison_id = self._save_comparison_result(job_no, new_file_id, comparison_result)
|
||||
|
||||
# 5. 변경사항 요약
|
||||
summary = self._generate_comparison_summary(comparison_result)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"revision_type": "post_cutting_plan",
|
||||
"comparison_id": comparison_id,
|
||||
"summary": summary,
|
||||
"changed_drawings": [d for d in comparison_result if d["has_changes"]],
|
||||
"unchanged_drawings": [d for d in comparison_result if not d["has_changes"]],
|
||||
"message": f"리비전 비교가 완료되었습니다. {summary['changed_drawings_count']}개 도면에서 변경사항이 발견되었습니다.",
|
||||
"next_action": "review_changes"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to handle post-cutting-plan revision: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Cutting Plan 작성 후 리비전 처리 실패: {str(e)}"
|
||||
}
|
||||
|
||||
def _get_previous_file(self, job_no: str, current_file_id: int) -> Optional[File]:
|
||||
"""이전 파일 조회"""
|
||||
return self.db.query(File).filter(
|
||||
and_(
|
||||
File.job_no == job_no,
|
||||
File.id < current_file_id,
|
||||
File.is_active == True
|
||||
)
|
||||
).order_by(File.id.desc()).first()
|
||||
|
||||
def _has_existing_cutting_plan(self, job_no: str) -> bool:
|
||||
"""기존 Cutting Plan 존재 여부 확인"""
|
||||
count = self.db.query(PipeCuttingPlan).filter(
|
||||
PipeCuttingPlan.job_no == job_no
|
||||
).count()
|
||||
return count > 0
|
||||
|
||||
def _delete_existing_pipe_data(self, job_no: str) -> int:
|
||||
"""기존 PIPE 관련 데이터 삭제"""
|
||||
try:
|
||||
# Cutting Plan 데이터 삭제
|
||||
cutting_plan_count = self.db.query(PipeCuttingPlan).filter(
|
||||
PipeCuttingPlan.job_no == job_no
|
||||
).count()
|
||||
|
||||
self.db.query(PipeCuttingPlan).filter(
|
||||
PipeCuttingPlan.job_no == job_no
|
||||
).delete()
|
||||
|
||||
# Length Calculation 데이터 삭제
|
||||
self.db.query(PipeLengthCalculation).filter(
|
||||
PipeLengthCalculation.file_id.in_(
|
||||
self.db.query(File.id).filter(File.job_no == job_no)
|
||||
)
|
||||
).delete()
|
||||
|
||||
self.db.commit()
|
||||
logger.info(f"Deleted {cutting_plan_count} cutting plan records for job {job_no}")
|
||||
|
||||
return cutting_plan_count
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to delete existing pipe data: {e}")
|
||||
raise
|
||||
|
||||
def _extract_pipe_materials_from_bom(self, file_id: int) -> List[Dict[str, Any]]:
|
||||
"""BOM 파일에서 PIPE 자재 추출 (리팩토링된 유틸리티 사용)"""
|
||||
return PipeDataExtractor.extract_pipe_materials_from_file(self.db, file_id)
|
||||
|
||||
def _get_existing_cutting_plan_data(self, job_no: str) -> List[Dict[str, Any]]:
|
||||
"""기존 Cutting Plan 데이터 조회"""
|
||||
try:
|
||||
cutting_plans = self.db.query(PipeCuttingPlan).filter(
|
||||
PipeCuttingPlan.job_no == job_no
|
||||
).all()
|
||||
|
||||
plan_data = []
|
||||
for plan in cutting_plans:
|
||||
plan_data.append({
|
||||
"id": plan.id,
|
||||
"area": plan.area or "",
|
||||
"drawing_name": plan.drawing_name,
|
||||
"line_no": plan.line_no,
|
||||
"material_grade": plan.material_grade or "",
|
||||
"schedule_spec": plan.schedule_spec or "",
|
||||
"nominal_size": plan.nominal_size or "",
|
||||
"length_mm": float(plan.length_mm or 0),
|
||||
"end_preparation": plan.end_preparation or "무개선"
|
||||
})
|
||||
|
||||
logger.info(f"Retrieved {len(plan_data)} cutting plan records for job {job_no}")
|
||||
return plan_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get existing cutting plan data: {e}")
|
||||
raise
|
||||
|
||||
def _compare_pipe_data_by_drawing(self, existing_plan: List[Dict], new_pipe_data: List[Dict]) -> List[Dict[str, Any]]:
|
||||
"""도면별 PIPE 데이터 비교"""
|
||||
try:
|
||||
# 도면별로 데이터 그룹화
|
||||
existing_by_drawing = self._group_by_drawing(existing_plan)
|
||||
new_by_drawing = self._group_by_drawing(new_pipe_data)
|
||||
|
||||
# 모든 도면 목록
|
||||
all_drawings = set(existing_by_drawing.keys()) | set(new_by_drawing.keys())
|
||||
|
||||
comparison_results = []
|
||||
|
||||
for drawing_name in sorted(all_drawings):
|
||||
existing_segments = existing_by_drawing.get(drawing_name, [])
|
||||
new_segments = new_by_drawing.get(drawing_name, [])
|
||||
|
||||
# 도면별 비교 수행
|
||||
drawing_comparison = self._compare_drawing_segments(
|
||||
drawing_name, existing_segments, new_segments
|
||||
)
|
||||
|
||||
comparison_results.append(drawing_comparison)
|
||||
|
||||
return comparison_results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to compare pipe data by drawing: {e}")
|
||||
raise
|
||||
|
||||
def _group_by_drawing(self, data: List[Dict]) -> Dict[str, List[Dict]]:
|
||||
"""데이터를 도면별로 그룹화"""
|
||||
grouped = {}
|
||||
for item in data:
|
||||
drawing = item.get("drawing_name", "UNKNOWN")
|
||||
if drawing not in grouped:
|
||||
grouped[drawing] = []
|
||||
grouped[drawing].append(item)
|
||||
return grouped
|
||||
|
||||
def _compare_drawing_segments(self, drawing_name: str, existing: List[Dict], new: List[Dict]) -> Dict[str, Any]:
|
||||
"""단일 도면의 세그먼트 비교"""
|
||||
try:
|
||||
# 세그먼트 매칭 (재질, 길이, 끝단가공 기준)
|
||||
matched_pairs, added_segments, removed_segments = self._match_segments(existing, new)
|
||||
|
||||
# 변경사항 분석
|
||||
unchanged_segments = []
|
||||
modified_segments = []
|
||||
|
||||
for existing_seg, new_seg in matched_pairs:
|
||||
if self._segments_are_identical(existing_seg, new_seg):
|
||||
unchanged_segments.append({
|
||||
"change_type": "unchanged",
|
||||
"segment_data": new_seg,
|
||||
"existing_data": existing_seg
|
||||
})
|
||||
else:
|
||||
changes = self._get_segment_changes(existing_seg, new_seg)
|
||||
modified_segments.append({
|
||||
"change_type": "modified",
|
||||
"segment_data": new_seg,
|
||||
"existing_data": existing_seg,
|
||||
"changes": changes
|
||||
})
|
||||
|
||||
# 추가된 세그먼트
|
||||
added_segment_data = [
|
||||
{
|
||||
"change_type": "added",
|
||||
"segment_data": seg,
|
||||
"existing_data": None
|
||||
}
|
||||
for seg in added_segments
|
||||
]
|
||||
|
||||
# 삭제된 세그먼트
|
||||
removed_segment_data = [
|
||||
{
|
||||
"change_type": "removed",
|
||||
"segment_data": None,
|
||||
"existing_data": seg
|
||||
}
|
||||
for seg in removed_segments
|
||||
]
|
||||
|
||||
# 전체 세그먼트 목록
|
||||
all_segments = unchanged_segments + modified_segments + added_segment_data + removed_segment_data
|
||||
|
||||
# 변경사항 여부 판단
|
||||
has_changes = len(modified_segments) > 0 or len(added_segments) > 0 or len(removed_segments) > 0
|
||||
|
||||
return {
|
||||
"drawing_name": drawing_name,
|
||||
"has_changes": has_changes,
|
||||
"segments": all_segments,
|
||||
"summary": {
|
||||
"total_segments": len(all_segments),
|
||||
"unchanged_count": len(unchanged_segments),
|
||||
"modified_count": len(modified_segments),
|
||||
"added_count": len(added_segments),
|
||||
"removed_count": len(removed_segments)
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to compare segments for drawing {drawing_name}: {e}")
|
||||
raise
|
||||
|
||||
def _match_segments(self, existing: List[Dict], new: List[Dict]) -> Tuple[List[Tuple], List[Dict], List[Dict]]:
|
||||
"""세그먼트 매칭 (재질, 길이 기준)"""
|
||||
matched_pairs = []
|
||||
remaining_new = new.copy()
|
||||
remaining_existing = existing.copy()
|
||||
|
||||
# 정확히 일치하는 세그먼트 찾기
|
||||
for existing_seg in existing.copy():
|
||||
for new_seg in remaining_new.copy():
|
||||
if self._segments_match_for_pairing(existing_seg, new_seg):
|
||||
matched_pairs.append((existing_seg, new_seg))
|
||||
remaining_existing.remove(existing_seg)
|
||||
remaining_new.remove(new_seg)
|
||||
break
|
||||
|
||||
# 남은 것들은 추가/삭제로 분류
|
||||
added_segments = remaining_new
|
||||
removed_segments = remaining_existing
|
||||
|
||||
return matched_pairs, added_segments, removed_segments
|
||||
|
||||
def _segments_match_for_pairing(self, seg1: Dict, seg2: Dict) -> bool:
|
||||
"""세그먼트 매칭 기준 (재질과 길이가 유사한지 확인)"""
|
||||
# 재질 비교
|
||||
material1 = seg1.get("material_grade", "").strip()
|
||||
material2 = seg2.get("material_grade", "").strip()
|
||||
|
||||
# 길이 비교 (허용 오차 1mm)
|
||||
length1 = seg1.get("length_mm", seg1.get("length", 0))
|
||||
length2 = seg2.get("length_mm", seg2.get("length", 0))
|
||||
|
||||
material_match = material1.lower() == material2.lower()
|
||||
length_match = abs(float(length1) - float(length2)) <= 1.0
|
||||
|
||||
return material_match and length_match
|
||||
|
||||
def _segments_are_identical(self, seg1: Dict, seg2: Dict) -> bool:
|
||||
"""세그먼트 완전 동일성 검사"""
|
||||
# 주요 속성들 비교
|
||||
material_match = seg1.get("material_grade", "").strip().lower() == seg2.get("material_grade", "").strip().lower()
|
||||
|
||||
length1 = seg1.get("length_mm", seg1.get("length", 0))
|
||||
length2 = seg2.get("length_mm", seg2.get("length", 0))
|
||||
length_match = abs(float(length1) - float(length2)) <= 0.1
|
||||
|
||||
end_prep1 = seg1.get("end_preparation", "무개선")
|
||||
end_prep2 = seg2.get("end_preparation", "무개선")
|
||||
end_prep_match = end_prep1 == end_prep2
|
||||
|
||||
return material_match and length_match and end_prep_match
|
||||
|
||||
def _get_segment_changes(self, existing: Dict, new: Dict) -> List[Dict[str, Any]]:
|
||||
"""세그먼트 변경사항 상세 분석"""
|
||||
changes = []
|
||||
|
||||
# 재질 변경
|
||||
old_material = existing.get("material_grade", "").strip()
|
||||
new_material = new.get("material_grade", "").strip()
|
||||
if old_material.lower() != new_material.lower():
|
||||
changes.append({
|
||||
"field": "material_grade",
|
||||
"old_value": old_material,
|
||||
"new_value": new_material
|
||||
})
|
||||
|
||||
# 길이 변경
|
||||
old_length = existing.get("length_mm", existing.get("length", 0))
|
||||
new_length = new.get("length_mm", new.get("length", 0))
|
||||
if abs(float(old_length) - float(new_length)) > 0.1:
|
||||
changes.append({
|
||||
"field": "length",
|
||||
"old_value": f"{old_length}mm",
|
||||
"new_value": f"{new_length}mm"
|
||||
})
|
||||
|
||||
# 끝단가공 변경
|
||||
old_end_prep = existing.get("end_preparation", "무개선")
|
||||
new_end_prep = new.get("end_preparation", "무개선")
|
||||
if old_end_prep != new_end_prep:
|
||||
changes.append({
|
||||
"field": "end_preparation",
|
||||
"old_value": old_end_prep,
|
||||
"new_value": new_end_prep
|
||||
})
|
||||
|
||||
return changes
|
||||
|
||||
def _save_comparison_result(self, job_no: str, new_file_id: int, comparison_result: List[Dict]) -> int:
|
||||
"""비교 결과를 데이터베이스에 저장"""
|
||||
try:
|
||||
# 이전 파일 ID 조회
|
||||
previous_file = self._get_previous_file(job_no, new_file_id)
|
||||
previous_file_id = previous_file.id if previous_file else None
|
||||
|
||||
# 통계 계산
|
||||
total_drawings = len(comparison_result)
|
||||
changed_drawings = len([d for d in comparison_result if d["has_changes"]])
|
||||
unchanged_drawings = total_drawings - changed_drawings
|
||||
|
||||
total_segments = sum(d["summary"]["total_segments"] for d in comparison_result)
|
||||
added_segments = sum(d["summary"]["added_count"] for d in comparison_result)
|
||||
removed_segments = sum(d["summary"]["removed_count"] for d in comparison_result)
|
||||
modified_segments = sum(d["summary"]["modified_count"] for d in comparison_result)
|
||||
unchanged_segments = sum(d["summary"]["unchanged_count"] for d in comparison_result)
|
||||
|
||||
# 비교 결과 저장
|
||||
comparison = PipeRevisionComparison(
|
||||
job_no=job_no,
|
||||
current_file_id=new_file_id,
|
||||
previous_cutting_plan_id=None, # 추후 구현
|
||||
total_drawings=total_drawings,
|
||||
changed_drawings=changed_drawings,
|
||||
unchanged_drawings=unchanged_drawings,
|
||||
total_segments=total_segments,
|
||||
added_segments=added_segments,
|
||||
removed_segments=removed_segments,
|
||||
modified_segments=modified_segments,
|
||||
unchanged_segments=unchanged_segments,
|
||||
created_by="system"
|
||||
)
|
||||
|
||||
self.db.add(comparison)
|
||||
self.db.flush() # ID 생성을 위해
|
||||
|
||||
# 상세 변경사항 저장
|
||||
for drawing_data in comparison_result:
|
||||
if drawing_data["has_changes"]:
|
||||
for segment in drawing_data["segments"]:
|
||||
if segment["change_type"] != "unchanged":
|
||||
change = PipeRevisionChange(
|
||||
comparison_id=comparison.id,
|
||||
drawing_name=drawing_data["drawing_name"],
|
||||
change_type=segment["change_type"]
|
||||
)
|
||||
|
||||
# 기존 데이터
|
||||
if segment["existing_data"]:
|
||||
existing = segment["existing_data"]
|
||||
change.old_line_no = existing.get("line_no")
|
||||
change.old_material_grade = existing.get("material_grade")
|
||||
change.old_schedule_spec = existing.get("schedule_spec")
|
||||
change.old_nominal_size = existing.get("nominal_size")
|
||||
change.old_length_mm = existing.get("length_mm", existing.get("length"))
|
||||
change.old_end_preparation = existing.get("end_preparation")
|
||||
|
||||
# 새 데이터
|
||||
if segment["segment_data"]:
|
||||
new_data = segment["segment_data"]
|
||||
change.new_line_no = new_data.get("line_no")
|
||||
change.new_material_grade = new_data.get("material_grade")
|
||||
change.new_schedule_spec = new_data.get("schedule_spec")
|
||||
change.new_nominal_size = new_data.get("nominal_size")
|
||||
change.new_length_mm = new_data.get("length_mm", new_data.get("length"))
|
||||
change.new_end_preparation = new_data.get("end_preparation")
|
||||
|
||||
self.db.add(change)
|
||||
|
||||
self.db.commit()
|
||||
logger.info(f"Saved comparison result with ID {comparison.id}")
|
||||
|
||||
return comparison.id
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to save comparison result: {e}")
|
||||
raise
|
||||
|
||||
def _generate_comparison_summary(self, comparison_result: List[Dict]) -> Dict[str, Any]:
|
||||
"""비교 결과 요약 생성"""
|
||||
total_drawings = len(comparison_result)
|
||||
changed_drawings = [d for d in comparison_result if d["has_changes"]]
|
||||
changed_drawings_count = len(changed_drawings)
|
||||
|
||||
total_segments = sum(d["summary"]["total_segments"] for d in comparison_result)
|
||||
added_segments = sum(d["summary"]["added_count"] for d in comparison_result)
|
||||
removed_segments = sum(d["summary"]["removed_count"] for d in comparison_result)
|
||||
modified_segments = sum(d["summary"]["modified_count"] for d in comparison_result)
|
||||
unchanged_segments = sum(d["summary"]["unchanged_count"] for d in comparison_result)
|
||||
|
||||
return {
|
||||
"total_drawings": total_drawings,
|
||||
"changed_drawings_count": changed_drawings_count,
|
||||
"unchanged_drawings_count": total_drawings - changed_drawings_count,
|
||||
"total_segments": total_segments,
|
||||
"added_segments": added_segments,
|
||||
"removed_segments": removed_segments,
|
||||
"modified_segments": modified_segments,
|
||||
"unchanged_segments": unchanged_segments,
|
||||
"change_percentage": round((changed_drawings_count / total_drawings * 100) if total_drawings > 0 else 0, 1)
|
||||
}
|
||||
|
||||
|
||||
def get_pipe_revision_service(db: Session = None) -> PipeRevisionService:
|
||||
"""PipeRevisionService 인스턴스 생성"""
|
||||
if db is None:
|
||||
db = next(get_db())
|
||||
return PipeRevisionService(db)
|
||||
220
backend/app/services/pipe_snapshot_excel_service.py
Normal file
220
backend/app/services/pipe_snapshot_excel_service.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
PIPE 스냅샷 기반 Excel 내보내기 서비스
|
||||
|
||||
확정된 Cutting Plan의 스냅샷 데이터를 기준으로 Excel 생성
|
||||
이후 리비전이 발생해도 Excel 내용은 변경되지 않음
|
||||
"""
|
||||
|
||||
import logging
|
||||
import pandas as pd
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import PipeIssueSnapshot, PipeIssueSegment
|
||||
from ..services.pipe_issue_snapshot_service import get_pipe_issue_snapshot_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PipeSnapshotExcelService:
|
||||
"""PIPE 스냅샷 기반 Excel 내보내기 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.snapshot_service = get_pipe_issue_snapshot_service(db)
|
||||
|
||||
def export_finalized_cutting_plan(self, job_no: str) -> Dict[str, Any]:
|
||||
"""
|
||||
확정된 Cutting Plan Excel 내보내기
|
||||
스냅샷 데이터 기준으로 고정된 Excel 생성
|
||||
"""
|
||||
try:
|
||||
# 1. 활성 스냅샷 확인
|
||||
snapshot_info = self.snapshot_service.get_snapshot_info(job_no)
|
||||
if not snapshot_info["has_snapshot"]:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "확정된 Cutting Plan이 없습니다. 먼저 Cutting Plan을 확정해주세요."
|
||||
}
|
||||
|
||||
if not snapshot_info["is_locked"]:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Cutting Plan이 아직 확정되지 않았습니다."
|
||||
}
|
||||
|
||||
snapshot_id = snapshot_info["snapshot_id"]
|
||||
|
||||
# 2. 스냅샷 데이터 조회
|
||||
segments_data = self.snapshot_service.get_snapshot_segments(snapshot_id)
|
||||
if not segments_data:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "내보낼 단관 데이터가 없습니다."
|
||||
}
|
||||
|
||||
# 3. Excel 파일 생성
|
||||
excel_buffer = self._create_cutting_plan_excel(
|
||||
segments_data,
|
||||
snapshot_info["snapshot_name"],
|
||||
job_no
|
||||
)
|
||||
|
||||
# 4. 파일명 생성
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"PIPE_Cutting_Plan_{job_no}_{timestamp}_FINALIZED.xlsx"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"excel_buffer": excel_buffer,
|
||||
"filename": filename,
|
||||
"snapshot_id": snapshot_id,
|
||||
"snapshot_name": snapshot_info["snapshot_name"],
|
||||
"total_segments": len(segments_data),
|
||||
"message": f"확정된 Cutting Plan Excel이 생성되었습니다. (스냅샷 기준)"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to export finalized cutting plan: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Excel 내보내기 실패: {str(e)}"
|
||||
}
|
||||
|
||||
def _create_cutting_plan_excel(self, segments_data: List[Dict], snapshot_name: str, job_no: str) -> BytesIO:
|
||||
"""스냅샷 데이터로 Excel 파일 생성"""
|
||||
|
||||
# DataFrame 생성
|
||||
df_data = []
|
||||
for segment in segments_data:
|
||||
df_data.append({
|
||||
'구역': segment.get('area', ''),
|
||||
'도면명': segment.get('drawing_name', ''),
|
||||
'라인번호': segment.get('line_no', ''),
|
||||
'재질': segment.get('material_grade', ''),
|
||||
'규격': segment.get('schedule_spec', ''),
|
||||
'호칭': segment.get('nominal_size', ''),
|
||||
'길이(mm)': segment.get('length_mm', 0),
|
||||
'끝단가공': segment.get('end_preparation', '무개선'),
|
||||
'파이프정보': segment.get('material_info', '')
|
||||
})
|
||||
|
||||
df = pd.DataFrame(df_data)
|
||||
|
||||
# Excel 버퍼 생성
|
||||
excel_buffer = BytesIO()
|
||||
|
||||
with pd.ExcelWriter(excel_buffer, engine='openpyxl') as writer:
|
||||
# 메인 시트 - 단관 목록
|
||||
df.to_excel(writer, sheet_name='단관 목록', index=False)
|
||||
|
||||
# 요약 시트
|
||||
summary_data = self._create_summary_data(segments_data, snapshot_name, job_no)
|
||||
summary_df = pd.DataFrame(summary_data)
|
||||
summary_df.to_excel(writer, sheet_name='요약', index=False)
|
||||
|
||||
# 구역별 시트
|
||||
areas = sorted(set(segment.get('area', '') for segment in segments_data if segment.get('area')))
|
||||
for area in areas:
|
||||
if area: # 빈 구역 제외
|
||||
area_segments = [s for s in segments_data if s.get('area') == area]
|
||||
area_df_data = []
|
||||
for segment in area_segments:
|
||||
area_df_data.append({
|
||||
'도면명': segment.get('drawing_name', ''),
|
||||
'라인번호': segment.get('line_no', ''),
|
||||
'재질': segment.get('material_grade', ''),
|
||||
'규격': segment.get('schedule_spec', ''),
|
||||
'호칭': segment.get('nominal_size', ''),
|
||||
'길이(mm)': segment.get('length_mm', 0),
|
||||
'끝단가공': segment.get('end_preparation', '무개선')
|
||||
})
|
||||
|
||||
area_df = pd.DataFrame(area_df_data)
|
||||
sheet_name = f'{area} 구역'
|
||||
area_df.to_excel(writer, sheet_name=sheet_name, index=False)
|
||||
|
||||
excel_buffer.seek(0)
|
||||
return excel_buffer
|
||||
|
||||
def _create_summary_data(self, segments_data: List[Dict], snapshot_name: str, job_no: str) -> List[Dict]:
|
||||
"""요약 정보 생성"""
|
||||
|
||||
# 기본 통계
|
||||
total_segments = len(segments_data)
|
||||
total_drawings = len(set(segment.get('drawing_name', '') for segment in segments_data))
|
||||
areas = sorted(set(segment.get('area', '') for segment in segments_data if segment.get('area')))
|
||||
|
||||
# 재질별 통계
|
||||
material_stats = {}
|
||||
for segment in segments_data:
|
||||
material = segment.get('material_grade', 'UNKNOWN')
|
||||
if material not in material_stats:
|
||||
material_stats[material] = {
|
||||
'count': 0,
|
||||
'total_length': 0
|
||||
}
|
||||
material_stats[material]['count'] += 1
|
||||
material_stats[material]['total_length'] += segment.get('length_mm', 0)
|
||||
|
||||
# 요약 데이터 구성
|
||||
summary_data = [
|
||||
{'항목': '작업번호', '값': job_no},
|
||||
{'항목': '스냅샷명', '값': snapshot_name},
|
||||
{'항목': '확정일시', '값': datetime.now().strftime('%Y-%m-%d %H:%M:%S')},
|
||||
{'항목': '총 단관 수', '값': total_segments},
|
||||
{'항목': '총 도면 수', '값': total_drawings},
|
||||
{'항목': '구역 수', '값': len(areas)},
|
||||
{'항목': '구역 목록', '값': ', '.join(areas)},
|
||||
{'항목': '', '값': ''}, # 빈 줄
|
||||
{'항목': '=== 재질별 통계 ===', '값': ''},
|
||||
]
|
||||
|
||||
for material, stats in material_stats.items():
|
||||
summary_data.extend([
|
||||
{'항목': f'{material} - 개수', '값': stats['count']},
|
||||
{'항목': f'{material} - 총길이(mm)', '값': f"{stats['total_length']:,.1f}"},
|
||||
{'항목': f'{material} - 총길이(m)', '값': f"{stats['total_length']/1000:,.3f}"}
|
||||
])
|
||||
|
||||
# 주의사항 추가
|
||||
summary_data.extend([
|
||||
{'항목': '', '값': ''}, # 빈 줄
|
||||
{'항목': '=== 주의사항 ===', '값': ''},
|
||||
{'항목': '⚠️ 확정된 데이터', '값': '이 Excel은 Cutting Plan 확정 시점의 데이터입니다.'},
|
||||
{'항목': '⚠️ 리비전 보호', '값': '이후 BOM 리비전이 발생해도 이 데이터는 변경되지 않습니다.'},
|
||||
{'항목': '⚠️ 수정 방법', '값': '변경이 필요한 경우 수동으로 편집하거나 새로운 Cutting Plan을 작성하세요.'},
|
||||
{'항목': '⚠️ 이슈 관리', '값': '현장 이슈는 별도 이슈 관리 시스템을 사용하세요.'}
|
||||
])
|
||||
|
||||
return summary_data
|
||||
|
||||
def check_finalization_status(self, job_no: str) -> Dict[str, Any]:
|
||||
"""Cutting Plan 확정 상태 확인"""
|
||||
try:
|
||||
snapshot_info = self.snapshot_service.get_snapshot_info(job_no)
|
||||
|
||||
return {
|
||||
"is_finalized": snapshot_info["has_snapshot"] and snapshot_info["is_locked"],
|
||||
"can_export_finalized": snapshot_info["has_snapshot"] and snapshot_info["is_locked"],
|
||||
"snapshot_info": snapshot_info if snapshot_info["has_snapshot"] else None,
|
||||
"message": "확정된 Cutting Plan Excel 내보내기 가능" if snapshot_info["has_snapshot"] and snapshot_info["is_locked"] else "Cutting Plan을 먼저 확정해주세요"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check finalization status: {e}")
|
||||
return {
|
||||
"is_finalized": False,
|
||||
"can_export_finalized": False,
|
||||
"message": f"확정 상태 확인 실패: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def get_pipe_snapshot_excel_service(db: Session = None) -> PipeSnapshotExcelService:
|
||||
"""PipeSnapshotExcelService 인스턴스 생성"""
|
||||
if db is None:
|
||||
db = next(get_db())
|
||||
return PipeSnapshotExcelService(db)
|
||||
224
backend/app/services/revision_comparison_service.py
Normal file
224
backend/app/services/revision_comparison_service.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
리비전 비교 전용 서비스
|
||||
두 리비전 간의 자재 비교 및 차이점 분석
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from decimal import Decimal
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
|
||||
from ..models import Material, File
|
||||
from ..utils.logger import get_logger
|
||||
from .database_service import DatabaseService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class RevisionComparisonService:
|
||||
"""리비전 비교 전용 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.db_service = DatabaseService(db)
|
||||
|
||||
def compare_revisions(
|
||||
self,
|
||||
current_file_id: int,
|
||||
previous_file_id: int,
|
||||
category_filter: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
두 리비전 간 자재 비교
|
||||
|
||||
Args:
|
||||
current_file_id: 현재 리비전 파일 ID
|
||||
previous_file_id: 이전 리비전 파일 ID
|
||||
category_filter: 특정 카테고리만 비교 (선택사항)
|
||||
|
||||
Returns:
|
||||
비교 결과 딕셔너리
|
||||
"""
|
||||
|
||||
# 이전/현재 자재 조회
|
||||
previous_materials = self._get_materials_for_comparison(previous_file_id, category_filter)
|
||||
current_materials = self._get_materials_for_comparison(current_file_id, category_filter)
|
||||
|
||||
# 비교 수행
|
||||
comparison_result = {
|
||||
"comparison_date": datetime.now().isoformat(),
|
||||
"current_file_id": current_file_id,
|
||||
"previous_file_id": previous_file_id,
|
||||
"category_filter": category_filter,
|
||||
"summary": {
|
||||
"previous_count": len(previous_materials),
|
||||
"current_count": len(current_materials),
|
||||
"unchanged": 0,
|
||||
"modified": 0,
|
||||
"added": 0,
|
||||
"removed": 0
|
||||
},
|
||||
"changes": {
|
||||
"unchanged": [],
|
||||
"modified": [],
|
||||
"added": [],
|
||||
"removed": []
|
||||
}
|
||||
}
|
||||
|
||||
# 이전 자재 기준으로 비교
|
||||
for key, prev_material in previous_materials.items():
|
||||
if key in current_materials:
|
||||
curr_material = current_materials[key]
|
||||
|
||||
# 자재 변경 여부 확인
|
||||
if self._is_material_changed(prev_material, curr_material):
|
||||
comparison_result["changes"]["modified"].append({
|
||||
"key": key,
|
||||
"previous": prev_material,
|
||||
"current": curr_material,
|
||||
"changes": self._get_material_changes(prev_material, curr_material)
|
||||
})
|
||||
comparison_result["summary"]["modified"] += 1
|
||||
else:
|
||||
comparison_result["changes"]["unchanged"].append({
|
||||
"key": key,
|
||||
"material": curr_material
|
||||
})
|
||||
comparison_result["summary"]["unchanged"] += 1
|
||||
else:
|
||||
# 제거된 자재
|
||||
comparison_result["changes"]["removed"].append({
|
||||
"key": key,
|
||||
"material": prev_material
|
||||
})
|
||||
comparison_result["summary"]["removed"] += 1
|
||||
|
||||
# 신규 자재
|
||||
for key, curr_material in current_materials.items():
|
||||
if key not in previous_materials:
|
||||
comparison_result["changes"]["added"].append({
|
||||
"key": key,
|
||||
"material": curr_material
|
||||
})
|
||||
comparison_result["summary"]["added"] += 1
|
||||
|
||||
return comparison_result
|
||||
|
||||
def get_category_comparison(
|
||||
self,
|
||||
current_file_id: int,
|
||||
previous_file_id: int,
|
||||
category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""특정 카테고리의 리비전 비교"""
|
||||
|
||||
return self.compare_revisions(current_file_id, previous_file_id, category)
|
||||
|
||||
# PIPE 관련 메서드는 별도 처리 예정
|
||||
|
||||
def _get_materials_for_comparison(
|
||||
self,
|
||||
file_id: int,
|
||||
category_filter: Optional[str] = None
|
||||
) -> Dict[str, Dict]:
|
||||
"""비교용 자재 데이터 조회"""
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
m.id, m.original_description, m.classified_category,
|
||||
m.material_grade, m.schedule, m.size_spec, m.main_nom, m.red_nom,
|
||||
m.quantity, m.unit, m.length, m.drawing_name, m.line_no,
|
||||
m.purchase_confirmed, m.confirmed_quantity, m.purchase_status,
|
||||
m.material_hash, m.revision_status, m.brand, m.user_requirement,
|
||||
m.line_number, m.is_active,
|
||||
-- 비교 키 생성 (PIPE 제외)
|
||||
COALESCE(m.material_hash,
|
||||
CONCAT(m.original_description, '|',
|
||||
COALESCE(m.material_grade, ''), '|',
|
||||
COALESCE(m.size_spec, ''))) as comparison_key
|
||||
FROM materials m
|
||||
WHERE m.file_id = :file_id AND m.is_active = true
|
||||
"""
|
||||
|
||||
params = {"file_id": file_id}
|
||||
|
||||
if category_filter:
|
||||
query += " AND m.classified_category = :category"
|
||||
params["category"] = category_filter
|
||||
|
||||
# PIPE 카테고리는 제외
|
||||
query += " AND m.classified_category != 'PIPE'"
|
||||
|
||||
query += " ORDER BY m.line_number"
|
||||
|
||||
result = self.db_service.execute_query(query, params)
|
||||
materials = {}
|
||||
|
||||
for row in result.fetchall():
|
||||
row_dict = dict(row._mapping)
|
||||
comparison_key = row_dict['comparison_key']
|
||||
|
||||
# PIPE 제외한 일반 자재 처리
|
||||
materials[comparison_key] = row_dict
|
||||
|
||||
return materials
|
||||
|
||||
def _is_material_changed(self, prev_material: Dict, curr_material: Dict) -> bool:
|
||||
"""자재 변경 여부 확인"""
|
||||
|
||||
# 주요 필드 비교
|
||||
compare_fields = ['quantity', 'material_grade', 'schedule', 'size_spec',
|
||||
'main_nom', 'red_nom', 'unit', 'length']
|
||||
|
||||
for field in compare_fields:
|
||||
prev_val = prev_material.get(field)
|
||||
curr_val = curr_material.get(field)
|
||||
|
||||
# 수치 필드는 부동소수점 오차 고려
|
||||
if field in ['quantity', 'length']:
|
||||
if prev_val is not None and curr_val is not None:
|
||||
if abs(float(prev_val) - float(curr_val)) > 0.001:
|
||||
return True
|
||||
elif prev_val != curr_val:
|
||||
return True
|
||||
else:
|
||||
if prev_val != curr_val:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _get_material_changes(self, prev_material: Dict, curr_material: Dict) -> Dict[str, Any]:
|
||||
"""자재 변경 내용 상세 분석"""
|
||||
|
||||
changes = {}
|
||||
compare_fields = ['quantity', 'material_grade', 'schedule', 'size_spec',
|
||||
'main_nom', 'red_nom', 'unit', 'length']
|
||||
|
||||
for field in compare_fields:
|
||||
prev_val = prev_material.get(field)
|
||||
curr_val = curr_material.get(field)
|
||||
|
||||
if field in ['quantity', 'length']:
|
||||
if prev_val is not None and curr_val is not None:
|
||||
if abs(float(prev_val) - float(curr_val)) > 0.001:
|
||||
changes[field] = {
|
||||
"previous": float(prev_val),
|
||||
"current": float(curr_val),
|
||||
"change": float(curr_val) - float(prev_val)
|
||||
}
|
||||
elif prev_val != curr_val:
|
||||
changes[field] = {
|
||||
"previous": prev_val,
|
||||
"current": curr_val
|
||||
}
|
||||
else:
|
||||
if prev_val != curr_val:
|
||||
changes[field] = {
|
||||
"previous": prev_val,
|
||||
"current": curr_val
|
||||
}
|
||||
|
||||
return changes
|
||||
478
backend/app/services/revision_logic_service.py
Normal file
478
backend/app/services/revision_logic_service.py
Normal file
@@ -0,0 +1,478 @@
|
||||
"""
|
||||
리비전 처리 로직 서비스
|
||||
구매 상태와 카테고리별 특성을 고려한 스마트 리비전 관리
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, and_, or_
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from decimal import Decimal
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
|
||||
from ..models import Material, File
|
||||
from ..utils.logger import get_logger
|
||||
from .database_service import DatabaseService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class RevisionLogicService:
|
||||
"""리비전 처리 로직 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.db_service = DatabaseService(db)
|
||||
|
||||
def process_revision_by_purchase_status(
|
||||
self,
|
||||
job_no: str,
|
||||
current_file_id: int,
|
||||
previous_file_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
구매 상태별 리비전 처리
|
||||
|
||||
Returns:
|
||||
{
|
||||
"needs_revision_page": bool, # 리비전 페이지 필요 여부
|
||||
"can_use_bom_page": bool, # 기존 BOM 페이지 사용 가능 여부
|
||||
"processing_results": dict, # 처리 결과
|
||||
"revision_materials": list, # 리비전 페이지에서 관리할 자재
|
||||
"inventory_materials": list, # 재고로 분류할 자재
|
||||
"deleted_materials": list # 삭제할 자재
|
||||
}
|
||||
"""
|
||||
|
||||
if not previous_file_id:
|
||||
previous_file_id = self._get_previous_file_id(job_no, current_file_id)
|
||||
|
||||
if not previous_file_id:
|
||||
return self._handle_first_revision(current_file_id)
|
||||
|
||||
# 이전/현재 자재 조회
|
||||
previous_materials = self._get_materials_with_details(previous_file_id)
|
||||
current_materials = self._get_materials_with_details(current_file_id)
|
||||
|
||||
# 카테고리별 처리
|
||||
processing_results = {}
|
||||
revision_materials = []
|
||||
inventory_materials = []
|
||||
deleted_materials = []
|
||||
|
||||
# 각 카테고리별로 처리
|
||||
categories = ['PIPE', 'FITTING', 'FLANGE', 'VALVE', 'GASKET', 'BOLT', 'SUPPORT', 'SPECIAL']
|
||||
|
||||
for category in categories:
|
||||
category_result = self._process_category_revision(
|
||||
category, previous_materials, current_materials
|
||||
)
|
||||
|
||||
processing_results[category] = category_result
|
||||
|
||||
# 자재 분류
|
||||
revision_materials.extend(category_result['revision_materials'])
|
||||
inventory_materials.extend(category_result['inventory_materials'])
|
||||
deleted_materials.extend(category_result['deleted_materials'])
|
||||
|
||||
return {
|
||||
"needs_revision_page": True, # 리비전이면 항상 리비전 페이지 필요
|
||||
"can_use_bom_page": False, # 리비전이면 기존 BOM 페이지 사용 불가
|
||||
"processing_results": processing_results,
|
||||
"revision_materials": revision_materials,
|
||||
"inventory_materials": inventory_materials,
|
||||
"deleted_materials": deleted_materials,
|
||||
"summary": self._generate_revision_summary(processing_results)
|
||||
}
|
||||
|
||||
def _process_category_revision(
|
||||
self,
|
||||
category: str,
|
||||
previous_materials: Dict[str, Dict],
|
||||
current_materials: Dict[str, Dict]
|
||||
) -> Dict[str, Any]:
|
||||
"""카테고리별 리비전 처리"""
|
||||
|
||||
# 카테고리별 자재 필터링
|
||||
prev_category_materials = {
|
||||
k: v for k, v in previous_materials.items()
|
||||
if v.get('classified_category') == category
|
||||
}
|
||||
|
||||
curr_category_materials = {
|
||||
k: v for k, v in current_materials.items()
|
||||
if v.get('classified_category') == category
|
||||
}
|
||||
|
||||
result = {
|
||||
"category": category,
|
||||
"revision_materials": [],
|
||||
"inventory_materials": [],
|
||||
"deleted_materials": [],
|
||||
"unchanged_materials": [],
|
||||
"processing_summary": {
|
||||
"purchased_unchanged": 0,
|
||||
"purchased_excess": 0,
|
||||
"purchased_insufficient": 0,
|
||||
"unpurchased_deleted": 0,
|
||||
"unpurchased_unchanged": 0,
|
||||
"unpurchased_updated": 0,
|
||||
"new_materials": 0
|
||||
}
|
||||
}
|
||||
|
||||
# 이전 자재 기준으로 비교
|
||||
for key, prev_material in prev_category_materials.items():
|
||||
if key in curr_category_materials:
|
||||
curr_material = curr_category_materials[key]
|
||||
|
||||
# GASKET, BOLT는 규칙 적용 전 수량으로 비교
|
||||
if category in ['GASKET', 'BOLT']:
|
||||
comparison = self._compare_materials_pre_calculation(prev_material, curr_material, category)
|
||||
else:
|
||||
comparison = self._compare_materials_standard(prev_material, curr_material, category)
|
||||
|
||||
# 구매 완료 자재 처리
|
||||
if prev_material.get('purchase_confirmed', False):
|
||||
processed = self._process_purchased_material(prev_material, curr_material, comparison, category)
|
||||
else:
|
||||
# 구매 미완료 자재 처리
|
||||
processed = self._process_unpurchased_material(prev_material, curr_material, comparison, category)
|
||||
|
||||
# 결과 분류
|
||||
if processed['action'] == 'revision_management':
|
||||
result['revision_materials'].append(processed)
|
||||
elif processed['action'] == 'inventory':
|
||||
result['inventory_materials'].append(processed)
|
||||
elif processed['action'] == 'unchanged':
|
||||
result['unchanged_materials'].append(processed)
|
||||
|
||||
# 통계 업데이트
|
||||
result['processing_summary'][processed['summary_key']] += 1
|
||||
|
||||
else:
|
||||
# 삭제된 자재 (현재 리비전에 없음)
|
||||
if prev_material.get('purchase_confirmed', False):
|
||||
# 구매 완료된 자재가 삭제됨 → 재고로 분류
|
||||
result['inventory_materials'].append({
|
||||
'material': prev_material,
|
||||
'action': 'inventory',
|
||||
'reason': 'purchased_but_removed_in_revision',
|
||||
'category': category
|
||||
})
|
||||
else:
|
||||
# 구매 미완료 자재가 삭제됨 → 완전 삭제
|
||||
result['deleted_materials'].append({
|
||||
'material': prev_material,
|
||||
'action': 'delete',
|
||||
'reason': 'no_longer_needed',
|
||||
'category': category
|
||||
})
|
||||
result['processing_summary']['unpurchased_deleted'] += 1
|
||||
|
||||
# 신규 자재 처리
|
||||
for key, curr_material in curr_category_materials.items():
|
||||
if key not in prev_category_materials:
|
||||
result['revision_materials'].append({
|
||||
'material': curr_material,
|
||||
'action': 'revision_management',
|
||||
'reason': 'new_material',
|
||||
'category': category,
|
||||
'summary_key': 'new_materials'
|
||||
})
|
||||
result['processing_summary']['new_materials'] += 1
|
||||
|
||||
return result
|
||||
|
||||
def _process_purchased_material(
|
||||
self,
|
||||
prev_material: Dict,
|
||||
curr_material: Dict,
|
||||
comparison: Dict,
|
||||
category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""구매 완료 자재 처리"""
|
||||
|
||||
if comparison['change_type'] == 'no_change':
|
||||
# 변동 없음 → 구매 완료 상태 유지, 더 이상 관리 불필요
|
||||
return {
|
||||
'material': curr_material,
|
||||
'action': 'unchanged',
|
||||
'reason': 'purchased_no_change',
|
||||
'category': category,
|
||||
'summary_key': 'purchased_unchanged'
|
||||
}
|
||||
|
||||
elif comparison['change_type'] == 'decreased':
|
||||
# 수량 감소/불필요 → 재고 자재로 분류
|
||||
excess_quantity = abs(comparison['quantity_change'])
|
||||
return {
|
||||
'material': prev_material, # 이전 자재 정보 사용
|
||||
'action': 'inventory',
|
||||
'reason': 'purchased_excess',
|
||||
'category': category,
|
||||
'excess_quantity': excess_quantity,
|
||||
'current_needed': curr_material.get('quantity', 0),
|
||||
'summary_key': 'purchased_excess'
|
||||
}
|
||||
|
||||
else: # increased
|
||||
# 수량 부족 → 리비전 페이지에서 추가 구매 관리
|
||||
additional_needed = comparison['quantity_change']
|
||||
return {
|
||||
'material': curr_material,
|
||||
'action': 'revision_management',
|
||||
'reason': 'purchased_insufficient',
|
||||
'category': category,
|
||||
'additional_needed': additional_needed,
|
||||
'already_purchased': prev_material.get('quantity', 0),
|
||||
'summary_key': 'purchased_insufficient'
|
||||
}
|
||||
|
||||
def _process_unpurchased_material(
|
||||
self,
|
||||
prev_material: Dict,
|
||||
curr_material: Dict,
|
||||
comparison: Dict,
|
||||
category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""구매 미완료 자재 처리"""
|
||||
|
||||
if comparison['change_type'] == 'no_change':
|
||||
# 수량 동일 → 리비전 페이지에서 구매 관리 계속
|
||||
return {
|
||||
'material': curr_material,
|
||||
'action': 'revision_management',
|
||||
'reason': 'unpurchased_unchanged',
|
||||
'category': category,
|
||||
'summary_key': 'unpurchased_unchanged'
|
||||
}
|
||||
|
||||
else:
|
||||
# 수량 변경 → 필요 수량만큼 리비전 페이지에서 관리
|
||||
return {
|
||||
'material': curr_material,
|
||||
'action': 'revision_management',
|
||||
'reason': 'unpurchased_quantity_changed',
|
||||
'category': category,
|
||||
'quantity_change': comparison['quantity_change'],
|
||||
'previous_quantity': prev_material.get('quantity', 0),
|
||||
'summary_key': 'unpurchased_updated'
|
||||
}
|
||||
|
||||
def _compare_materials_standard(
|
||||
self,
|
||||
prev_material: Dict,
|
||||
curr_material: Dict,
|
||||
category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""표준 자재 비교 (PIPE 제외)"""
|
||||
|
||||
prev_qty = float(prev_material.get('quantity', 0))
|
||||
curr_qty = float(curr_material.get('quantity', 0))
|
||||
|
||||
quantity_change = curr_qty - prev_qty
|
||||
|
||||
if abs(quantity_change) < 0.001: # 부동소수점 오차 고려
|
||||
change_type = 'no_change'
|
||||
elif quantity_change > 0:
|
||||
change_type = 'increased'
|
||||
else:
|
||||
change_type = 'decreased'
|
||||
|
||||
return {
|
||||
'quantity_change': quantity_change,
|
||||
'change_type': change_type,
|
||||
'previous_quantity': prev_qty,
|
||||
'current_quantity': curr_qty
|
||||
}
|
||||
|
||||
def _compare_materials_pre_calculation(
|
||||
self,
|
||||
prev_material: Dict,
|
||||
curr_material: Dict,
|
||||
category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""규칙 적용 전 수량으로 비교 (GASKET, BOLT)"""
|
||||
|
||||
# 원본 수량 (규칙 적용 전)으로 비교
|
||||
prev_original_qty = float(prev_material.get('original_quantity', prev_material.get('quantity', 0)))
|
||||
curr_original_qty = float(curr_material.get('original_quantity', curr_material.get('quantity', 0)))
|
||||
|
||||
quantity_change = curr_original_qty - prev_original_qty
|
||||
|
||||
if abs(quantity_change) < 0.001:
|
||||
change_type = 'no_change'
|
||||
elif quantity_change > 0:
|
||||
change_type = 'increased'
|
||||
else:
|
||||
change_type = 'decreased'
|
||||
|
||||
# 최종 계산된 수량도 포함
|
||||
final_prev_qty = float(prev_material.get('quantity', 0))
|
||||
final_curr_qty = float(curr_material.get('quantity', 0))
|
||||
|
||||
return {
|
||||
'quantity_change': quantity_change,
|
||||
'change_type': change_type,
|
||||
'previous_quantity': prev_original_qty,
|
||||
'current_quantity': curr_original_qty,
|
||||
'final_previous_quantity': final_prev_qty,
|
||||
'final_current_quantity': final_curr_qty,
|
||||
'calculation_rule_applied': True
|
||||
}
|
||||
|
||||
def _get_materials_with_details(self, file_id: int) -> Dict[str, Dict]:
|
||||
"""파일의 자재를 상세 정보와 함께 조회"""
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
m.id, m.original_description, m.classified_category,
|
||||
m.material_grade, m.schedule, m.size_spec, m.main_nom, m.red_nom,
|
||||
m.quantity, m.unit, m.length, m.drawing_name, m.line_no,
|
||||
m.purchase_confirmed, m.confirmed_quantity, m.purchase_status,
|
||||
m.material_hash, m.revision_status, m.brand, m.user_requirement,
|
||||
-- PIPE 자재 특별 키 생성
|
||||
CASE
|
||||
WHEN m.classified_category = 'PIPE' THEN
|
||||
CONCAT(m.drawing_name, '|', m.line_no, '|', COALESCE(m.length, 0))
|
||||
ELSE
|
||||
m.material_hash
|
||||
END as comparison_key
|
||||
FROM materials m
|
||||
WHERE m.file_id = :file_id AND m.is_active = true
|
||||
ORDER BY m.line_number
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {"file_id": file_id})
|
||||
materials = {}
|
||||
|
||||
for row in result.fetchall():
|
||||
row_dict = dict(row._mapping)
|
||||
comparison_key = row_dict['comparison_key']
|
||||
|
||||
# PIPE 자재의 경우 도면-라인넘버별로 길이 합산
|
||||
if row_dict['classified_category'] == 'PIPE':
|
||||
if comparison_key in materials:
|
||||
# 기존 자재에 길이 합산
|
||||
materials[comparison_key]['quantity'] += row_dict['quantity']
|
||||
materials[comparison_key]['total_length'] = (
|
||||
materials[comparison_key].get('total_length', 0) +
|
||||
(row_dict['length'] or 0) * row_dict['quantity']
|
||||
)
|
||||
else:
|
||||
row_dict['total_length'] = (row_dict['length'] or 0) * row_dict['quantity']
|
||||
materials[comparison_key] = row_dict
|
||||
else:
|
||||
materials[comparison_key] = row_dict
|
||||
|
||||
return materials
|
||||
|
||||
def _get_previous_file_id(self, job_no: str, current_file_id: int) -> Optional[int]:
|
||||
"""이전 파일 ID 자동 탐지"""
|
||||
|
||||
query = """
|
||||
SELECT id, revision
|
||||
FROM files
|
||||
WHERE job_no = :job_no AND id != :current_file_id AND is_active = true
|
||||
ORDER BY upload_date DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {
|
||||
"job_no": job_no,
|
||||
"current_file_id": current_file_id
|
||||
})
|
||||
|
||||
row = result.fetchone()
|
||||
return row.id if row else None
|
||||
|
||||
def _handle_first_revision(self, current_file_id: int) -> Dict[str, Any]:
|
||||
"""첫 번째 리비전 처리"""
|
||||
|
||||
materials = self._get_materials_with_details(current_file_id)
|
||||
|
||||
return {
|
||||
"needs_revision_page": True, # 첫 리비전은 항상 리비전 페이지 필요
|
||||
"can_use_bom_page": False,
|
||||
"processing_results": {},
|
||||
"revision_materials": [{"material": mat, "action": "revision_management", "reason": "first_revision"} for mat in materials.values()],
|
||||
"inventory_materials": [],
|
||||
"deleted_materials": [],
|
||||
"summary": {
|
||||
"is_first_revision": True,
|
||||
"total_materials": len(materials)
|
||||
}
|
||||
}
|
||||
|
||||
def _generate_revision_summary(self, processing_results: Dict) -> Dict[str, Any]:
|
||||
"""리비전 처리 요약 생성"""
|
||||
|
||||
summary = {
|
||||
"total_categories": len(processing_results),
|
||||
"total_revision_materials": 0,
|
||||
"total_inventory_materials": 0,
|
||||
"total_deleted_materials": 0,
|
||||
"by_category": {}
|
||||
}
|
||||
|
||||
for category, result in processing_results.items():
|
||||
summary["total_revision_materials"] += len(result['revision_materials'])
|
||||
summary["total_inventory_materials"] += len(result['inventory_materials'])
|
||||
summary["total_deleted_materials"] += len(result['deleted_materials'])
|
||||
|
||||
summary["by_category"][category] = {
|
||||
"revision_count": len(result['revision_materials']),
|
||||
"inventory_count": len(result['inventory_materials']),
|
||||
"deleted_count": len(result['deleted_materials']),
|
||||
"processing_summary": result['processing_summary']
|
||||
}
|
||||
|
||||
return summary
|
||||
|
||||
def should_redirect_to_revision_page(
|
||||
self,
|
||||
job_no: str,
|
||||
current_file_id: int,
|
||||
previous_file_id: Optional[int] = None
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
리비전 페이지로 리다이렉트해야 하는지 판단
|
||||
실제 변경사항이 있을 때만 리비전 페이지로 이동
|
||||
|
||||
Returns:
|
||||
(should_redirect: bool, reason: str)
|
||||
"""
|
||||
|
||||
try:
|
||||
# 이전 파일이 있는지 확인 (리비전 여부 판단)
|
||||
if not previous_file_id:
|
||||
previous_file_id = self._get_previous_file_id(job_no, current_file_id)
|
||||
|
||||
if not previous_file_id:
|
||||
# 첫 번째 파일 (리비전 아님) → 기존 BOM 페이지 사용
|
||||
return False, "첫 번째 BOM 파일이므로 기존 페이지에서 관리합니다."
|
||||
|
||||
# 실제 변경사항이 있는지 확인
|
||||
processing_results = self.process_revision_by_purchase_status(
|
||||
job_no, current_file_id, previous_file_id
|
||||
)
|
||||
|
||||
# 변경사항 통계 확인
|
||||
summary = processing_results.get('summary', {})
|
||||
total_changes = (
|
||||
summary.get('revision_materials', 0) +
|
||||
summary.get('inventory_materials', 0) +
|
||||
summary.get('deleted_materials', 0)
|
||||
)
|
||||
|
||||
if total_changes > 0:
|
||||
# 실제 변경사항이 있으면 리비전 페이지로
|
||||
return True, f"리비전 변경사항이 감지되었습니다 (변경: {total_changes}개). 리비전 페이지에서 관리해야 합니다."
|
||||
else:
|
||||
# 변경사항이 없으면 기존 BOM 페이지 사용
|
||||
return False, "리비전 파일이지만 변경사항이 없어 기존 페이지에서 관리합니다."
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to determine revision redirect: {e}")
|
||||
return False, "리비전 상태 확인 실패 - 기존 페이지 사용"
|
||||
425
backend/app/services/revision_material_service.py
Normal file
425
backend/app/services/revision_material_service.py
Normal file
@@ -0,0 +1,425 @@
|
||||
"""
|
||||
리비전 자재 처리 전용 서비스
|
||||
구매 상태별 자재 처리 로직
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List, Dict, Any, Optional
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
|
||||
from ..models import Material, File
|
||||
from ..utils.logger import get_logger
|
||||
from .database_service import DatabaseService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class RevisionMaterialService:
|
||||
"""리비전 자재 처리 전용 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.db_service = DatabaseService(db)
|
||||
|
||||
def process_material_by_purchase_status(
|
||||
self,
|
||||
prev_material: Dict,
|
||||
curr_material: Dict,
|
||||
category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
구매 상태별 자재 처리
|
||||
|
||||
Args:
|
||||
prev_material: 이전 리비전 자재
|
||||
curr_material: 현재 리비전 자재
|
||||
category: 자재 카테고리
|
||||
|
||||
Returns:
|
||||
처리 결과
|
||||
"""
|
||||
|
||||
# 수량 변화 계산
|
||||
quantity_change = self._calculate_quantity_change(prev_material, curr_material, category)
|
||||
|
||||
# 구매 완료 자재 처리
|
||||
if prev_material.get('purchase_confirmed', False):
|
||||
return self._process_purchased_material(prev_material, curr_material, quantity_change, category)
|
||||
else:
|
||||
# 구매 미완료 자재 처리
|
||||
return self._process_unpurchased_material(prev_material, curr_material, quantity_change, category)
|
||||
|
||||
def process_new_material(self, material: Dict, category: str) -> Dict[str, Any]:
|
||||
"""신규 자재 처리"""
|
||||
|
||||
return {
|
||||
'material_id': material['id'],
|
||||
'category': category,
|
||||
'action': 'new_material',
|
||||
'status': 'needs_purchase',
|
||||
'quantity': material.get('quantity', 0),
|
||||
'description': material.get('original_description', ''),
|
||||
'processing_note': '신규 자재 - 구매 필요',
|
||||
'ui_display': {
|
||||
'show_in_revision_page': True,
|
||||
'highlight_color': 'green',
|
||||
'action_required': '구매 신청',
|
||||
'badge': 'NEW'
|
||||
}
|
||||
}
|
||||
|
||||
def process_removed_material(self, material: Dict, category: str) -> Dict[str, Any]:
|
||||
"""제거된 자재 처리"""
|
||||
|
||||
if material.get('purchase_confirmed', False):
|
||||
# 구매 완료된 자재가 제거됨 → 재고로 분류
|
||||
return {
|
||||
'material_id': material['id'],
|
||||
'category': category,
|
||||
'action': 'move_to_inventory',
|
||||
'status': 'excess_inventory',
|
||||
'quantity': material.get('quantity', 0),
|
||||
'description': material.get('original_description', ''),
|
||||
'processing_note': '구매 완료 후 리비전에서 제거됨 - 재고 보관',
|
||||
'ui_display': {
|
||||
'show_in_revision_page': True,
|
||||
'highlight_color': 'orange',
|
||||
'action_required': '재고 관리',
|
||||
'badge': 'INVENTORY'
|
||||
}
|
||||
}
|
||||
else:
|
||||
# 구매 미완료 자재가 제거됨 → 완전 삭제
|
||||
return {
|
||||
'material_id': material['id'],
|
||||
'category': category,
|
||||
'action': 'delete',
|
||||
'status': 'deleted',
|
||||
'quantity': material.get('quantity', 0),
|
||||
'description': material.get('original_description', ''),
|
||||
'processing_note': '리비전에서 제거됨 - 구매 불필요',
|
||||
'ui_display': {
|
||||
'show_in_revision_page': False, # 삭제된 자재는 표시 안함
|
||||
'highlight_color': 'red',
|
||||
'action_required': '삭제 완료',
|
||||
'badge': 'DELETED'
|
||||
}
|
||||
}
|
||||
|
||||
def _process_purchased_material(
|
||||
self,
|
||||
prev_material: Dict,
|
||||
curr_material: Dict,
|
||||
quantity_change: Dict,
|
||||
category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""구매 완료 자재 처리"""
|
||||
|
||||
if quantity_change['change_type'] == 'no_change':
|
||||
# 변동 없음 → 구매 완료 상태 유지
|
||||
return {
|
||||
'material_id': curr_material['id'],
|
||||
'category': category,
|
||||
'action': 'maintain_status',
|
||||
'status': 'purchased_completed',
|
||||
'quantity': curr_material.get('quantity', 0),
|
||||
'description': curr_material.get('original_description', ''),
|
||||
'processing_note': '구매 완료 - 변동 없음',
|
||||
'ui_display': {
|
||||
'show_in_revision_page': False, # 변동 없는 구매완료 자재는 숨김
|
||||
'highlight_color': 'gray',
|
||||
'action_required': '관리 불필요',
|
||||
'badge': 'COMPLETED'
|
||||
}
|
||||
}
|
||||
|
||||
elif quantity_change['change_type'] == 'decreased':
|
||||
# 수량 감소 → 재고 자재로 분류
|
||||
excess_quantity = abs(quantity_change['quantity_change'])
|
||||
return {
|
||||
'material_id': curr_material['id'],
|
||||
'category': category,
|
||||
'action': 'partial_inventory',
|
||||
'status': 'excess_inventory',
|
||||
'quantity': curr_material.get('quantity', 0),
|
||||
'excess_quantity': excess_quantity,
|
||||
'purchased_quantity': prev_material.get('quantity', 0),
|
||||
'description': curr_material.get('original_description', ''),
|
||||
'processing_note': f'구매 완료 후 수량 감소 - 잉여 {excess_quantity}개 재고 보관',
|
||||
'ui_display': {
|
||||
'show_in_revision_page': True,
|
||||
'highlight_color': 'orange',
|
||||
'action_required': '잉여 재고 관리',
|
||||
'badge': 'EXCESS'
|
||||
}
|
||||
}
|
||||
|
||||
else: # increased
|
||||
# 수량 부족 → 추가 구매 필요
|
||||
additional_needed = quantity_change['quantity_change']
|
||||
return {
|
||||
'material_id': curr_material['id'],
|
||||
'category': category,
|
||||
'action': 'additional_purchase',
|
||||
'status': 'needs_additional_purchase',
|
||||
'quantity': curr_material.get('quantity', 0),
|
||||
'additional_needed': additional_needed,
|
||||
'already_purchased': prev_material.get('quantity', 0),
|
||||
'description': curr_material.get('original_description', ''),
|
||||
'processing_note': f'구매 완료 후 수량 부족 - 추가 {additional_needed}개 구매 필요',
|
||||
'ui_display': {
|
||||
'show_in_revision_page': True,
|
||||
'highlight_color': 'red',
|
||||
'action_required': '추가 구매 신청',
|
||||
'badge': 'ADDITIONAL'
|
||||
}
|
||||
}
|
||||
|
||||
def _process_unpurchased_material(
|
||||
self,
|
||||
prev_material: Dict,
|
||||
curr_material: Dict,
|
||||
quantity_change: Dict,
|
||||
category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""구매 미완료 자재 처리"""
|
||||
|
||||
if quantity_change['change_type'] == 'no_change':
|
||||
# 수량 동일 → 구매 관리 계속
|
||||
return {
|
||||
'material_id': curr_material['id'],
|
||||
'category': category,
|
||||
'action': 'continue_purchase',
|
||||
'status': 'pending_purchase',
|
||||
'quantity': curr_material.get('quantity', 0),
|
||||
'description': curr_material.get('original_description', ''),
|
||||
'processing_note': '수량 변동 없음 - 구매 진행',
|
||||
'ui_display': {
|
||||
'show_in_revision_page': True,
|
||||
'highlight_color': 'blue',
|
||||
'action_required': '구매 신청',
|
||||
'badge': 'PENDING'
|
||||
}
|
||||
}
|
||||
|
||||
else:
|
||||
# 수량 변경 → 수량 업데이트 후 구매 관리
|
||||
return {
|
||||
'material_id': curr_material['id'],
|
||||
'category': category,
|
||||
'action': 'update_quantity',
|
||||
'status': 'quantity_updated',
|
||||
'quantity': curr_material.get('quantity', 0),
|
||||
'previous_quantity': prev_material.get('quantity', 0),
|
||||
'quantity_change': quantity_change['quantity_change'],
|
||||
'description': curr_material.get('original_description', ''),
|
||||
'processing_note': f'수량 변경: {prev_material.get("quantity", 0)} → {curr_material.get("quantity", 0)}',
|
||||
'ui_display': {
|
||||
'show_in_revision_page': True,
|
||||
'highlight_color': 'yellow',
|
||||
'action_required': '수량 확인 후 구매 신청',
|
||||
'badge': 'UPDATED'
|
||||
}
|
||||
}
|
||||
|
||||
def _calculate_quantity_change(
|
||||
self,
|
||||
prev_material: Dict,
|
||||
curr_material: Dict,
|
||||
category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""수량 변화 계산"""
|
||||
|
||||
# GASKET, BOLT는 규칙 적용 전 수량으로 비교
|
||||
if category in ['GASKET', 'BOLT']:
|
||||
prev_qty = float(prev_material.get('original_quantity', prev_material.get('quantity', 0)))
|
||||
curr_qty = float(curr_material.get('original_quantity', curr_material.get('quantity', 0)))
|
||||
else:
|
||||
prev_qty = float(prev_material.get('quantity', 0))
|
||||
curr_qty = float(curr_material.get('quantity', 0))
|
||||
|
||||
quantity_change = curr_qty - prev_qty
|
||||
|
||||
if abs(quantity_change) < 0.001: # 부동소수점 오차 고려
|
||||
change_type = 'no_change'
|
||||
elif quantity_change > 0:
|
||||
change_type = 'increased'
|
||||
else:
|
||||
change_type = 'decreased'
|
||||
|
||||
return {
|
||||
'previous_quantity': prev_qty,
|
||||
'current_quantity': curr_qty,
|
||||
'quantity_change': quantity_change,
|
||||
'change_type': change_type,
|
||||
'is_gasket_bolt': category in ['GASKET', 'BOLT']
|
||||
}
|
||||
|
||||
def get_category_materials_for_revision(
|
||||
self,
|
||||
file_id: int,
|
||||
category: str,
|
||||
include_processing_info: bool = True
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
리비전 페이지용 카테고리별 자재 조회
|
||||
|
||||
Args:
|
||||
file_id: 파일 ID
|
||||
category: 카테고리
|
||||
include_processing_info: 처리 정보 포함 여부
|
||||
|
||||
Returns:
|
||||
자재 목록 (처리 정보 포함)
|
||||
"""
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
m.id, m.original_description, m.classified_category,
|
||||
m.material_grade, m.schedule, m.size_spec, m.main_nom, m.red_nom,
|
||||
m.quantity, m.unit, m.length, m.drawing_name, m.line_no,
|
||||
m.purchase_confirmed, m.confirmed_quantity, m.purchase_status,
|
||||
m.material_hash, m.revision_status, m.brand, m.user_requirement,
|
||||
m.line_number, m.is_active, m.notes,
|
||||
-- 추가 정보
|
||||
COALESCE(m.purchase_confirmed_at, m.created_at) as status_date,
|
||||
COALESCE(m.purchase_confirmed_by, 'system') as status_by
|
||||
FROM materials m
|
||||
WHERE m.file_id = :file_id
|
||||
AND m.classified_category = :category
|
||||
AND m.classified_category != 'PIPE'
|
||||
AND m.is_active = true
|
||||
ORDER BY m.line_number
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {
|
||||
"file_id": file_id,
|
||||
"category": category
|
||||
})
|
||||
|
||||
materials = []
|
||||
for row in result.fetchall():
|
||||
material_dict = dict(row._mapping)
|
||||
|
||||
if include_processing_info:
|
||||
# 처리 정보 추가
|
||||
material_dict['processing_info'] = self._get_material_processing_info(material_dict)
|
||||
|
||||
materials.append(material_dict)
|
||||
|
||||
return materials
|
||||
|
||||
def _get_material_processing_info(self, material: Dict) -> Dict[str, Any]:
|
||||
"""자재 처리 정보 생성"""
|
||||
|
||||
revision_status = material.get('revision_status', '')
|
||||
purchase_confirmed = material.get('purchase_confirmed', False)
|
||||
|
||||
if revision_status == 'new_in_revision':
|
||||
return {
|
||||
'display_status': 'NEW',
|
||||
'color': 'green',
|
||||
'action': '신규 구매 필요',
|
||||
'priority': 'high'
|
||||
}
|
||||
elif revision_status == 'additional_purchase_needed':
|
||||
return {
|
||||
'display_status': 'ADDITIONAL',
|
||||
'color': 'red',
|
||||
'action': '추가 구매 필요',
|
||||
'priority': 'high'
|
||||
}
|
||||
elif revision_status == 'excess_inventory':
|
||||
return {
|
||||
'display_status': 'EXCESS',
|
||||
'color': 'orange',
|
||||
'action': '재고 관리',
|
||||
'priority': 'medium'
|
||||
}
|
||||
elif revision_status == 'quantity_updated':
|
||||
return {
|
||||
'display_status': 'UPDATED',
|
||||
'color': 'yellow',
|
||||
'action': '수량 확인',
|
||||
'priority': 'medium'
|
||||
}
|
||||
elif purchase_confirmed:
|
||||
return {
|
||||
'display_status': 'COMPLETED',
|
||||
'color': 'gray',
|
||||
'action': '완료',
|
||||
'priority': 'low'
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'display_status': 'PENDING',
|
||||
'color': 'blue',
|
||||
'action': '구매 대기',
|
||||
'priority': 'medium'
|
||||
}
|
||||
|
||||
def apply_material_processing_results(
|
||||
self,
|
||||
processing_results: List[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""자재 처리 결과를 DB에 적용"""
|
||||
|
||||
try:
|
||||
applied_count = 0
|
||||
error_count = 0
|
||||
|
||||
for result in processing_results:
|
||||
try:
|
||||
material_id = result['material_id']
|
||||
action = result['action']
|
||||
status = result['status']
|
||||
|
||||
if action == 'delete':
|
||||
# 자재 비활성화
|
||||
update_query = """
|
||||
UPDATE materials
|
||||
SET is_active = false,
|
||||
revision_status = 'deleted',
|
||||
notes = CONCAT(COALESCE(notes, ''), '\n', :note)
|
||||
WHERE id = :material_id
|
||||
"""
|
||||
else:
|
||||
# 자재 상태 업데이트
|
||||
update_query = """
|
||||
UPDATE materials
|
||||
SET revision_status = :status,
|
||||
notes = CONCAT(COALESCE(notes, ''), '\n', :note)
|
||||
WHERE id = :material_id
|
||||
"""
|
||||
|
||||
self.db_service.execute_query(update_query, {
|
||||
"material_id": material_id,
|
||||
"status": status,
|
||||
"note": result.get('processing_note', '')
|
||||
})
|
||||
|
||||
applied_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply processing result for material {result.get('material_id')}: {e}")
|
||||
error_count += 1
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"applied_count": applied_count,
|
||||
"error_count": error_count,
|
||||
"message": f"자재 처리 완료: {applied_count}개 적용, {error_count}개 오류"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to apply material processing results: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": "자재 처리 적용 중 오류 발생"
|
||||
}
|
||||
421
backend/app/services/revision_status_service.py
Normal file
421
backend/app/services/revision_status_service.py
Normal file
@@ -0,0 +1,421 @@
|
||||
"""
|
||||
리비전 상태 관리 서비스
|
||||
리비전 진행 상태, 히스토리, 확정 등 관리
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, desc
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from ..models import File, RevisionComparison, RevisionChangeLog
|
||||
from ..utils.logger import get_logger
|
||||
from .database_service import DatabaseService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class RevisionStatusService:
|
||||
"""리비전 상태 관리 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.db_service = DatabaseService(db)
|
||||
|
||||
def get_revision_status(self, job_no: str, file_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
리비전 상태 조회
|
||||
|
||||
Args:
|
||||
job_no: 작업 번호
|
||||
file_id: 파일 ID
|
||||
|
||||
Returns:
|
||||
리비전 상태 정보
|
||||
"""
|
||||
|
||||
# 파일 정보 조회
|
||||
current_file = self._get_file_info(file_id)
|
||||
if not current_file:
|
||||
return {"error": "파일을 찾을 수 없습니다."}
|
||||
|
||||
# 같은 작업의 모든 파일 조회
|
||||
all_files = self._get_job_files(job_no)
|
||||
|
||||
# 리비전 히스토리 구성
|
||||
revision_history = self._build_revision_history(all_files, file_id)
|
||||
|
||||
# 현재 리비전의 처리 상태
|
||||
processing_status = self._get_processing_status(file_id)
|
||||
|
||||
return {
|
||||
"job_no": job_no,
|
||||
"current_file": current_file,
|
||||
"revision_history": revision_history,
|
||||
"processing_status": processing_status,
|
||||
"is_latest": revision_history.get("is_latest", False),
|
||||
"can_upload_new_revision": revision_history.get("can_upload_new", True),
|
||||
"status_summary": self._generate_status_summary(processing_status)
|
||||
}
|
||||
|
||||
def get_revision_history(self, job_no: str) -> List[Dict[str, Any]]:
|
||||
"""작업의 전체 리비전 히스토리 조회"""
|
||||
|
||||
files = self._get_job_files(job_no)
|
||||
|
||||
history = []
|
||||
for i, file_info in enumerate(files):
|
||||
# 이전 파일과의 비교 정보
|
||||
comparison_info = None
|
||||
if i > 0:
|
||||
prev_file = files[i-1]
|
||||
comparison_info = self._get_comparison_summary(file_info['id'], prev_file['id'])
|
||||
|
||||
history.append({
|
||||
"file_id": file_info['id'],
|
||||
"revision": file_info['revision'],
|
||||
"filename": file_info['original_filename'],
|
||||
"upload_date": file_info['upload_date'],
|
||||
"uploaded_by": file_info['uploaded_by'],
|
||||
"file_size": file_info['file_size'],
|
||||
"material_count": self._get_material_count(file_info['id']),
|
||||
"comparison_with_previous": comparison_info,
|
||||
"is_latest": i == 0, # 최신순 정렬이므로 첫 번째가 최신
|
||||
"processing_status": self._get_processing_status(file_info['id'])
|
||||
})
|
||||
|
||||
return history
|
||||
|
||||
def create_revision_comparison_record(
|
||||
self,
|
||||
job_no: str,
|
||||
current_file_id: int,
|
||||
previous_file_id: int,
|
||||
comparison_result: Dict[str, Any],
|
||||
created_by: str
|
||||
) -> int:
|
||||
"""리비전 비교 기록 생성"""
|
||||
|
||||
try:
|
||||
comparison_record = RevisionComparison(
|
||||
job_no=job_no,
|
||||
current_file_id=current_file_id,
|
||||
previous_file_id=previous_file_id,
|
||||
comparison_result=comparison_result,
|
||||
summary_stats=comparison_result.get("summary", {}),
|
||||
created_by=created_by,
|
||||
is_applied=False
|
||||
)
|
||||
|
||||
self.db.add(comparison_record)
|
||||
self.db.commit()
|
||||
self.db.refresh(comparison_record)
|
||||
|
||||
logger.info(f"Created revision comparison record: {comparison_record.id}")
|
||||
return comparison_record.id
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to create revision comparison record: {e}")
|
||||
raise
|
||||
|
||||
def apply_revision_comparison(
|
||||
self,
|
||||
comparison_id: int,
|
||||
applied_by: str
|
||||
) -> Dict[str, Any]:
|
||||
"""리비전 비교 결과 적용"""
|
||||
|
||||
try:
|
||||
# 비교 기록 조회
|
||||
comparison = self.db.query(RevisionComparison).filter(
|
||||
RevisionComparison.id == comparison_id
|
||||
).first()
|
||||
|
||||
if not comparison:
|
||||
return {"success": False, "error": "비교 기록을 찾을 수 없습니다."}
|
||||
|
||||
if comparison.is_applied:
|
||||
return {"success": False, "error": "이미 적용된 비교 결과입니다."}
|
||||
|
||||
# 적용 처리
|
||||
comparison.is_applied = True
|
||||
comparison.applied_at = datetime.utcnow()
|
||||
comparison.applied_by = applied_by
|
||||
|
||||
# 변경 로그 생성
|
||||
self._create_change_logs(comparison)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Applied revision comparison: {comparison_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"comparison_id": comparison_id,
|
||||
"applied_at": comparison.applied_at.isoformat(),
|
||||
"applied_by": applied_by
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to apply revision comparison: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def get_pending_revisions(self, job_no: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""대기 중인 리비전 목록 조회"""
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
rc.id, rc.job_no, rc.current_file_id, rc.previous_file_id,
|
||||
rc.comparison_date, rc.created_by, rc.summary_stats,
|
||||
cf.original_filename as current_filename,
|
||||
cf.revision as current_revision,
|
||||
pf.original_filename as previous_filename,
|
||||
pf.revision as previous_revision
|
||||
FROM revision_comparisons rc
|
||||
JOIN files cf ON rc.current_file_id = cf.id
|
||||
LEFT JOIN files pf ON rc.previous_file_id = pf.id
|
||||
WHERE rc.is_applied = false
|
||||
"""
|
||||
|
||||
params = {}
|
||||
if job_no:
|
||||
query += " AND rc.job_no = :job_no"
|
||||
params["job_no"] = job_no
|
||||
|
||||
query += " ORDER BY rc.comparison_date DESC"
|
||||
|
||||
result = self.db_service.execute_query(query, params)
|
||||
|
||||
pending_revisions = []
|
||||
for row in result.fetchall():
|
||||
row_dict = dict(row._mapping)
|
||||
pending_revisions.append({
|
||||
"comparison_id": row_dict['id'],
|
||||
"job_no": row_dict['job_no'],
|
||||
"current_file": {
|
||||
"id": row_dict['current_file_id'],
|
||||
"filename": row_dict['current_filename'],
|
||||
"revision": row_dict['current_revision']
|
||||
},
|
||||
"previous_file": {
|
||||
"id": row_dict['previous_file_id'],
|
||||
"filename": row_dict['previous_filename'],
|
||||
"revision": row_dict['previous_revision']
|
||||
} if row_dict['previous_file_id'] else None,
|
||||
"comparison_date": row_dict['comparison_date'],
|
||||
"created_by": row_dict['created_by'],
|
||||
"summary_stats": row_dict['summary_stats']
|
||||
})
|
||||
|
||||
return pending_revisions
|
||||
|
||||
def _get_file_info(self, file_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""파일 정보 조회"""
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
id, filename, original_filename, file_path, job_no, revision,
|
||||
bom_name, description, file_size, parsed_count,
|
||||
upload_date, uploaded_by, is_active
|
||||
FROM files
|
||||
WHERE id = :file_id
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {"file_id": file_id})
|
||||
row = result.fetchone()
|
||||
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
def _get_job_files(self, job_no: str) -> List[Dict[str, Any]]:
|
||||
"""작업의 모든 파일 조회 (최신순)"""
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
id, filename, original_filename, file_path, job_no, revision,
|
||||
bom_name, description, file_size, parsed_count,
|
||||
upload_date, uploaded_by, is_active
|
||||
FROM files
|
||||
WHERE job_no = :job_no AND is_active = true
|
||||
ORDER BY upload_date DESC, id DESC
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {"job_no": job_no})
|
||||
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
def _build_revision_history(self, all_files: List[Dict], current_file_id: int) -> Dict[str, Any]:
|
||||
"""리비전 히스토리 구성"""
|
||||
|
||||
current_index = None
|
||||
for i, file_info in enumerate(all_files):
|
||||
if file_info['id'] == current_file_id:
|
||||
current_index = i
|
||||
break
|
||||
|
||||
if current_index is None:
|
||||
return {"error": "현재 파일을 찾을 수 없습니다."}
|
||||
|
||||
return {
|
||||
"total_revisions": len(all_files),
|
||||
"current_position": current_index + 1, # 1-based
|
||||
"is_latest": current_index == 0,
|
||||
"is_first": current_index == len(all_files) - 1,
|
||||
"can_upload_new": current_index == 0, # 최신 리비전에서만 새 리비전 업로드 가능
|
||||
"previous_file_id": all_files[current_index + 1]['id'] if current_index < len(all_files) - 1 else None,
|
||||
"next_file_id": all_files[current_index - 1]['id'] if current_index > 0 else None
|
||||
}
|
||||
|
||||
def _get_processing_status(self, file_id: int) -> Dict[str, Any]:
|
||||
"""파일의 처리 상태 조회"""
|
||||
|
||||
# 자재별 처리 상태 통계
|
||||
query = """
|
||||
SELECT
|
||||
classified_category,
|
||||
COUNT(*) as total_count,
|
||||
SUM(CASE WHEN purchase_confirmed = true THEN 1 ELSE 0 END) as purchased_count,
|
||||
SUM(CASE WHEN revision_status IS NOT NULL THEN 1 ELSE 0 END) as processed_count,
|
||||
COUNT(DISTINCT COALESCE(revision_status, 'pending')) as status_types
|
||||
FROM materials
|
||||
WHERE file_id = :file_id AND is_active = true AND classified_category != 'PIPE'
|
||||
GROUP BY classified_category
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {"file_id": file_id})
|
||||
|
||||
category_status = {}
|
||||
total_materials = 0
|
||||
total_purchased = 0
|
||||
total_processed = 0
|
||||
|
||||
for row in result.fetchall():
|
||||
row_dict = dict(row._mapping)
|
||||
category = row_dict['classified_category']
|
||||
|
||||
category_status[category] = {
|
||||
"total": row_dict['total_count'],
|
||||
"purchased": row_dict['purchased_count'],
|
||||
"processed": row_dict['processed_count'],
|
||||
"pending": row_dict['total_count'] - row_dict['processed_count']
|
||||
}
|
||||
|
||||
total_materials += row_dict['total_count']
|
||||
total_purchased += row_dict['purchased_count']
|
||||
total_processed += row_dict['processed_count']
|
||||
|
||||
return {
|
||||
"file_id": file_id,
|
||||
"total_materials": total_materials,
|
||||
"total_purchased": total_purchased,
|
||||
"total_processed": total_processed,
|
||||
"pending_processing": total_materials - total_processed,
|
||||
"category_breakdown": category_status,
|
||||
"completion_percentage": (total_processed / total_materials * 100) if total_materials > 0 else 0
|
||||
}
|
||||
|
||||
def _get_comparison_summary(self, current_file_id: int, previous_file_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""비교 요약 정보 조회"""
|
||||
|
||||
query = """
|
||||
SELECT summary_stats, comparison_date, is_applied
|
||||
FROM revision_comparisons
|
||||
WHERE current_file_id = :current_file_id AND previous_file_id = :previous_file_id
|
||||
ORDER BY comparison_date DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {
|
||||
"current_file_id": current_file_id,
|
||||
"previous_file_id": previous_file_id
|
||||
})
|
||||
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
row_dict = dict(row._mapping)
|
||||
return {
|
||||
"summary_stats": row_dict['summary_stats'],
|
||||
"comparison_date": row_dict['comparison_date'],
|
||||
"is_applied": row_dict['is_applied']
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def _get_material_count(self, file_id: int) -> int:
|
||||
"""파일의 자재 개수 조회"""
|
||||
|
||||
query = """
|
||||
SELECT COUNT(*) as count
|
||||
FROM materials
|
||||
WHERE file_id = :file_id AND is_active = true AND classified_category != 'PIPE'
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {"file_id": file_id})
|
||||
row = result.fetchone()
|
||||
|
||||
return row.count if row else 0
|
||||
|
||||
def _create_change_logs(self, comparison: RevisionComparison):
|
||||
"""변경 로그 생성"""
|
||||
|
||||
try:
|
||||
changes = comparison.comparison_result.get("changes", {})
|
||||
|
||||
# 각 변경사항에 대해 로그 생성
|
||||
for change_type, change_list in changes.items():
|
||||
for change_item in change_list:
|
||||
change_log = RevisionChangeLog(
|
||||
comparison_id=comparison.id,
|
||||
material_id=change_item.get("material", {}).get("id"),
|
||||
change_type=change_type,
|
||||
previous_data=change_item.get("previous"),
|
||||
current_data=change_item.get("current") or change_item.get("material"),
|
||||
action_taken=change_item.get("action", change_type),
|
||||
notes=change_item.get("reason", "")
|
||||
)
|
||||
|
||||
self.db.add(change_log)
|
||||
|
||||
logger.info(f"Created change logs for comparison {comparison.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create change logs: {e}")
|
||||
raise
|
||||
|
||||
def _generate_status_summary(self, processing_status: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""상태 요약 생성"""
|
||||
|
||||
total = processing_status.get("total_materials", 0)
|
||||
processed = processing_status.get("total_processed", 0)
|
||||
purchased = processing_status.get("total_purchased", 0)
|
||||
|
||||
if total == 0:
|
||||
return {"status": "empty", "message": "자료가 없습니다."}
|
||||
|
||||
completion_rate = processed / total
|
||||
|
||||
if completion_rate >= 1.0:
|
||||
status = "completed"
|
||||
message = "모든 자재 처리 완료"
|
||||
elif completion_rate >= 0.8:
|
||||
status = "nearly_complete"
|
||||
message = f"처리 진행 중 ({processed}/{total})"
|
||||
elif completion_rate >= 0.5:
|
||||
status = "in_progress"
|
||||
message = f"처리 진행 중 ({processed}/{total})"
|
||||
else:
|
||||
status = "started"
|
||||
message = f"처리 시작됨 ({processed}/{total})"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"message": message,
|
||||
"completion_rate": completion_rate,
|
||||
"stats": {
|
||||
"total": total,
|
||||
"processed": processed,
|
||||
"purchased": purchased,
|
||||
"pending": total - processed
|
||||
}
|
||||
}
|
||||
583
backend/app/utils/pipe_utils.py
Normal file
583
backend/app/utils/pipe_utils.py
Normal file
@@ -0,0 +1,583 @@
|
||||
"""
|
||||
PIPE 시스템 공통 유틸리티
|
||||
|
||||
모든 PIPE 관련 서비스에서 공통으로 사용되는 함수들을 모아놓은 유틸리티 모듈
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
from decimal import Decimal
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ========== PIPE 상수 정의 ==========
|
||||
|
||||
class PipeConstants:
|
||||
"""PIPE 시스템에서 사용되는 상수들"""
|
||||
|
||||
# 길이 관련
|
||||
STANDARD_PIPE_LENGTH_MM = 6000 # 표준 파이프 길이 (6M)
|
||||
CUTTING_LOSS_PER_CUT_MM = 2 # 절단당 손실 (2mm)
|
||||
|
||||
# 분류 관련
|
||||
PIPE_CATEGORY = "PIPE"
|
||||
|
||||
# 끝단 처리 타입
|
||||
END_PREPARATION_TYPES = {
|
||||
"무개선": "PLAIN",
|
||||
"한개선": "SINGLE_BEVEL",
|
||||
"양개선": "DOUBLE_BEVEL"
|
||||
}
|
||||
|
||||
# 상태 관련
|
||||
REVISION_TYPES = {
|
||||
"NO_REVISION": "no_revision",
|
||||
"PRE_CUTTING_PLAN": "pre_cutting_plan",
|
||||
"POST_CUTTING_PLAN": "post_cutting_plan"
|
||||
}
|
||||
|
||||
CHANGE_TYPES = {
|
||||
"ADDED": "added",
|
||||
"REMOVED": "removed",
|
||||
"MODIFIED": "modified",
|
||||
"UNCHANGED": "unchanged"
|
||||
}
|
||||
|
||||
|
||||
# ========== 데이터 추출 유틸리티 ==========
|
||||
|
||||
class PipeDataExtractor:
|
||||
"""PIPE 데이터 추출 관련 유틸리티"""
|
||||
|
||||
@staticmethod
|
||||
def extract_pipe_materials_from_file(db: Session, file_id: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
파일에서 PIPE 자재 데이터 추출
|
||||
|
||||
Args:
|
||||
db: 데이터베이스 세션
|
||||
file_id: 파일 ID
|
||||
|
||||
Returns:
|
||||
PIPE 자재 리스트
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
m.id,
|
||||
m.drawing_name,
|
||||
m.line_no,
|
||||
m.description,
|
||||
m.classified_category,
|
||||
m.full_material_grade,
|
||||
m.main_nom,
|
||||
m.red_nom,
|
||||
m.length,
|
||||
m.total_length,
|
||||
m.quantity,
|
||||
m.row_number,
|
||||
m.original_description
|
||||
FROM materials m
|
||||
WHERE m.file_id = :file_id
|
||||
AND m.classified_category = 'PIPE'
|
||||
AND m.is_active = true
|
||||
ORDER BY m.drawing_name, m.line_no, m.row_number
|
||||
""")
|
||||
|
||||
result = db.execute(query, {"file_id": file_id})
|
||||
materials = []
|
||||
|
||||
for row in result:
|
||||
materials.append({
|
||||
"id": row.id,
|
||||
"drawing_name": row.drawing_name or "UNKNOWN",
|
||||
"line_no": row.line_no or "",
|
||||
"description": row.description or "",
|
||||
"original_description": row.original_description or "",
|
||||
"material_grade": row.full_material_grade or "UNKNOWN",
|
||||
"main_nom": row.main_nom or "",
|
||||
"red_nom": row.red_nom or "",
|
||||
"length": float(row.length or 0),
|
||||
"total_length": float(row.total_length or 0),
|
||||
"quantity": int(row.quantity or 1),
|
||||
"row_number": row.row_number or 0
|
||||
})
|
||||
|
||||
logger.info(f"✅ {len(materials)}개 PIPE 자재 추출 완료 (파일 ID: {file_id})")
|
||||
return materials
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ PIPE 자재 추출 실패: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def parse_pipe_description(description: str) -> Dict[str, Any]:
|
||||
"""
|
||||
PIPE 설명에서 정보 추출
|
||||
|
||||
Args:
|
||||
description: 자재 설명
|
||||
|
||||
Returns:
|
||||
추출된 정보 딕셔너리
|
||||
"""
|
||||
# 기본값 설정
|
||||
result = {
|
||||
"material_grade": "UNKNOWN",
|
||||
"schedule": "UNKNOWN",
|
||||
"nominal_size": "UNKNOWN",
|
||||
"length_info": None,
|
||||
"end_preparation": "무개선"
|
||||
}
|
||||
|
||||
if not description:
|
||||
return result
|
||||
|
||||
# 간단한 파싱 로직 (실제로는 더 복잡할 수 있음)
|
||||
description_upper = description.upper()
|
||||
|
||||
# 재질 추출 (A106, A53 등)
|
||||
if "A106" in description_upper:
|
||||
result["material_grade"] = "A106 GR.B"
|
||||
elif "A53" in description_upper:
|
||||
result["material_grade"] = "A53 GR.B"
|
||||
|
||||
# 스케줄 추출 (SCH40, SCH80 등)
|
||||
if "SCH40" in description_upper:
|
||||
result["schedule"] = "SCH40"
|
||||
elif "SCH80" in description_upper:
|
||||
result["schedule"] = "SCH80"
|
||||
|
||||
# 끝단 처리 추출
|
||||
if "양개선" in description or "DOUBLE" in description_upper:
|
||||
result["end_preparation"] = "양개선"
|
||||
elif "한개선" in description or "SINGLE" in description_upper:
|
||||
result["end_preparation"] = "한개선"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ========== 계산 유틸리티 ==========
|
||||
|
||||
class PipeCalculator:
|
||||
"""PIPE 관련 계산 유틸리티"""
|
||||
|
||||
@staticmethod
|
||||
def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict[str, Any]:
|
||||
"""
|
||||
PIPE 구매 수량 계산
|
||||
|
||||
Args:
|
||||
materials: PIPE 자재 리스트
|
||||
|
||||
Returns:
|
||||
계산 결과
|
||||
"""
|
||||
total_bom_length = 0
|
||||
cutting_count = 0
|
||||
pipe_details = []
|
||||
|
||||
for material in materials:
|
||||
# 길이 정보 추출 (Decimal 타입 처리)
|
||||
length_mm = float(material.get('length', 0) or 0)
|
||||
if not length_mm:
|
||||
length_mm = float(material.get('length_mm', 0) or 0)
|
||||
|
||||
quantity = float(material.get('quantity', 1) or 1)
|
||||
|
||||
if length_mm > 0:
|
||||
total_length = length_mm * quantity
|
||||
total_bom_length += total_length
|
||||
cutting_count += quantity
|
||||
|
||||
pipe_details.append({
|
||||
'description': material.get('description', ''),
|
||||
'original_description': material.get('original_description', ''),
|
||||
'drawing_name': material.get('drawing_name', ''),
|
||||
'line_no': material.get('line_no', ''),
|
||||
'length_mm': length_mm,
|
||||
'quantity': quantity,
|
||||
'total_length': total_length
|
||||
})
|
||||
|
||||
# 절단 손실 계산
|
||||
cutting_loss = cutting_count * PipeConstants.CUTTING_LOSS_PER_CUT_MM
|
||||
|
||||
# 총 필요 길이
|
||||
required_length = total_bom_length + cutting_loss
|
||||
|
||||
# 6M 단위로 올림 계산
|
||||
pipes_needed = math.ceil(required_length / PipeConstants.STANDARD_PIPE_LENGTH_MM) if required_length > 0 else 0
|
||||
total_purchase_length = pipes_needed * PipeConstants.STANDARD_PIPE_LENGTH_MM
|
||||
waste_length = total_purchase_length - required_length if pipes_needed > 0 else 0
|
||||
|
||||
return {
|
||||
'bom_quantity': total_bom_length,
|
||||
'cutting_count': cutting_count,
|
||||
'cutting_loss': cutting_loss,
|
||||
'required_length': required_length,
|
||||
'pipes_count': pipes_needed,
|
||||
'calculated_qty': total_purchase_length,
|
||||
'waste_length': waste_length,
|
||||
'utilization_rate': (required_length / total_purchase_length * 100) if total_purchase_length > 0 else 0,
|
||||
'unit': 'mm',
|
||||
'pipe_details': pipe_details,
|
||||
'summary': {
|
||||
'total_materials': len(materials),
|
||||
'total_drawings': len(set(m.get('drawing_name', '') for m in materials if m.get('drawing_name'))),
|
||||
'average_length': total_bom_length / len(materials) if materials else 0
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def calculate_length_difference(old_length: float, new_length: float) -> Dict[str, Any]:
|
||||
"""
|
||||
길이 변화량 계산
|
||||
|
||||
Args:
|
||||
old_length: 이전 길이
|
||||
new_length: 새로운 길이
|
||||
|
||||
Returns:
|
||||
변화량 정보
|
||||
"""
|
||||
difference = new_length - old_length
|
||||
percentage = (difference / old_length * 100) if old_length > 0 else 0
|
||||
|
||||
return {
|
||||
'old_length': old_length,
|
||||
'new_length': new_length,
|
||||
'difference': difference,
|
||||
'percentage': percentage,
|
||||
'change_type': 'increased' if difference > 0 else 'decreased' if difference < 0 else 'unchanged'
|
||||
}
|
||||
|
||||
|
||||
# ========== 비교 유틸리티 ==========
|
||||
|
||||
class PipeComparator:
|
||||
"""PIPE 데이터 비교 유틸리티"""
|
||||
|
||||
@staticmethod
|
||||
def compare_pipe_segments(old_segments: List[Dict], new_segments: List[Dict]) -> Dict[str, Any]:
|
||||
"""
|
||||
단관 데이터 비교
|
||||
|
||||
Args:
|
||||
old_segments: 이전 단관 데이터
|
||||
new_segments: 새로운 단관 데이터
|
||||
|
||||
Returns:
|
||||
비교 결과
|
||||
"""
|
||||
# 키 생성 함수
|
||||
def create_segment_key(segment):
|
||||
return (
|
||||
segment.get('drawing_name', ''),
|
||||
segment.get('material_grade', ''),
|
||||
segment.get('length', 0),
|
||||
segment.get('end_preparation', '무개선')
|
||||
)
|
||||
|
||||
# 기존 데이터를 키로 매핑
|
||||
old_map = {}
|
||||
for segment in old_segments:
|
||||
key = create_segment_key(segment)
|
||||
if key not in old_map:
|
||||
old_map[key] = []
|
||||
old_map[key].append(segment)
|
||||
|
||||
# 새로운 데이터를 키로 매핑
|
||||
new_map = {}
|
||||
for segment in new_segments:
|
||||
key = create_segment_key(segment)
|
||||
if key not in new_map:
|
||||
new_map[key] = []
|
||||
new_map[key].append(segment)
|
||||
|
||||
# 비교 결과 생성
|
||||
changes = {
|
||||
'added': [],
|
||||
'removed': [],
|
||||
'modified': [],
|
||||
'unchanged': []
|
||||
}
|
||||
|
||||
all_keys = set(old_map.keys()) | set(new_map.keys())
|
||||
|
||||
for key in all_keys:
|
||||
old_count = len(old_map.get(key, []))
|
||||
new_count = len(new_map.get(key, []))
|
||||
|
||||
if old_count == 0:
|
||||
# 새로 추가된 항목
|
||||
for segment in new_map[key]:
|
||||
changes['added'].append({
|
||||
**segment,
|
||||
'change_type': 'added',
|
||||
'quantity_change': new_count
|
||||
})
|
||||
elif new_count == 0:
|
||||
# 삭제된 항목
|
||||
for segment in old_map[key]:
|
||||
changes['removed'].append({
|
||||
**segment,
|
||||
'change_type': 'removed',
|
||||
'quantity_change': -old_count
|
||||
})
|
||||
elif old_count != new_count:
|
||||
# 수량이 변경된 항목
|
||||
base_segment = new_map[key][0] if new_map[key] else old_map[key][0]
|
||||
changes['modified'].append({
|
||||
**base_segment,
|
||||
'change_type': 'modified',
|
||||
'old_quantity': old_count,
|
||||
'new_quantity': new_count,
|
||||
'quantity_change': new_count - old_count
|
||||
})
|
||||
else:
|
||||
# 변경되지 않은 항목
|
||||
base_segment = new_map[key][0]
|
||||
changes['unchanged'].append({
|
||||
**base_segment,
|
||||
'change_type': 'unchanged',
|
||||
'quantity': old_count
|
||||
})
|
||||
|
||||
# 통계 생성
|
||||
stats = {
|
||||
'total_old': len(old_segments),
|
||||
'total_new': len(new_segments),
|
||||
'added_count': len(changes['added']),
|
||||
'removed_count': len(changes['removed']),
|
||||
'modified_count': len(changes['modified']),
|
||||
'unchanged_count': len(changes['unchanged']),
|
||||
'changed_drawings': len(set(
|
||||
item.get('drawing_name', '') for change_list in [changes['added'], changes['removed'], changes['modified']]
|
||||
for item in change_list if item.get('drawing_name')
|
||||
))
|
||||
}
|
||||
|
||||
return {
|
||||
'changes': changes,
|
||||
'statistics': stats,
|
||||
'has_changes': stats['added_count'] + stats['removed_count'] + stats['modified_count'] > 0
|
||||
}
|
||||
|
||||
|
||||
# ========== 검증 유틸리티 ==========
|
||||
|
||||
class PipeValidator:
|
||||
"""PIPE 데이터 검증 유틸리티"""
|
||||
|
||||
@staticmethod
|
||||
def validate_pipe_data(pipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
PIPE 데이터 유효성 검증
|
||||
|
||||
Args:
|
||||
pipe_data: 검증할 PIPE 데이터
|
||||
|
||||
Returns:
|
||||
검증 결과
|
||||
"""
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
# 필수 필드 검증
|
||||
required_fields = ['drawing_name', 'material_grade', 'length']
|
||||
for field in required_fields:
|
||||
if not pipe_data.get(field):
|
||||
errors.append(f"필수 필드 누락: {field}")
|
||||
|
||||
# 길이 검증
|
||||
length = pipe_data.get('length', 0)
|
||||
if length <= 0:
|
||||
errors.append("길이는 0보다 커야 합니다")
|
||||
elif length > 20000: # 20m 초과시 경고
|
||||
warnings.append(f"길이가 비정상적으로 큽니다: {length}mm")
|
||||
|
||||
# 수량 검증
|
||||
quantity = pipe_data.get('quantity', 1)
|
||||
if quantity <= 0:
|
||||
errors.append("수량은 0보다 커야 합니다")
|
||||
|
||||
# 도면명 검증
|
||||
drawing_name = pipe_data.get('drawing_name', '')
|
||||
if drawing_name == 'UNKNOWN':
|
||||
warnings.append("도면명이 지정되지 않았습니다")
|
||||
|
||||
return {
|
||||
'is_valid': len(errors) == 0,
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
'error_count': len(errors),
|
||||
'warning_count': len(warnings)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def validate_cutting_plan_data(cutting_plan: List[Dict]) -> Dict[str, Any]:
|
||||
"""
|
||||
Cutting Plan 데이터 전체 검증
|
||||
|
||||
Args:
|
||||
cutting_plan: Cutting Plan 데이터 리스트
|
||||
|
||||
Returns:
|
||||
검증 결과
|
||||
"""
|
||||
total_errors = []
|
||||
total_warnings = []
|
||||
valid_items = 0
|
||||
|
||||
for i, item in enumerate(cutting_plan):
|
||||
validation = PipeValidator.validate_pipe_data(item)
|
||||
|
||||
if validation['is_valid']:
|
||||
valid_items += 1
|
||||
else:
|
||||
for error in validation['errors']:
|
||||
total_errors.append(f"항목 {i+1}: {error}")
|
||||
|
||||
for warning in validation['warnings']:
|
||||
total_warnings.append(f"항목 {i+1}: {warning}")
|
||||
|
||||
return {
|
||||
'is_valid': len(total_errors) == 0,
|
||||
'total_items': len(cutting_plan),
|
||||
'valid_items': valid_items,
|
||||
'invalid_items': len(cutting_plan) - valid_items,
|
||||
'errors': total_errors,
|
||||
'warnings': total_warnings,
|
||||
'validation_rate': (valid_items / len(cutting_plan) * 100) if cutting_plan else 0
|
||||
}
|
||||
|
||||
|
||||
# ========== 포맷팅 유틸리티 ==========
|
||||
|
||||
class PipeFormatter:
|
||||
"""PIPE 데이터 포맷팅 유틸리티"""
|
||||
|
||||
@staticmethod
|
||||
def format_length(length_mm: float, unit: str = 'mm') -> str:
|
||||
"""
|
||||
길이 포맷팅
|
||||
|
||||
Args:
|
||||
length_mm: 길이 (mm)
|
||||
unit: 표시 단위
|
||||
|
||||
Returns:
|
||||
포맷된 길이 문자열
|
||||
"""
|
||||
if unit == 'm':
|
||||
return f"{length_mm / 1000:.3f}m"
|
||||
elif unit == 'mm':
|
||||
return f"{length_mm:.0f}mm"
|
||||
else:
|
||||
return f"{length_mm}"
|
||||
|
||||
@staticmethod
|
||||
def format_pipe_description(pipe_data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
PIPE 설명 포맷팅
|
||||
|
||||
Args:
|
||||
pipe_data: PIPE 데이터
|
||||
|
||||
Returns:
|
||||
포맷된 설명
|
||||
"""
|
||||
parts = []
|
||||
|
||||
if pipe_data.get('material_grade'):
|
||||
parts.append(pipe_data['material_grade'])
|
||||
|
||||
if pipe_data.get('main_nom'):
|
||||
parts.append(f"{pipe_data['main_nom']}")
|
||||
|
||||
if pipe_data.get('schedule'):
|
||||
parts.append(pipe_data['schedule'])
|
||||
|
||||
if pipe_data.get('length'):
|
||||
parts.append(PipeFormatter.format_length(pipe_data['length']))
|
||||
|
||||
return " ".join(parts) if parts else "PIPE"
|
||||
|
||||
@staticmethod
|
||||
def format_change_summary(changes: Dict[str, List]) -> str:
|
||||
"""
|
||||
변경사항 요약 포맷팅
|
||||
|
||||
Args:
|
||||
changes: 변경사항 딕셔너리
|
||||
|
||||
Returns:
|
||||
포맷된 요약 문자열
|
||||
"""
|
||||
summary_parts = []
|
||||
|
||||
if changes.get('added'):
|
||||
summary_parts.append(f"추가 {len(changes['added'])}개")
|
||||
|
||||
if changes.get('removed'):
|
||||
summary_parts.append(f"삭제 {len(changes['removed'])}개")
|
||||
|
||||
if changes.get('modified'):
|
||||
summary_parts.append(f"수정 {len(changes['modified'])}개")
|
||||
|
||||
if not summary_parts:
|
||||
return "변경사항 없음"
|
||||
|
||||
return ", ".join(summary_parts)
|
||||
|
||||
|
||||
# ========== 로깅 유틸리티 ==========
|
||||
|
||||
class PipeLogger:
|
||||
"""PIPE 시스템 전용 로거 유틸리티"""
|
||||
|
||||
@staticmethod
|
||||
def log_pipe_operation(operation: str, job_no: str, details: Dict[str, Any] = None):
|
||||
"""
|
||||
PIPE 작업 로깅
|
||||
|
||||
Args:
|
||||
operation: 작업 유형
|
||||
job_no: 작업 번호
|
||||
details: 상세 정보
|
||||
"""
|
||||
message = f"🔧 PIPE {operation} | Job: {job_no}"
|
||||
|
||||
if details:
|
||||
detail_parts = []
|
||||
for key, value in details.items():
|
||||
detail_parts.append(f"{key}: {value}")
|
||||
message += f" | {', '.join(detail_parts)}"
|
||||
|
||||
logger.info(message)
|
||||
|
||||
@staticmethod
|
||||
def log_pipe_error(operation: str, job_no: str, error: Exception, context: Dict[str, Any] = None):
|
||||
"""
|
||||
PIPE 오류 로깅
|
||||
|
||||
Args:
|
||||
operation: 작업 유형
|
||||
job_no: 작업 번호
|
||||
error: 오류 객체
|
||||
context: 컨텍스트 정보
|
||||
"""
|
||||
message = f"❌ PIPE {operation} 실패 | Job: {job_no} | Error: {str(error)}"
|
||||
|
||||
if context:
|
||||
context_parts = []
|
||||
for key, value in context.items():
|
||||
context_parts.append(f"{key}: {value}")
|
||||
message += f" | Context: {', '.join(context_parts)}"
|
||||
|
||||
logger.error(message)
|
||||
199
backend/scripts/auto_migrator.py
Normal file
199
backend/scripts/auto_migrator.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
자동 마이그레이션 실행기 - 스키마 분석 결과를 바탕으로 자동으로 DB를 업데이트
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from datetime import datetime
|
||||
from schema_analyzer import SchemaAnalyzer
|
||||
|
||||
class AutoMigrator:
|
||||
def __init__(self):
|
||||
self.db_config = {
|
||||
'host': 'localhost',
|
||||
'port': 5432,
|
||||
'database': 'tk_mp_bom',
|
||||
'user': 'tkmp_user',
|
||||
'password': 'tkmp_password'
|
||||
}
|
||||
self.analyzer = SchemaAnalyzer()
|
||||
self.migration_log = []
|
||||
|
||||
def run_full_analysis_and_migration(self):
|
||||
"""전체 분석 및 마이그레이션 실행"""
|
||||
print("🚀 자동 마이그레이션 시작")
|
||||
|
||||
# 1. 스키마 분석
|
||||
print("\n1️⃣ 스키마 분석 중...")
|
||||
self.analyzer.analyze_code_models()
|
||||
self.analyzer.analyze_db_schema()
|
||||
analysis_result = self.analyzer.compare_schemas()
|
||||
|
||||
# 2. 분석 결과 확인
|
||||
if not analysis_result['missing_tables'] and not analysis_result['missing_columns']:
|
||||
print("✅ 스키마가 이미 최신 상태입니다!")
|
||||
return True
|
||||
|
||||
# 3. 마이그레이션 실행
|
||||
print("\n2️⃣ 마이그레이션 실행 중...")
|
||||
success = self._execute_migrations(analysis_result)
|
||||
|
||||
# 4. 결과 로깅
|
||||
self._log_migration_result(success, analysis_result)
|
||||
|
||||
return success
|
||||
|
||||
def _execute_migrations(self, analysis_result) -> bool:
|
||||
"""마이그레이션 실행"""
|
||||
try:
|
||||
conn = psycopg2.connect(**self.db_config)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 트랜잭션 시작
|
||||
conn.autocommit = False
|
||||
|
||||
# 1. 누락된 테이블 생성
|
||||
for table_name in analysis_result['missing_tables']:
|
||||
success = self._create_missing_table(cursor, table_name)
|
||||
if not success:
|
||||
conn.rollback()
|
||||
return False
|
||||
|
||||
# 2. 누락된 컬럼 추가
|
||||
for missing_col in analysis_result['missing_columns']:
|
||||
success = self._add_missing_column(cursor, missing_col)
|
||||
if not success:
|
||||
conn.rollback()
|
||||
return False
|
||||
|
||||
# 커밋
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
print("✅ 마이그레이션 성공!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 마이그레이션 실패: {e}")
|
||||
if 'conn' in locals():
|
||||
conn.rollback()
|
||||
return False
|
||||
|
||||
def _create_missing_table(self, cursor, table_name: str) -> bool:
|
||||
"""누락된 테이블 생성"""
|
||||
try:
|
||||
create_sql = self.analyzer._generate_create_table_sql(table_name)
|
||||
print(f" 📋 테이블 생성: {table_name}")
|
||||
cursor.execute(create_sql)
|
||||
|
||||
self.migration_log.append({
|
||||
'type': 'CREATE_TABLE',
|
||||
'table': table_name,
|
||||
'sql': create_sql,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ 테이블 생성 실패 {table_name}: {e}")
|
||||
return False
|
||||
|
||||
def _add_missing_column(self, cursor, missing_col: dict) -> bool:
|
||||
"""누락된 컬럼 추가"""
|
||||
try:
|
||||
alter_sql = self.analyzer._generate_add_column_sql(missing_col)
|
||||
print(f" 🔧 컬럼 추가: {missing_col['table']}.{missing_col['column']}")
|
||||
cursor.execute(alter_sql)
|
||||
|
||||
self.migration_log.append({
|
||||
'type': 'ADD_COLUMN',
|
||||
'table': missing_col['table'],
|
||||
'column': missing_col['column'],
|
||||
'sql': alter_sql,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ 컬럼 추가 실패 {missing_col['table']}.{missing_col['column']}: {e}")
|
||||
return False
|
||||
|
||||
def _log_migration_result(self, success: bool, analysis_result: dict):
|
||||
"""마이그레이션 결과 로깅"""
|
||||
log_entry = {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'success': success,
|
||||
'analysis_result': analysis_result,
|
||||
'migration_log': self.migration_log
|
||||
}
|
||||
|
||||
# 로그 파일에 저장
|
||||
import json
|
||||
log_filename = f"migration_log_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
|
||||
with open(log_filename, 'w', encoding='utf-8') as f:
|
||||
json.dump(log_entry, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"📄 마이그레이션 로그 저장: {log_filename}")
|
||||
|
||||
def fix_immediate_issues(self):
|
||||
"""즉시 해결이 필요한 문제들 수정"""
|
||||
print("🔧 즉시 해결 필요한 문제들 수정 중...")
|
||||
|
||||
immediate_fixes = [
|
||||
{
|
||||
'description': 'users 테이블에 status 컬럼 추가',
|
||||
'sql': "ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'active'",
|
||||
'check_sql': "SELECT column_name FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'status'"
|
||||
}
|
||||
]
|
||||
|
||||
try:
|
||||
conn = psycopg2.connect(**self.db_config)
|
||||
cursor = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
for fix in immediate_fixes:
|
||||
# 이미 존재하는지 확인
|
||||
cursor.execute(fix['check_sql'])
|
||||
if cursor.fetchone():
|
||||
print(f" ✅ 이미 존재: {fix['description']}")
|
||||
continue
|
||||
|
||||
# 수정 실행
|
||||
cursor.execute(fix['sql'])
|
||||
conn.commit()
|
||||
print(f" 🔧 수정 완료: {fix['description']}")
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 즉시 수정 실패: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
migrator = AutoMigrator()
|
||||
|
||||
# 1. 즉시 해결 필요한 문제들 수정
|
||||
migrator.fix_immediate_issues()
|
||||
|
||||
# 2. 전체 분석 및 마이그레이션
|
||||
success = migrator.run_full_analysis_and_migration()
|
||||
|
||||
if success:
|
||||
print("\n🎉 모든 마이그레이션이 성공적으로 완료되었습니다!")
|
||||
else:
|
||||
print("\n💥 마이그레이션 중 오류가 발생했습니다. 로그를 확인해주세요.")
|
||||
|
||||
return success
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
405
backend/scripts/docker_migrator.py
Normal file
405
backend/scripts/docker_migrator.py
Normal file
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Docker 환경용 마이그레이션 스크립트
|
||||
컨테이너 내부에서 실행되는 간단한 마이그레이션 도구
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from datetime import datetime
|
||||
|
||||
class DockerMigrator:
|
||||
def __init__(self):
|
||||
# Docker 환경의 DB 연결 설정
|
||||
self.db_config = {
|
||||
'host': os.getenv('DB_HOST', 'tk-mp-postgres'),
|
||||
'port': int(os.getenv('DB_PORT', 5432)),
|
||||
'database': os.getenv('DB_NAME', 'tk_mp_bom'),
|
||||
'user': os.getenv('DB_USER', 'tkmp_user'),
|
||||
'password': os.getenv('DB_PASSWORD', 'tkmp_password_2025')
|
||||
}
|
||||
|
||||
def check_and_fix_schema(self):
|
||||
"""스키마 체크 및 수정"""
|
||||
print("🔍 스키마 체크 시작...")
|
||||
|
||||
fixes_applied = []
|
||||
|
||||
try:
|
||||
conn = psycopg2.connect(**self.db_config)
|
||||
cursor = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
# 1. users.status 컬럼 체크 및 추가
|
||||
if self._check_and_add_users_status(cursor):
|
||||
fixes_applied.append("users.status 컬럼 추가")
|
||||
|
||||
# 2. files 테이블 누락 컬럼들 체크 및 추가
|
||||
if self._check_and_add_files_columns(cursor):
|
||||
fixes_applied.append("files 테이블 누락 컬럼들 추가")
|
||||
|
||||
# 3. materials 테이블 누락 컬럼들 체크 및 추가
|
||||
if self._check_and_add_materials_columns(cursor):
|
||||
fixes_applied.append("materials 테이블 누락 컬럼들 추가")
|
||||
|
||||
# 3.5. material_purchase_tracking 테이블 누락 컬럼들 체크 및 추가
|
||||
if self._check_and_add_mpt_columns(cursor):
|
||||
fixes_applied.append("material_purchase_tracking 테이블 누락 컬럼들 추가")
|
||||
|
||||
# 4. 누락된 상세 테이블들 체크 및 생성
|
||||
if self._check_and_create_detail_tables(cursor):
|
||||
fixes_applied.append("누락된 상세 테이블들 생성")
|
||||
|
||||
# 5. 기타 필요한 수정사항들...
|
||||
# 향후 추가될 수 있는 다른 스키마 수정사항들
|
||||
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
if fixes_applied:
|
||||
print("✅ 스키마 수정 완료:")
|
||||
for fix in fixes_applied:
|
||||
print(f" - {fix}")
|
||||
else:
|
||||
print("✅ 스키마가 이미 최신 상태입니다.")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 스키마 체크 실패: {e}")
|
||||
return False
|
||||
|
||||
def _check_and_add_users_status(self, cursor) -> bool:
|
||||
"""users.status 컬럼 체크 및 추가"""
|
||||
try:
|
||||
# status 컬럼이 존재하는지 확인
|
||||
cursor.execute("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND column_name = 'status'
|
||||
""")
|
||||
|
||||
if cursor.fetchone():
|
||||
return False # 이미 존재함
|
||||
|
||||
# status 컬럼 추가
|
||||
cursor.execute("ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'active'")
|
||||
|
||||
# 기존 사용자들의 status를 'active'로 설정
|
||||
cursor.execute("UPDATE users SET status = 'active' WHERE status IS NULL")
|
||||
|
||||
print(" 🔧 users.status 컬럼 추가됨")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ users.status 컬럼 추가 실패: {e}")
|
||||
return False
|
||||
|
||||
def _check_and_add_files_columns(self, cursor) -> bool:
|
||||
"""files 테이블 누락 컬럼들 체크 및 추가"""
|
||||
try:
|
||||
# 필요한 컬럼들과 그 정의
|
||||
required_columns = {
|
||||
'job_no': 'VARCHAR(100)',
|
||||
'bom_name': 'VARCHAR(255)',
|
||||
'description': 'TEXT',
|
||||
'parsed_count': 'INTEGER'
|
||||
}
|
||||
|
||||
added_columns = []
|
||||
|
||||
for column_name, column_type in required_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():
|
||||
# 컬럼 추가
|
||||
cursor.execute(f"ALTER TABLE files ADD COLUMN {column_name} {column_type}")
|
||||
added_columns.append(column_name)
|
||||
print(f" 🔧 files.{column_name} 컬럼 추가됨")
|
||||
|
||||
return len(added_columns) > 0
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ files 테이블 컬럼 추가 실패: {e}")
|
||||
return False
|
||||
|
||||
def _check_and_add_materials_columns(self, cursor) -> bool:
|
||||
"""materials 테이블 누락 컬럼들 체크 및 추가 (테스팅 서버 기준)"""
|
||||
try:
|
||||
# 테스팅 서버 기준 필요한 컬럼들
|
||||
required_columns = {
|
||||
# 사이즈 정보
|
||||
'main_nom': 'VARCHAR(50)',
|
||||
'red_nom': 'VARCHAR(50)',
|
||||
'row_number': 'INTEGER',
|
||||
|
||||
# 재질 정보
|
||||
'full_material_grade': 'VARCHAR(100)',
|
||||
'standard': 'VARCHAR(100)',
|
||||
'grade': 'VARCHAR(100)',
|
||||
'subcategory': 'VARCHAR(100)',
|
||||
|
||||
# 사용자 입력 정보
|
||||
'brand': 'VARCHAR(100)',
|
||||
'user_requirement': 'TEXT',
|
||||
|
||||
# 메타데이터
|
||||
'material_hash': 'VARCHAR(64)',
|
||||
'classified_by': 'VARCHAR(100)',
|
||||
'updated_by': 'VARCHAR(100)',
|
||||
'revision_status': 'VARCHAR(20) DEFAULT \'current\'',
|
||||
|
||||
# 추가 필드들
|
||||
'length': 'NUMERIC(10,3)',
|
||||
'total_length': 'NUMERIC(10,3)',
|
||||
'is_active': 'BOOLEAN DEFAULT true',
|
||||
'purchase_confirmed': 'BOOLEAN DEFAULT false',
|
||||
'confirmed_quantity': 'NUMERIC(10,3)',
|
||||
'purchase_status': 'VARCHAR(20)',
|
||||
'purchase_confirmed_by': 'VARCHAR(100)',
|
||||
'purchase_confirmed_at': 'TIMESTAMP',
|
||||
'normalized_description': 'TEXT'
|
||||
}
|
||||
|
||||
added_columns = []
|
||||
|
||||
for column_name, column_type in required_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():
|
||||
# 컬럼 추가
|
||||
cursor.execute(f"ALTER TABLE materials ADD COLUMN {column_name} {column_type}")
|
||||
added_columns.append(column_name)
|
||||
print(f" 🔧 materials.{column_name} 컬럼 추가됨")
|
||||
|
||||
if added_columns:
|
||||
print(f" ✅ materials 테이블에 {len(added_columns)}개 컬럼 추가 완료")
|
||||
|
||||
return len(added_columns) > 0
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ materials 테이블 컬럼 추가 실패: {e}")
|
||||
return False
|
||||
|
||||
def _check_and_add_mpt_columns(self, cursor) -> bool:
|
||||
"""material_purchase_tracking 테이블 누락 컬럼들 체크 및 추가"""
|
||||
try:
|
||||
# 필요한 컬럼들
|
||||
required_columns = {
|
||||
'description': 'TEXT',
|
||||
'purchase_status': 'VARCHAR(20)'
|
||||
}
|
||||
|
||||
added_columns = []
|
||||
|
||||
for column_name, column_type in required_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():
|
||||
# 컬럼 추가
|
||||
cursor.execute(f"ALTER TABLE material_purchase_tracking ADD COLUMN {column_name} {column_type}")
|
||||
added_columns.append(column_name)
|
||||
print(f" 🔧 material_purchase_tracking.{column_name} 컬럼 추가됨")
|
||||
|
||||
if added_columns:
|
||||
print(f" ✅ material_purchase_tracking 테이블에 {len(added_columns)}개 컬럼 추가 완료")
|
||||
|
||||
return len(added_columns) > 0
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ material_purchase_tracking 테이블 컬럼 추가 실패: {e}")
|
||||
return False
|
||||
|
||||
def _check_and_create_detail_tables(self, cursor) -> bool:
|
||||
"""누락된 상세 테이블들 체크 및 생성"""
|
||||
try:
|
||||
# 필요한 상세 테이블들과 그 구조
|
||||
required_tables = {
|
||||
'support_details': """
|
||||
CREATE TABLE support_details (
|
||||
id SERIAL PRIMARY KEY,
|
||||
material_id INTEGER NOT NULL,
|
||||
file_id INTEGER NOT NULL,
|
||||
support_type VARCHAR(50),
|
||||
support_subtype VARCHAR(50),
|
||||
load_rating VARCHAR(50),
|
||||
load_capacity VARCHAR(50),
|
||||
material_standard VARCHAR(100),
|
||||
material_grade VARCHAR(100),
|
||||
pipe_size VARCHAR(50),
|
||||
length_mm NUMERIC(10,3),
|
||||
width_mm NUMERIC(10,3),
|
||||
height_mm NUMERIC(10,3),
|
||||
classification_confidence DOUBLE PRECISION,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
'special_material_details': """
|
||||
CREATE TABLE special_material_details (
|
||||
id SERIAL PRIMARY KEY,
|
||||
material_id INTEGER NOT NULL,
|
||||
file_id INTEGER NOT NULL,
|
||||
special_type VARCHAR(50),
|
||||
special_subtype VARCHAR(50),
|
||||
material_standard VARCHAR(100),
|
||||
material_grade VARCHAR(100),
|
||||
size_spec VARCHAR(50),
|
||||
classification_confidence DOUBLE PRECISION,
|
||||
additional_info JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
'purchase_requests': """
|
||||
CREATE TABLE purchase_requests (
|
||||
request_id SERIAL PRIMARY KEY,
|
||||
request_no VARCHAR(50) UNIQUE,
|
||||
file_id INTEGER,
|
||||
job_no VARCHAR(50),
|
||||
category VARCHAR(50),
|
||||
material_count INTEGER,
|
||||
excel_file_path VARCHAR(500),
|
||||
requested_by INTEGER,
|
||||
requested_at TIMESTAMP,
|
||||
status VARCHAR(20) DEFAULT 'requested',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (file_id) REFERENCES files(id),
|
||||
FOREIGN KEY (requested_by) REFERENCES users(user_id)
|
||||
)
|
||||
""",
|
||||
'purchase_request_items': """
|
||||
CREATE TABLE purchase_request_items (
|
||||
item_id SERIAL PRIMARY KEY,
|
||||
request_id INTEGER NOT NULL,
|
||||
material_id INTEGER NOT NULL,
|
||||
quantity INTEGER,
|
||||
unit VARCHAR(20),
|
||||
description TEXT,
|
||||
user_requirement TEXT,
|
||||
is_ordered BOOLEAN DEFAULT false,
|
||||
is_received BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (request_id) REFERENCES purchase_requests(request_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
}
|
||||
|
||||
created_tables = []
|
||||
|
||||
for table_name, create_sql in required_tables.items():
|
||||
# 테이블이 존재하는지 확인
|
||||
cursor.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = %s
|
||||
)
|
||||
""", (table_name,))
|
||||
|
||||
result = cursor.fetchone()
|
||||
if isinstance(result, dict):
|
||||
table_exists = result.get('exists', False)
|
||||
else:
|
||||
table_exists = result[0] if result else False
|
||||
if not table_exists:
|
||||
# 테이블 생성
|
||||
cursor.execute(create_sql)
|
||||
created_tables.append(table_name)
|
||||
print(f" 🏗️ {table_name} 테이블 생성됨")
|
||||
|
||||
if created_tables:
|
||||
print(f" ✅ {len(created_tables)}개 상세 테이블 생성 완료")
|
||||
|
||||
return len(created_tables) > 0
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ 상세 테이블 생성 실패: {e}")
|
||||
import traceback
|
||||
print(f" 상세 오류: {traceback.format_exc()}")
|
||||
return False
|
||||
|
||||
def verify_critical_tables(self):
|
||||
"""중요 테이블들이 존재하는지 확인"""
|
||||
print("🔍 중요 테이블 존재 여부 확인...")
|
||||
|
||||
critical_tables = [
|
||||
'users', 'projects', 'files', 'materials',
|
||||
'pipe_details', 'fitting_details', 'flange_details',
|
||||
'valve_details', 'gasket_details', 'bolt_details'
|
||||
]
|
||||
|
||||
try:
|
||||
conn = psycopg2.connect(**self.db_config)
|
||||
cursor = conn.cursor()
|
||||
|
||||
missing_tables = []
|
||||
|
||||
for table in critical_tables:
|
||||
cursor.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = %s
|
||||
)
|
||||
""", (table,))
|
||||
|
||||
if not cursor.fetchone()[0]:
|
||||
missing_tables.append(table)
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
if missing_tables:
|
||||
print("❌ 누락된 중요 테이블들:")
|
||||
for table in missing_tables:
|
||||
print(f" - {table}")
|
||||
return False
|
||||
else:
|
||||
print("✅ 모든 중요 테이블이 존재합니다.")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 테이블 확인 실패: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
print("🚀 Docker 환경 마이그레이션 시작")
|
||||
|
||||
migrator = DockerMigrator()
|
||||
|
||||
# 1. 중요 테이블 존재 여부 확인
|
||||
if not migrator.verify_critical_tables():
|
||||
print("💥 중요 테이블이 누락되어 있습니다!")
|
||||
return False
|
||||
|
||||
# 2. 스키마 체크 및 수정
|
||||
if not migrator.check_and_fix_schema():
|
||||
print("💥 스키마 수정 실패!")
|
||||
return False
|
||||
|
||||
print("🎉 마이그레이션 완료!")
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
361
backend/scripts/schema_analyzer.py
Normal file
361
backend/scripts/schema_analyzer.py
Normal file
@@ -0,0 +1,361 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
스키마 분석기 - 코드와 DB 스키마를 비교하여 누락된 테이블/컬럼을 찾는 도구
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import json
|
||||
from typing import Dict, List, Set, Tuple
|
||||
from datetime import datetime
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
# 프로젝트 루트를 Python 경로에 추가
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
class SchemaAnalyzer:
|
||||
def __init__(self):
|
||||
self.db_config = {
|
||||
'host': 'localhost',
|
||||
'port': 5432,
|
||||
'database': 'tk_mp_bom',
|
||||
'user': 'tkmp_user',
|
||||
'password': 'tkmp_password'
|
||||
}
|
||||
self.code_tables = {}
|
||||
self.db_tables = {}
|
||||
self.analysis_result = {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'missing_tables': [],
|
||||
'missing_columns': [],
|
||||
'extra_tables': [],
|
||||
'schema_issues': []
|
||||
}
|
||||
|
||||
def analyze_code_models(self) -> Dict[str, Dict]:
|
||||
"""코드에서 SQLAlchemy 모델을 분석하여 테이블 구조 추출"""
|
||||
print("📋 코드 모델 분석 중...")
|
||||
|
||||
model_files = [
|
||||
'backend/app/models.py',
|
||||
'backend/app/auth/models.py'
|
||||
]
|
||||
|
||||
for file_path in model_files:
|
||||
if os.path.exists(file_path):
|
||||
self._parse_model_file(file_path)
|
||||
|
||||
print(f"✅ 코드에서 {len(self.code_tables)}개 테이블 발견")
|
||||
return self.code_tables
|
||||
|
||||
def _parse_model_file(self, file_path: str):
|
||||
"""모델 파일을 파싱하여 테이블 정보 추출"""
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 클래스 정의 찾기
|
||||
class_pattern = r'class\s+(\w+)\(Base\):(.*?)(?=class\s+\w+\(Base\):|$)'
|
||||
classes = re.findall(class_pattern, content, re.DOTALL)
|
||||
|
||||
for class_name, class_content in classes:
|
||||
table_info = self._parse_class_content(class_name, class_content)
|
||||
if table_info:
|
||||
table_name = table_info['table_name']
|
||||
self.code_tables[table_name] = table_info
|
||||
|
||||
def _parse_class_content(self, class_name: str, content: str) -> Dict:
|
||||
"""클래스 내용을 파싱하여 테이블 정보 추출"""
|
||||
# __tablename__ 찾기
|
||||
tablename_match = re.search(r'__tablename__\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if not tablename_match:
|
||||
return None
|
||||
|
||||
table_name = tablename_match.group(1)
|
||||
|
||||
# 컬럼 정의 찾기
|
||||
column_pattern = r'(\w+)\s*=\s*Column\((.*?)\)'
|
||||
columns = {}
|
||||
|
||||
for match in re.finditer(column_pattern, content, re.DOTALL):
|
||||
column_name = match.group(1)
|
||||
column_def = match.group(2)
|
||||
|
||||
# 컬럼 타입과 속성 파싱
|
||||
column_info = self._parse_column_definition(column_def)
|
||||
columns[column_name] = column_info
|
||||
|
||||
return {
|
||||
'class_name': class_name,
|
||||
'table_name': table_name,
|
||||
'columns': columns,
|
||||
'file_path': None # 나중에 설정
|
||||
}
|
||||
|
||||
def _parse_column_definition(self, column_def: str) -> Dict:
|
||||
"""컬럼 정의를 파싱하여 타입과 속성 추출"""
|
||||
# 기본 타입 매핑
|
||||
type_mapping = {
|
||||
'Integer': 'integer',
|
||||
'String': 'character varying',
|
||||
'Text': 'text',
|
||||
'Boolean': 'boolean',
|
||||
'DateTime': 'timestamp without time zone',
|
||||
'Numeric': 'numeric',
|
||||
'JSON': 'json'
|
||||
}
|
||||
|
||||
# 타입 추출
|
||||
type_match = re.search(r'(Integer|String|Text|Boolean|DateTime|Numeric|JSON)', column_def)
|
||||
column_type = 'unknown'
|
||||
if type_match:
|
||||
sqlalchemy_type = type_match.group(1)
|
||||
column_type = type_mapping.get(sqlalchemy_type, sqlalchemy_type.lower())
|
||||
|
||||
# 속성 추출
|
||||
nullable = 'nullable=False' not in column_def
|
||||
primary_key = 'primary_key=True' in column_def
|
||||
unique = 'unique=True' in column_def
|
||||
default = None
|
||||
|
||||
# default 값 추출
|
||||
default_match = re.search(r'default=([^,)]+)', column_def)
|
||||
if default_match:
|
||||
default = default_match.group(1).strip()
|
||||
|
||||
return {
|
||||
'type': column_type,
|
||||
'nullable': nullable,
|
||||
'primary_key': primary_key,
|
||||
'unique': unique,
|
||||
'default': default
|
||||
}
|
||||
|
||||
def analyze_db_schema(self) -> Dict[str, Dict]:
|
||||
"""실제 DB 스키마 분석"""
|
||||
print("🗄️ DB 스키마 분석 중...")
|
||||
|
||||
try:
|
||||
conn = psycopg2.connect(**self.db_config)
|
||||
cursor = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
# 테이블 목록 조회
|
||||
cursor.execute("""
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
ORDER BY tablename
|
||||
""")
|
||||
|
||||
tables = [row['tablename'] for row in cursor.fetchall()]
|
||||
|
||||
# 각 테이블의 컬럼 정보 조회
|
||||
for table_name in tables:
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default,
|
||||
character_maximum_length
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = %s
|
||||
AND table_schema = 'public'
|
||||
ORDER BY ordinal_position
|
||||
""", (table_name,))
|
||||
|
||||
columns = {}
|
||||
for row in cursor.fetchall():
|
||||
columns[row['column_name']] = {
|
||||
'type': row['data_type'],
|
||||
'nullable': row['is_nullable'] == 'YES',
|
||||
'default': row['column_default'],
|
||||
'max_length': row['character_maximum_length']
|
||||
}
|
||||
|
||||
self.db_tables[table_name] = {
|
||||
'table_name': table_name,
|
||||
'columns': columns
|
||||
}
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
print(f"✅ DB에서 {len(self.db_tables)}개 테이블 발견")
|
||||
return self.db_tables
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ DB 연결 실패: {e}")
|
||||
return {}
|
||||
|
||||
def compare_schemas(self) -> Dict:
|
||||
"""코드와 DB 스키마 비교"""
|
||||
print("🔍 스키마 비교 중...")
|
||||
|
||||
code_table_names = set(self.code_tables.keys())
|
||||
db_table_names = set(self.db_tables.keys())
|
||||
|
||||
# 누락된 테이블 (코드에는 있지만 DB에는 없음)
|
||||
missing_tables = code_table_names - db_table_names
|
||||
self.analysis_result['missing_tables'] = list(missing_tables)
|
||||
|
||||
# 추가 테이블 (DB에는 있지만 코드에는 없음)
|
||||
extra_tables = db_table_names - code_table_names
|
||||
self.analysis_result['extra_tables'] = list(extra_tables)
|
||||
|
||||
# 공통 테이블의 컬럼 비교
|
||||
common_tables = code_table_names & db_table_names
|
||||
|
||||
for table_name in common_tables:
|
||||
missing_columns = self._compare_table_columns(table_name)
|
||||
if missing_columns:
|
||||
self.analysis_result['missing_columns'].extend(missing_columns)
|
||||
|
||||
return self.analysis_result
|
||||
|
||||
def _compare_table_columns(self, table_name: str) -> List[Dict]:
|
||||
"""특정 테이블의 컬럼 비교"""
|
||||
code_columns = set(self.code_tables[table_name]['columns'].keys())
|
||||
db_columns = set(self.db_tables[table_name]['columns'].keys())
|
||||
|
||||
missing_columns = []
|
||||
|
||||
# 누락된 컬럼들
|
||||
for column_name in code_columns - db_columns:
|
||||
column_info = self.code_tables[table_name]['columns'][column_name]
|
||||
missing_columns.append({
|
||||
'table': table_name,
|
||||
'column': column_name,
|
||||
'type': column_info['type'],
|
||||
'nullable': column_info['nullable'],
|
||||
'default': column_info['default']
|
||||
})
|
||||
|
||||
return missing_columns
|
||||
|
||||
def generate_migration_sql(self) -> str:
|
||||
"""누락된 스키마에 대한 마이그레이션 SQL 생성"""
|
||||
sql_statements = []
|
||||
sql_statements.append("-- 자동 생성된 마이그레이션 SQL")
|
||||
sql_statements.append(f"-- 생성 시간: {datetime.now()}")
|
||||
sql_statements.append("")
|
||||
|
||||
# 누락된 테이블 생성
|
||||
for table_name in self.analysis_result['missing_tables']:
|
||||
if table_name in self.code_tables:
|
||||
create_sql = self._generate_create_table_sql(table_name)
|
||||
sql_statements.append(create_sql)
|
||||
sql_statements.append("")
|
||||
|
||||
# 누락된 컬럼 추가
|
||||
for missing_col in self.analysis_result['missing_columns']:
|
||||
alter_sql = self._generate_add_column_sql(missing_col)
|
||||
sql_statements.append(alter_sql)
|
||||
|
||||
return "\n".join(sql_statements)
|
||||
|
||||
def _generate_create_table_sql(self, table_name: str) -> str:
|
||||
"""테이블 생성 SQL 생성"""
|
||||
table_info = self.code_tables[table_name]
|
||||
columns = []
|
||||
|
||||
for col_name, col_info in table_info['columns'].items():
|
||||
col_def = f" {col_name} {col_info['type']}"
|
||||
|
||||
if not col_info['nullable']:
|
||||
col_def += " NOT NULL"
|
||||
|
||||
if col_info['default']:
|
||||
col_def += f" DEFAULT {col_info['default']}"
|
||||
|
||||
if col_info['primary_key']:
|
||||
col_def += " PRIMARY KEY"
|
||||
|
||||
columns.append(col_def)
|
||||
|
||||
return f"CREATE TABLE {table_name} (\n" + ",\n".join(columns) + "\n);"
|
||||
|
||||
def _generate_add_column_sql(self, missing_col: Dict) -> str:
|
||||
"""컬럼 추가 SQL 생성"""
|
||||
sql = f"ALTER TABLE {missing_col['table']} ADD COLUMN {missing_col['column']} {missing_col['type']}"
|
||||
|
||||
if missing_col['default']:
|
||||
sql += f" DEFAULT {missing_col['default']}"
|
||||
|
||||
if not missing_col['nullable']:
|
||||
sql += " NOT NULL"
|
||||
|
||||
return sql + ";"
|
||||
|
||||
def save_analysis_report(self, filename: str = "schema_analysis_report.json"):
|
||||
"""분석 결과를 JSON 파일로 저장"""
|
||||
with open(filename, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.analysis_result, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"📄 분석 보고서 저장: {filename}")
|
||||
|
||||
def print_summary(self):
|
||||
"""분석 결과 요약 출력"""
|
||||
print("\n" + "="*60)
|
||||
print("📊 스키마 분석 결과 요약")
|
||||
print("="*60)
|
||||
|
||||
print(f"🔍 분석 시간: {self.analysis_result['timestamp']}")
|
||||
print(f"📋 코드 테이블: {len(self.code_tables)}개")
|
||||
print(f"🗄️ DB 테이블: {len(self.db_tables)}개")
|
||||
|
||||
if self.analysis_result['missing_tables']:
|
||||
print(f"\n❌ 누락된 테이블 ({len(self.analysis_result['missing_tables'])}개):")
|
||||
for table in self.analysis_result['missing_tables']:
|
||||
print(f" - {table}")
|
||||
|
||||
if self.analysis_result['missing_columns']:
|
||||
print(f"\n❌ 누락된 컬럼 ({len(self.analysis_result['missing_columns'])}개):")
|
||||
for col in self.analysis_result['missing_columns']:
|
||||
print(f" - {col['table']}.{col['column']} ({col['type']})")
|
||||
|
||||
if self.analysis_result['extra_tables']:
|
||||
print(f"\n➕ 추가 테이블 ({len(self.analysis_result['extra_tables'])}개):")
|
||||
for table in self.analysis_result['extra_tables']:
|
||||
print(f" - {table}")
|
||||
|
||||
if not any([self.analysis_result['missing_tables'],
|
||||
self.analysis_result['missing_columns']]):
|
||||
print("\n✅ 스키마가 완전히 동기화되어 있습니다!")
|
||||
|
||||
def main():
|
||||
print("🚀 TK-MP-Project 스키마 분석기 시작")
|
||||
|
||||
analyzer = SchemaAnalyzer()
|
||||
|
||||
# 1. 코드 모델 분석
|
||||
analyzer.analyze_code_models()
|
||||
|
||||
# 2. DB 스키마 분석
|
||||
analyzer.analyze_db_schema()
|
||||
|
||||
# 3. 스키마 비교
|
||||
analyzer.compare_schemas()
|
||||
|
||||
# 4. 결과 출력
|
||||
analyzer.print_summary()
|
||||
|
||||
# 5. 마이그레이션 SQL 생성
|
||||
if analyzer.analysis_result['missing_tables'] or analyzer.analysis_result['missing_columns']:
|
||||
migration_sql = analyzer.generate_migration_sql()
|
||||
|
||||
with open('migration.sql', 'w', encoding='utf-8') as f:
|
||||
f.write(migration_sql)
|
||||
|
||||
print(f"\n📝 마이그레이션 SQL 생성: migration.sql")
|
||||
print("다음 명령으로 실행 가능:")
|
||||
print("docker exec tk-mp-postgres psql -U tkmp_user -d tk_mp_bom -f /path/to/migration.sql")
|
||||
|
||||
# 6. 분석 보고서 저장
|
||||
analyzer.save_analysis_report()
|
||||
|
||||
return analyzer.analysis_result
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
278
backend/scripts/schema_monitor.py
Normal file
278
backend/scripts/schema_monitor.py
Normal file
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
스키마 모니터링 시스템 - 코드 변경사항을 감지하고 스키마 분석을 자동으로 실행
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Dict, List
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from schema_analyzer import SchemaAnalyzer
|
||||
from auto_migrator import AutoMigrator
|
||||
|
||||
class SchemaMonitor(FileSystemEventHandler):
|
||||
def __init__(self):
|
||||
self.analyzer = SchemaAnalyzer()
|
||||
self.migrator = AutoMigrator()
|
||||
self.last_check = datetime.now()
|
||||
self.monitored_files = [
|
||||
'backend/app/models.py',
|
||||
'backend/app/auth/models.py'
|
||||
]
|
||||
self.change_log = []
|
||||
|
||||
def on_modified(self, event):
|
||||
"""파일 변경 감지"""
|
||||
if event.is_directory:
|
||||
return
|
||||
|
||||
# 모니터링 대상 파일인지 확인
|
||||
file_path = event.src_path.replace('\\', '/')
|
||||
if not any(monitored in file_path for monitored in self.monitored_files):
|
||||
return
|
||||
|
||||
print(f"📝 파일 변경 감지: {file_path}")
|
||||
|
||||
# 변경 로그 기록
|
||||
self.change_log.append({
|
||||
'file': file_path,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'event_type': 'modified'
|
||||
})
|
||||
|
||||
# 스키마 분석 실행 (디바운싱 적용)
|
||||
self._schedule_schema_check()
|
||||
|
||||
def _schedule_schema_check(self):
|
||||
"""스키마 체크 스케줄링 (디바운싱)"""
|
||||
# 마지막 체크로부터 5초 후에 실행
|
||||
time.sleep(5)
|
||||
|
||||
current_time = datetime.now()
|
||||
if (current_time - self.last_check).seconds >= 5:
|
||||
self.last_check = current_time
|
||||
self._run_schema_check()
|
||||
|
||||
def _run_schema_check(self):
|
||||
"""스키마 체크 실행"""
|
||||
print("\n🔍 스키마 변경사항 체크 중...")
|
||||
|
||||
# 분석 실행
|
||||
self.analyzer.analyze_code_models()
|
||||
self.analyzer.analyze_db_schema()
|
||||
analysis_result = self.analyzer.compare_schemas()
|
||||
|
||||
# 변경사항이 있는지 확인
|
||||
has_changes = (
|
||||
analysis_result['missing_tables'] or
|
||||
analysis_result['missing_columns']
|
||||
)
|
||||
|
||||
if has_changes:
|
||||
print("⚠️ 스키마 불일치 발견!")
|
||||
self.analyzer.print_summary()
|
||||
|
||||
# 자동 마이그레이션 실행 여부 확인
|
||||
self._handle_schema_changes(analysis_result)
|
||||
else:
|
||||
print("✅ 스키마가 동기화되어 있습니다.")
|
||||
|
||||
def _handle_schema_changes(self, analysis_result: Dict):
|
||||
"""스키마 변경사항 처리"""
|
||||
print("\n🤖 자동 마이그레이션을 실행하시겠습니까?")
|
||||
print("1. 자동 실행")
|
||||
print("2. SQL 파일만 생성")
|
||||
print("3. 무시")
|
||||
|
||||
# 개발 환경에서는 자동으로 실행
|
||||
choice = "1" # 자동 실행
|
||||
|
||||
if choice == "1":
|
||||
print("🚀 자동 마이그레이션 실행 중...")
|
||||
success = self.migrator._execute_migrations(analysis_result)
|
||||
|
||||
if success:
|
||||
print("✅ 마이그레이션 완료!")
|
||||
self._notify_migration_success(analysis_result)
|
||||
else:
|
||||
print("❌ 마이그레이션 실패!")
|
||||
self._notify_migration_failure(analysis_result)
|
||||
|
||||
elif choice == "2":
|
||||
migration_sql = self.analyzer.generate_migration_sql()
|
||||
filename = f"migration_{datetime.now().strftime('%Y%m%d_%H%M%S')}.sql"
|
||||
|
||||
with open(filename, 'w', encoding='utf-8') as f:
|
||||
f.write(migration_sql)
|
||||
|
||||
print(f"📝 마이그레이션 SQL 생성: {filename}")
|
||||
|
||||
def _notify_migration_success(self, analysis_result: Dict):
|
||||
"""마이그레이션 성공 알림"""
|
||||
notification = {
|
||||
'type': 'MIGRATION_SUCCESS',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'changes': {
|
||||
'tables_created': len(analysis_result['missing_tables']),
|
||||
'columns_added': len(analysis_result['missing_columns'])
|
||||
}
|
||||
}
|
||||
|
||||
self._save_notification(notification)
|
||||
|
||||
def _notify_migration_failure(self, analysis_result: Dict):
|
||||
"""마이그레이션 실패 알림"""
|
||||
notification = {
|
||||
'type': 'MIGRATION_FAILURE',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'analysis_result': analysis_result
|
||||
}
|
||||
|
||||
self._save_notification(notification)
|
||||
|
||||
def _save_notification(self, notification: Dict):
|
||||
"""알림 저장"""
|
||||
notifications_file = 'schema_notifications.json'
|
||||
|
||||
notifications = []
|
||||
if os.path.exists(notifications_file):
|
||||
with open(notifications_file, 'r', encoding='utf-8') as f:
|
||||
notifications = json.load(f)
|
||||
|
||||
notifications.append(notification)
|
||||
|
||||
# 최근 100개만 유지
|
||||
notifications = notifications[-100:]
|
||||
|
||||
with open(notifications_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(notifications, f, indent=2, ensure_ascii=False)
|
||||
|
||||
def start_monitoring(self):
|
||||
"""모니터링 시작"""
|
||||
print("👀 스키마 모니터링 시작...")
|
||||
print("모니터링 대상 파일:")
|
||||
for file_path in self.monitored_files:
|
||||
print(f" - {file_path}")
|
||||
|
||||
observer = Observer()
|
||||
observer.schedule(self, 'backend/app', recursive=True)
|
||||
observer.start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
observer.stop()
|
||||
print("\n🛑 모니터링 중단")
|
||||
|
||||
observer.join()
|
||||
|
||||
class SchemaValidator:
|
||||
"""스키마 검증 도구"""
|
||||
|
||||
def __init__(self):
|
||||
self.analyzer = SchemaAnalyzer()
|
||||
|
||||
def validate_deployment_readiness(self) -> bool:
|
||||
"""배포 준비 상태 검증"""
|
||||
print("🔍 배포 준비 상태 검증 중...")
|
||||
|
||||
# 스키마 분석
|
||||
self.analyzer.analyze_code_models()
|
||||
self.analyzer.analyze_db_schema()
|
||||
analysis_result = self.analyzer.compare_schemas()
|
||||
|
||||
# 검증 결과
|
||||
is_ready = not (
|
||||
analysis_result['missing_tables'] or
|
||||
analysis_result['missing_columns']
|
||||
)
|
||||
|
||||
if is_ready:
|
||||
print("✅ 배포 준비 완료! 스키마가 완전히 동기화되어 있습니다.")
|
||||
else:
|
||||
print("❌ 배포 준비 미완료! 스키마 불일치가 발견되었습니다.")
|
||||
self.analyzer.print_summary()
|
||||
|
||||
return is_ready
|
||||
|
||||
def generate_deployment_migration(self) -> str:
|
||||
"""배포용 마이그레이션 스크립트 생성"""
|
||||
print("📝 배포용 마이그레이션 스크립트 생성 중...")
|
||||
|
||||
self.analyzer.analyze_code_models()
|
||||
self.analyzer.analyze_db_schema()
|
||||
analysis_result = self.analyzer.compare_schemas()
|
||||
|
||||
if not analysis_result['missing_tables'] and not analysis_result['missing_columns']:
|
||||
print("✅ 마이그레이션이 필요하지 않습니다.")
|
||||
return ""
|
||||
|
||||
# 배포용 마이그레이션 스크립트 생성
|
||||
migration_sql = self.analyzer.generate_migration_sql()
|
||||
|
||||
# 안전성 체크 추가
|
||||
safe_migration = self._add_safety_checks(migration_sql)
|
||||
|
||||
filename = f"deployment_migration_{datetime.now().strftime('%Y%m%d_%H%M%S')}.sql"
|
||||
with open(filename, 'w', encoding='utf-8') as f:
|
||||
f.write(safe_migration)
|
||||
|
||||
print(f"📄 배포용 마이그레이션 생성: {filename}")
|
||||
return filename
|
||||
|
||||
def _add_safety_checks(self, migration_sql: str) -> str:
|
||||
"""마이그레이션에 안전성 체크 추가"""
|
||||
safety_header = """-- 배포용 마이그레이션 스크립트
|
||||
-- 생성 시간: {timestamp}
|
||||
-- 주의: 프로덕션 환경에서 실행하기 전에 백업을 수행하세요!
|
||||
|
||||
-- 트랜잭션 시작
|
||||
BEGIN;
|
||||
|
||||
-- 백업 테이블 생성 (필요시)
|
||||
-- CREATE TABLE users_backup AS SELECT * FROM users;
|
||||
|
||||
""".format(timestamp=datetime.now())
|
||||
|
||||
safety_footer = """
|
||||
-- 검증 쿼리 (필요시 주석 해제)
|
||||
-- SELECT COUNT(*) FROM users WHERE status IS NOT NULL;
|
||||
|
||||
-- 모든 것이 정상이면 커밋, 문제가 있으면 ROLLBACK 실행
|
||||
COMMIT;
|
||||
-- ROLLBACK;
|
||||
"""
|
||||
|
||||
return safety_header + migration_sql + safety_footer
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='TK-MP-Project 스키마 모니터링 도구')
|
||||
parser.add_argument('--mode', choices=['monitor', 'validate', 'deploy'],
|
||||
default='monitor', help='실행 모드')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.mode == 'monitor':
|
||||
monitor = SchemaMonitor()
|
||||
monitor.start_monitoring()
|
||||
|
||||
elif args.mode == 'validate':
|
||||
validator = SchemaValidator()
|
||||
is_ready = validator.validate_deployment_readiness()
|
||||
sys.exit(0 if is_ready else 1)
|
||||
|
||||
elif args.mode == 'deploy':
|
||||
validator = SchemaValidator()
|
||||
migration_file = validator.generate_deployment_migration()
|
||||
if migration_file:
|
||||
print(f"배포 시 다음 명령으로 실행: psql -f {migration_file}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
117
frontend/src/components/ErrorBoundary.jsx
Normal file
117
frontend/src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* React Error Boundary 컴포넌트
|
||||
*
|
||||
* 애플리케이션에서 발생하는 JavaScript 오류를 캐치하고 처리
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
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) {
|
||||
// 에러 로깅
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
|
||||
this.setState({
|
||||
error: error,
|
||||
errorInfo: errorInfo
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// 폴백 UI를 커스터마이징할 수 있습니다.
|
||||
return (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
margin: '20px',
|
||||
border: '1px solid #ff6b6b',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#fff5f5',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<h2 style={{ color: '#d63031', marginBottom: '15px' }}>
|
||||
⚠️ 오류가 발생했습니다
|
||||
</h2>
|
||||
<p style={{ color: '#636e72', marginBottom: '20px' }}>
|
||||
예상치 못한 오류가 발생했습니다. 페이지를 새로고침하거나 관리자에게 문의해주세요.
|
||||
</p>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<details style={{
|
||||
marginTop: '20px',
|
||||
textAlign: 'left',
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '15px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #dee2e6'
|
||||
}}>
|
||||
<summary style={{
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold',
|
||||
color: '#495057',
|
||||
marginBottom: '10px'
|
||||
}}>
|
||||
개발자 정보 (클릭하여 펼치기)
|
||||
</summary>
|
||||
<div style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
color: '#6c757d',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}>
|
||||
<strong>Error:</strong> {this.state.error && this.state.error.toString()}
|
||||
<br /><br />
|
||||
<strong>Component Stack:</strong>
|
||||
{this.state.errorInfo.componentStack}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
backgroundColor: '#0984e3',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
marginRight: '10px'
|
||||
}}
|
||||
>
|
||||
🔄 페이지 새로고침
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
style={{
|
||||
backgroundColor: '#636e72',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
← 이전 페이지
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
260
frontend/src/components/revision/FittingRevisionManager.jsx
Normal file
260
frontend/src/components/revision/FittingRevisionManager.jsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import React, { useState } from 'react';
|
||||
import { api } from '../../api';
|
||||
import { LoadingSpinner, ErrorMessage } from '../common';
|
||||
|
||||
const FittingRevisionManager = ({
|
||||
currentFileId,
|
||||
previousFileId,
|
||||
jobNo,
|
||||
onRevisionComplete,
|
||||
materials = []
|
||||
}) => {
|
||||
const [fittingComparison, setFittingComparison] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// FITTING 자재만 필터링
|
||||
const fittingMaterials = materials.filter(m => m.classified_category === 'FITTING');
|
||||
|
||||
const handleFittingRevisionCompare = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const params = {
|
||||
job_no: jobNo,
|
||||
current_file_id: currentFileId,
|
||||
category_filter: 'FITTING',
|
||||
save_comparison: true
|
||||
};
|
||||
|
||||
if (previousFileId) {
|
||||
params.previous_file_id = previousFileId;
|
||||
}
|
||||
|
||||
const response = await api.post('/enhanced-revision/compare-revisions', null, { params });
|
||||
setFittingComparison(response.data.data);
|
||||
|
||||
if (onRevisionComplete) {
|
||||
onRevisionComplete('FITTING', response.data.data);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
setError('FITTING 리비전 비교 실패: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderFittingChanges = () => {
|
||||
if (!fittingComparison?.changes) return null;
|
||||
|
||||
const changes = fittingComparison.changes;
|
||||
|
||||
return (
|
||||
<div className="fitting-changes">
|
||||
<h4>🔩 FITTING 자재 변경사항</h4>
|
||||
|
||||
{/* 구매 완료 FITTING 변경사항 */}
|
||||
{changes.purchased_materials?.additional_purchase_needed?.length > 0 && (
|
||||
<div className="change-section purchased-fittings">
|
||||
<h5>📈 추가 구매 필요 (구매완료 FITTING)</h5>
|
||||
<div className="fitting-list">
|
||||
{changes.purchased_materials.additional_purchase_needed
|
||||
.filter(item => item.material.classified_category === 'FITTING')
|
||||
.map((item, idx) => (
|
||||
<div key={idx} className="fitting-item additional-purchase">
|
||||
<div className="fitting-info">
|
||||
<span className="fitting-desc">
|
||||
{item.material.original_description}
|
||||
</span>
|
||||
<span className="fitting-spec">
|
||||
{item.material.material_grade} {item.material.main_nom}
|
||||
</span>
|
||||
</div>
|
||||
<div className="quantity-change">
|
||||
<span className="previous">이전: {item.previous_quantity}개</span>
|
||||
<span className="arrow">→</span>
|
||||
<span className="current">현재: {item.current_quantity}개</span>
|
||||
<span className="additional">추가: +{item.additional_needed}개</span>
|
||||
</div>
|
||||
<div className="action-required">
|
||||
⚠️ 추가 구매 필요
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 잉여 재고 FITTING */}
|
||||
{changes.purchased_materials?.excess_inventory?.length > 0 && (
|
||||
<div className="change-section excess-fittings">
|
||||
<h5>📉 잉여 재고 (구매완료 FITTING)</h5>
|
||||
<div className="fitting-list">
|
||||
{changes.purchased_materials.excess_inventory
|
||||
.filter(item => item.material.classified_category === 'FITTING')
|
||||
.map((item, idx) => (
|
||||
<div key={idx} className="fitting-item excess-inventory">
|
||||
<div className="fitting-info">
|
||||
<span className="fitting-desc">
|
||||
{item.material.original_description}
|
||||
</span>
|
||||
<span className="fitting-spec">
|
||||
{item.material.material_grade} {item.material.main_nom}
|
||||
</span>
|
||||
</div>
|
||||
<div className="quantity-change">
|
||||
<span className="previous">이전: {item.previous_quantity}개</span>
|
||||
<span className="arrow">→</span>
|
||||
<span className="current">현재: {item.current_quantity}개</span>
|
||||
<span className="excess">잉여: -{item.excess_quantity}개</span>
|
||||
</div>
|
||||
<div className="action-required">
|
||||
📦 재고로 보관
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구매 미완료 FITTING 변경사항 */}
|
||||
{changes.unpurchased_materials?.quantity_updated?.length > 0 && (
|
||||
<div className="change-section unpurchased-fittings">
|
||||
<h5>📊 수량 변경 (구매미완료 FITTING)</h5>
|
||||
<div className="fitting-list">
|
||||
{changes.unpurchased_materials.quantity_updated
|
||||
.filter(item => item.material.classified_category === 'FITTING')
|
||||
.map((item, idx) => (
|
||||
<div key={idx} className="fitting-item quantity-updated">
|
||||
<div className="fitting-info">
|
||||
<span className="fitting-desc">
|
||||
{item.material.original_description}
|
||||
</span>
|
||||
<span className="fitting-spec">
|
||||
{item.material.material_grade} {item.material.main_nom}
|
||||
</span>
|
||||
</div>
|
||||
<div className="quantity-change">
|
||||
<span className="previous">이전: {item.previous_quantity}개</span>
|
||||
<span className="arrow">→</span>
|
||||
<span className="current">현재: {item.current_quantity}개</span>
|
||||
</div>
|
||||
<div className="action-required">
|
||||
✅ 구매 수량 업데이트
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 신규 FITTING */}
|
||||
{changes.new_materials?.filter(item => item.material.classified_category === 'FITTING').length > 0 && (
|
||||
<div className="change-section new-fittings">
|
||||
<h5>✅ 신규 FITTING</h5>
|
||||
<div className="fitting-list">
|
||||
{changes.new_materials
|
||||
.filter(item => item.material.classified_category === 'FITTING')
|
||||
.map((item, idx) => (
|
||||
<div key={idx} className="fitting-item new-material">
|
||||
<div className="fitting-info">
|
||||
<span className="fitting-desc">
|
||||
{item.material.original_description}
|
||||
</span>
|
||||
<span className="fitting-spec">
|
||||
{item.material.material_grade} {item.material.main_nom}
|
||||
</span>
|
||||
</div>
|
||||
<div className="quantity-info">
|
||||
<span className="quantity">수량: {item.material.quantity}개</span>
|
||||
</div>
|
||||
<div className="action-required">
|
||||
🆕 신규 구매 필요
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 삭제된 FITTING */}
|
||||
{changes.deleted_materials?.filter(item => item.material.classified_category === 'FITTING').length > 0 && (
|
||||
<div className="change-section deleted-fittings">
|
||||
<h5>❌ 삭제된 FITTING</h5>
|
||||
<div className="fitting-list">
|
||||
{changes.deleted_materials
|
||||
.filter(item => item.material.classified_category === 'FITTING')
|
||||
.map((item, idx) => (
|
||||
<div key={idx} className="fitting-item deleted-material">
|
||||
<div className="fitting-info">
|
||||
<span className="fitting-desc">
|
||||
{item.material.original_description}
|
||||
</span>
|
||||
<span className="fitting-spec">
|
||||
{item.material.material_grade} {item.material.main_nom}
|
||||
</span>
|
||||
</div>
|
||||
<div className="quantity-info">
|
||||
<span className="quantity">이전 수량: {item.material.quantity}개</span>
|
||||
</div>
|
||||
<div className="action-required">
|
||||
🗑️ 리비전에서 제거됨
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFittingSummary = () => {
|
||||
const totalFittings = fittingMaterials.length;
|
||||
const purchasedFittings = fittingMaterials.filter(m => m.purchase_confirmed).length;
|
||||
const pendingFittings = totalFittings - purchasedFittings;
|
||||
|
||||
return (
|
||||
<div className="fitting-summary">
|
||||
<h4>🔩 FITTING 요약</h4>
|
||||
<div className="summary-stats">
|
||||
<div className="stat-card">
|
||||
<span className="stat-label">총 FITTING</span>
|
||||
<span className="stat-value">{totalFittings}개</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-label">구매완료</span>
|
||||
<span className="stat-value">{purchasedFittings}개</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-label">구매대기</span>
|
||||
<span className="stat-value">{pendingFittings}개</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fitting-revision-manager">
|
||||
{error && <ErrorMessage message={error} onClose={() => setError('')} />}
|
||||
|
||||
<div className="fitting-controls">
|
||||
<button
|
||||
className="btn-compare-fitting"
|
||||
onClick={handleFittingRevisionCompare}
|
||||
disabled={loading || !currentFileId}
|
||||
>
|
||||
{loading ? <LoadingSpinner size="small" /> : '🔍 FITTING 리비전 비교'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{renderFittingSummary()}
|
||||
{renderFittingChanges()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FittingRevisionManager;
|
||||
270
frontend/src/components/revision/PipeRevisionManager.jsx
Normal file
270
frontend/src/components/revision/PipeRevisionManager.jsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../../api';
|
||||
import { LoadingSpinner, ErrorMessage } from '../common';
|
||||
|
||||
const PipeRevisionManager = ({
|
||||
currentFileId,
|
||||
previousFileId,
|
||||
jobNo,
|
||||
onRevisionComplete,
|
||||
materials = []
|
||||
}) => {
|
||||
const [pipeComparison, setPipeComparison] = useState(null);
|
||||
const [pipeLengthSummary, setPipeLengthSummary] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// PIPE 자재만 필터링
|
||||
const pipeMaterials = materials.filter(m => m.classified_category === 'PIPE');
|
||||
|
||||
useEffect(() => {
|
||||
if (currentFileId) {
|
||||
loadPipeLengthSummary();
|
||||
}
|
||||
}, [currentFileId]);
|
||||
|
||||
const loadPipeLengthSummary = async () => {
|
||||
try {
|
||||
const response = await api.get(`/enhanced-revision/pipe-length-summary/${currentFileId}`);
|
||||
setPipeLengthSummary(response.data.data);
|
||||
} catch (err) {
|
||||
console.error('PIPE 길이 요약 조회 실패:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePipeRevisionCompare = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const params = {
|
||||
job_no: jobNo,
|
||||
current_file_id: currentFileId,
|
||||
category_filter: 'PIPE',
|
||||
save_comparison: true
|
||||
};
|
||||
|
||||
if (previousFileId) {
|
||||
params.previous_file_id = previousFileId;
|
||||
}
|
||||
|
||||
const response = await api.post('/enhanced-revision/compare-revisions', null, { params });
|
||||
setPipeComparison(response.data.data);
|
||||
|
||||
if (onRevisionComplete) {
|
||||
onRevisionComplete('PIPE', response.data.data);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
setError('PIPE 리비전 비교 실패: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecalculatePipeLengths = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.post(`/enhanced-revision/recalculate-pipe-lengths/${currentFileId}`);
|
||||
|
||||
if (response.data.success) {
|
||||
alert(`PIPE 자재 ${response.data.data.updated_count}개의 길이를 재계산했습니다.`);
|
||||
loadPipeLengthSummary();
|
||||
}
|
||||
} catch (err) {
|
||||
setError('PIPE 길이 재계산 실패: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderPipeChanges = () => {
|
||||
if (!pipeComparison?.changes) return null;
|
||||
|
||||
const changes = pipeComparison.changes;
|
||||
|
||||
return (
|
||||
<div className="pipe-changes">
|
||||
<h4>🔧 PIPE 자재 변경사항</h4>
|
||||
|
||||
{/* 구매 완료 PIPE 변경사항 */}
|
||||
{changes.purchased_materials?.additional_purchase_needed?.length > 0 && (
|
||||
<div className="change-section purchased-pipes">
|
||||
<h5>📈 추가 구매 필요 (구매완료 PIPE)</h5>
|
||||
<div className="pipe-list">
|
||||
{changes.purchased_materials.additional_purchase_needed
|
||||
.filter(item => item.material.classified_category === 'PIPE')
|
||||
.map((item, idx) => (
|
||||
<div key={idx} className="pipe-item additional-purchase">
|
||||
<div className="pipe-info">
|
||||
<span className="drawing-line">
|
||||
{item.material.drawing_name} - {item.material.line_no}
|
||||
</span>
|
||||
<span className="pipe-spec">
|
||||
{item.material.material_grade} {item.material.schedule} {item.material.main_nom}
|
||||
</span>
|
||||
</div>
|
||||
<div className="length-change">
|
||||
<span className="previous">이전: {item.previous_quantity}m</span>
|
||||
<span className="arrow">→</span>
|
||||
<span className="current">현재: {item.current_quantity}m</span>
|
||||
<span className="additional">추가: +{item.additional_needed}m</span>
|
||||
</div>
|
||||
<div className="action-required">
|
||||
⚠️ 추가 구매 필요
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 잉여 재고 PIPE */}
|
||||
{changes.purchased_materials?.excess_inventory?.length > 0 && (
|
||||
<div className="change-section excess-pipes">
|
||||
<h5>📉 잉여 재고 (구매완료 PIPE)</h5>
|
||||
<div className="pipe-list">
|
||||
{changes.purchased_materials.excess_inventory
|
||||
.filter(item => item.material.classified_category === 'PIPE')
|
||||
.map((item, idx) => (
|
||||
<div key={idx} className="pipe-item excess-inventory">
|
||||
<div className="pipe-info">
|
||||
<span className="drawing-line">
|
||||
{item.material.drawing_name} - {item.material.line_no}
|
||||
</span>
|
||||
<span className="pipe-spec">
|
||||
{item.material.material_grade} {item.material.schedule} {item.material.main_nom}
|
||||
</span>
|
||||
</div>
|
||||
<div className="length-change">
|
||||
<span className="previous">이전: {item.previous_quantity}m</span>
|
||||
<span className="arrow">→</span>
|
||||
<span className="current">현재: {item.current_quantity}m</span>
|
||||
<span className="excess">잉여: -{item.excess_quantity}m</span>
|
||||
</div>
|
||||
<div className="action-required">
|
||||
📦 재고로 보관
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구매 미완료 PIPE 변경사항 */}
|
||||
{changes.unpurchased_materials?.quantity_updated?.length > 0 && (
|
||||
<div className="change-section unpurchased-pipes">
|
||||
<h5>📊 수량 변경 (구매미완료 PIPE)</h5>
|
||||
<div className="pipe-list">
|
||||
{changes.unpurchased_materials.quantity_updated
|
||||
.filter(item => item.material.classified_category === 'PIPE')
|
||||
.map((item, idx) => (
|
||||
<div key={idx} className="pipe-item quantity-updated">
|
||||
<div className="pipe-info">
|
||||
<span className="drawing-line">
|
||||
{item.material.drawing_name} - {item.material.line_no}
|
||||
</span>
|
||||
<span className="pipe-spec">
|
||||
{item.material.material_grade} {item.material.schedule} {item.material.main_nom}
|
||||
</span>
|
||||
</div>
|
||||
<div className="length-change">
|
||||
<span className="previous">이전: {item.previous_quantity}m</span>
|
||||
<span className="arrow">→</span>
|
||||
<span className="current">현재: {item.current_quantity}m</span>
|
||||
</div>
|
||||
<div className="action-required">
|
||||
✅ 구매 수량 업데이트
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPipeLengthSummary = () => {
|
||||
if (!pipeLengthSummary) return null;
|
||||
|
||||
return (
|
||||
<div className="pipe-length-summary">
|
||||
<div className="summary-header">
|
||||
<h4>🔧 PIPE 길이 요약</h4>
|
||||
<button
|
||||
className="btn-recalculate"
|
||||
onClick={handleRecalculatePipeLengths}
|
||||
disabled={loading}
|
||||
>
|
||||
🔄 길이 재계산
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pipe-stats">
|
||||
<div className="stat-card">
|
||||
<span className="stat-label">총 라인</span>
|
||||
<span className="stat-value">{pipeLengthSummary.total_lines}개</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-label">총 길이</span>
|
||||
<span className="stat-value">{pipeLengthSummary.total_length?.toFixed(2)}m</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-label">현재 자재 수</span>
|
||||
<span className="stat-value">{pipeMaterials.length}개</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pipe-lines">
|
||||
{pipeLengthSummary.pipe_lines?.slice(0, 5).map((line, idx) => (
|
||||
<div key={idx} className={`pipe-line ${line.purchase_status}`}>
|
||||
<div className="line-info">
|
||||
<span className="drawing-line">
|
||||
{line.drawing_name} - {line.line_no}
|
||||
</span>
|
||||
<span className="material-spec">
|
||||
{line.material_grade} {line.schedule} {line.nominal_size}
|
||||
</span>
|
||||
</div>
|
||||
<div className="line-stats">
|
||||
<span className="length">길이: {line.total_length?.toFixed(2)}m</span>
|
||||
<span className="segments">구간: {line.segment_count}개</span>
|
||||
<span className={`status ${line.purchase_status}`}>
|
||||
{line.purchase_status === 'purchased' ? '구매완료' :
|
||||
line.purchase_status === 'pending' ? '구매대기' : '혼재'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{pipeLengthSummary.pipe_lines?.length > 5 && (
|
||||
<div className="more-lines">
|
||||
... 외 {pipeLengthSummary.pipe_lines.length - 5}개 라인
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pipe-revision-manager">
|
||||
{error && <ErrorMessage message={error} onClose={() => setError('')} />}
|
||||
|
||||
<div className="pipe-controls">
|
||||
<button
|
||||
className="btn-compare-pipe"
|
||||
onClick={handlePipeRevisionCompare}
|
||||
disabled={loading || !currentFileId}
|
||||
>
|
||||
{loading ? <LoadingSpinner size="small" /> : '🔍 PIPE 리비전 비교'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{renderPipeLengthSummary()}
|
||||
{renderPipeChanges()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PipeRevisionManager;
|
||||
663
frontend/src/components/revision/RevisionComparisonView.css
Normal file
663
frontend/src/components/revision/RevisionComparisonView.css
Normal file
@@ -0,0 +1,663 @@
|
||||
/* 리비전 비교 뷰 컴포넌트 스타일 */
|
||||
|
||||
.revision-comparison-view {
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 로딩 및 빈 상태 */
|
||||
.revision-comparison-loading,
|
||||
.revision-comparison-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.revision-comparison-loading p,
|
||||
.revision-comparison-empty p {
|
||||
margin-top: 16px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.comparison-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.header-info h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.header-info p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.comparison-date {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-export,
|
||||
.btn-refresh {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-export:hover,
|
||||
.btn-refresh:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.btn-export:disabled,
|
||||
.btn-refresh:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 요약 카드 */
|
||||
.comparison-summary-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* 필터 섹션 */
|
||||
.comparison-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-group select,
|
||||
.filter-group input[type="text"] {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.filter-group select:focus,
|
||||
.filter-group input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.filter-group input[type="checkbox"] {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.btn-reset-filters {
|
||||
padding: 6px 12px;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-reset-filters:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
/* 필터링된 요약 */
|
||||
.filtered-summary {
|
||||
padding: 12px 20px;
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.filter-note {
|
||||
color: #6b7280;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* 탭 네비게이션 */
|
||||
.comparison-tabs {
|
||||
display: flex;
|
||||
background: white;
|
||||
border-radius: 8px 8px 0 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
flex: 1;
|
||||
padding: 12px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tab-button:first-child {
|
||||
border-radius: 8px 0 0 0;
|
||||
}
|
||||
|
||||
.tab-button:last-child {
|
||||
border-radius: 0 8px 0 0;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
background: #f9fafb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: white;
|
||||
color: #6366f1;
|
||||
border-bottom-color: #6366f1;
|
||||
}
|
||||
|
||||
/* 탭 콘텐츠 */
|
||||
.comparison-content {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0 0 8px 8px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* 요약 탭 */
|
||||
.comparison-summary-tab {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 20px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f3f4f6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.change-previews {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.change-preview {
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.change-preview h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.preview-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.material-desc {
|
||||
flex: 1;
|
||||
color: #374151;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-more {
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 상세 변경사항 탭 */
|
||||
.comparison-details-tab {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.change-type-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.change-type-header {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.change-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.change-item-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.change-item-card:hover {
|
||||
border-color: #d1d5db;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.change-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.change-item-header:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-select {
|
||||
padding: 4px 12px;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-select:hover {
|
||||
background: #5558e3;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.change-item-details {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
/* 자재별 보기 탭 */
|
||||
.comparison-materials-tab {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.materials-table {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr 100px;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.table-body {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr 100px;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.table-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.btn-select-small {
|
||||
padding: 2px 8px;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-select-small:hover {
|
||||
background: #5558e3;
|
||||
}
|
||||
|
||||
/* 자재 변경 상세 정보 */
|
||||
.material-change-details {
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.detail-section h5 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.detail-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-item .label {
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.detail-item .value {
|
||||
color: #374151;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.change-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.old-value {
|
||||
color: #dc2626;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.new-value {
|
||||
color: #059669;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.change-amount {
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 1200px) {
|
||||
.comparison-filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.change-previews {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.revision-comparison-view {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.comparison-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.btn-export,
|
||||
.btn-refresh {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.comparison-tabs {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.tab-button:first-child {
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.tab-button:last-child {
|
||||
border-radius: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-header,
|
||||
.table-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.table-header span,
|
||||
.table-row span {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.table-header span:before,
|
||||
.table-row span:before {
|
||||
content: attr(data-label) ': ';
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
display: inline-block;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detail-item .value {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* 애니메이션 */
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.change-item-details {
|
||||
animation: slideDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
.comparison-content {
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 스크롤바 스타일 */
|
||||
.table-body::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.table-body::-webkit-scrollbar-track {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.table-body::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.table-body::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
608
frontend/src/components/revision/RevisionComparisonView.jsx
Normal file
608
frontend/src/components/revision/RevisionComparisonView.jsx
Normal file
@@ -0,0 +1,608 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useRevisionComparison, useRevisionFiltering } from '../../hooks/useRevisionComparison';
|
||||
import RevisionStatusIndicator, { RevisionChangeSummary } from './RevisionStatusIndicator';
|
||||
import { LoadingSpinner, ErrorMessage } from '../common';
|
||||
import './RevisionComparisonView.css';
|
||||
|
||||
/**
|
||||
* 리비전 비교 뷰 컴포넌트
|
||||
*/
|
||||
const RevisionComparisonView = ({
|
||||
currentFileId,
|
||||
previousFileId,
|
||||
category = null,
|
||||
onMaterialSelect = null,
|
||||
className = ''
|
||||
}) => {
|
||||
const [selectedTab, setSelectedTab] = useState('summary');
|
||||
const [expandedItems, setExpandedItems] = useState(new Set());
|
||||
|
||||
const {
|
||||
comparisonResult,
|
||||
loading,
|
||||
error,
|
||||
compareRevisions,
|
||||
getComparisonSummary,
|
||||
getChangesByType,
|
||||
exportComparisonReport
|
||||
} = useRevisionComparison();
|
||||
|
||||
const {
|
||||
filters,
|
||||
updateFilter,
|
||||
getFilteredChanges,
|
||||
getFilterSummary,
|
||||
resetFilters
|
||||
} = useRevisionFiltering(comparisonResult);
|
||||
|
||||
// 초기 비교 실행
|
||||
React.useEffect(() => {
|
||||
if (currentFileId && previousFileId) {
|
||||
compareRevisions(currentFileId, previousFileId, category);
|
||||
}
|
||||
}, [currentFileId, previousFileId, category, compareRevisions]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
return getComparisonSummary(comparisonResult);
|
||||
}, [comparisonResult, getComparisonSummary]);
|
||||
|
||||
const filteredChanges = useMemo(() => {
|
||||
return getFilteredChanges();
|
||||
}, [getFilteredChanges]);
|
||||
|
||||
const filteredSummary = useMemo(() => {
|
||||
return getFilterSummary();
|
||||
}, [getFilterSummary]);
|
||||
|
||||
const toggleItemExpansion = (itemId) => {
|
||||
setExpandedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(itemId)) {
|
||||
newSet.delete(itemId);
|
||||
} else {
|
||||
newSet.add(itemId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleExport = async (format) => {
|
||||
await exportComparisonReport(comparisonResult, format);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="revision-comparison-loading">
|
||||
<LoadingSpinner />
|
||||
<p>리비전을 비교하고 있습니다...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} />;
|
||||
}
|
||||
|
||||
if (!comparisonResult) {
|
||||
return (
|
||||
<div className="revision-comparison-empty">
|
||||
<p>비교할 리비전을 선택해주세요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`revision-comparison-view ${className}`}>
|
||||
{/* 헤더 */}
|
||||
<div className="comparison-header">
|
||||
<div className="header-info">
|
||||
<h3>리비전 비교 결과</h3>
|
||||
<p>
|
||||
{category ? `${category} 카테고리` : '전체'} 자재 비교
|
||||
{comparisonResult.comparison_date && (
|
||||
<span className="comparison-date">
|
||||
({new Date(comparisonResult.comparison_date).toLocaleString()})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="btn-export"
|
||||
onClick={() => handleExport('excel')}
|
||||
disabled={loading}
|
||||
>
|
||||
📊 Excel 내보내기
|
||||
</button>
|
||||
<button
|
||||
className="btn-refresh"
|
||||
onClick={() => compareRevisions(currentFileId, previousFileId, category)}
|
||||
disabled={loading}
|
||||
>
|
||||
🔄 새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
{summary && (
|
||||
<RevisionChangeSummary
|
||||
changes={comparisonResult.changes}
|
||||
className="comparison-summary-card"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 필터 섹션 */}
|
||||
<div className="comparison-filters">
|
||||
<div className="filter-group">
|
||||
<label>변경 타입:</label>
|
||||
<select
|
||||
value={filters.changeType}
|
||||
onChange={(e) => updateFilter('changeType', e.target.value)}
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
<option value="added">추가됨</option>
|
||||
<option value="modified">수정됨</option>
|
||||
<option value="removed">제거됨</option>
|
||||
<option value="unchanged">변경없음</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{!category && (
|
||||
<div className="filter-group">
|
||||
<label>카테고리:</label>
|
||||
<select
|
||||
value={filters.category}
|
||||
onChange={(e) => updateFilter('category', e.target.value)}
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
<option value="FITTING">FITTING</option>
|
||||
<option value="FLANGE">FLANGE</option>
|
||||
<option value="VALVE">VALVE</option>
|
||||
<option value="GASKET">GASKET</option>
|
||||
<option value="BOLT">BOLT</option>
|
||||
<option value="SUPPORT">SUPPORT</option>
|
||||
<option value="SPECIAL">SPECIAL</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="filter-group">
|
||||
<label>검색:</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="자재명 검색..."
|
||||
value={filters.searchTerm}
|
||||
onChange={(e) => updateFilter('searchTerm', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.showOnlySignificant}
|
||||
onChange={(e) => updateFilter('showOnlySignificant', e.target.checked)}
|
||||
/>
|
||||
중요한 변경사항만
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button className="btn-reset-filters" onClick={resetFilters}>
|
||||
필터 초기화
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 필터링된 요약 */}
|
||||
{filteredSummary && (
|
||||
<div className="filtered-summary">
|
||||
<span>필터 결과: 총 {filteredSummary.total}개</span>
|
||||
{filteredSummary.total !== summary?.totalChanges && (
|
||||
<span className="filter-note">
|
||||
(전체 {summary.totalChanges}개 중)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
<div className="comparison-tabs">
|
||||
<button
|
||||
className={`tab-button ${selectedTab === 'summary' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedTab('summary')}
|
||||
>
|
||||
요약
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${selectedTab === 'details' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedTab('details')}
|
||||
>
|
||||
상세 변경사항
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${selectedTab === 'materials' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedTab('materials')}
|
||||
>
|
||||
자재별 보기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
<div className="comparison-content">
|
||||
{selectedTab === 'summary' && (
|
||||
<ComparisonSummaryTab
|
||||
summary={summary}
|
||||
filteredChanges={filteredChanges}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedTab === 'details' && (
|
||||
<ComparisonDetailsTab
|
||||
changes={filteredChanges || comparisonResult.changes}
|
||||
expandedItems={expandedItems}
|
||||
onToggleExpansion={toggleItemExpansion}
|
||||
onMaterialSelect={onMaterialSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedTab === 'materials' && (
|
||||
<ComparisonMaterialsTab
|
||||
changes={filteredChanges || comparisonResult.changes}
|
||||
onMaterialSelect={onMaterialSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 요약 탭 컴포넌트
|
||||
*/
|
||||
const ComparisonSummaryTab = ({ summary, filteredChanges }) => {
|
||||
if (!summary) return null;
|
||||
|
||||
const getChangeTypeStats = () => {
|
||||
if (!filteredChanges) return null;
|
||||
|
||||
return Object.entries(filteredChanges).map(([changeType, items]) => ({
|
||||
type: changeType,
|
||||
count: items.length,
|
||||
items: items.slice(0, 5) // 상위 5개만 표시
|
||||
}));
|
||||
};
|
||||
|
||||
const changeStats = getChangeTypeStats();
|
||||
|
||||
return (
|
||||
<div className="comparison-summary-tab">
|
||||
<div className="summary-stats">
|
||||
<div className="stat-card">
|
||||
<h4>전체 변경률</h4>
|
||||
<div className="stat-value">
|
||||
{summary.changePercentage?.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<h4>안정성</h4>
|
||||
<div className="stat-value">
|
||||
{(100 - (summary.changePercentage || 0)).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<h4>총 변경사항</h4>
|
||||
<div className="stat-value">
|
||||
{summary.totalChanges}개
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{changeStats && (
|
||||
<div className="change-previews">
|
||||
{changeStats.map(stat => (
|
||||
stat.count > 0 && (
|
||||
<div key={stat.type} className="change-preview">
|
||||
<h4>
|
||||
{getChangeTypeLabel(stat.type)} ({stat.count}개)
|
||||
</h4>
|
||||
<div className="preview-items">
|
||||
{stat.items.map((item, idx) => (
|
||||
<div key={idx} className="preview-item">
|
||||
<span className="material-desc">
|
||||
{getMaterialDescription(item)}
|
||||
</span>
|
||||
<RevisionStatusIndicator
|
||||
status={getStatusFromChangeType(stat.type)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{stat.count > 5 && (
|
||||
<div className="preview-more">
|
||||
외 {stat.count - 5}개 더...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 상세 변경사항 탭 컴포넌트
|
||||
*/
|
||||
const ComparisonDetailsTab = ({
|
||||
changes,
|
||||
expandedItems,
|
||||
onToggleExpansion,
|
||||
onMaterialSelect
|
||||
}) => {
|
||||
if (!changes) return null;
|
||||
|
||||
return (
|
||||
<div className="comparison-details-tab">
|
||||
{Object.entries(changes).map(([changeType, items]) => (
|
||||
items.length > 0 && (
|
||||
<div key={changeType} className="change-type-section">
|
||||
<h4 className="change-type-header">
|
||||
{getChangeTypeLabel(changeType)} ({items.length}개)
|
||||
</h4>
|
||||
|
||||
<div className="change-items">
|
||||
{items.map((item, idx) => {
|
||||
const itemId = `${changeType}-${idx}`;
|
||||
const isExpanded = expandedItems.has(itemId);
|
||||
|
||||
return (
|
||||
<div key={idx} className="change-item-card">
|
||||
<div
|
||||
className="change-item-header"
|
||||
onClick={() => onToggleExpansion(itemId)}
|
||||
>
|
||||
<div className="item-info">
|
||||
<span className="material-desc">
|
||||
{getMaterialDescription(item)}
|
||||
</span>
|
||||
<RevisionStatusIndicator
|
||||
status={getStatusFromChangeType(changeType)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="item-actions">
|
||||
{onMaterialSelect && (
|
||||
<button
|
||||
className="btn-select"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMaterialSelect(item);
|
||||
}}
|
||||
>
|
||||
선택
|
||||
</button>
|
||||
)}
|
||||
<span className="expand-icon">
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="change-item-details">
|
||||
<MaterialChangeDetails
|
||||
item={item}
|
||||
changeType={changeType}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 자재별 보기 탭 컴포넌트
|
||||
*/
|
||||
const ComparisonMaterialsTab = ({ changes, onMaterialSelect }) => {
|
||||
if (!changes) return null;
|
||||
|
||||
// 모든 자재를 하나의 리스트로 통합
|
||||
const allMaterials = [];
|
||||
Object.entries(changes).forEach(([changeType, items]) => {
|
||||
items.forEach(item => {
|
||||
allMaterials.push({
|
||||
...item,
|
||||
changeType
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 자재명으로 정렬
|
||||
allMaterials.sort((a, b) => {
|
||||
const descA = getMaterialDescription(a);
|
||||
const descB = getMaterialDescription(b);
|
||||
return descA.localeCompare(descB);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="comparison-materials-tab">
|
||||
<div className="materials-table">
|
||||
<div className="table-header">
|
||||
<span>자재명</span>
|
||||
<span>카테고리</span>
|
||||
<span>변경 타입</span>
|
||||
<span>수량 변화</span>
|
||||
<span>액션</span>
|
||||
</div>
|
||||
|
||||
<div className="table-body">
|
||||
{allMaterials.map((item, idx) => (
|
||||
<div key={idx} className="table-row">
|
||||
<span className="material-desc">
|
||||
{getMaterialDescription(item)}
|
||||
</span>
|
||||
<span className="material-category">
|
||||
{getMaterialCategory(item)}
|
||||
</span>
|
||||
<span className="change-type">
|
||||
<RevisionStatusIndicator
|
||||
status={getStatusFromChangeType(item.changeType)}
|
||||
size="small"
|
||||
/>
|
||||
</span>
|
||||
<span className="quantity-change">
|
||||
{getQuantityChangeText(item)}
|
||||
</span>
|
||||
<span className="actions">
|
||||
{onMaterialSelect && (
|
||||
<button
|
||||
className="btn-select-small"
|
||||
onClick={() => onMaterialSelect(item)}
|
||||
>
|
||||
선택
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 자재 변경 상세 정보 컴포넌트
|
||||
*/
|
||||
const MaterialChangeDetails = ({ item, changeType }) => {
|
||||
const material = item.material || item.current || item.previous;
|
||||
const changes = item.changes || {};
|
||||
|
||||
return (
|
||||
<div className="material-change-details">
|
||||
<div className="detail-grid">
|
||||
<div className="detail-section">
|
||||
<h5>기본 정보</h5>
|
||||
<div className="detail-items">
|
||||
<div className="detail-item">
|
||||
<span className="label">자재명:</span>
|
||||
<span className="value">{material?.original_description}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="label">카테고리:</span>
|
||||
<span className="value">{material?.classified_category}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="label">재질/규격:</span>
|
||||
<span className="value">
|
||||
{material?.material_grade} {material?.size_spec}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.keys(changes).length > 0 && (
|
||||
<div className="detail-section">
|
||||
<h5>변경 내용</h5>
|
||||
<div className="detail-items">
|
||||
{Object.entries(changes).map(([field, change]) => (
|
||||
<div key={field} className="detail-item">
|
||||
<span className="label">{getFieldLabel(field)}:</span>
|
||||
<span className="value change-value">
|
||||
{typeof change === 'object' ? (
|
||||
<>
|
||||
<span className="old-value">{change.previous}</span>
|
||||
<span className="arrow">→</span>
|
||||
<span className="new-value">{change.current}</span>
|
||||
{change.change && (
|
||||
<span className="change-amount">
|
||||
({change.change > 0 ? '+' : ''}{change.change})
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
change
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 유틸리티 함수들
|
||||
const getChangeTypeLabel = (changeType) => {
|
||||
const labels = {
|
||||
'added': '추가된 자재',
|
||||
'modified': '수정된 자재',
|
||||
'removed': '제거된 자재',
|
||||
'unchanged': '변경없는 자재'
|
||||
};
|
||||
return labels[changeType] || changeType;
|
||||
};
|
||||
|
||||
const getStatusFromChangeType = (changeType) => {
|
||||
const statusMap = {
|
||||
'added': 'NEW',
|
||||
'modified': 'UPDATED',
|
||||
'removed': 'DELETED',
|
||||
'unchanged': 'COMPLETED'
|
||||
};
|
||||
return statusMap[changeType] || 'PENDING';
|
||||
};
|
||||
|
||||
const getMaterialDescription = (item) => {
|
||||
const material = item.material || item.current || item.previous;
|
||||
return material?.original_description || '알 수 없는 자재';
|
||||
};
|
||||
|
||||
const getMaterialCategory = (item) => {
|
||||
const material = item.material || item.current || item.previous;
|
||||
return material?.classified_category || 'UNKNOWN';
|
||||
};
|
||||
|
||||
const getQuantityChangeText = (item) => {
|
||||
const changes = item.changes || {};
|
||||
const quantityChange = changes.quantity;
|
||||
|
||||
if (!quantityChange) return '-';
|
||||
|
||||
if (typeof quantityChange === 'object') {
|
||||
return `${quantityChange.previous} → ${quantityChange.current}`;
|
||||
}
|
||||
|
||||
return quantityChange;
|
||||
};
|
||||
|
||||
const getFieldLabel = (field) => {
|
||||
const labels = {
|
||||
'quantity': '수량',
|
||||
'material_grade': '재질',
|
||||
'schedule': 'Schedule',
|
||||
'size_spec': '규격',
|
||||
'main_nom': '주 호칭',
|
||||
'red_nom': '축소 호칭',
|
||||
'unit': '단위',
|
||||
'length': '길이'
|
||||
};
|
||||
return labels[field] || field;
|
||||
};
|
||||
|
||||
export default RevisionComparisonView;
|
||||
323
frontend/src/components/revision/RevisionStatusIndicator.css
Normal file
323
frontend/src/components/revision/RevisionStatusIndicator.css
Normal file
@@ -0,0 +1,323 @@
|
||||
/* 리비전 상태 표시 컴포넌트 스타일 */
|
||||
|
||||
.revision-status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.revision-status-indicator:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 크기별 스타일 */
|
||||
.revision-status-small {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.revision-status-small .status-icon {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.revision-status-medium {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.revision-status-medium .status-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.revision-status-large {
|
||||
font-size: 14px;
|
||||
padding: 6px 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.revision-status-large .status-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 우선순위 배지 */
|
||||
.revision-priority-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.priority-icon {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* 진행률 바 */
|
||||
.revision-progress-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress-count {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-percentage {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 카테고리 요약 */
|
||||
.revision-category-summary {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.summary-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.category-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.category-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.category-progress {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.category-progress-bar {
|
||||
height: 100%;
|
||||
background-color: #3b82f6;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* 변경사항 요약 카드 */
|
||||
.revision-change-summary {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-title h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.total-count {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
background: #f3f4f6;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.change-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.change-item {
|
||||
text-align: center;
|
||||
padding: 12px 8px;
|
||||
border-radius: 8px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #f3f4f6;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.change-item:hover {
|
||||
background: #f3f4f6;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.change-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.change-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.change-label {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.change-count {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.change-percentage {
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.revision-status-indicator {
|
||||
font-size: 11px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.category-info {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.change-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 애니메이션 */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.revision-status-indicator.loading {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
background: linear-gradient(90deg,
|
||||
currentColor 0%,
|
||||
currentColor 50%,
|
||||
rgba(255,255,255,0.3) 50%,
|
||||
rgba(255,255,255,0.3) 100%);
|
||||
background-size: 20px 100%;
|
||||
animation: progress-shine 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes progress-shine {
|
||||
0% {
|
||||
background-position: -20px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
}
|
||||
279
frontend/src/components/revision/RevisionStatusIndicator.jsx
Normal file
279
frontend/src/components/revision/RevisionStatusIndicator.jsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import React from 'react';
|
||||
import './RevisionStatusIndicator.css';
|
||||
|
||||
/**
|
||||
* 리비전 상태 표시 컴포넌트
|
||||
*/
|
||||
const RevisionStatusIndicator = ({
|
||||
status,
|
||||
size = 'medium',
|
||||
showText = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const getStatusConfig = (status) => {
|
||||
const configs = {
|
||||
'NEW': {
|
||||
color: '#10b981',
|
||||
bgColor: '#d1fae5',
|
||||
text: '신규',
|
||||
icon: '✨'
|
||||
},
|
||||
'ADDITIONAL': {
|
||||
color: '#ef4444',
|
||||
bgColor: '#fee2e2',
|
||||
text: '추가구매',
|
||||
icon: '➕'
|
||||
},
|
||||
'EXCESS': {
|
||||
color: '#f59e0b',
|
||||
bgColor: '#fef3c7',
|
||||
text: '잉여재고',
|
||||
icon: '📦'
|
||||
},
|
||||
'UPDATED': {
|
||||
color: '#3b82f6',
|
||||
bgColor: '#dbeafe',
|
||||
text: '수량변경',
|
||||
icon: '🔄'
|
||||
},
|
||||
'PENDING': {
|
||||
color: '#6366f1',
|
||||
bgColor: '#e0e7ff',
|
||||
text: '구매대기',
|
||||
icon: '⏳'
|
||||
},
|
||||
'COMPLETED': {
|
||||
color: '#6b7280',
|
||||
bgColor: '#f3f4f6',
|
||||
text: '완료',
|
||||
icon: '✅'
|
||||
},
|
||||
'DELETED': {
|
||||
color: '#dc2626',
|
||||
bgColor: '#fecaca',
|
||||
text: '삭제',
|
||||
icon: '❌'
|
||||
},
|
||||
'INVENTORY': {
|
||||
color: '#f97316',
|
||||
bgColor: '#fed7aa',
|
||||
text: '재고',
|
||||
icon: '🏪'
|
||||
}
|
||||
};
|
||||
|
||||
return configs[status] || {
|
||||
color: '#6b7280',
|
||||
bgColor: '#f3f4f6',
|
||||
text: status || 'Unknown',
|
||||
icon: '❓'
|
||||
};
|
||||
};
|
||||
|
||||
const config = getStatusConfig(status);
|
||||
const sizeClass = `revision-status-${size}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`revision-status-indicator ${sizeClass} ${className}`}
|
||||
style={{
|
||||
backgroundColor: config.bgColor,
|
||||
color: config.color,
|
||||
borderColor: config.color
|
||||
}}
|
||||
>
|
||||
<span className="status-icon">{config.icon}</span>
|
||||
{showText && <span className="status-text">{config.text}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 리비전 처리 우선순위 표시
|
||||
*/
|
||||
export const RevisionPriorityBadge = ({ priority, className = '' }) => {
|
||||
const getPriorityConfig = (priority) => {
|
||||
const configs = {
|
||||
'high': {
|
||||
color: '#dc2626',
|
||||
bgColor: '#fecaca',
|
||||
text: '높음',
|
||||
icon: '🔥'
|
||||
},
|
||||
'medium': {
|
||||
color: '#f59e0b',
|
||||
bgColor: '#fef3c7',
|
||||
text: '보통',
|
||||
icon: '⚡'
|
||||
},
|
||||
'low': {
|
||||
color: '#10b981',
|
||||
bgColor: '#d1fae5',
|
||||
text: '낮음',
|
||||
icon: '🌱'
|
||||
}
|
||||
};
|
||||
|
||||
return configs[priority] || configs['medium'];
|
||||
};
|
||||
|
||||
const config = getPriorityConfig(priority);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`revision-priority-badge ${className}`}
|
||||
style={{
|
||||
backgroundColor: config.bgColor,
|
||||
color: config.color
|
||||
}}
|
||||
>
|
||||
<span className="priority-icon">{config.icon}</span>
|
||||
<span className="priority-text">{config.text}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 리비전 진행률 표시
|
||||
*/
|
||||
export const RevisionProgressBar = ({
|
||||
current,
|
||||
total,
|
||||
label = '',
|
||||
showPercentage = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const percentage = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||
|
||||
const getProgressColor = (percentage) => {
|
||||
if (percentage >= 100) return '#10b981';
|
||||
if (percentage >= 80) return '#3b82f6';
|
||||
if (percentage >= 50) return '#f59e0b';
|
||||
return '#ef4444';
|
||||
};
|
||||
|
||||
const progressColor = getProgressColor(percentage);
|
||||
|
||||
return (
|
||||
<div className={`revision-progress-bar ${className}`}>
|
||||
{label && <div className="progress-label">{label}</div>}
|
||||
<div className="progress-container">
|
||||
<div className="progress-track">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
backgroundColor: progressColor
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="progress-info">
|
||||
<span className="progress-count">{current}/{total}</span>
|
||||
{showPercentage && (
|
||||
<span className="progress-percentage">{percentage}%</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 리비전 카테고리 상태 요약
|
||||
*/
|
||||
export const RevisionCategorySummary = ({ categoryStats, className = '' }) => {
|
||||
if (!categoryStats) return null;
|
||||
|
||||
const totalMaterials = Object.values(categoryStats).reduce(
|
||||
(sum, stats) => sum + stats.total, 0
|
||||
);
|
||||
|
||||
const processedMaterials = Object.values(categoryStats).reduce(
|
||||
(sum, stats) => sum + stats.processed, 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`revision-category-summary ${className}`}>
|
||||
<div className="summary-header">
|
||||
<h4>카테고리별 처리 현황</h4>
|
||||
<RevisionProgressBar
|
||||
current={processedMaterials}
|
||||
total={totalMaterials}
|
||||
label="전체 진행률"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="category-list">
|
||||
{Object.entries(categoryStats).map(([category, stats]) => (
|
||||
<div key={category} className="category-item">
|
||||
<div className="category-info">
|
||||
<span className="category-name">{category}</span>
|
||||
<span className="category-count">
|
||||
{stats.processed}/{stats.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="category-progress">
|
||||
<div
|
||||
className="category-progress-bar"
|
||||
style={{
|
||||
width: `${stats.total > 0 ? (stats.processed / stats.total) * 100 : 0}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 리비전 변경사항 요약 카드
|
||||
*/
|
||||
export const RevisionChangeSummary = ({ changes, className = '' }) => {
|
||||
if (!changes) return null;
|
||||
|
||||
const changeTypes = [
|
||||
{ key: 'added', label: '추가', icon: '➕', color: '#10b981' },
|
||||
{ key: 'modified', label: '수정', icon: '🔄', color: '#3b82f6' },
|
||||
{ key: 'removed', label: '제거', icon: '➖', color: '#ef4444' },
|
||||
{ key: 'unchanged', label: '변경없음', icon: '✅', color: '#6b7280' }
|
||||
];
|
||||
|
||||
const totalChanges = changeTypes.reduce(
|
||||
(sum, type) => sum + (changes[type.key]?.length || 0), 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`revision-change-summary ${className}`}>
|
||||
<div className="summary-title">
|
||||
<h4>변경사항 요약</h4>
|
||||
<span className="total-count">총 {totalChanges}개</span>
|
||||
</div>
|
||||
|
||||
<div className="change-grid">
|
||||
{changeTypes.map(type => {
|
||||
const count = changes[type.key]?.length || 0;
|
||||
const percentage = totalChanges > 0 ? (count / totalChanges) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div key={type.key} className="change-item">
|
||||
<div className="change-header">
|
||||
<span className="change-icon">{type.icon}</span>
|
||||
<span className="change-label">{type.label}</span>
|
||||
</div>
|
||||
<div className="change-count" style={{ color: type.color }}>
|
||||
{count}
|
||||
</div>
|
||||
<div className="change-percentage">
|
||||
{percentage.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevisionStatusIndicator;
|
||||
333
frontend/src/hooks/usePipeIssue.js
Normal file
333
frontend/src/hooks/usePipeIssue.js
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* PIPE 이슈 관리 훅
|
||||
*
|
||||
* 스냅샷 기반 도면별/단관별 이슈 관리 기능 제공
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
export const usePipeIssue = (jobNo = null, snapshotId = null) => {
|
||||
// 상태 관리
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 데이터 상태
|
||||
const [snapshots, setSnapshots] = useState([]);
|
||||
const [currentSnapshot, setCurrentSnapshot] = useState(null);
|
||||
const [drawingIssues, setDrawingIssues] = useState([]);
|
||||
const [segmentIssues, setSegmentIssues] = useState([]);
|
||||
const [issueReport, setIssueReport] = useState(null);
|
||||
|
||||
// 필터 상태
|
||||
const [selectedArea, setSelectedArea] = useState('');
|
||||
const [selectedDrawing, setSelectedDrawing] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
|
||||
// 스냅샷 목록 조회
|
||||
const fetchSnapshots = useCallback(async () => {
|
||||
if (!jobNo) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await api.get(`/pipe-issue/snapshots/${jobNo}`);
|
||||
setSnapshots(response.data.snapshots || []);
|
||||
|
||||
// 첫 번째 활성 스냅샷을 기본 선택
|
||||
const activeSnapshot = response.data.snapshots?.find(s => s.is_locked);
|
||||
if (activeSnapshot && !currentSnapshot) {
|
||||
setCurrentSnapshot(activeSnapshot);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || '스냅샷 조회 실패';
|
||||
setError(errorMessage);
|
||||
console.error('스냅샷 조회 실패:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobNo, currentSnapshot]);
|
||||
|
||||
// 도면 이슈 목록 조회
|
||||
const fetchDrawingIssues = useCallback(async (filters = {}) => {
|
||||
const targetSnapshotId = filters.snapshotId || snapshotId || currentSnapshot?.snapshot_id;
|
||||
if (!targetSnapshotId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.area || selectedArea) params.append('area', filters.area || selectedArea);
|
||||
if (filters.drawing_name || selectedDrawing) params.append('drawing_name', filters.drawing_name || selectedDrawing);
|
||||
if (filters.status_filter || statusFilter) params.append('status_filter', filters.status_filter || statusFilter);
|
||||
|
||||
const response = await api.get(`/pipe-issue/drawing-issues/${targetSnapshotId}?${params}`);
|
||||
setDrawingIssues(response.data.issues || []);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || '도면 이슈 조회 실패';
|
||||
setError(errorMessage);
|
||||
console.error('도면 이슈 조회 실패:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [snapshotId, currentSnapshot, selectedArea, selectedDrawing, statusFilter]);
|
||||
|
||||
// 단관 이슈 목록 조회
|
||||
const fetchSegmentIssues = useCallback(async (filters = {}) => {
|
||||
const targetSnapshotId = filters.snapshotId || snapshotId || currentSnapshot?.snapshot_id;
|
||||
if (!targetSnapshotId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.segment_id) params.append('segment_id', filters.segment_id);
|
||||
if (filters.status_filter || statusFilter) params.append('status_filter', filters.status_filter || statusFilter);
|
||||
|
||||
const response = await api.get(`/pipe-issue/segment-issues/${targetSnapshotId}?${params}`);
|
||||
setSegmentIssues(response.data.issues || []);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || '단관 이슈 조회 실패';
|
||||
setError(errorMessage);
|
||||
console.error('단관 이슈 조회 실패:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [snapshotId, currentSnapshot, statusFilter]);
|
||||
|
||||
// 도면 이슈 생성
|
||||
const createDrawingIssue = useCallback(async (issueData) => {
|
||||
const targetSnapshotId = issueData.snapshot_id || snapshotId || currentSnapshot?.snapshot_id;
|
||||
if (!targetSnapshotId) {
|
||||
setError('스냅샷 ID가 필요합니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await api.post('/pipe-issue/drawing-issues', {
|
||||
...issueData,
|
||||
snapshot_id: targetSnapshotId
|
||||
});
|
||||
|
||||
// 목록 새로고침
|
||||
await fetchDrawingIssues();
|
||||
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || '도면 이슈 생성 실패';
|
||||
setError(errorMessage);
|
||||
console.error('도면 이슈 생성 실패:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [snapshotId, currentSnapshot, fetchDrawingIssues]);
|
||||
|
||||
// 단관 이슈 생성
|
||||
const createSegmentIssue = useCallback(async (issueData) => {
|
||||
const targetSnapshotId = issueData.snapshot_id || snapshotId || currentSnapshot?.snapshot_id;
|
||||
if (!targetSnapshotId) {
|
||||
setError('스냅샷 ID가 필요합니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await api.post('/pipe-issue/segment-issues', {
|
||||
...issueData,
|
||||
snapshot_id: targetSnapshotId
|
||||
});
|
||||
|
||||
// 목록 새로고침
|
||||
await fetchSegmentIssues();
|
||||
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || '단관 이슈 생성 실패';
|
||||
setError(errorMessage);
|
||||
console.error('단관 이슈 생성 실패:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [snapshotId, currentSnapshot, fetchSegmentIssues]);
|
||||
|
||||
// 도면 이슈 상태 업데이트
|
||||
const updateDrawingIssueStatus = useCallback(async (issueId, statusData) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await api.put(`/pipe-issue/drawing-issues/${issueId}/status`, statusData);
|
||||
|
||||
// 목록 새로고침
|
||||
await fetchDrawingIssues();
|
||||
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || '도면 이슈 상태 업데이트 실패';
|
||||
setError(errorMessage);
|
||||
console.error('도면 이슈 상태 업데이트 실패:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchDrawingIssues]);
|
||||
|
||||
// 단관 이슈 상태 업데이트
|
||||
const updateSegmentIssueStatus = useCallback(async (issueId, statusData) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await api.put(`/pipe-issue/segment-issues/${issueId}/status`, statusData);
|
||||
|
||||
// 목록 새로고침
|
||||
await fetchSegmentIssues();
|
||||
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || '단관 이슈 상태 업데이트 실패';
|
||||
setError(errorMessage);
|
||||
console.error('단관 이슈 상태 업데이트 실패:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchSegmentIssues]);
|
||||
|
||||
// 이슈 리포트 생성
|
||||
const generateIssueReport = useCallback(async () => {
|
||||
const targetSnapshotId = snapshotId || currentSnapshot?.snapshot_id;
|
||||
if (!targetSnapshotId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await api.get(`/pipe-issue/report/${targetSnapshotId}`);
|
||||
setIssueReport(response.data);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || '이슈 리포트 생성 실패';
|
||||
setError(errorMessage);
|
||||
console.error('이슈 리포트 생성 실패:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [snapshotId, currentSnapshot]);
|
||||
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
if (jobNo) {
|
||||
fetchSnapshots();
|
||||
}
|
||||
}, [jobNo, fetchSnapshots]);
|
||||
|
||||
// 스냅샷 변경 시 이슈 목록 새로고침
|
||||
useEffect(() => {
|
||||
if (currentSnapshot?.snapshot_id) {
|
||||
fetchDrawingIssues();
|
||||
fetchSegmentIssues();
|
||||
}
|
||||
}, [currentSnapshot, fetchDrawingIssues, fetchSegmentIssues]);
|
||||
|
||||
// 필터 변경 시 이슈 목록 새로고침
|
||||
useEffect(() => {
|
||||
if (currentSnapshot?.snapshot_id) {
|
||||
fetchDrawingIssues();
|
||||
}
|
||||
}, [selectedArea, selectedDrawing, statusFilter, fetchDrawingIssues]);
|
||||
|
||||
// 편의 함수들
|
||||
const getIssueStats = useCallback(() => {
|
||||
const totalDrawingIssues = drawingIssues.length;
|
||||
const totalSegmentIssues = segmentIssues.length;
|
||||
|
||||
const drawingStats = {
|
||||
total: totalDrawingIssues,
|
||||
open: drawingIssues.filter(i => i.status === 'open').length,
|
||||
in_progress: drawingIssues.filter(i => i.status === 'in_progress').length,
|
||||
resolved: drawingIssues.filter(i => i.status === 'resolved').length,
|
||||
critical: drawingIssues.filter(i => i.severity === 'critical').length,
|
||||
high: drawingIssues.filter(i => i.severity === 'high').length
|
||||
};
|
||||
|
||||
const segmentStats = {
|
||||
total: totalSegmentIssues,
|
||||
open: segmentIssues.filter(i => i.status === 'open').length,
|
||||
in_progress: segmentIssues.filter(i => i.status === 'in_progress').length,
|
||||
resolved: segmentIssues.filter(i => i.status === 'resolved').length,
|
||||
critical: segmentIssues.filter(i => i.severity === 'critical').length,
|
||||
high: segmentIssues.filter(i => i.severity === 'high').length
|
||||
};
|
||||
|
||||
return {
|
||||
drawing: drawingStats,
|
||||
segment: segmentStats,
|
||||
total: totalDrawingIssues + totalSegmentIssues
|
||||
};
|
||||
}, [drawingIssues, segmentIssues]);
|
||||
|
||||
const hasActiveSnapshot = currentSnapshot && currentSnapshot.is_locked;
|
||||
const canManageIssues = hasActiveSnapshot;
|
||||
|
||||
return {
|
||||
// 상태
|
||||
loading,
|
||||
error,
|
||||
snapshots,
|
||||
currentSnapshot,
|
||||
drawingIssues,
|
||||
segmentIssues,
|
||||
issueReport,
|
||||
|
||||
// 필터
|
||||
selectedArea,
|
||||
selectedDrawing,
|
||||
statusFilter,
|
||||
setSelectedArea,
|
||||
setSelectedDrawing,
|
||||
setStatusFilter,
|
||||
|
||||
// 액션
|
||||
fetchSnapshots,
|
||||
fetchDrawingIssues,
|
||||
fetchSegmentIssues,
|
||||
createDrawingIssue,
|
||||
createSegmentIssue,
|
||||
updateDrawingIssueStatus,
|
||||
updateSegmentIssueStatus,
|
||||
generateIssueReport,
|
||||
setCurrentSnapshot,
|
||||
|
||||
// 편의 함수
|
||||
getIssueStats,
|
||||
clearError: () => setError(''),
|
||||
|
||||
// 상태 확인
|
||||
hasActiveSnapshot,
|
||||
canManageIssues,
|
||||
|
||||
// 통계
|
||||
stats: getIssueStats()
|
||||
};
|
||||
};
|
||||
|
||||
export default usePipeIssue;
|
||||
386
frontend/src/hooks/usePipeRevision.js
Normal file
386
frontend/src/hooks/usePipeRevision.js
Normal file
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* PIPE 전용 리비전 관리 훅
|
||||
*
|
||||
* Cutting Plan 작성 전/후에 따른 차별화된 리비전 처리
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import api from '../api';
|
||||
import { PipeLogger, PIPE_CONSTANTS } from '../utils/pipeUtils';
|
||||
|
||||
export const usePipeRevision = (jobNo, fileId) => {
|
||||
const [revisionStatus, setRevisionStatus] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [comparisonResult, setComparisonResult] = useState(null);
|
||||
|
||||
// 리비전 상태 확인
|
||||
const checkRevisionStatus = useCallback(async () => {
|
||||
if (!jobNo || !fileId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
PipeLogger.logPipeOperation('리비전 상태 확인', jobNo, { fileId });
|
||||
|
||||
const response = await api.post('/pipe-revision/check-status', {
|
||||
job_no: jobNo,
|
||||
new_file_id: parseInt(fileId)
|
||||
});
|
||||
|
||||
setRevisionStatus(response.data);
|
||||
|
||||
PipeLogger.logPipeOperation('리비전 상태 확인 완료', jobNo, {
|
||||
revisionType: response.data.revision_type,
|
||||
requiresAction: response.data.requires_action
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || 'PIPE 리비전 상태 확인 실패';
|
||||
setError(errorMessage);
|
||||
|
||||
PipeLogger.logPipeError('리비전 상태 확인', jobNo, err, { fileId });
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobNo, fileId]);
|
||||
|
||||
// Cutting Plan 작성 전 리비전 처리
|
||||
const handlePreCuttingPlanRevision = useCallback(async () => {
|
||||
if (!jobNo || !fileId) return null;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await api.post('/pipe-revision/handle-pre-cutting-plan', {
|
||||
job_no: jobNo,
|
||||
new_file_id: parseInt(fileId)
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || 'Cutting Plan 작성 전 리비전 처리 실패';
|
||||
setError(errorMessage);
|
||||
console.error('Pre-cutting-plan 리비전 처리 실패:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobNo, fileId]);
|
||||
|
||||
// Cutting Plan 작성 후 리비전 처리
|
||||
const handlePostCuttingPlanRevision = useCallback(async () => {
|
||||
if (!jobNo || !fileId) return null;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await api.post('/pipe-revision/handle-post-cutting-plan', {
|
||||
job_no: jobNo,
|
||||
new_file_id: parseInt(fileId)
|
||||
});
|
||||
|
||||
setComparisonResult(response.data);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || 'Cutting Plan 작성 후 리비전 처리 실패';
|
||||
setError(errorMessage);
|
||||
console.error('Post-cutting-plan 리비전 처리 실패:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobNo, fileId]);
|
||||
|
||||
// 리비전 비교 결과 상세 조회
|
||||
const getComparisonDetails = useCallback(async (comparisonId) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await api.get(`/pipe-revision/comparison/${comparisonId}`);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || '리비전 비교 결과 조회 실패';
|
||||
setError(errorMessage);
|
||||
console.error('리비전 비교 결과 조회 실패:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 리비전 변경사항 적용
|
||||
const applyRevisionChanges = useCallback(async (comparisonId) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await api.post(`/pipe-revision/comparison/${comparisonId}/apply`);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || '리비전 변경사항 적용 실패';
|
||||
setError(errorMessage);
|
||||
console.error('리비전 변경사항 적용 실패:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 구매 영향 분석
|
||||
const getPurchaseImpact = useCallback(async (comparisonId) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await api.get(`/pipe-revision/comparison/${comparisonId}/purchase-impact`);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || '구매 영향 분석 실패';
|
||||
setError(errorMessage);
|
||||
console.error('구매 영향 분석 실패:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 리비전 이력 조회
|
||||
const getRevisionHistory = useCallback(async () => {
|
||||
if (!jobNo) return null;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await api.get(`/pipe-revision/job/${jobNo}/history`);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || '리비전 이력 조회 실패';
|
||||
setError(errorMessage);
|
||||
console.error('리비전 이력 조회 실패:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobNo]);
|
||||
|
||||
// 자동 리비전 처리 (상태에 따라 적절한 처리 수행)
|
||||
const processRevisionAutomatically = useCallback(async () => {
|
||||
try {
|
||||
// 1. 리비전 상태 확인
|
||||
const status = await checkRevisionStatus();
|
||||
if (!status || !status.requires_action) {
|
||||
return {
|
||||
success: true,
|
||||
type: 'no_action_needed',
|
||||
message: status?.message || '리비전 처리가 필요하지 않습니다.'
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 리비전 타입에 따른 처리
|
||||
if (status.revision_type === 'pre_cutting_plan') {
|
||||
const result = await handlePreCuttingPlanRevision();
|
||||
return {
|
||||
success: result !== null,
|
||||
type: 'pre_cutting_plan',
|
||||
data: result,
|
||||
message: result?.message || 'Cutting Plan 작성 전 리비전 처리 완료'
|
||||
};
|
||||
} else if (status.revision_type === 'post_cutting_plan') {
|
||||
const result = await handlePostCuttingPlanRevision();
|
||||
return {
|
||||
success: result !== null,
|
||||
type: 'post_cutting_plan',
|
||||
data: result,
|
||||
message: result?.message || 'Cutting Plan 작성 후 리비전 처리 완료'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
type: 'unknown',
|
||||
message: '알 수 없는 리비전 타입입니다.'
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
console.error('자동 리비전 처리 실패:', err);
|
||||
return {
|
||||
success: false,
|
||||
type: 'error',
|
||||
message: '자동 리비전 처리 중 오류가 발생했습니다.'
|
||||
};
|
||||
}
|
||||
}, [checkRevisionStatus, handlePreCuttingPlanRevision, handlePostCuttingPlanRevision]);
|
||||
|
||||
// 컴포넌트 마운트 시 리비전 상태 확인
|
||||
useEffect(() => {
|
||||
if (jobNo && fileId) {
|
||||
checkRevisionStatus();
|
||||
}
|
||||
}, [jobNo, fileId, checkRevisionStatus]);
|
||||
|
||||
return {
|
||||
// 상태
|
||||
revisionStatus,
|
||||
comparisonResult,
|
||||
loading,
|
||||
error,
|
||||
|
||||
// 액션
|
||||
checkRevisionStatus,
|
||||
handlePreCuttingPlanRevision,
|
||||
handlePostCuttingPlanRevision,
|
||||
getComparisonDetails,
|
||||
applyRevisionChanges,
|
||||
getPurchaseImpact,
|
||||
getRevisionHistory,
|
||||
processRevisionAutomatically,
|
||||
|
||||
// Cutting Plan 확정 (스냅샷 생성)
|
||||
finalizeCuttingPlan: useCallback(async () => {
|
||||
if (!jobNo) return null;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await api.post('/pipe-snapshot/finalize-cutting-plan', {
|
||||
job_no: jobNo,
|
||||
created_by: 'user' // 추후 실제 사용자 정보로 변경
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || 'Cutting Plan 확정 실패';
|
||||
setError(errorMessage);
|
||||
console.error('Cutting Plan 확정 실패:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobNo]),
|
||||
|
||||
// 스냅샷 상태 확인
|
||||
getSnapshotStatus: useCallback(async () => {
|
||||
if (!jobNo) return null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/pipe-snapshot/status/${jobNo}`);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
console.error('스냅샷 상태 확인 실패:', err);
|
||||
return null;
|
||||
}
|
||||
}, [jobNo]),
|
||||
|
||||
// 스냅샷 단관 정보 조회
|
||||
getSnapshotSegments: useCallback(async (snapshotId, area = null, drawingName = null) => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (area) params.append('area', area);
|
||||
if (drawingName) params.append('drawing_name', drawingName);
|
||||
|
||||
const response = await api.get(`/pipe-snapshot/segments/${snapshotId}?${params}`);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
console.error('스냅샷 단관 조회 실패:', err);
|
||||
return null;
|
||||
}
|
||||
}, []),
|
||||
|
||||
// 사용 가능한 구역 목록 조회
|
||||
getAvailableAreas: useCallback(async (snapshotId) => {
|
||||
try {
|
||||
const response = await api.get(`/pipe-snapshot/areas/${snapshotId}`);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
console.error('구역 목록 조회 실패:', err);
|
||||
return null;
|
||||
}
|
||||
}, []),
|
||||
|
||||
// 사용 가능한 도면 목록 조회
|
||||
getAvailableDrawings: useCallback(async (snapshotId, area = null) => {
|
||||
try {
|
||||
const params = area ? `?area=${encodeURIComponent(area)}` : '';
|
||||
const response = await api.get(`/pipe-snapshot/drawings/${snapshotId}${params}`);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
console.error('도면 목록 조회 실패:', err);
|
||||
return null;
|
||||
}
|
||||
}, []),
|
||||
|
||||
// 리비전 보호 상태 확인
|
||||
checkRevisionProtection: useCallback(async () => {
|
||||
if (!jobNo) return null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/pipe-snapshot/revision-protection/${jobNo}`);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
console.error('리비전 보호 상태 확인 실패:', err);
|
||||
return null;
|
||||
}
|
||||
}, [jobNo]),
|
||||
|
||||
// 확정된 Excel 내보내기
|
||||
exportFinalizedExcel: useCallback(async () => {
|
||||
if (!jobNo) return null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/pipe-excel/export-finalized/${jobNo}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
// 파일 다운로드
|
||||
const blob = new Blob([response.data], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `PIPE_Cutting_Plan_${jobNo}_FINALIZED.xlsx`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
return { success: true, message: 'Excel 파일이 다운로드되었습니다.' };
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || '확정된 Excel 내보내기 실패';
|
||||
setError(errorMessage);
|
||||
console.error('확정된 Excel 내보내기 실패:', err);
|
||||
return { success: false, message: errorMessage };
|
||||
}
|
||||
}, [jobNo]),
|
||||
|
||||
// 확정 상태 확인
|
||||
checkFinalizationStatus: useCallback(async () => {
|
||||
if (!jobNo) return null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/pipe-excel/check-finalization/${jobNo}`);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
console.error('확정 상태 확인 실패:', err);
|
||||
return null;
|
||||
}
|
||||
}, [jobNo]),
|
||||
|
||||
// 유틸리티
|
||||
clearError: () => setError(''),
|
||||
isPreCuttingPlan: revisionStatus?.revision_type === 'pre_cutting_plan',
|
||||
isPostCuttingPlan: revisionStatus?.revision_type === 'post_cutting_plan',
|
||||
requiresAction: revisionStatus?.requires_action || false
|
||||
};
|
||||
};
|
||||
|
||||
export default usePipeRevision;
|
||||
389
frontend/src/hooks/useRevisionComparison.js
Normal file
389
frontend/src/hooks/useRevisionComparison.js
Normal file
@@ -0,0 +1,389 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
/**
|
||||
* 리비전 비교 훅
|
||||
* 두 리비전 간의 자재 비교 및 차이점 분석
|
||||
*/
|
||||
export const useRevisionComparison = () => {
|
||||
const [comparisonResult, setComparisonResult] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const compareRevisions = useCallback(async (currentFileId, previousFileId, categoryFilter = null) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
current_file_id: currentFileId,
|
||||
previous_file_id: previousFileId
|
||||
});
|
||||
|
||||
if (categoryFilter) {
|
||||
params.append('category_filter', categoryFilter);
|
||||
}
|
||||
|
||||
const response = await api.post(`/revision-comparison/compare?${params}`);
|
||||
|
||||
if (response.data.success) {
|
||||
setComparisonResult(response.data.data);
|
||||
return response.data.data;
|
||||
} else {
|
||||
throw new Error(response.data.message || '리비전 비교 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('리비전 비교 실패:', err);
|
||||
setError(err.message);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getCategoryComparison = useCallback(async (currentFileId, previousFileId, category) => {
|
||||
if (category === 'PIPE') {
|
||||
console.warn('PIPE 카테고리는 별도 처리가 필요합니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return await compareRevisions(currentFileId, previousFileId, category);
|
||||
}, [compareRevisions]);
|
||||
|
||||
const getComparisonSummary = useCallback((comparison) => {
|
||||
if (!comparison || !comparison.summary) return null;
|
||||
|
||||
const { summary } = comparison;
|
||||
|
||||
return {
|
||||
totalChanges: summary.modified + summary.added + summary.removed,
|
||||
unchanged: summary.unchanged,
|
||||
modified: summary.modified,
|
||||
added: summary.added,
|
||||
removed: summary.removed,
|
||||
changePercentage: summary.previous_count > 0
|
||||
? ((summary.modified + summary.added + summary.removed) / summary.previous_count * 100)
|
||||
: 0,
|
||||
hasSignificantChanges: (summary.modified + summary.added + summary.removed) > 0
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getChangesByType = useCallback((comparison, changeType) => {
|
||||
if (!comparison || !comparison.changes) return [];
|
||||
return comparison.changes[changeType] || [];
|
||||
}, []);
|
||||
|
||||
const getMaterialChanges = useCallback((comparison, materialId) => {
|
||||
if (!comparison || !comparison.changes) return null;
|
||||
|
||||
// 모든 변경 타입에서 해당 자재 찾기
|
||||
for (const [changeType, changes] of Object.entries(comparison.changes)) {
|
||||
const materialChange = changes.find(change => {
|
||||
const material = change.material || change.current || change.previous;
|
||||
return material && material.id === materialId;
|
||||
});
|
||||
|
||||
if (materialChange) {
|
||||
return {
|
||||
changeType,
|
||||
...materialChange
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const filterChangesByCategory = useCallback((comparison, category) => {
|
||||
if (!comparison || !comparison.changes) return null;
|
||||
|
||||
const filteredChanges = {};
|
||||
|
||||
for (const [changeType, changes] of Object.entries(comparison.changes)) {
|
||||
filteredChanges[changeType] = changes.filter(change => {
|
||||
const material = change.material || change.current || change.previous;
|
||||
return material && material.classified_category === category;
|
||||
});
|
||||
}
|
||||
|
||||
// 필터링된 요약 통계 계산
|
||||
const filteredSummary = {
|
||||
unchanged: filteredChanges.unchanged?.length || 0,
|
||||
modified: filteredChanges.modified?.length || 0,
|
||||
added: filteredChanges.added?.length || 0,
|
||||
removed: filteredChanges.removed?.length || 0
|
||||
};
|
||||
|
||||
return {
|
||||
...comparison,
|
||||
changes: filteredChanges,
|
||||
summary: {
|
||||
...comparison.summary,
|
||||
...filteredSummary,
|
||||
category_filter: category
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const exportComparisonReport = useCallback(async (comparison, format = 'excel') => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await api.post('/revision-comparison/export', {
|
||||
comparison_data: comparison,
|
||||
format
|
||||
}, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
// 파일 다운로드
|
||||
const blob = new Blob([response.data], {
|
||||
type: format === 'excel'
|
||||
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
: 'application/pdf'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `revision_comparison_${new Date().toISOString().split('T')[0]}.${format === 'excel' ? 'xlsx' : 'pdf'}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('비교 보고서 내보내기 실패:', err);
|
||||
setError(err.message);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearComparison = useCallback(() => {
|
||||
setComparisonResult(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
comparisonResult,
|
||||
loading,
|
||||
error,
|
||||
compareRevisions,
|
||||
getCategoryComparison,
|
||||
getComparisonSummary,
|
||||
getChangesByType,
|
||||
getMaterialChanges,
|
||||
filterChangesByCategory,
|
||||
exportComparisonReport,
|
||||
clearComparison,
|
||||
setError
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 리비전 차이점 시각화 훅
|
||||
*/
|
||||
export const useRevisionVisualization = (comparison) => {
|
||||
const getChangeVisualizationData = useCallback(() => {
|
||||
if (!comparison || !comparison.summary) return null;
|
||||
|
||||
const { summary } = comparison;
|
||||
|
||||
return {
|
||||
pieChart: [
|
||||
{ name: '변경없음', value: summary.unchanged, color: '#10b981' },
|
||||
{ name: '수정됨', value: summary.modified, color: '#f59e0b' },
|
||||
{ name: '추가됨', value: summary.added, color: '#3b82f6' },
|
||||
{ name: '제거됨', value: summary.removed, color: '#ef4444' }
|
||||
].filter(item => item.value > 0),
|
||||
|
||||
barChart: {
|
||||
categories: ['변경없음', '수정됨', '추가됨', '제거됨'],
|
||||
data: [summary.unchanged, summary.modified, summary.added, summary.removed],
|
||||
colors: ['#10b981', '#f59e0b', '#3b82f6', '#ef4444']
|
||||
},
|
||||
|
||||
summary: {
|
||||
totalItems: summary.previous_count + summary.added,
|
||||
changeRate: summary.previous_count > 0
|
||||
? ((summary.modified + summary.added + summary.removed) / summary.previous_count * 100)
|
||||
: 0,
|
||||
stabilityRate: summary.previous_count > 0
|
||||
? (summary.unchanged / summary.previous_count * 100)
|
||||
: 0
|
||||
}
|
||||
};
|
||||
}, [comparison]);
|
||||
|
||||
const getCategoryBreakdown = useCallback(() => {
|
||||
if (!comparison || !comparison.changes) return null;
|
||||
|
||||
const categoryStats = {};
|
||||
|
||||
// 모든 변경사항을 카테고리별로 분류
|
||||
Object.entries(comparison.changes).forEach(([changeType, changes]) => {
|
||||
changes.forEach(change => {
|
||||
const material = change.material || change.current || change.previous;
|
||||
const category = material?.classified_category || 'UNKNOWN';
|
||||
|
||||
if (!categoryStats[category]) {
|
||||
categoryStats[category] = {
|
||||
unchanged: 0,
|
||||
modified: 0,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
categoryStats[category][changeType]++;
|
||||
categoryStats[category].total++;
|
||||
});
|
||||
});
|
||||
|
||||
return Object.entries(categoryStats).map(([category, stats]) => ({
|
||||
category,
|
||||
...stats,
|
||||
changeRate: stats.total > 0 ? ((stats.modified + stats.added + stats.removed) / stats.total * 100) : 0
|
||||
}));
|
||||
}, [comparison]);
|
||||
|
||||
const getTimelineData = useCallback(() => {
|
||||
if (!comparison) return null;
|
||||
|
||||
return {
|
||||
comparisonDate: comparison.comparison_date,
|
||||
previousVersion: {
|
||||
fileId: comparison.previous_file_id,
|
||||
materialCount: comparison.summary?.previous_count || 0
|
||||
},
|
||||
currentVersion: {
|
||||
fileId: comparison.current_file_id,
|
||||
materialCount: comparison.summary?.current_count || 0
|
||||
},
|
||||
changes: {
|
||||
added: comparison.summary?.added || 0,
|
||||
removed: comparison.summary?.removed || 0,
|
||||
modified: comparison.summary?.modified || 0
|
||||
}
|
||||
};
|
||||
}, [comparison]);
|
||||
|
||||
return {
|
||||
getChangeVisualizationData,
|
||||
getCategoryBreakdown,
|
||||
getTimelineData
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 리비전 비교 필터링 훅
|
||||
*/
|
||||
export const useRevisionFiltering = (comparison) => {
|
||||
const [filters, setFilters] = useState({
|
||||
category: 'all',
|
||||
changeType: 'all',
|
||||
searchTerm: '',
|
||||
showOnlySignificant: false
|
||||
});
|
||||
|
||||
const updateFilter = useCallback((filterName, value) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[filterName]: value
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const getFilteredChanges = useCallback(() => {
|
||||
if (!comparison || !comparison.changes) return null;
|
||||
|
||||
let filteredChanges = { ...comparison.changes };
|
||||
|
||||
// 카테고리 필터
|
||||
if (filters.category !== 'all') {
|
||||
Object.keys(filteredChanges).forEach(changeType => {
|
||||
filteredChanges[changeType] = filteredChanges[changeType].filter(change => {
|
||||
const material = change.material || change.current || change.previous;
|
||||
return material?.classified_category === filters.category;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 변경 타입 필터
|
||||
if (filters.changeType !== 'all') {
|
||||
const selectedChanges = filteredChanges[filters.changeType] || [];
|
||||
filteredChanges = {
|
||||
unchanged: filters.changeType === 'unchanged' ? selectedChanges : [],
|
||||
modified: filters.changeType === 'modified' ? selectedChanges : [],
|
||||
added: filters.changeType === 'added' ? selectedChanges : [],
|
||||
removed: filters.changeType === 'removed' ? selectedChanges : []
|
||||
};
|
||||
}
|
||||
|
||||
// 검색어 필터
|
||||
if (filters.searchTerm) {
|
||||
const searchLower = filters.searchTerm.toLowerCase();
|
||||
Object.keys(filteredChanges).forEach(changeType => {
|
||||
filteredChanges[changeType] = filteredChanges[changeType].filter(change => {
|
||||
const material = change.material || change.current || change.previous;
|
||||
const description = material?.original_description || '';
|
||||
return description.toLowerCase().includes(searchLower);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 중요한 변경사항만 표시
|
||||
if (filters.showOnlySignificant) {
|
||||
Object.keys(filteredChanges).forEach(changeType => {
|
||||
if (changeType === 'unchanged') {
|
||||
filteredChanges[changeType] = []; // 변경없는 항목 제외
|
||||
} else {
|
||||
filteredChanges[changeType] = filteredChanges[changeType].filter(change => {
|
||||
// 수량 변화가 큰 경우만 표시
|
||||
const changes = change.changes || {};
|
||||
const quantityChange = changes.quantity?.change || 0;
|
||||
return Math.abs(quantityChange) > 1; // 1개 이상 변경된 경우만
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return filteredChanges;
|
||||
}, [comparison, filters]);
|
||||
|
||||
const getFilterSummary = useCallback(() => {
|
||||
const filteredChanges = getFilteredChanges();
|
||||
if (!filteredChanges) return null;
|
||||
|
||||
const summary = {
|
||||
unchanged: filteredChanges.unchanged?.length || 0,
|
||||
modified: filteredChanges.modified?.length || 0,
|
||||
added: filteredChanges.added?.length || 0,
|
||||
removed: filteredChanges.removed?.length || 0
|
||||
};
|
||||
|
||||
summary.total = summary.unchanged + summary.modified + summary.added + summary.removed;
|
||||
|
||||
return summary;
|
||||
}, [getFilteredChanges]);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setFilters({
|
||||
category: 'all',
|
||||
changeType: 'all',
|
||||
searchTerm: '',
|
||||
showOnlySignificant: false
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
filters,
|
||||
updateFilter,
|
||||
getFilteredChanges,
|
||||
getFilterSummary,
|
||||
resetFilters
|
||||
};
|
||||
};
|
||||
314
frontend/src/hooks/useRevisionLogic.js
Normal file
314
frontend/src/hooks/useRevisionLogic.js
Normal file
@@ -0,0 +1,314 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
/**
|
||||
* 리비전 로직 처리 훅
|
||||
* 구매 상태별 자재 처리 로직
|
||||
*/
|
||||
export const useRevisionLogic = (jobNo, currentFileId, previousFileId = null) => {
|
||||
const [processingResult, setProcessingResult] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const processRevision = useCallback(async () => {
|
||||
if (!jobNo || !currentFileId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
job_no: jobNo,
|
||||
file_id: currentFileId
|
||||
});
|
||||
|
||||
if (previousFileId) {
|
||||
params.append('previous_file_id', previousFileId);
|
||||
}
|
||||
|
||||
const response = await api.post(`/revision-redirect/process-revision/${jobNo}/${currentFileId}?${params}`);
|
||||
|
||||
if (response.data.success) {
|
||||
setProcessingResult(response.data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('리비전 처리 실패:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobNo, currentFileId, previousFileId]);
|
||||
|
||||
const applyProcessingResults = useCallback(async (results) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await api.post('/revision-material/apply-results', {
|
||||
processing_results: results
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error(response.data.message || '처리 결과 적용 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('처리 결과 적용 실패:', err);
|
||||
setError(err.message);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
processingResult,
|
||||
loading,
|
||||
error,
|
||||
processRevision,
|
||||
applyProcessingResults,
|
||||
setError
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리별 자재 처리 훅
|
||||
*/
|
||||
export const useCategoryMaterialProcessing = (fileId, category) => {
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [processingInfo, setProcessingInfo] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadCategoryMaterials = useCallback(async () => {
|
||||
if (!fileId || !category || category === 'PIPE') return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await api.get(`/revision-material/category/${fileId}/${category}`);
|
||||
|
||||
if (response.data.success) {
|
||||
setMaterials(response.data.data.materials || []);
|
||||
setProcessingInfo(response.data.data.processing_info || {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('카테고리 자재 조회 실패:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fileId, category]);
|
||||
|
||||
const processMaterial = useCallback(async (materialId, action, additionalData = {}) => {
|
||||
try {
|
||||
const response = await api.post(`/revision-material/process/${materialId}`, {
|
||||
action,
|
||||
...additionalData
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
// 자재 목록 새로고침
|
||||
await loadCategoryMaterials();
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error(response.data.message || '자재 처리 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('자재 처리 실패:', err);
|
||||
setError(err.message);
|
||||
throw err;
|
||||
}
|
||||
}, [loadCategoryMaterials]);
|
||||
|
||||
const updateMaterialStatus = useCallback((materialId, newStatus, additionalInfo = {}) => {
|
||||
setMaterials(prev =>
|
||||
prev.map(material =>
|
||||
material.id === materialId
|
||||
? {
|
||||
...material,
|
||||
revision_status: newStatus,
|
||||
processing_info: {
|
||||
...material.processing_info,
|
||||
...additionalInfo
|
||||
}
|
||||
}
|
||||
: material
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadCategoryMaterials();
|
||||
}, [loadCategoryMaterials]);
|
||||
|
||||
return {
|
||||
materials,
|
||||
processingInfo,
|
||||
loading,
|
||||
error,
|
||||
loadCategoryMaterials,
|
||||
processMaterial,
|
||||
updateMaterialStatus,
|
||||
setError
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 자재 선택 및 일괄 처리 훅
|
||||
*/
|
||||
export const useMaterialSelection = (materials = []) => {
|
||||
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
|
||||
const toggleMaterial = useCallback((materialId) => {
|
||||
setSelectedMaterials(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(materialId)) {
|
||||
newSet.delete(materialId);
|
||||
} else {
|
||||
newSet.add(materialId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (selectAll) {
|
||||
setSelectedMaterials(new Set());
|
||||
} else {
|
||||
const selectableMaterials = materials
|
||||
.filter(material => material.processing_info?.action !== '완료')
|
||||
.map(material => material.id);
|
||||
setSelectedMaterials(new Set(selectableMaterials));
|
||||
}
|
||||
setSelectAll(!selectAll);
|
||||
}, [selectAll, materials]);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedMaterials(new Set());
|
||||
setSelectAll(false);
|
||||
}, []);
|
||||
|
||||
const getSelectedMaterials = useCallback(() => {
|
||||
return materials.filter(material => selectedMaterials.has(material.id));
|
||||
}, [materials, selectedMaterials]);
|
||||
|
||||
const getSelectionSummary = useCallback(() => {
|
||||
const selected = getSelectedMaterials();
|
||||
const byStatus = selected.reduce((acc, material) => {
|
||||
const status = material.processing_info?.display_status || 'UNKNOWN';
|
||||
acc[status] = (acc[status] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
total: selected.length,
|
||||
byStatus,
|
||||
canProcess: selected.length > 0
|
||||
};
|
||||
}, [getSelectedMaterials]);
|
||||
|
||||
// materials 변경 시 selectAll 상태 업데이트
|
||||
useEffect(() => {
|
||||
const selectableMaterials = materials
|
||||
.filter(material => material.processing_info?.action !== '완료');
|
||||
|
||||
if (selectableMaterials.length === 0) {
|
||||
setSelectAll(false);
|
||||
} else {
|
||||
const allSelected = selectableMaterials.every(material =>
|
||||
selectedMaterials.has(material.id)
|
||||
);
|
||||
setSelectAll(allSelected);
|
||||
}
|
||||
}, [materials, selectedMaterials]);
|
||||
|
||||
return {
|
||||
selectedMaterials,
|
||||
selectAll,
|
||||
toggleMaterial,
|
||||
toggleSelectAll,
|
||||
clearSelection,
|
||||
getSelectedMaterials,
|
||||
getSelectionSummary
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 리비전 처리 상태 추적 훅
|
||||
*/
|
||||
export const useRevisionProcessingStatus = (jobNo, fileId) => {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
if (!jobNo || !fileId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await api.get(`/revision-status/${jobNo}/${fileId}`);
|
||||
|
||||
if (response.data.success) {
|
||||
setStatus(response.data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('리비전 상태 조회 실패:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobNo, fileId]);
|
||||
|
||||
const updateProcessingProgress = useCallback((category, processed, total) => {
|
||||
setStatus(prev => {
|
||||
if (!prev) return prev;
|
||||
|
||||
const newCategoryStatus = {
|
||||
...prev.processing_status.category_breakdown[category],
|
||||
processed,
|
||||
pending: total - processed
|
||||
};
|
||||
|
||||
const newCategoryBreakdown = {
|
||||
...prev.processing_status.category_breakdown,
|
||||
[category]: newCategoryStatus
|
||||
};
|
||||
|
||||
// 전체 통계 재계산
|
||||
const totalProcessed = Object.values(newCategoryBreakdown)
|
||||
.reduce((sum, cat) => sum + cat.processed, 0);
|
||||
const totalMaterials = Object.values(newCategoryBreakdown)
|
||||
.reduce((sum, cat) => sum + cat.total, 0);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
processing_status: {
|
||||
...prev.processing_status,
|
||||
total_processed: totalProcessed,
|
||||
pending_processing: totalMaterials - totalProcessed,
|
||||
completion_percentage: totalMaterials > 0 ? (totalProcessed / totalMaterials * 100) : 0,
|
||||
category_breakdown: newCategoryBreakdown
|
||||
}
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
return {
|
||||
status,
|
||||
loading,
|
||||
error,
|
||||
loadStatus,
|
||||
updateProcessingProgress,
|
||||
setError
|
||||
};
|
||||
};
|
||||
108
frontend/src/hooks/useRevisionRedirect.js
Normal file
108
frontend/src/hooks/useRevisionRedirect.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
/**
|
||||
* 리비전 리다이렉트 훅
|
||||
* BOM 페이지 접근 시 리비전 페이지로 리다이렉트 필요성 확인
|
||||
*/
|
||||
export const useRevisionRedirect = (jobNo, fileId, previousFileId = null) => {
|
||||
const [redirectInfo, setRedirectInfo] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobNo || !fileId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
checkRevisionRedirect();
|
||||
}, [jobNo, fileId, previousFileId]);
|
||||
|
||||
const checkRevisionRedirect = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
job_no: jobNo,
|
||||
file_id: fileId
|
||||
});
|
||||
|
||||
if (previousFileId) {
|
||||
params.append('previous_file_id', previousFileId);
|
||||
}
|
||||
|
||||
const response = await api.get(`/revision-redirect/check/${jobNo}/${fileId}?${params}`);
|
||||
|
||||
if (response.data.success) {
|
||||
setRedirectInfo(response.data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('리비전 리다이렉트 확인 실패:', err);
|
||||
setError(err.message);
|
||||
// 에러 발생 시 기존 BOM 페이지 사용
|
||||
setRedirectInfo({
|
||||
should_redirect: false,
|
||||
reason: '리비전 상태 확인 실패 - 기존 페이지 사용',
|
||||
redirect_url: null,
|
||||
processing_summary: null
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
redirectInfo,
|
||||
loading,
|
||||
error,
|
||||
refetch: checkRevisionRedirect
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 리비전 처리 로직 훅
|
||||
* 리비전 페이지에서 사용할 상세 처리 결과 조회
|
||||
*/
|
||||
export const useRevisionProcessing = (jobNo, fileId, previousFileId = null) => {
|
||||
const [processingResult, setProcessingResult] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const processRevision = async () => {
|
||||
if (!jobNo || !fileId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
job_no: jobNo,
|
||||
file_id: fileId
|
||||
});
|
||||
|
||||
if (previousFileId) {
|
||||
params.append('previous_file_id', previousFileId);
|
||||
}
|
||||
|
||||
const response = await api.post(`/revision-redirect/process-revision/${jobNo}/${fileId}?${params}`);
|
||||
|
||||
if (response.data.success) {
|
||||
setProcessingResult(response.data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('리비전 처리 실패:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
processingResult,
|
||||
loading,
|
||||
error,
|
||||
processRevision
|
||||
};
|
||||
};
|
||||
399
frontend/src/hooks/useRevisionStatus.js
Normal file
399
frontend/src/hooks/useRevisionStatus.js
Normal file
@@ -0,0 +1,399 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
/**
|
||||
* 리비전 상태 관리 훅
|
||||
* 리비전 진행 상태, 히스토리, 확정 등 관리
|
||||
*/
|
||||
export const useRevisionStatus = (jobNo, fileId) => {
|
||||
const [revisionStatus, setRevisionStatus] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadRevisionStatus = useCallback(async () => {
|
||||
if (!jobNo || !fileId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await api.get(`/revision-status/${jobNo}/${fileId}`);
|
||||
|
||||
if (response.data.success) {
|
||||
setRevisionStatus(response.data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('리비전 상태 조회 실패:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobNo, fileId]);
|
||||
|
||||
const createComparisonRecord = useCallback(async (previousFileId, comparisonResult) => {
|
||||
try {
|
||||
const response = await api.post('/revision-status/create-comparison', {
|
||||
job_no: jobNo,
|
||||
current_file_id: fileId,
|
||||
previous_file_id: previousFileId,
|
||||
comparison_result: comparisonResult
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
// 상태 새로고침
|
||||
await loadRevisionStatus();
|
||||
return response.data.data.comparison_id;
|
||||
} else {
|
||||
throw new Error(response.data.message || '비교 기록 생성 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('비교 기록 생성 실패:', err);
|
||||
setError(err.message);
|
||||
throw err;
|
||||
}
|
||||
}, [jobNo, fileId, loadRevisionStatus]);
|
||||
|
||||
const applyComparison = useCallback(async (comparisonId) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await api.post(`/revision-status/apply-comparison/${comparisonId}`);
|
||||
|
||||
if (response.data.success) {
|
||||
// 상태 새로고침
|
||||
await loadRevisionStatus();
|
||||
return response.data.data;
|
||||
} else {
|
||||
throw new Error(response.data.message || '비교 결과 적용 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('비교 결과 적용 실패:', err);
|
||||
setError(err.message);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadRevisionStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRevisionStatus();
|
||||
}, [loadRevisionStatus]);
|
||||
|
||||
return {
|
||||
revisionStatus,
|
||||
loading,
|
||||
error,
|
||||
loadRevisionStatus,
|
||||
createComparisonRecord,
|
||||
applyComparison,
|
||||
setError
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 리비전 히스토리 훅
|
||||
*/
|
||||
export const useRevisionHistory = (jobNo) => {
|
||||
const [history, setHistory] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
if (!jobNo) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await api.get(`/revision-status/history/${jobNo}`);
|
||||
|
||||
if (response.data.success) {
|
||||
setHistory(response.data.data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('리비전 히스토리 조회 실패:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobNo]);
|
||||
|
||||
const getRevisionByFileId = useCallback((fileId) => {
|
||||
return history.find(revision => revision.file_id === fileId);
|
||||
}, [history]);
|
||||
|
||||
const getLatestRevision = useCallback(() => {
|
||||
return history.find(revision => revision.is_latest);
|
||||
}, [history]);
|
||||
|
||||
const getPreviousRevision = useCallback((currentFileId) => {
|
||||
const currentIndex = history.findIndex(revision => revision.file_id === currentFileId);
|
||||
return currentIndex > -1 && currentIndex < history.length - 1
|
||||
? history[currentIndex + 1]
|
||||
: null;
|
||||
}, [history]);
|
||||
|
||||
const getNextRevision = useCallback((currentFileId) => {
|
||||
const currentIndex = history.findIndex(revision => revision.file_id === currentFileId);
|
||||
return currentIndex > 0
|
||||
? history[currentIndex - 1]
|
||||
: null;
|
||||
}, [history]);
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
}, [loadHistory]);
|
||||
|
||||
return {
|
||||
history,
|
||||
loading,
|
||||
error,
|
||||
loadHistory,
|
||||
getRevisionByFileId,
|
||||
getLatestRevision,
|
||||
getPreviousRevision,
|
||||
getNextRevision,
|
||||
setError
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 대기 중인 리비전 관리 훅
|
||||
*/
|
||||
export const usePendingRevisions = (jobNo = null) => {
|
||||
const [pendingRevisions, setPendingRevisions] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadPendingRevisions = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const url = jobNo
|
||||
? `/revision-status/pending?job_no=${jobNo}`
|
||||
: '/revision-status/pending';
|
||||
|
||||
const response = await api.get(url);
|
||||
|
||||
if (response.data.success) {
|
||||
setPendingRevisions(response.data.data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('대기 중인 리비전 조회 실패:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobNo]);
|
||||
|
||||
const approvePendingRevision = useCallback(async (comparisonId) => {
|
||||
try {
|
||||
const response = await api.post(`/revision-status/apply-comparison/${comparisonId}`);
|
||||
|
||||
if (response.data.success) {
|
||||
// 대기 목록 새로고침
|
||||
await loadPendingRevisions();
|
||||
return response.data.data;
|
||||
} else {
|
||||
throw new Error(response.data.message || '리비전 승인 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('리비전 승인 실패:', err);
|
||||
setError(err.message);
|
||||
throw err;
|
||||
}
|
||||
}, [loadPendingRevisions]);
|
||||
|
||||
const rejectPendingRevision = useCallback(async (comparisonId, reason = '') => {
|
||||
try {
|
||||
const response = await api.post(`/revision-status/reject-comparison/${comparisonId}`, {
|
||||
reason
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
// 대기 목록 새로고침
|
||||
await loadPendingRevisions();
|
||||
return response.data.data;
|
||||
} else {
|
||||
throw new Error(response.data.message || '리비전 거부 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('리비전 거부 실패:', err);
|
||||
setError(err.message);
|
||||
throw err;
|
||||
}
|
||||
}, [loadPendingRevisions]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPendingRevisions();
|
||||
}, [loadPendingRevisions]);
|
||||
|
||||
return {
|
||||
pendingRevisions,
|
||||
loading,
|
||||
error,
|
||||
loadPendingRevisions,
|
||||
approvePendingRevision,
|
||||
rejectPendingRevision,
|
||||
setError
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 리비전 업로드 훅
|
||||
*/
|
||||
export const useRevisionUpload = (jobNo, currentFileId) => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const uploadNewRevision = useCallback(async (file, revisionInfo = {}) => {
|
||||
if (!file || !jobNo || !currentFileId) {
|
||||
throw new Error('필수 정보가 누락되었습니다.');
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('job_no', jobNo);
|
||||
formData.append('parent_file_id', currentFileId);
|
||||
|
||||
// 리비전 정보 추가
|
||||
Object.entries(revisionInfo).forEach(([key, value]) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
|
||||
const response = await api.post('/files/upload-revision', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const progress = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
);
|
||||
setUploadProgress(progress);
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setUploadProgress(100);
|
||||
return response.data.data;
|
||||
} else {
|
||||
throw new Error(response.data.message || '리비전 업로드 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('리비전 업로드 실패:', err);
|
||||
setError(err.message);
|
||||
throw err;
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [jobNo, currentFileId]);
|
||||
|
||||
const validateRevisionFile = useCallback((file) => {
|
||||
const errors = [];
|
||||
|
||||
// 파일 크기 검증 (100MB 제한)
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
errors.push('파일 크기는 100MB를 초과할 수 없습니다.');
|
||||
}
|
||||
|
||||
// 파일 형식 검증
|
||||
const allowedTypes = [
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/csv'
|
||||
];
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
errors.push('Excel 파일(.xlsx, .xls) 또는 CSV 파일만 업로드 가능합니다.');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resetUpload = useCallback(() => {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
uploading,
|
||||
uploadProgress,
|
||||
error,
|
||||
uploadNewRevision,
|
||||
validateRevisionFile,
|
||||
resetUpload,
|
||||
setError
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 리비전 네비게이션 훅
|
||||
*/
|
||||
export const useRevisionNavigation = (jobNo, currentFileId) => {
|
||||
const { history } = useRevisionHistory(jobNo);
|
||||
|
||||
const getCurrentRevisionIndex = useCallback(() => {
|
||||
return history.findIndex(revision => revision.file_id === currentFileId);
|
||||
}, [history, currentFileId]);
|
||||
|
||||
const canNavigateToPrevious = useCallback(() => {
|
||||
const currentIndex = getCurrentRevisionIndex();
|
||||
return currentIndex > -1 && currentIndex < history.length - 1;
|
||||
}, [getCurrentRevisionIndex, history.length]);
|
||||
|
||||
const canNavigateToNext = useCallback(() => {
|
||||
const currentIndex = getCurrentRevisionIndex();
|
||||
return currentIndex > 0;
|
||||
}, [getCurrentRevisionIndex]);
|
||||
|
||||
const getPreviousRevision = useCallback(() => {
|
||||
const currentIndex = getCurrentRevisionIndex();
|
||||
return canNavigateToPrevious() ? history[currentIndex + 1] : null;
|
||||
}, [getCurrentRevisionIndex, canNavigateToPrevious, history]);
|
||||
|
||||
const getNextRevision = useCallback(() => {
|
||||
const currentIndex = getCurrentRevisionIndex();
|
||||
return canNavigateToNext() ? history[currentIndex - 1] : null;
|
||||
}, [getCurrentRevisionIndex, canNavigateToNext, history]);
|
||||
|
||||
const isLatestRevision = useCallback(() => {
|
||||
const currentIndex = getCurrentRevisionIndex();
|
||||
return currentIndex === 0;
|
||||
}, [getCurrentRevisionIndex]);
|
||||
|
||||
const isFirstRevision = useCallback(() => {
|
||||
const currentIndex = getCurrentRevisionIndex();
|
||||
return currentIndex === history.length - 1;
|
||||
}, [getCurrentRevisionIndex, history.length]);
|
||||
|
||||
const getRevisionPosition = useCallback(() => {
|
||||
const currentIndex = getCurrentRevisionIndex();
|
||||
return {
|
||||
current: currentIndex + 1,
|
||||
total: history.length,
|
||||
isLatest: isLatestRevision(),
|
||||
isFirst: isFirstRevision()
|
||||
};
|
||||
}, [getCurrentRevisionIndex, history.length, isLatestRevision, isFirstRevision]);
|
||||
|
||||
return {
|
||||
canNavigateToPrevious,
|
||||
canNavigateToNext,
|
||||
getPreviousRevision,
|
||||
getNextRevision,
|
||||
isLatestRevision,
|
||||
isFirstRevision,
|
||||
getRevisionPosition
|
||||
};
|
||||
};
|
||||
815
frontend/src/pages/EnhancedRevisionPage.css
Normal file
815
frontend/src/pages/EnhancedRevisionPage.css
Normal file
@@ -0,0 +1,815 @@
|
||||
/* Enhanced Revision Page - 기존 스타일 통일 */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.materials-page {
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif;
|
||||
overflow-x: auto;
|
||||
min-width: 1400px;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.materials-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: #5558e3;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.header-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 메인 콘텐츠 */
|
||||
.materials-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 컨트롤 섹션 */
|
||||
.control-section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #1f2937;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.control-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-weight: 600;
|
||||
color: #34495e;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.control-group select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
color: #374151;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.control-group select:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.control-group select:disabled {
|
||||
background-color: #f9fafb;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-compare {
|
||||
padding: 8px 16px;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-compare:hover:not(:disabled) {
|
||||
background: #5558e3;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-compare:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 메인 콘텐츠 레이아웃 */
|
||||
.revision-content {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
.content-left, .content-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
/* 비교 결과 */
|
||||
.comparison-result {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.result-header h3 {
|
||||
margin: 0;
|
||||
color: #1f2937;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-apply {
|
||||
padding: 8px 16px;
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-apply:hover:not(:disabled) {
|
||||
background: #059669;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 비교 요약 */
|
||||
.comparison-summary {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.comparison-summary h3 {
|
||||
margin: 0 0 20px 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
.summary-card.purchased {
|
||||
background: #e8f5e8;
|
||||
border-left-color: #27ae60;
|
||||
}
|
||||
|
||||
.summary-card.unpurchased {
|
||||
background: #fff3cd;
|
||||
border-left-color: #ffc107;
|
||||
}
|
||||
|
||||
.summary-card.changes {
|
||||
background: #e3f2fd;
|
||||
border-left-color: #2196f3;
|
||||
}
|
||||
|
||||
.summary-card h4 {
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 1.1em;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
background: white;
|
||||
color: #2c3e50;
|
||||
border: 1px solid #e1e8ed;
|
||||
}
|
||||
|
||||
.stat-item.increase {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
border-color: #ffcdd2;
|
||||
}
|
||||
|
||||
.stat-item.decrease {
|
||||
background: #e8f5e8;
|
||||
color: #2e7d32;
|
||||
border-color: #c8e6c9;
|
||||
}
|
||||
|
||||
.stat-item.new {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
border-color: #bbdefb;
|
||||
}
|
||||
|
||||
.stat-item.deleted {
|
||||
background: #fce4ec;
|
||||
color: #ad1457;
|
||||
border-color: #f8bbd9;
|
||||
}
|
||||
|
||||
/* 변경사항 상세 */
|
||||
.change-details {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.change-details h3 {
|
||||
margin: 0 0 20px 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.change-section {
|
||||
margin-bottom: 25px;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.change-section h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.change-category {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.change-category h5 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #34495e;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.material-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.material-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #e1e8ed;
|
||||
}
|
||||
|
||||
.change-category.additional-purchase .material-item {
|
||||
border-left-color: #e74c3c;
|
||||
}
|
||||
|
||||
.change-category.excess-inventory .material-item {
|
||||
border-left-color: #f39c12;
|
||||
}
|
||||
|
||||
.change-category.quantity-updated .material-item {
|
||||
border-left-color: #3498db;
|
||||
}
|
||||
|
||||
.change-category.quantity-reduced .material-item {
|
||||
border-left-color: #95a5a6;
|
||||
}
|
||||
|
||||
.change-category.new-materials .material-item {
|
||||
border-left-color: #27ae60;
|
||||
}
|
||||
|
||||
.change-category.deleted-materials .material-item {
|
||||
border-left-color: #e74c3c;
|
||||
}
|
||||
|
||||
.material-desc {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.quantity-change, .quantity-info {
|
||||
font-weight: 600;
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.reason {
|
||||
font-style: italic;
|
||||
color: #95a5a6;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* PIPE 길이 요약 */
|
||||
.pipe-length-summary {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.summary-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #ecf0f1;
|
||||
}
|
||||
|
||||
.summary-header h3 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.btn-recalculate {
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-recalculate:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 10px rgba(243, 156, 18, 0.3);
|
||||
}
|
||||
|
||||
.pipe-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pipe-stats span {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.pipe-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pipe-line {
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid;
|
||||
background: white;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pipe-line:hover {
|
||||
transform: translateX(5px);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.pipe-line.purchased {
|
||||
border-left-color: #27ae60;
|
||||
background: #e8f5e8;
|
||||
}
|
||||
|
||||
.pipe-line.pending {
|
||||
border-left-color: #f39c12;
|
||||
background: #fff3cd;
|
||||
}
|
||||
|
||||
.pipe-line.mixed {
|
||||
border-left-color: #e74c3c;
|
||||
background: #ffebee;
|
||||
}
|
||||
|
||||
.line-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.drawing-line {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.material-spec {
|
||||
font-size: 0.9em;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.line-stats {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.line-stats span {
|
||||
font-size: 0.9em;
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status.purchased {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status.pending {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.status.mixed {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
/* 비교 이력 */
|
||||
.comparison-history {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.comparison-history h3 {
|
||||
margin: 0 0 20px 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.history-item.applied {
|
||||
border-color: #27ae60;
|
||||
background: #e8f5e8;
|
||||
}
|
||||
|
||||
.history-item.pending {
|
||||
border-color: #f39c12;
|
||||
background: #fff3cd;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.comparison-date {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.status.applied {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status.pending {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.history-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.history-summary span {
|
||||
font-size: 0.85em;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.btn-apply-small {
|
||||
padding: 6px 12px;
|
||||
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.btn-apply-small:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(39, 174, 96, 0.3);
|
||||
}
|
||||
|
||||
.no-history {
|
||||
text-align: center;
|
||||
color: #95a5a6;
|
||||
font-style: italic;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 1200px) {
|
||||
.revision-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.control-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.enhanced-revision-page {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
.revision-controls,
|
||||
.comparison-result,
|
||||
.pipe-length-summary,
|
||||
.comparison-history {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.control-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.line-info {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.line-stats {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.material-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 카테고리별 자재 관리 섹션 */
|
||||
.category-materials-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.category-materials-section h3 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
background: #f8fafc;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.category-card:hover {
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.category-card.has-revisions {
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fbbf24 100%);
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.category-card.has-revisions:hover {
|
||||
border-color: #d97706;
|
||||
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.25);
|
||||
}
|
||||
|
||||
.category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
font-size: 24px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.category-info h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.category-desc {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.category-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.category-stats .stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.category-stats .stat-item.revision {
|
||||
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.category-stats .stat-item.inventory {
|
||||
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.category-stats .stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.category-stats .stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.empty-category {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 카테고리 카드 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.category-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.category-stats {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
683
frontend/src/pages/EnhancedRevisionPage.jsx
Normal file
683
frontend/src/pages/EnhancedRevisionPage.jsx
Normal file
@@ -0,0 +1,683 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../components/common';
|
||||
import FittingRevisionPage from './revision/FittingRevisionPage';
|
||||
import FlangeRevisionPage from './revision/FlangeRevisionPage';
|
||||
import SpecialRevisionPage from './revision/SpecialRevisionPage';
|
||||
import SupportRevisionPage from './revision/SupportRevisionPage';
|
||||
import UnclassifiedRevisionPage from './revision/UnclassifiedRevisionPage';
|
||||
import ValveRevisionPage from './revision/ValveRevisionPage';
|
||||
import GasketRevisionPage from './revision/GasketRevisionPage';
|
||||
import BoltRevisionPage from './revision/BoltRevisionPage';
|
||||
import PipeCuttingPlanPage from './revision/PipeCuttingPlanPage';
|
||||
import './EnhancedRevisionPage.css';
|
||||
|
||||
const EnhancedRevisionPage = ({ onNavigate, user }) => {
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [selectedJob, setSelectedJob] = useState('');
|
||||
const [files, setFiles] = useState([]);
|
||||
const [currentFile, setCurrentFile] = useState('');
|
||||
const [previousFile, setPreviousFile] = useState('');
|
||||
const [comparisonResult, setComparisonResult] = useState(null);
|
||||
const [comparisonHistory, setComparisonHistory] = useState([]);
|
||||
const [pipeLengthSummary, setPipeLengthSummary] = useState(null);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showApplyDialog, setShowApplyDialog] = useState(false);
|
||||
const [selectedComparison, setSelectedComparison] = useState(null);
|
||||
|
||||
// 카테고리별 페이지 라우팅
|
||||
const [selectedCategory, setSelectedCategory] = useState('');
|
||||
const [categoryMaterials, setCategoryMaterials] = useState({});
|
||||
|
||||
// 작업 목록 조회
|
||||
useEffect(() => {
|
||||
fetchJobs();
|
||||
}, []);
|
||||
|
||||
// 선택된 작업의 파일 목록 조회
|
||||
useEffect(() => {
|
||||
if (selectedJob) {
|
||||
fetchJobFiles();
|
||||
fetchComparisonHistory();
|
||||
}
|
||||
}, [selectedJob]);
|
||||
|
||||
// 현재 파일의 PIPE 길이 요약 및 카테고리별 자재 조회
|
||||
useEffect(() => {
|
||||
if (currentFile) {
|
||||
fetchPipeLengthSummary();
|
||||
fetchCategoryMaterials();
|
||||
}
|
||||
}, [currentFile]);
|
||||
|
||||
const fetchJobs = async () => {
|
||||
try {
|
||||
const response = await api.get('/dashboard/projects');
|
||||
setJobs(response.data.projects || []);
|
||||
} catch (err) {
|
||||
setError('작업 목록 조회 실패: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchJobFiles = async () => {
|
||||
try {
|
||||
const response = await api.get(`/files/by-job/${selectedJob}`);
|
||||
setFiles(response.data || []);
|
||||
} catch (err) {
|
||||
setError('파일 목록 조회 실패: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchComparisonHistory = async () => {
|
||||
try {
|
||||
const response = await api.get(`/enhanced-revision/comparison-history/${selectedJob}`);
|
||||
setComparisonHistory(response.data.data || []);
|
||||
} catch (err) {
|
||||
console.error('비교 이력 조회 실패:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPipeLengthSummary = async () => {
|
||||
try {
|
||||
const response = await api.get(`/enhanced-revision/pipe-length-summary/${currentFile}`);
|
||||
setPipeLengthSummary(response.data.data);
|
||||
} catch (err) {
|
||||
console.error('PIPE 길이 요약 조회 실패:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCategoryMaterials = async () => {
|
||||
if (!currentFile) return;
|
||||
|
||||
try {
|
||||
const categories = ['PIPE', 'FITTING', 'FLANGE', 'VALVE', 'GASKET', 'BOLT', 'SUPPORT', 'SPECIAL', 'UNCLASSIFIED'];
|
||||
const materialStats = {};
|
||||
|
||||
for (const category of categories) {
|
||||
try {
|
||||
const response = await api.get(`/revision-material/category/${currentFile}/${category}`);
|
||||
materialStats[category] = {
|
||||
count: response.data.data?.materials?.length || 0,
|
||||
processing_info: response.data.data?.processing_info || {}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch ${category} materials:`, err);
|
||||
materialStats[category] = { count: 0, processing_info: {} };
|
||||
}
|
||||
}
|
||||
|
||||
setCategoryMaterials(materialStats);
|
||||
} catch (err) {
|
||||
console.error('카테고리별 자재 조회 실패:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompareRevisions = async () => {
|
||||
if (!selectedJob || !currentFile) {
|
||||
setError('작업과 현재 파일을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const params = {
|
||||
job_no: selectedJob,
|
||||
current_file_id: parseInt(currentFile),
|
||||
save_comparison: true
|
||||
};
|
||||
|
||||
if (previousFile) {
|
||||
params.previous_file_id = parseInt(previousFile);
|
||||
}
|
||||
|
||||
const response = await api.post('/enhanced-revision/compare-revisions', null, { params });
|
||||
setComparisonResult(response.data.data);
|
||||
|
||||
// 비교 이력 새로고침
|
||||
fetchComparisonHistory();
|
||||
|
||||
} catch (err) {
|
||||
setError('리비전 비교 실패: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyChanges = async (comparisonId) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await api.post(`/enhanced-revision/apply-revision-changes/${comparisonId}`);
|
||||
|
||||
if (response.data.success) {
|
||||
alert('리비전 변경사항이 성공적으로 적용되었습니다.');
|
||||
fetchComparisonHistory();
|
||||
setComparisonResult(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('변경사항 적용 실패: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowApplyDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecalculatePipeLengths = async () => {
|
||||
if (!currentFile) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.post(`/enhanced-revision/recalculate-pipe-lengths/${currentFile}`);
|
||||
|
||||
if (response.data.success) {
|
||||
alert(`PIPE 자재 ${response.data.data.updated_count}개의 길이를 재계산했습니다.`);
|
||||
fetchPipeLengthSummary();
|
||||
}
|
||||
} catch (err) {
|
||||
setError('PIPE 길이 재계산 실패: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderComparisonSummary = (summary) => {
|
||||
if (!summary) return null;
|
||||
|
||||
return (
|
||||
<div className="comparison-summary">
|
||||
<h3>📊 비교 요약</h3>
|
||||
<div className="summary-grid">
|
||||
<div className="summary-card purchased">
|
||||
<h4>🛒 구매 완료 자재</h4>
|
||||
<div className="summary-stats">
|
||||
<span className="stat-item">유지: {summary.purchased_maintained}</span>
|
||||
<span className="stat-item increase">추가구매: {summary.purchased_increased}</span>
|
||||
<span className="stat-item decrease">잉여재고: {summary.purchased_decreased}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-card unpurchased">
|
||||
<h4>📋 구매 미완료 자재</h4>
|
||||
<div className="summary-stats">
|
||||
<span className="stat-item">유지: {summary.unpurchased_maintained}</span>
|
||||
<span className="stat-item increase">수량증가: {summary.unpurchased_increased}</span>
|
||||
<span className="stat-item decrease">수량감소: {summary.unpurchased_decreased}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-card changes">
|
||||
<h4>🔄 변경사항</h4>
|
||||
<div className="summary-stats">
|
||||
<span className="stat-item new">신규: {summary.new_materials}</span>
|
||||
<span className="stat-item deleted">삭제: {summary.deleted_materials}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderChangeDetails = (changes) => {
|
||||
if (!changes) return null;
|
||||
|
||||
return (
|
||||
<div className="change-details">
|
||||
<h3>📋 상세 변경사항</h3>
|
||||
|
||||
{/* 구매 완료 자재 변경사항 */}
|
||||
{changes.purchased_materials && (
|
||||
<div className="change-section">
|
||||
<h4>🛒 구매 완료 자재</h4>
|
||||
|
||||
{changes.purchased_materials.additional_purchase_needed?.length > 0 && (
|
||||
<div className="change-category additional-purchase">
|
||||
<h5>📈 추가 구매 필요</h5>
|
||||
<div className="material-list">
|
||||
{changes.purchased_materials.additional_purchase_needed.map((item, idx) => (
|
||||
<div key={idx} className="material-item">
|
||||
<span className="material-desc">{item.material.original_description}</span>
|
||||
<span className="quantity-change">
|
||||
{item.previous_quantity} → {item.current_quantity}
|
||||
(+{item.additional_needed})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{changes.purchased_materials.excess_inventory?.length > 0 && (
|
||||
<div className="change-category excess-inventory">
|
||||
<h5>📉 잉여 재고</h5>
|
||||
<div className="material-list">
|
||||
{changes.purchased_materials.excess_inventory.map((item, idx) => (
|
||||
<div key={idx} className="material-item">
|
||||
<span className="material-desc">{item.material.original_description}</span>
|
||||
<span className="quantity-change">
|
||||
{item.previous_quantity} → {item.current_quantity}
|
||||
(-{item.excess_quantity})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구매 미완료 자재 변경사항 */}
|
||||
{changes.unpurchased_materials && (
|
||||
<div className="change-section">
|
||||
<h4>📋 구매 미완료 자재</h4>
|
||||
|
||||
{changes.unpurchased_materials.quantity_updated?.length > 0 && (
|
||||
<div className="change-category quantity-updated">
|
||||
<h5>📊 수량 변경</h5>
|
||||
<div className="material-list">
|
||||
{changes.unpurchased_materials.quantity_updated.map((item, idx) => (
|
||||
<div key={idx} className="material-item">
|
||||
<span className="material-desc">{item.material.original_description}</span>
|
||||
<span className="quantity-change">
|
||||
{item.previous_quantity} → {item.current_quantity}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{changes.unpurchased_materials.quantity_reduced?.length > 0 && (
|
||||
<div className="change-category quantity-reduced">
|
||||
<h5>📉 수량 감소</h5>
|
||||
<div className="material-list">
|
||||
{changes.unpurchased_materials.quantity_reduced.map((item, idx) => (
|
||||
<div key={idx} className="material-item">
|
||||
<span className="material-desc">{item.material.original_description}</span>
|
||||
<span className="quantity-change">
|
||||
{item.previous_quantity} → {item.current_quantity}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 신규/삭제 자재 */}
|
||||
{(changes.new_materials?.length > 0 || changes.deleted_materials?.length > 0) && (
|
||||
<div className="change-section">
|
||||
<h4>🔄 신규/삭제 자재</h4>
|
||||
|
||||
{changes.new_materials?.length > 0 && (
|
||||
<div className="change-category new-materials">
|
||||
<h5>✅ 신규 자재</h5>
|
||||
<div className="material-list">
|
||||
{changes.new_materials.map((item, idx) => (
|
||||
<div key={idx} className="material-item">
|
||||
<span className="material-desc">{item.material.original_description}</span>
|
||||
<span className="quantity-info">수량: {item.material.quantity}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{changes.deleted_materials?.length > 0 && (
|
||||
<div className="change-category deleted-materials">
|
||||
<h5>❌ 삭제된 자재</h5>
|
||||
<div className="material-list">
|
||||
{changes.deleted_materials.map((item, idx) => (
|
||||
<div key={idx} className="material-item">
|
||||
<span className="material-desc">{item.material.original_description}</span>
|
||||
<span className="reason">{item.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPipeLengthSummary = () => {
|
||||
if (!pipeLengthSummary) return null;
|
||||
|
||||
return (
|
||||
<div className="pipe-length-summary">
|
||||
<div className="summary-header">
|
||||
<h3>🔧 PIPE 자재 길이 요약</h3>
|
||||
<button
|
||||
className="btn-recalculate"
|
||||
onClick={handleRecalculatePipeLengths}
|
||||
disabled={loading}
|
||||
>
|
||||
🔄 길이 재계산
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pipe-stats">
|
||||
<span>총 라인: {pipeLengthSummary.total_lines}개</span>
|
||||
<span>총 길이: {pipeLengthSummary.total_length?.toFixed(2)}m</span>
|
||||
</div>
|
||||
|
||||
<div className="pipe-lines">
|
||||
{pipeLengthSummary.pipe_lines?.map((line, idx) => (
|
||||
<div key={idx} className={`pipe-line ${line.purchase_status}`}>
|
||||
<div className="line-info">
|
||||
<span className="drawing-line">
|
||||
{line.drawing_name} - {line.line_no}
|
||||
</span>
|
||||
<span className="material-spec">
|
||||
{line.material_grade} {line.schedule} {line.nominal_size}
|
||||
</span>
|
||||
</div>
|
||||
<div className="line-stats">
|
||||
<span className="length">길이: {line.total_length?.toFixed(2)}m</span>
|
||||
<span className="segments">구간: {line.segment_count}개</span>
|
||||
<span className={`status ${line.purchase_status}`}>
|
||||
{line.purchase_status === 'purchased' ? '구매완료' :
|
||||
line.purchase_status === 'pending' ? '구매대기' : '혼재'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 카테고리별 페이지 렌더링
|
||||
if (selectedCategory && currentFile && previousFile) {
|
||||
const categoryProps = {
|
||||
jobNo: selectedJob,
|
||||
fileId: parseInt(currentFile),
|
||||
previousFileId: parseInt(previousFile),
|
||||
onNavigate: (page) => {
|
||||
if (page === 'enhanced-revision') {
|
||||
setSelectedCategory('');
|
||||
} else {
|
||||
onNavigate(page);
|
||||
}
|
||||
},
|
||||
user
|
||||
};
|
||||
|
||||
switch (selectedCategory) {
|
||||
case 'FITTING':
|
||||
return <FittingRevisionPage {...categoryProps} />;
|
||||
case 'FLANGE':
|
||||
return <FlangeRevisionPage {...categoryProps} />;
|
||||
case 'SPECIAL':
|
||||
return <SpecialRevisionPage {...categoryProps} />;
|
||||
case 'SUPPORT':
|
||||
return <SupportRevisionPage {...categoryProps} />;
|
||||
case 'UNCLASSIFIED':
|
||||
return <UnclassifiedRevisionPage {...categoryProps} />;
|
||||
case 'VALVE':
|
||||
return <ValveRevisionPage {...categoryProps} />;
|
||||
case 'GASKET':
|
||||
return <GasketRevisionPage {...categoryProps} />;
|
||||
case 'BOLT':
|
||||
return <BoltRevisionPage {...categoryProps} />;
|
||||
case 'PIPE':
|
||||
return <PipeCuttingPlanPage {...categoryProps} />;
|
||||
default:
|
||||
setSelectedCategory('');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="materials-page">
|
||||
{/* 헤더 */}
|
||||
<div className="materials-header">
|
||||
<div className="header-left">
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => onNavigate ? onNavigate('dashboard') : window.history.back()}
|
||||
>
|
||||
← 뒤로가기
|
||||
</button>
|
||||
<div className="header-center">
|
||||
<h1 style={{ margin: 0, fontSize: '24px', fontWeight: '600', color: '#1f2937' }}>
|
||||
🔄 강화된 리비전 관리
|
||||
</h1>
|
||||
<span style={{ color: '#6b7280', fontSize: '14px' }}>
|
||||
구매 상태를 고려한 스마트 리비전 비교
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <ErrorMessage message={error} onClose={() => setError('')} />}
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div className="materials-content">
|
||||
<div className="control-section">
|
||||
<div className="section-header">
|
||||
<h3>📂 비교 설정</h3>
|
||||
</div>
|
||||
<div className="control-grid">
|
||||
<div className="control-group">
|
||||
<label>작업 선택:</label>
|
||||
<select
|
||||
value={selectedJob}
|
||||
onChange={(e) => setSelectedJob(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">작업을 선택하세요</option>
|
||||
{jobs.map(job => (
|
||||
<option key={job.job_no} value={job.job_no}>
|
||||
{job.job_no} - {job.job_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>현재 파일:</label>
|
||||
<select
|
||||
value={currentFile}
|
||||
onChange={(e) => setCurrentFile(e.target.value)}
|
||||
disabled={loading || !selectedJob}
|
||||
>
|
||||
<option value="">현재 파일을 선택하세요</option>
|
||||
{files.map(file => (
|
||||
<option key={file.id} value={file.id}>
|
||||
{file.original_filename} ({file.revision})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>이전 파일 (선택사항):</label>
|
||||
<select
|
||||
value={previousFile}
|
||||
onChange={(e) => setPreviousFile(e.target.value)}
|
||||
disabled={loading || !selectedJob}
|
||||
>
|
||||
<option value="">자동 탐지</option>
|
||||
{files.filter(f => f.id !== parseInt(currentFile)).map(file => (
|
||||
<option key={file.id} value={file.id}>
|
||||
{file.original_filename} ({file.revision})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<button
|
||||
className="btn-compare"
|
||||
onClick={handleCompareRevisions}
|
||||
disabled={loading || !selectedJob || !currentFile}
|
||||
>
|
||||
{loading ? <LoadingSpinner size="small" /> : '🔍 리비전 비교'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="revision-content">
|
||||
<div className="content-left">
|
||||
{/* 비교 결과 */}
|
||||
{comparisonResult && (
|
||||
<div className="comparison-result">
|
||||
<div className="result-header">
|
||||
<h3>📊 비교 결과</h3>
|
||||
{comparisonResult.comparison_id && (
|
||||
<button
|
||||
className="btn-apply"
|
||||
onClick={() => {
|
||||
setSelectedComparison(comparisonResult.comparison_id);
|
||||
setShowApplyDialog(true);
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
✅ 변경사항 적용
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderComparisonSummary(comparisonResult.summary)}
|
||||
{renderChangeDetails(comparisonResult.changes)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PIPE 길이 요약 */}
|
||||
{renderPipeLengthSummary()}
|
||||
|
||||
{/* 카테고리별 자재 관리 */}
|
||||
{currentFile && previousFile && Object.keys(categoryMaterials).length > 0 && (
|
||||
<div className="category-materials-section">
|
||||
<h3>📂 카테고리별 리비전 관리</h3>
|
||||
<div className="category-grid">
|
||||
{[
|
||||
{ key: 'PIPE', name: 'PIPE', icon: '🔧', description: 'Cutting Plan 관리' },
|
||||
{ key: 'FITTING', name: 'FITTING', icon: '🔧', description: '피팅 자재' },
|
||||
{ key: 'FLANGE', name: 'FLANGE', icon: '🔩', description: '플랜지 자재' },
|
||||
{ key: 'VALVE', name: 'VALVE', icon: '🚰', description: '밸브 자재' },
|
||||
{ key: 'GASKET', name: 'GASKET', icon: '⭕', description: '가스켓 자재' },
|
||||
{ key: 'BOLT', name: 'BOLT', icon: '🔩', description: '볼트 자재' },
|
||||
{ key: 'SUPPORT', name: 'SUPPORT', icon: '🏗️', description: '지지대 자재' },
|
||||
{ key: 'SPECIAL', name: 'SPECIAL', icon: '⭐', description: '특수 자재' },
|
||||
{ key: 'UNCLASSIFIED', name: 'UNCLASSIFIED', icon: '❓', description: '미분류 자재' }
|
||||
].map(category => {
|
||||
const stats = categoryMaterials[category.key] || { count: 0, processing_info: {} };
|
||||
const hasRevisionMaterials = stats.processing_info?.by_status?.REVISION_MATERIAL > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={category.key}
|
||||
className={`category-card ${hasRevisionMaterials ? 'has-revisions' : ''}`}
|
||||
onClick={() => stats.count > 0 && setSelectedCategory(category.key)}
|
||||
style={{ cursor: stats.count > 0 ? 'pointer' : 'not-allowed' }}
|
||||
>
|
||||
<div className="category-header">
|
||||
<span className="category-icon">{category.icon}</span>
|
||||
<div className="category-info">
|
||||
<h4>{category.name}</h4>
|
||||
<span className="category-desc">{category.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="category-stats">
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">전체</span>
|
||||
<span className="stat-value">{stats.count}</span>
|
||||
</div>
|
||||
{hasRevisionMaterials && (
|
||||
<div className="stat-item revision">
|
||||
<span className="stat-label">리비전</span>
|
||||
<span className="stat-value">{stats.processing_info.by_status.REVISION_MATERIAL}</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.processing_info?.by_status?.INVENTORY_MATERIAL > 0 && (
|
||||
<div className="stat-item inventory">
|
||||
<span className="stat-label">재고</span>
|
||||
<span className="stat-value">{stats.processing_info.by_status.INVENTORY_MATERIAL}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{stats.count === 0 && (
|
||||
<div className="empty-category">자료 없음</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="content-right">
|
||||
{/* 비교 이력 */}
|
||||
<div className="comparison-history">
|
||||
<h3>📋 비교 이력</h3>
|
||||
{comparisonHistory.length > 0 ? (
|
||||
<div className="history-list">
|
||||
{comparisonHistory.map(comp => (
|
||||
<div key={comp.id} className={`history-item ${comp.is_applied ? 'applied' : 'pending'}`}>
|
||||
<div className="history-header">
|
||||
<span className="comparison-date">
|
||||
{new Date(comp.comparison_date).toLocaleString()}
|
||||
</span>
|
||||
<span className={`status ${comp.is_applied ? 'applied' : 'pending'}`}>
|
||||
{comp.is_applied ? '적용완료' : '대기중'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="history-summary">
|
||||
{comp.summary_stats && (
|
||||
<>
|
||||
<span>구매완료 변경: {comp.summary_stats.purchased_increased + comp.summary_stats.purchased_decreased}</span>
|
||||
<span>구매미완료 변경: {comp.summary_stats.unpurchased_increased + comp.summary_stats.unpurchased_decreased}</span>
|
||||
<span>신규/삭제: {comp.summary_stats.new_materials + comp.summary_stats.deleted_materials}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!comp.is_applied && (
|
||||
<button
|
||||
className="btn-apply-small"
|
||||
onClick={() => {
|
||||
setSelectedComparison(comp.id);
|
||||
setShowApplyDialog(true);
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
적용
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="no-history">비교 이력이 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 적용 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
isOpen={showApplyDialog}
|
||||
title="변경사항 적용 확인"
|
||||
message="리비전 변경사항을 실제 데이터베이스에 적용하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||||
onConfirm={() => handleApplyChanges(selectedComparison)}
|
||||
onCancel={() => {
|
||||
setShowApplyDialog(false);
|
||||
setSelectedComparison(null);
|
||||
}}
|
||||
confirmText="적용"
|
||||
cancelText="취소"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedRevisionPage;
|
||||
593
frontend/src/pages/PipeIssueManagementPage.css
Normal file
593
frontend/src/pages/PipeIssueManagementPage.css
Normal file
@@ -0,0 +1,593 @@
|
||||
/* PIPE 이슈 관리 페이지 스타일 */
|
||||
|
||||
.pipe-issue-management-page {
|
||||
padding: 20px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background-color: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 페이지 헤더 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.header-content p {
|
||||
margin: 5px 0 0 0;
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.header-actions .btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
/* 스냅샷 정보 섹션 */
|
||||
.snapshot-info-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.no-snapshot-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 30px;
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
font-size: 3rem;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.warning-content h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.warning-content p {
|
||||
margin: 0 0 15px 0;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.snapshot-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.snapshot-details h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.snapshot-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 이슈 통계 섹션 */
|
||||
.issue-stats-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
color: #3498db;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-breakdown {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-breakdown .stat-item {
|
||||
font-size: 0.8rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-breakdown .stat-item.open {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
|
||||
.stat-breakdown .stat-item.progress {
|
||||
background-color: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.stat-breakdown .stat-item.resolved {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
.stat-breakdown .stat-item.critical {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
|
||||
.stat-breakdown .stat-item.high {
|
||||
background-color: #fd7e14;
|
||||
}
|
||||
|
||||
/* 필터 및 액션 섹션 */
|
||||
.filter-actions-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
background-color: #17a2b8;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-info:hover {
|
||||
background-color: #138496;
|
||||
}
|
||||
|
||||
/* 이슈 목록 섹션 */
|
||||
.drawing-issues-section,
|
||||
.segment-issues-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.no-issues {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.issues-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
/* 이슈 카드 */
|
||||
.issue-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
padding: 20px;
|
||||
border-left: 4px solid #ddd;
|
||||
}
|
||||
|
||||
.issue-card.drawing-issue {
|
||||
border-left-color: #007bff;
|
||||
}
|
||||
|
||||
.issue-card.segment-issue {
|
||||
border-left-color: #28a745;
|
||||
}
|
||||
|
||||
.issue-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.issue-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.area-badge,
|
||||
.issue-type-badge {
|
||||
background-color: #e9ecef;
|
||||
color: #495057;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.drawing-name,
|
||||
.segment-id {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.issue-badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.severity-badge,
|
||||
.status-badge {
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.issue-content {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.issue-content p {
|
||||
margin: 0;
|
||||
color: #495057;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.issue-changes {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.change-item {
|
||||
background-color: #f8f9fa;
|
||||
color: #495057;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.issue-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.issue-meta {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
color: #6c757d;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.issue-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background-color: #e0a800;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
/* 모달 스타일 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content.large {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #6c757d;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.modal-content form {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 20px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
/* 리포트 스타일 */
|
||||
.report-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.report-summary {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.report-summary h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.report-section {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.report-section h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.report-stats p {
|
||||
margin: 0 0 10px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.report-stats .stats-breakdown {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.report-stats .stat-item {
|
||||
background-color: #e9ecef;
|
||||
color: #495057;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.pipe-issue-management-page {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.filter-actions-section {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.issue-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.issue-footer {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
781
frontend/src/pages/PipeIssueManagementPage.jsx
Normal file
781
frontend/src/pages/PipeIssueManagementPage.jsx
Normal file
@@ -0,0 +1,781 @@
|
||||
/**
|
||||
* PIPE 단관 이슈 관리 페이지
|
||||
*
|
||||
* 스냅샷 기반 도면별/단관별 이슈 관리 UI
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePipeIssue } from '../hooks/usePipeIssue';
|
||||
import { usePipeRevision } from '../hooks/usePipeRevision';
|
||||
import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../components/common';
|
||||
import './PipeIssueManagementPage.css';
|
||||
|
||||
const PipeIssueManagementPage = ({ onNavigate, jobNo, fileId }) => {
|
||||
// 훅 사용
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
snapshots,
|
||||
currentSnapshot,
|
||||
drawingIssues,
|
||||
segmentIssues,
|
||||
selectedArea,
|
||||
selectedDrawing,
|
||||
statusFilter,
|
||||
setSelectedArea,
|
||||
setSelectedDrawing,
|
||||
setStatusFilter,
|
||||
fetchSnapshots,
|
||||
createDrawingIssue,
|
||||
createSegmentIssue,
|
||||
updateDrawingIssueStatus,
|
||||
updateSegmentIssueStatus,
|
||||
generateIssueReport,
|
||||
setCurrentSnapshot,
|
||||
stats,
|
||||
canManageIssues,
|
||||
clearError
|
||||
} = usePipeIssue(jobNo);
|
||||
|
||||
const {
|
||||
getSnapshotSegments,
|
||||
getAvailableAreas,
|
||||
getAvailableDrawings
|
||||
} = usePipeRevision(jobNo, fileId);
|
||||
|
||||
// 로컬 상태
|
||||
const [showCreateDrawingIssue, setShowCreateDrawingIssue] = useState(false);
|
||||
const [showCreateSegmentIssue, setShowCreateSegmentIssue] = useState(false);
|
||||
const [selectedSegment, setSelectedSegment] = useState(null);
|
||||
const [segments, setSegments] = useState([]);
|
||||
const [availableAreas, setAvailableAreas] = useState([]);
|
||||
const [availableDrawings, setAvailableDrawings] = useState([]);
|
||||
const [issueReport, setIssueReport] = useState(null);
|
||||
|
||||
// 도면 이슈 생성 폼 상태
|
||||
const [drawingIssueForm, setDrawingIssueForm] = useState({
|
||||
area: '',
|
||||
drawing_name: '',
|
||||
issue_description: '',
|
||||
severity: 'medium'
|
||||
});
|
||||
|
||||
// 단관 이슈 생성 폼 상태
|
||||
const [segmentIssueForm, setSegmentIssueForm] = useState({
|
||||
segment_id: '',
|
||||
issue_description: '',
|
||||
issue_type: 'other',
|
||||
length_change: '',
|
||||
new_length: '',
|
||||
material_change: '',
|
||||
severity: 'medium'
|
||||
});
|
||||
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
if (jobNo) {
|
||||
fetchSnapshots();
|
||||
}
|
||||
}, [jobNo, fetchSnapshots]);
|
||||
|
||||
// 스냅샷 변경 시 관련 데이터 로드
|
||||
useEffect(() => {
|
||||
if (currentSnapshot?.snapshot_id) {
|
||||
loadSnapshotData();
|
||||
}
|
||||
}, [currentSnapshot]);
|
||||
|
||||
// 스냅샷 데이터 로드
|
||||
const loadSnapshotData = async () => {
|
||||
if (!currentSnapshot?.snapshot_id) return;
|
||||
|
||||
try {
|
||||
// 구역 목록 로드
|
||||
const areasData = await getAvailableAreas(currentSnapshot.snapshot_id);
|
||||
setAvailableAreas(areasData?.areas || []);
|
||||
|
||||
// 도면 목록 로드
|
||||
const drawingsData = await getAvailableDrawings(currentSnapshot.snapshot_id);
|
||||
setAvailableDrawings(drawingsData?.drawings || []);
|
||||
|
||||
// 단관 목록 로드
|
||||
const segmentsData = await getSnapshotSegments(currentSnapshot.snapshot_id);
|
||||
setSegments(segmentsData?.segments || []);
|
||||
} catch (error) {
|
||||
console.error('스냅샷 데이터 로드 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 구역 변경 시 도면 목록 업데이트
|
||||
useEffect(() => {
|
||||
if (currentSnapshot?.snapshot_id && selectedArea) {
|
||||
const loadDrawings = async () => {
|
||||
const drawingsData = await getAvailableDrawings(currentSnapshot.snapshot_id, selectedArea);
|
||||
setAvailableDrawings(drawingsData?.drawings || []);
|
||||
};
|
||||
loadDrawings();
|
||||
}
|
||||
}, [selectedArea, currentSnapshot, getAvailableDrawings]);
|
||||
|
||||
// 도면 이슈 생성 핸들러
|
||||
const handleCreateDrawingIssue = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!drawingIssueForm.area || !drawingIssueForm.drawing_name || !drawingIssueForm.issue_description) {
|
||||
alert('모든 필수 필드를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await createDrawingIssue(drawingIssueForm);
|
||||
if (result) {
|
||||
alert('✅ 도면 이슈가 생성되었습니다.');
|
||||
setShowCreateDrawingIssue(false);
|
||||
setDrawingIssueForm({
|
||||
area: '',
|
||||
drawing_name: '',
|
||||
issue_description: '',
|
||||
severity: 'medium'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 단관 이슈 생성 핸들러
|
||||
const handleCreateSegmentIssue = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!segmentIssueForm.segment_id || !segmentIssueForm.issue_description) {
|
||||
alert('모든 필수 필드를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = {
|
||||
...segmentIssueForm,
|
||||
length_change: segmentIssueForm.length_change ? parseFloat(segmentIssueForm.length_change) : null,
|
||||
new_length: segmentIssueForm.new_length ? parseFloat(segmentIssueForm.new_length) : null
|
||||
};
|
||||
|
||||
const result = await createSegmentIssue(formData);
|
||||
if (result) {
|
||||
alert('✅ 단관 이슈가 생성되었습니다.');
|
||||
setShowCreateSegmentIssue(false);
|
||||
setSegmentIssueForm({
|
||||
segment_id: '',
|
||||
issue_description: '',
|
||||
issue_type: 'other',
|
||||
length_change: '',
|
||||
new_length: '',
|
||||
material_change: '',
|
||||
severity: 'medium'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 이슈 상태 업데이트 핸들러
|
||||
const handleUpdateIssueStatus = async (issueId, issueType, newStatus) => {
|
||||
const statusData = {
|
||||
status: newStatus,
|
||||
resolved_by: newStatus === 'resolved' ? 'user' : null
|
||||
};
|
||||
|
||||
let result;
|
||||
if (issueType === 'drawing') {
|
||||
result = await updateDrawingIssueStatus(issueId, statusData);
|
||||
} else {
|
||||
result = await updateSegmentIssueStatus(issueId, statusData);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
alert(`✅ 이슈 상태가 '${newStatus}'로 변경되었습니다.`);
|
||||
}
|
||||
};
|
||||
|
||||
// 이슈 리포트 생성 핸들러
|
||||
const handleGenerateReport = async () => {
|
||||
const report = await generateIssueReport();
|
||||
if (report) {
|
||||
setIssueReport(report);
|
||||
alert('✅ 이슈 리포트가 생성되었습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 심각도 배지 색상
|
||||
const getSeverityColor = (severity) => {
|
||||
switch (severity) {
|
||||
case 'critical': return '#dc3545';
|
||||
case 'high': return '#fd7e14';
|
||||
case 'medium': return '#ffc107';
|
||||
case 'low': return '#28a745';
|
||||
default: return '#6c757d';
|
||||
}
|
||||
};
|
||||
|
||||
// 상태 배지 색상
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'open': return '#dc3545';
|
||||
case 'in_progress': return '#ffc107';
|
||||
case 'resolved': return '#28a745';
|
||||
default: return '#6c757d';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingSpinner message="이슈 관리 데이터 로딩 중..." />;
|
||||
|
||||
return (
|
||||
<div className="pipe-issue-management-page">
|
||||
<div className="page-header">
|
||||
<div className="header-content">
|
||||
<h1>🛠️ PIPE 단관 이슈 관리</h1>
|
||||
<p>확정된 Cutting Plan 기준 이슈 관리 (리비전 보호)</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => onNavigate('pipe-cutting-plan')}
|
||||
>
|
||||
← Cutting Plan으로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <ErrorMessage message={error} onClose={clearError} />}
|
||||
|
||||
{/* 스냅샷 정보 */}
|
||||
<div className="snapshot-info-section">
|
||||
<div className="section-header">
|
||||
<h2>📸 스냅샷 정보</h2>
|
||||
</div>
|
||||
|
||||
{!canManageIssues ? (
|
||||
<div className="no-snapshot-warning">
|
||||
<div className="warning-icon">⚠️</div>
|
||||
<div className="warning-content">
|
||||
<h3>확정된 Cutting Plan이 없습니다</h3>
|
||||
<p>이슈 관리를 위해서는 먼저 Cutting Plan을 확정해야 합니다.</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => onNavigate('pipe-cutting-plan')}
|
||||
>
|
||||
Cutting Plan 확정하러 가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="snapshot-card">
|
||||
<div className="snapshot-details">
|
||||
<h3>{currentSnapshot.snapshot_name}</h3>
|
||||
<div className="snapshot-stats">
|
||||
<span className="stat-item">
|
||||
📊 총 단관: {currentSnapshot.total_segments}개
|
||||
</span>
|
||||
<span className="stat-item">
|
||||
📋 총 도면: {currentSnapshot.total_drawings}개
|
||||
</span>
|
||||
<span className="stat-item">
|
||||
🔒 확정일: {new Date(currentSnapshot.locked_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="snapshot-actions">
|
||||
<button
|
||||
className="btn btn-info"
|
||||
onClick={handleGenerateReport}
|
||||
>
|
||||
📊 이슈 리포트 생성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canManageIssues && (
|
||||
<>
|
||||
{/* 이슈 통계 */}
|
||||
<div className="issue-stats-section">
|
||||
<div className="section-header">
|
||||
<h2>📈 이슈 현황</h2>
|
||||
</div>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<h3>도면 이슈</h3>
|
||||
<div className="stat-number">{stats.drawing.total}</div>
|
||||
<div className="stat-breakdown">
|
||||
<span className="stat-item open">미해결: {stats.drawing.open}</span>
|
||||
<span className="stat-item progress">진행중: {stats.drawing.in_progress}</span>
|
||||
<span className="stat-item resolved">완료: {stats.drawing.resolved}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<h3>단관 이슈</h3>
|
||||
<div className="stat-number">{stats.segment.total}</div>
|
||||
<div className="stat-breakdown">
|
||||
<span className="stat-item open">미해결: {stats.segment.open}</span>
|
||||
<span className="stat-item progress">진행중: {stats.segment.in_progress}</span>
|
||||
<span className="stat-item resolved">완료: {stats.segment.resolved}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<h3>전체 이슈</h3>
|
||||
<div className="stat-number">{stats.total}</div>
|
||||
<div className="stat-breakdown">
|
||||
<span className="stat-item critical">긴급: {stats.drawing.critical + stats.segment.critical}</span>
|
||||
<span className="stat-item high">높음: {stats.drawing.high + stats.segment.high}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 및 액션 */}
|
||||
<div className="filter-actions-section">
|
||||
<div className="filters">
|
||||
<select
|
||||
value={selectedArea}
|
||||
onChange={(e) => setSelectedArea(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">전체 구역</option>
|
||||
{availableAreas.map(area => (
|
||||
<option key={area} value={area}>{area}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedDrawing}
|
||||
onChange={(e) => setSelectedDrawing(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">전체 도면</option>
|
||||
{availableDrawings.map(drawing => (
|
||||
<option key={drawing} value={drawing}>{drawing}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">전체 상태</option>
|
||||
<option value="open">미해결</option>
|
||||
<option value="in_progress">진행중</option>
|
||||
<option value="resolved">완료</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowCreateDrawingIssue(true)}
|
||||
>
|
||||
📋 도면 이슈 등록
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowCreateSegmentIssue(true)}
|
||||
>
|
||||
🔧 단관 이슈 등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 도면 이슈 목록 */}
|
||||
<div className="drawing-issues-section">
|
||||
<div className="section-header">
|
||||
<h2>📋 도면 이슈 목록</h2>
|
||||
</div>
|
||||
|
||||
{drawingIssues.length === 0 ? (
|
||||
<div className="no-issues">
|
||||
<p>등록된 도면 이슈가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="issues-list">
|
||||
{drawingIssues.map(issue => (
|
||||
<div key={issue.id} className="issue-card drawing-issue">
|
||||
<div className="issue-header">
|
||||
<div className="issue-title">
|
||||
<span className="area-badge">{issue.area}</span>
|
||||
<span className="drawing-name">{issue.drawing_name}</span>
|
||||
</div>
|
||||
<div className="issue-badges">
|
||||
<span
|
||||
className="severity-badge"
|
||||
style={{ backgroundColor: getSeverityColor(issue.severity) }}
|
||||
>
|
||||
{issue.severity}
|
||||
</span>
|
||||
<span
|
||||
className="status-badge"
|
||||
style={{ backgroundColor: getStatusColor(issue.status) }}
|
||||
>
|
||||
{issue.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="issue-content">
|
||||
<p>{issue.issue_description}</p>
|
||||
</div>
|
||||
<div className="issue-footer">
|
||||
<div className="issue-meta">
|
||||
<span>보고자: {issue.reported_by}</span>
|
||||
<span>등록일: {new Date(issue.reported_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="issue-actions">
|
||||
{issue.status !== 'resolved' && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm btn-warning"
|
||||
onClick={() => handleUpdateIssueStatus(issue.id, 'drawing', 'in_progress')}
|
||||
>
|
||||
진행중으로 변경
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => handleUpdateIssueStatus(issue.id, 'drawing', 'resolved')}
|
||||
>
|
||||
완료로 변경
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 단관 이슈 목록 */}
|
||||
<div className="segment-issues-section">
|
||||
<div className="section-header">
|
||||
<h2>🔧 단관 이슈 목록</h2>
|
||||
</div>
|
||||
|
||||
{segmentIssues.length === 0 ? (
|
||||
<div className="no-issues">
|
||||
<p>등록된 단관 이슈가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="issues-list">
|
||||
{segmentIssues.map(issue => (
|
||||
<div key={issue.id} className="issue-card segment-issue">
|
||||
<div className="issue-header">
|
||||
<div className="issue-title">
|
||||
<span className="segment-id">단관 #{issue.segment_id}</span>
|
||||
{issue.issue_type && (
|
||||
<span className="issue-type-badge">{issue.issue_type}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="issue-badges">
|
||||
<span
|
||||
className="severity-badge"
|
||||
style={{ backgroundColor: getSeverityColor(issue.severity) }}
|
||||
>
|
||||
{issue.severity}
|
||||
</span>
|
||||
<span
|
||||
className="status-badge"
|
||||
style={{ backgroundColor: getStatusColor(issue.status) }}
|
||||
>
|
||||
{issue.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="issue-content">
|
||||
<p>{issue.issue_description}</p>
|
||||
{(issue.length_change || issue.new_length || issue.material_change) && (
|
||||
<div className="issue-changes">
|
||||
{issue.length_change && (
|
||||
<span className="change-item">
|
||||
길이 변경: {issue.length_change > 0 ? '+' : ''}{issue.length_change}mm
|
||||
</span>
|
||||
)}
|
||||
{issue.new_length && (
|
||||
<span className="change-item">
|
||||
최종 길이: {issue.new_length}mm
|
||||
</span>
|
||||
)}
|
||||
{issue.material_change && (
|
||||
<span className="change-item">
|
||||
재질 변경: {issue.material_change}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="issue-footer">
|
||||
<div className="issue-meta">
|
||||
<span>보고자: {issue.reported_by}</span>
|
||||
<span>등록일: {new Date(issue.reported_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="issue-actions">
|
||||
{issue.status !== 'resolved' && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm btn-warning"
|
||||
onClick={() => handleUpdateIssueStatus(issue.id, 'segment', 'in_progress')}
|
||||
>
|
||||
진행중으로 변경
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => handleUpdateIssueStatus(issue.id, 'segment', 'resolved')}
|
||||
>
|
||||
완료로 변경
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 도면 이슈 생성 모달 */}
|
||||
{showCreateDrawingIssue && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h3>📋 도면 이슈 등록</h3>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={() => setShowCreateDrawingIssue(false)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleCreateDrawingIssue}>
|
||||
<div className="form-group">
|
||||
<label>구역 *</label>
|
||||
<select
|
||||
value={drawingIssueForm.area}
|
||||
onChange={(e) => setDrawingIssueForm({...drawingIssueForm, area: e.target.value})}
|
||||
required
|
||||
>
|
||||
<option value="">구역 선택</option>
|
||||
{availableAreas.map(area => (
|
||||
<option key={area} value={area}>{area}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>도면명 *</label>
|
||||
<select
|
||||
value={drawingIssueForm.drawing_name}
|
||||
onChange={(e) => setDrawingIssueForm({...drawingIssueForm, drawing_name: e.target.value})}
|
||||
required
|
||||
>
|
||||
<option value="">도면 선택</option>
|
||||
{availableDrawings.map(drawing => (
|
||||
<option key={drawing} value={drawing}>{drawing}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>이슈 설명 *</label>
|
||||
<textarea
|
||||
value={drawingIssueForm.issue_description}
|
||||
onChange={(e) => setDrawingIssueForm({...drawingIssueForm, issue_description: e.target.value})}
|
||||
placeholder="도면 전반적인 문제점을 상세히 설명해주세요..."
|
||||
rows="4"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>심각도</label>
|
||||
<select
|
||||
value={drawingIssueForm.severity}
|
||||
onChange={(e) => setDrawingIssueForm({...drawingIssueForm, severity: e.target.value})}
|
||||
>
|
||||
<option value="low">낮음</option>
|
||||
<option value="medium">보통</option>
|
||||
<option value="high">높음</option>
|
||||
<option value="critical">긴급</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowCreateDrawingIssue(false)}>
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
등록
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 단관 이슈 생성 모달 */}
|
||||
{showCreateSegmentIssue && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h3>🔧 단관 이슈 등록</h3>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={() => setShowCreateSegmentIssue(false)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleCreateSegmentIssue}>
|
||||
<div className="form-group">
|
||||
<label>단관 선택 *</label>
|
||||
<select
|
||||
value={segmentIssueForm.segment_id}
|
||||
onChange={(e) => setSegmentIssueForm({...segmentIssueForm, segment_id: e.target.value})}
|
||||
required
|
||||
>
|
||||
<option value="">단관 선택</option>
|
||||
{segments.map(segment => (
|
||||
<option key={segment.id} value={segment.id}>
|
||||
{segment.area} - {segment.drawing_name} - {segment.line_no} ({segment.length_mm}mm)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>이슈 유형</label>
|
||||
<select
|
||||
value={segmentIssueForm.issue_type}
|
||||
onChange={(e) => setSegmentIssueForm({...segmentIssueForm, issue_type: e.target.value})}
|
||||
>
|
||||
<option value="cutting">절단</option>
|
||||
<option value="installation">설치</option>
|
||||
<option value="material">재질</option>
|
||||
<option value="routing">라우팅</option>
|
||||
<option value="other">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>이슈 설명 *</label>
|
||||
<textarea
|
||||
value={segmentIssueForm.issue_description}
|
||||
onChange={(e) => setSegmentIssueForm({...segmentIssueForm, issue_description: e.target.value})}
|
||||
placeholder="단관에서 발생한 문제점을 상세히 설명해주세요..."
|
||||
rows="4"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>길이 변경량 (mm)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={segmentIssueForm.length_change}
|
||||
onChange={(e) => setSegmentIssueForm({...segmentIssueForm, length_change: e.target.value})}
|
||||
placeholder="예: -30 (30mm 절단)"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>최종 길이 (mm)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={segmentIssueForm.new_length}
|
||||
onChange={(e) => setSegmentIssueForm({...segmentIssueForm, new_length: e.target.value})}
|
||||
placeholder="예: 1470"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>재질 변경</label>
|
||||
<input
|
||||
type="text"
|
||||
value={segmentIssueForm.material_change}
|
||||
onChange={(e) => setSegmentIssueForm({...segmentIssueForm, material_change: e.target.value})}
|
||||
placeholder="변경된 재질 정보"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>심각도</label>
|
||||
<select
|
||||
value={segmentIssueForm.severity}
|
||||
onChange={(e) => setSegmentIssueForm({...segmentIssueForm, severity: e.target.value})}
|
||||
>
|
||||
<option value="low">낮음</option>
|
||||
<option value="medium">보통</option>
|
||||
<option value="high">높음</option>
|
||||
<option value="critical">긴급</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowCreateSegmentIssue(false)}>
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
등록
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 이슈 리포트 모달 */}
|
||||
{issueReport && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content large">
|
||||
<div className="modal-header">
|
||||
<h3>📊 이슈 리포트</h3>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={() => setIssueReport(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="report-content">
|
||||
<div className="report-summary">
|
||||
<h4>전체 요약</h4>
|
||||
<p>총 이슈: {issueReport.total_issues}개</p>
|
||||
<p>생성일: {new Date(issueReport.report_generated_at).toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="report-section">
|
||||
<h4>도면 이슈 통계</h4>
|
||||
<div className="report-stats">
|
||||
<p>총 개수: {issueReport.drawing_issues.total}</p>
|
||||
<div className="stats-breakdown">
|
||||
{Object.entries(issueReport.drawing_issues.by_status).map(([status, count]) => (
|
||||
<span key={status} className="stat-item">
|
||||
{status}: {count}개
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="report-section">
|
||||
<h4>단관 이슈 통계</h4>
|
||||
<div className="report-stats">
|
||||
<p>총 개수: {issueReport.segment_issues.total}</p>
|
||||
<div className="stats-breakdown">
|
||||
{Object.entries(issueReport.segment_issues.by_status).map(([status, count]) => (
|
||||
<span key={status} className="stat-item">
|
||||
{status}: {count}개
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-secondary" onClick={() => setIssueReport(null)}>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PipeIssueManagementPage;
|
||||
463
frontend/src/pages/revision/BoltRevisionPage.jsx
Normal file
463
frontend/src/pages/revision/BoltRevisionPage.jsx
Normal file
@@ -0,0 +1,463 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useRevisionLogic } from '../../hooks/useRevisionLogic';
|
||||
import { useRevisionComparison } from '../../hooks/useRevisionComparison';
|
||||
import { useRevisionStatus } from '../../hooks/useRevisionStatus';
|
||||
import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
|
||||
import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
|
||||
import './CategoryRevisionPage.css';
|
||||
|
||||
const BoltRevisionPage = ({
|
||||
jobNo,
|
||||
fileId,
|
||||
previousFileId,
|
||||
onNavigate,
|
||||
user
|
||||
}) => {
|
||||
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [actionType, setActionType] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('description');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
const [boltTypeFilter, setBoltTypeFilter] = useState('all');
|
||||
const [threadTypeFilter, setThreadTypeFilter] = useState('all');
|
||||
const [lengthFilter, setLengthFilter] = useState('all');
|
||||
|
||||
// 리비전 로직 훅
|
||||
const {
|
||||
materials,
|
||||
loading: materialsLoading,
|
||||
error: materialsError,
|
||||
processingInfo,
|
||||
handleMaterialSelection,
|
||||
handleBulkAction,
|
||||
refreshMaterials
|
||||
} = useRevisionLogic(fileId, 'BOLT');
|
||||
|
||||
// 리비전 비교 훅
|
||||
const {
|
||||
comparisonResult,
|
||||
loading: comparisonLoading,
|
||||
error: comparisonError,
|
||||
performComparison,
|
||||
getFilteredComparison
|
||||
} = useRevisionComparison(fileId, previousFileId);
|
||||
|
||||
// 리비전 상태 훅
|
||||
const {
|
||||
revisionStatus,
|
||||
loading: statusLoading,
|
||||
error: statusError,
|
||||
uploadNewRevision,
|
||||
navigateToRevision
|
||||
} = useRevisionStatus(jobNo, fileId);
|
||||
|
||||
// 필터링 및 정렬된 자재 목록
|
||||
const filteredAndSortedMaterials = useMemo(() => {
|
||||
if (!materials) return [];
|
||||
|
||||
let filtered = materials.filter(material => {
|
||||
const matchesSearch = !searchTerm ||
|
||||
material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.bolt_type?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === 'all' ||
|
||||
material.processing_info?.display_status === statusFilter;
|
||||
|
||||
const matchesBoltType = boltTypeFilter === 'all' ||
|
||||
material.bolt_type === boltTypeFilter;
|
||||
|
||||
const matchesThreadType = threadTypeFilter === 'all' ||
|
||||
material.thread_type === threadTypeFilter;
|
||||
|
||||
const matchesLength = lengthFilter === 'all' ||
|
||||
material.bolt_length === lengthFilter;
|
||||
|
||||
return matchesSearch && matchesStatus && matchesBoltType && matchesThreadType && matchesLength;
|
||||
});
|
||||
|
||||
// 정렬
|
||||
filtered.sort((a, b) => {
|
||||
let aValue = a[sortBy] || '';
|
||||
let bValue = b[sortBy] || '';
|
||||
|
||||
if (typeof aValue === 'string') {
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
|
||||
} else {
|
||||
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [materials, searchTerm, statusFilter, boltTypeFilter, threadTypeFilter, lengthFilter, sortBy, sortOrder]);
|
||||
|
||||
// 고유 값들 추출 (필터 옵션용)
|
||||
const uniqueValues = useMemo(() => {
|
||||
if (!materials) return { boltTypes: [], threadTypes: [], lengths: [] };
|
||||
|
||||
const boltTypes = [...new Set(materials.map(m => m.bolt_type).filter(Boolean))];
|
||||
const threadTypes = [...new Set(materials.map(m => m.thread_type).filter(Boolean))];
|
||||
const lengths = [...new Set(materials.map(m => m.bolt_length).filter(Boolean))];
|
||||
|
||||
return { boltTypes, threadTypes, lengths };
|
||||
}, [materials]);
|
||||
|
||||
// 초기 비교 수행
|
||||
useEffect(() => {
|
||||
if (fileId && previousFileId && !comparisonResult) {
|
||||
performComparison('BOLT');
|
||||
}
|
||||
}, [fileId, previousFileId, comparisonResult, performComparison]);
|
||||
|
||||
// 자재 선택 처리
|
||||
const handleMaterialSelect = (materialId, isSelected) => {
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (isSelected) {
|
||||
newSelected.add(materialId);
|
||||
} else {
|
||||
newSelected.delete(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
handleMaterialSelection(materialId, isSelected);
|
||||
};
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleSelectAll = (isSelected) => {
|
||||
if (isSelected) {
|
||||
const allIds = new Set(filteredAndSortedMaterials.map(m => m.id));
|
||||
setSelectedMaterials(allIds);
|
||||
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true));
|
||||
} else {
|
||||
setSelectedMaterials(new Set());
|
||||
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false));
|
||||
}
|
||||
};
|
||||
|
||||
// 액션 실행
|
||||
const executeAction = async (action) => {
|
||||
setActionType(action);
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const confirmAction = async () => {
|
||||
try {
|
||||
await handleBulkAction(actionType, Array.from(selectedMaterials));
|
||||
setSelectedMaterials(new Set());
|
||||
setShowConfirmDialog(false);
|
||||
await refreshMaterials();
|
||||
} catch (error) {
|
||||
console.error('Action failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 상태별 색상 클래스
|
||||
const getStatusClass = (status) => {
|
||||
switch (status) {
|
||||
case 'REVISION_MATERIAL': return 'status-revision';
|
||||
case 'INVENTORY_MATERIAL': return 'status-inventory';
|
||||
case 'DELETED_MATERIAL': return 'status-deleted';
|
||||
case 'NEW_MATERIAL': return 'status-new';
|
||||
default: return 'status-normal';
|
||||
}
|
||||
};
|
||||
|
||||
// 수량 표시 (정수로 변환)
|
||||
const formatQuantity = (quantity) => {
|
||||
if (quantity === null || quantity === undefined) return '-';
|
||||
return Math.round(parseFloat(quantity) || 0).toString();
|
||||
};
|
||||
|
||||
// BOLT 설명 생성 (볼트 타입과 규격 포함)
|
||||
const generateBoltDescription = (material) => {
|
||||
const parts = [];
|
||||
|
||||
if (material.bolt_type) parts.push(material.bolt_type);
|
||||
if (material.thread_size) parts.push(material.thread_size);
|
||||
if (material.bolt_length) parts.push(`L${material.bolt_length}mm`);
|
||||
if (material.thread_type) parts.push(material.thread_type);
|
||||
if (material.material_grade) parts.push(material.material_grade);
|
||||
|
||||
const baseDesc = material.description || material.item_name || 'BOLT';
|
||||
|
||||
return parts.length > 0 ? `${baseDesc} (${parts.join(', ')})` : baseDesc;
|
||||
};
|
||||
|
||||
// 볼트 세트 정보 표시
|
||||
const formatBoltSet = (material) => {
|
||||
const parts = [];
|
||||
if (material.bolt_count) parts.push(`볼트 ${material.bolt_count}개`);
|
||||
if (material.nut_count) parts.push(`너트 ${material.nut_count}개`);
|
||||
if (material.washer_count) parts.push(`와셔 ${material.washer_count}개`);
|
||||
|
||||
return parts.length > 0 ? parts.join(' + ') : '-';
|
||||
};
|
||||
|
||||
if (materialsLoading || comparisonLoading || statusLoading) {
|
||||
return <LoadingSpinner message="BOLT 리비전 데이터 로딩 중..." />;
|
||||
}
|
||||
|
||||
const error = materialsError || comparisonError || statusError;
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="category-revision-page">
|
||||
{/* 헤더 */}
|
||||
<div className="materials-header">
|
||||
<div className="header-left">
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => onNavigate('enhanced-revision')}
|
||||
>
|
||||
← 리비전 관리로
|
||||
</button>
|
||||
<div className="header-center">
|
||||
<h1>🔩 BOLT 리비전 관리</h1>
|
||||
<span className="header-subtitle">
|
||||
볼트 타입과 나사 규격을 고려한 BOLT 자재 리비전 처리
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<RevisionStatusIndicator
|
||||
revisionStatus={revisionStatus}
|
||||
onUploadRevision={uploadNewRevision}
|
||||
onNavigateToRevision={navigateToRevision}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컨트롤 섹션 */}
|
||||
<div className="control-section">
|
||||
<div className="section-header">
|
||||
<h3>📊 자재 현황 및 필터</h3>
|
||||
{processingInfo && (
|
||||
<div className="processing-summary">
|
||||
전체: {processingInfo.total_materials}개 |
|
||||
리비전: {processingInfo.by_status.REVISION_MATERIAL || 0}개 |
|
||||
재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0}개 |
|
||||
삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}개
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="control-grid">
|
||||
<div className="control-group">
|
||||
<label>검색:</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="자재명, 볼트타입, 도면명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>상태 필터:</label>
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
<option value="REVISION_MATERIAL">리비전 자재</option>
|
||||
<option value="INVENTORY_MATERIAL">재고 자재</option>
|
||||
<option value="DELETED_MATERIAL">삭제 자재</option>
|
||||
<option value="NEW_MATERIAL">신규 자재</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>볼트 타입:</label>
|
||||
<select value={boltTypeFilter} onChange={(e) => setBoltTypeFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
{uniqueValues.boltTypes.map(type => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>나사 타입:</label>
|
||||
<select value={threadTypeFilter} onChange={(e) => setThreadTypeFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
{uniqueValues.threadTypes.map(type => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>길이:</label>
|
||||
<select value={lengthFilter} onChange={(e) => setLengthFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
{uniqueValues.lengths.map(length => (
|
||||
<option key={length} value={length}>{length}mm</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>정렬:</label>
|
||||
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
|
||||
<option value="description">자재명</option>
|
||||
<option value="bolt_type">볼트 타입</option>
|
||||
<option value="thread_size">나사 크기</option>
|
||||
<option value="bolt_length">길이</option>
|
||||
<option value="quantity">수량</option>
|
||||
</select>
|
||||
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
|
||||
<option value="asc">오름차순</option>
|
||||
<option value="desc">내림차순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 자재 액션 */}
|
||||
{selectedMaterials.size > 0 && (
|
||||
<div className="selected-actions">
|
||||
<span className="selected-count">
|
||||
{selectedMaterials.size}개 선택됨
|
||||
</span>
|
||||
<div className="action-buttons">
|
||||
<button
|
||||
className="btn-action btn-purchase"
|
||||
onClick={() => executeAction('request_purchase')}
|
||||
>
|
||||
구매 신청
|
||||
</button>
|
||||
<button
|
||||
className="btn-action btn-inventory"
|
||||
onClick={() => executeAction('mark_inventory')}
|
||||
>
|
||||
재고 처리
|
||||
</button>
|
||||
<button
|
||||
className="btn-action btn-delete"
|
||||
onClick={() => executeAction('mark_deleted')}
|
||||
>
|
||||
삭제 처리
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 자재 테이블 */}
|
||||
<div className="materials-table-container">
|
||||
<div className="table-header">
|
||||
<div className="header-cell checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.size === filteredAndSortedMaterials.length && filteredAndSortedMaterials.length > 0}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="header-cell">상태</div>
|
||||
<div className="header-cell">자재명</div>
|
||||
<div className="header-cell">볼트 타입</div>
|
||||
<div className="header-cell">나사 크기</div>
|
||||
<div className="header-cell">길이</div>
|
||||
<div className="header-cell">세트 구성</div>
|
||||
<div className="header-cell">수량</div>
|
||||
<div className="header-cell">단위</div>
|
||||
<div className="header-cell">액션</div>
|
||||
</div>
|
||||
|
||||
<div className="table-body">
|
||||
{filteredAndSortedMaterials.map((material) => (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`table-row ${getStatusClass(material.processing_info?.display_status)}`}
|
||||
>
|
||||
<div className="table-cell checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onChange={(e) => handleMaterialSelect(material.id, e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-cell">
|
||||
<span className={`status-badge ${getStatusClass(material.processing_info?.display_status)}`}>
|
||||
{material.processing_info?.display_status || 'NORMAL'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="table-cell">
|
||||
<div className="material-info">
|
||||
<div className="material-name">{generateBoltDescription(material)}</div>
|
||||
{material.processing_info?.notes && (
|
||||
<div className="material-notes">{material.processing_info.notes}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-cell">{material.bolt_type || '-'}</div>
|
||||
<div className="table-cell">{material.thread_size || '-'}</div>
|
||||
<div className="table-cell">{material.bolt_length ? `${material.bolt_length}mm` : '-'}</div>
|
||||
<div className="table-cell">{formatBoltSet(material)}</div>
|
||||
<div className="table-cell quantity-cell">
|
||||
<span className="quantity-value">
|
||||
{formatQuantity(material.quantity)}
|
||||
</span>
|
||||
{material.processing_info?.quantity_change && (
|
||||
<span className="quantity-change">
|
||||
({material.processing_info.quantity_change > 0 ? '+' : ''}
|
||||
{formatQuantity(material.processing_info.quantity_change)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="table-cell">{material.unit || 'SET'}</div>
|
||||
<div className="table-cell">
|
||||
<div className="action-buttons-small">
|
||||
<button
|
||||
className="btn-small btn-view"
|
||||
onClick={() => {/* 상세 보기 로직 */}}
|
||||
title="상세 정보"
|
||||
>
|
||||
👁️
|
||||
</button>
|
||||
<button
|
||||
className="btn-small btn-edit"
|
||||
onClick={() => {/* 편집 로직 */}}
|
||||
title="편집"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
className="btn-small btn-torque"
|
||||
onClick={() => {/* 토크 계산 로직 */}}
|
||||
title="토크 계산"
|
||||
>
|
||||
🔧
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredAndSortedMaterials.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>조건에 맞는 BOLT 자재가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 */}
|
||||
{showConfirmDialog && (
|
||||
<ConfirmDialog
|
||||
title="작업 확인"
|
||||
message={`선택된 ${selectedMaterials.size}개 자재에 대해 "${actionType}" 작업을 수행하시겠습니까?`}
|
||||
onConfirm={confirmAction}
|
||||
onCancel={() => setShowConfirmDialog(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoltRevisionPage;
|
||||
537
frontend/src/pages/revision/CategoryRevisionPage.css
Normal file
537
frontend/src/pages/revision/CategoryRevisionPage.css
Normal file
@@ -0,0 +1,537 @@
|
||||
/* 카테고리별 리비전 페이지 공통 스타일 */
|
||||
|
||||
.category-revision-page {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 헤더 스타일 - 기존 materials-page와 통일 */
|
||||
.materials-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
padding: 8px 16px;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: #e2e8f0;
|
||||
border-color: #94a3b8;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.header-center h1 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 컨트롤 섹션 */
|
||||
.control-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.processing-summary {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.control-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.control-group input,
|
||||
.control-group select {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.control-group input:focus,
|
||||
.control-group select:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
/* 선택된 자재 액션 */
|
||||
.selected-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: #f0f9ff;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-purchase {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-purchase:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.btn-inventory {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-inventory:hover {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
/* 자재 테이블 */
|
||||
.materials-table-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 50px 100px 2fr 1fr 80px 80px 1fr 1fr 100px;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.header-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.checkbox-cell {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.table-body {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 50px 100px 2fr 1fr 80px 80px 1fr 1fr 100px;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
transition: all 0.2s ease;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.table-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.table-cell.checkbox-cell {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.table-cell.quantity-cell {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* 상태별 스타일 */
|
||||
.table-row.status-revision {
|
||||
background: #fef3c7;
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.table-row.status-inventory {
|
||||
background: #dbeafe;
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.table-row.status-deleted {
|
||||
background: #fee2e2;
|
||||
border-left: 4px solid #ef4444;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.table-row.status-new {
|
||||
background: #dcfce7;
|
||||
border-left: 4px solid #22c55e;
|
||||
}
|
||||
|
||||
.table-row.status-normal {
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* 상태 배지 */
|
||||
.status-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.status-badge.status-revision {
|
||||
background: #fbbf24;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-badge.status-inventory {
|
||||
background: #60a5fa;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.status-badge.status-deleted {
|
||||
background: #f87171;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.status-badge.status-new {
|
||||
background: #4ade80;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-badge.status-normal {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* 자재 정보 */
|
||||
.material-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.material-name {
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.material-notes {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 수량 표시 */
|
||||
.quantity-value {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.quantity-change {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* 액션 버튼 */
|
||||
.action-buttons-small {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-small:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.btn-view {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.empty-state {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 1400px) {
|
||||
.table-header,
|
||||
.table-row {
|
||||
grid-template-columns: 50px 100px 2fr 1fr 80px 80px 1fr 100px;
|
||||
}
|
||||
|
||||
.table-header .header-cell:nth-child(7),
|
||||
.table-row .table-cell:nth-child(7) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.control-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.table-header,
|
||||
.table-row {
|
||||
grid-template-columns: 50px 100px 2fr 80px 80px 100px;
|
||||
}
|
||||
|
||||
.table-header .header-cell:nth-child(4),
|
||||
.table-row .table-cell:nth-child(4) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.category-revision-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.materials-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.control-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.selected-actions {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.table-header,
|
||||
.table-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.table-cell:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-cell::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.checkbox-cell::before {
|
||||
content: "선택";
|
||||
}
|
||||
}
|
||||
|
||||
/* 애니메이션 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.table-row {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.control-section,
|
||||
.materials-table-container {
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* 스크롤바 스타일 */
|
||||
.table-body::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.table-body::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.table-body::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.table-body::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
141
frontend/src/pages/revision/FittingRevisionPage.jsx
Normal file
141
frontend/src/pages/revision/FittingRevisionPage.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRevisionLogic } from '../../hooks/useRevisionLogic';
|
||||
import { useRevisionStatus } from '../../hooks/useRevisionStatus';
|
||||
import { LoadingSpinner, ErrorMessage } from '../../components/common';
|
||||
import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
|
||||
import { FittingMaterialsView } from '../../components/bom';
|
||||
import './CategoryRevisionPage.css';
|
||||
|
||||
const FittingRevisionPage = ({
|
||||
jobNo,
|
||||
fileId,
|
||||
previousFileId,
|
||||
onNavigate,
|
||||
user
|
||||
}) => {
|
||||
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
|
||||
const [userRequirements, setUserRequirements] = useState({});
|
||||
const [purchasedMaterials, setPurchasedMaterials] = useState(new Set());
|
||||
|
||||
// 리비전 로직 훅
|
||||
const {
|
||||
materials,
|
||||
loading: materialsLoading,
|
||||
error: materialsError,
|
||||
processingInfo,
|
||||
handleMaterialSelection,
|
||||
handleBulkAction,
|
||||
refreshMaterials
|
||||
} = useRevisionLogic(fileId, 'FITTING');
|
||||
|
||||
// 리비전 상태 훅
|
||||
const {
|
||||
revisionStatus,
|
||||
loading: statusLoading,
|
||||
error: statusError,
|
||||
uploadNewRevision,
|
||||
navigateToRevision
|
||||
} = useRevisionStatus(jobNo, fileId);
|
||||
|
||||
// 자재 업데이트 함수
|
||||
const updateMaterial = (materialId, updates) => {
|
||||
// 리비전 페이지에서는 자재 업데이트 로직을 별도로 처리
|
||||
console.log('Material update in revision page:', materialId, updates);
|
||||
};
|
||||
|
||||
if (materialsLoading || statusLoading) {
|
||||
return <LoadingSpinner message="FITTING 리비전 데이터 로딩 중..." />;
|
||||
}
|
||||
|
||||
const error = materialsError || statusError;
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
|
||||
}
|
||||
|
||||
// FITTING 카테고리만 필터링
|
||||
const fittingMaterials = materials.filter(material =>
|
||||
material.classified_category === 'FITTING' ||
|
||||
material.category === 'FITTING'
|
||||
);
|
||||
|
||||
// 기존 BOM 관리 페이지와 동일한 props 구성
|
||||
const commonProps = {
|
||||
materials: fittingMaterials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate: (materialIds) => {
|
||||
setPurchasedMaterials(prev => {
|
||||
const newSet = new Set(prev);
|
||||
materialIds.forEach(id => newSet.add(id));
|
||||
return newSet;
|
||||
});
|
||||
},
|
||||
updateMaterial,
|
||||
fileId,
|
||||
jobNo,
|
||||
user,
|
||||
onNavigate
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="category-revision-page">
|
||||
{/* 헤더 */}
|
||||
<div className="materials-header">
|
||||
<div className="header-left">
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => onNavigate('enhanced-revision')}
|
||||
>
|
||||
← 리비전 관리로
|
||||
</button>
|
||||
<div className="header-center">
|
||||
<h1>🔧 FITTING 리비전 관리</h1>
|
||||
<span className="header-subtitle">
|
||||
구매 상태를 고려한 FITTING 자재 리비전 처리
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<RevisionStatusIndicator
|
||||
revisionStatus={revisionStatus}
|
||||
onUploadRevision={uploadNewRevision}
|
||||
onNavigateToRevision={navigateToRevision}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리비전 처리 상태 요약 */}
|
||||
{processingInfo && (
|
||||
<div className="revision-summary">
|
||||
<h3>📊 리비전 처리 현황</h3>
|
||||
<div className="summary-stats">
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">전체 자재</span>
|
||||
<span className="stat-value">{processingInfo.total_materials || 0}개</span>
|
||||
</div>
|
||||
<div className="stat-item revision">
|
||||
<span className="stat-label">리비전 자재</span>
|
||||
<span className="stat-value">{processingInfo.by_status?.REVISION_MATERIAL || 0}개</span>
|
||||
</div>
|
||||
<div className="stat-item inventory">
|
||||
<span className="stat-label">재고 자재</span>
|
||||
<span className="stat-value">{processingInfo.by_status?.INVENTORY_MATERIAL || 0}개</span>
|
||||
</div>
|
||||
<div className="stat-item deleted">
|
||||
<span className="stat-label">삭제 자재</span>
|
||||
<span className="stat-value">{processingInfo.by_status?.DELETED_MATERIAL || 0}개</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기존 FITTING 자재 뷰 컴포넌트 사용 */}
|
||||
<FittingMaterialsView {...commonProps} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FittingRevisionPage;
|
||||
141
frontend/src/pages/revision/FlangeRevisionPage.jsx
Normal file
141
frontend/src/pages/revision/FlangeRevisionPage.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRevisionLogic } from '../../hooks/useRevisionLogic';
|
||||
import { useRevisionStatus } from '../../hooks/useRevisionStatus';
|
||||
import { LoadingSpinner, ErrorMessage } from '../../components/common';
|
||||
import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
|
||||
import { FlangeMaterialsView } from '../../components/bom';
|
||||
import './CategoryRevisionPage.css';
|
||||
|
||||
const FlangeRevisionPage = ({
|
||||
jobNo,
|
||||
fileId,
|
||||
previousFileId,
|
||||
onNavigate,
|
||||
user
|
||||
}) => {
|
||||
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
|
||||
const [userRequirements, setUserRequirements] = useState({});
|
||||
const [purchasedMaterials, setPurchasedMaterials] = useState(new Set());
|
||||
|
||||
// 리비전 로직 훅
|
||||
const {
|
||||
materials,
|
||||
loading: materialsLoading,
|
||||
error: materialsError,
|
||||
processingInfo,
|
||||
handleMaterialSelection,
|
||||
handleBulkAction,
|
||||
refreshMaterials
|
||||
} = useRevisionLogic(fileId, 'FLANGE');
|
||||
|
||||
// 리비전 상태 훅
|
||||
const {
|
||||
revisionStatus,
|
||||
loading: statusLoading,
|
||||
error: statusError,
|
||||
uploadNewRevision,
|
||||
navigateToRevision
|
||||
} = useRevisionStatus(jobNo, fileId);
|
||||
|
||||
// 자재 업데이트 함수
|
||||
const updateMaterial = (materialId, updates) => {
|
||||
// 리비전 페이지에서는 자재 업데이트 로직을 별도로 처리
|
||||
console.log('Material update in revision page:', materialId, updates);
|
||||
};
|
||||
|
||||
if (materialsLoading || statusLoading) {
|
||||
return <LoadingSpinner message="FLANGE 리비전 데이터 로딩 중..." />;
|
||||
}
|
||||
|
||||
const error = materialsError || statusError;
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
|
||||
}
|
||||
|
||||
// FLANGE 카테고리만 필터링
|
||||
const flangeMaterials = materials.filter(material =>
|
||||
material.classified_category === 'FLANGE' ||
|
||||
material.category === 'FLANGE'
|
||||
);
|
||||
|
||||
// 기존 BOM 관리 페이지와 동일한 props 구성
|
||||
const commonProps = {
|
||||
materials: flangeMaterials,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
userRequirements,
|
||||
setUserRequirements,
|
||||
purchasedMaterials,
|
||||
onPurchasedMaterialsUpdate: (materialIds) => {
|
||||
setPurchasedMaterials(prev => {
|
||||
const newSet = new Set(prev);
|
||||
materialIds.forEach(id => newSet.add(id));
|
||||
return newSet;
|
||||
});
|
||||
},
|
||||
updateMaterial,
|
||||
fileId,
|
||||
jobNo,
|
||||
user,
|
||||
onNavigate
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="category-revision-page">
|
||||
{/* 헤더 */}
|
||||
<div className="materials-header">
|
||||
<div className="header-left">
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => onNavigate('enhanced-revision')}
|
||||
>
|
||||
← 리비전 관리로
|
||||
</button>
|
||||
<div className="header-center">
|
||||
<h1>🔩 FLANGE 리비전 관리</h1>
|
||||
<span className="header-subtitle">
|
||||
구매 상태를 고려한 FLANGE 자재 리비전 처리
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<RevisionStatusIndicator
|
||||
revisionStatus={revisionStatus}
|
||||
onUploadRevision={uploadNewRevision}
|
||||
onNavigateToRevision={navigateToRevision}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리비전 처리 상태 요약 */}
|
||||
{processingInfo && (
|
||||
<div className="revision-summary">
|
||||
<h3>📊 리비전 처리 현황</h3>
|
||||
<div className="summary-stats">
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">전체 자재</span>
|
||||
<span className="stat-value">{processingInfo.total_materials || 0}개</span>
|
||||
</div>
|
||||
<div className="stat-item revision">
|
||||
<span className="stat-label">리비전 자재</span>
|
||||
<span className="stat-value">{processingInfo.by_status?.REVISION_MATERIAL || 0}개</span>
|
||||
</div>
|
||||
<div className="stat-item inventory">
|
||||
<span className="stat-label">재고 자재</span>
|
||||
<span className="stat-value">{processingInfo.by_status?.INVENTORY_MATERIAL || 0}개</span>
|
||||
</div>
|
||||
<div className="stat-item deleted">
|
||||
<span className="stat-label">삭제 자재</span>
|
||||
<span className="stat-value">{processingInfo.by_status?.DELETED_MATERIAL || 0}개</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기존 FLANGE 자재 뷰 컴포넌트 사용 */}
|
||||
<FlangeMaterialsView {...commonProps} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlangeRevisionPage;
|
||||
459
frontend/src/pages/revision/GasketRevisionPage.jsx
Normal file
459
frontend/src/pages/revision/GasketRevisionPage.jsx
Normal file
@@ -0,0 +1,459 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useRevisionLogic } from '../../hooks/useRevisionLogic';
|
||||
import { useRevisionComparison } from '../../hooks/useRevisionComparison';
|
||||
import { useRevisionStatus } from '../../hooks/useRevisionStatus';
|
||||
import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
|
||||
import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
|
||||
import './CategoryRevisionPage.css';
|
||||
|
||||
const GasketRevisionPage = ({
|
||||
jobNo,
|
||||
fileId,
|
||||
previousFileId,
|
||||
onNavigate,
|
||||
user
|
||||
}) => {
|
||||
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [actionType, setActionType] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('description');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
const [gasketTypeFilter, setGasketTypeFilter] = useState('all');
|
||||
const [materialTypeFilter, setMaterialTypeFilter] = useState('all');
|
||||
const [pressureRatingFilter, setPressureRatingFilter] = useState('all');
|
||||
|
||||
// 리비전 로직 훅
|
||||
const {
|
||||
materials,
|
||||
loading: materialsLoading,
|
||||
error: materialsError,
|
||||
processingInfo,
|
||||
handleMaterialSelection,
|
||||
handleBulkAction,
|
||||
refreshMaterials
|
||||
} = useRevisionLogic(fileId, 'GASKET');
|
||||
|
||||
// 리비전 비교 훅
|
||||
const {
|
||||
comparisonResult,
|
||||
loading: comparisonLoading,
|
||||
error: comparisonError,
|
||||
performComparison,
|
||||
getFilteredComparison
|
||||
} = useRevisionComparison(fileId, previousFileId);
|
||||
|
||||
// 리비전 상태 훅
|
||||
const {
|
||||
revisionStatus,
|
||||
loading: statusLoading,
|
||||
error: statusError,
|
||||
uploadNewRevision,
|
||||
navigateToRevision
|
||||
} = useRevisionStatus(jobNo, fileId);
|
||||
|
||||
// 필터링 및 정렬된 자재 목록
|
||||
const filteredAndSortedMaterials = useMemo(() => {
|
||||
if (!materials) return [];
|
||||
|
||||
let filtered = materials.filter(material => {
|
||||
const matchesSearch = !searchTerm ||
|
||||
material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.gasket_type?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === 'all' ||
|
||||
material.processing_info?.display_status === statusFilter;
|
||||
|
||||
const matchesGasketType = gasketTypeFilter === 'all' ||
|
||||
material.gasket_type === gasketTypeFilter;
|
||||
|
||||
const matchesMaterialType = materialTypeFilter === 'all' ||
|
||||
material.material_type === materialTypeFilter;
|
||||
|
||||
const matchesPressureRating = pressureRatingFilter === 'all' ||
|
||||
material.pressure_rating === pressureRatingFilter;
|
||||
|
||||
return matchesSearch && matchesStatus && matchesGasketType && matchesMaterialType && matchesPressureRating;
|
||||
});
|
||||
|
||||
// 정렬
|
||||
filtered.sort((a, b) => {
|
||||
let aValue = a[sortBy] || '';
|
||||
let bValue = b[sortBy] || '';
|
||||
|
||||
if (typeof aValue === 'string') {
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
|
||||
} else {
|
||||
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [materials, searchTerm, statusFilter, gasketTypeFilter, materialTypeFilter, pressureRatingFilter, sortBy, sortOrder]);
|
||||
|
||||
// 고유 값들 추출 (필터 옵션용)
|
||||
const uniqueValues = useMemo(() => {
|
||||
if (!materials) return { gasketTypes: [], materialTypes: [], pressureRatings: [] };
|
||||
|
||||
const gasketTypes = [...new Set(materials.map(m => m.gasket_type).filter(Boolean))];
|
||||
const materialTypes = [...new Set(materials.map(m => m.material_type).filter(Boolean))];
|
||||
const pressureRatings = [...new Set(materials.map(m => m.pressure_rating).filter(Boolean))];
|
||||
|
||||
return { gasketTypes, materialTypes, pressureRatings };
|
||||
}, [materials]);
|
||||
|
||||
// 초기 비교 수행
|
||||
useEffect(() => {
|
||||
if (fileId && previousFileId && !comparisonResult) {
|
||||
performComparison('GASKET');
|
||||
}
|
||||
}, [fileId, previousFileId, comparisonResult, performComparison]);
|
||||
|
||||
// 자재 선택 처리
|
||||
const handleMaterialSelect = (materialId, isSelected) => {
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (isSelected) {
|
||||
newSelected.add(materialId);
|
||||
} else {
|
||||
newSelected.delete(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
handleMaterialSelection(materialId, isSelected);
|
||||
};
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleSelectAll = (isSelected) => {
|
||||
if (isSelected) {
|
||||
const allIds = new Set(filteredAndSortedMaterials.map(m => m.id));
|
||||
setSelectedMaterials(allIds);
|
||||
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true));
|
||||
} else {
|
||||
setSelectedMaterials(new Set());
|
||||
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false));
|
||||
}
|
||||
};
|
||||
|
||||
// 액션 실행
|
||||
const executeAction = async (action) => {
|
||||
setActionType(action);
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const confirmAction = async () => {
|
||||
try {
|
||||
await handleBulkAction(actionType, Array.from(selectedMaterials));
|
||||
setSelectedMaterials(new Set());
|
||||
setShowConfirmDialog(false);
|
||||
await refreshMaterials();
|
||||
} catch (error) {
|
||||
console.error('Action failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 상태별 색상 클래스
|
||||
const getStatusClass = (status) => {
|
||||
switch (status) {
|
||||
case 'REVISION_MATERIAL': return 'status-revision';
|
||||
case 'INVENTORY_MATERIAL': return 'status-inventory';
|
||||
case 'DELETED_MATERIAL': return 'status-deleted';
|
||||
case 'NEW_MATERIAL': return 'status-new';
|
||||
default: return 'status-normal';
|
||||
}
|
||||
};
|
||||
|
||||
// 수량 표시 (정수로 변환)
|
||||
const formatQuantity = (quantity) => {
|
||||
if (quantity === null || quantity === undefined) return '-';
|
||||
return Math.round(parseFloat(quantity) || 0).toString();
|
||||
};
|
||||
|
||||
// GASKET 설명 생성 (가스켓 타입과 재질 포함)
|
||||
const generateGasketDescription = (material) => {
|
||||
const parts = [];
|
||||
|
||||
if (material.gasket_type) parts.push(material.gasket_type);
|
||||
if (material.nominal_size) parts.push(material.nominal_size);
|
||||
if (material.material_type) parts.push(material.material_type);
|
||||
if (material.pressure_rating) parts.push(`${material.pressure_rating}#`);
|
||||
|
||||
const baseDesc = material.description || material.item_name || 'GASKET';
|
||||
|
||||
return parts.length > 0 ? `${baseDesc} (${parts.join(', ')})` : baseDesc;
|
||||
};
|
||||
|
||||
// 가스켓 두께 표시
|
||||
const formatThickness = (material) => {
|
||||
if (material.thickness) return `${material.thickness}mm`;
|
||||
if (material.gasket_thickness) return `${material.gasket_thickness}mm`;
|
||||
return '-';
|
||||
};
|
||||
|
||||
if (materialsLoading || comparisonLoading || statusLoading) {
|
||||
return <LoadingSpinner message="GASKET 리비전 데이터 로딩 중..." />;
|
||||
}
|
||||
|
||||
const error = materialsError || comparisonError || statusError;
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="category-revision-page">
|
||||
{/* 헤더 */}
|
||||
<div className="materials-header">
|
||||
<div className="header-left">
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => onNavigate('enhanced-revision')}
|
||||
>
|
||||
← 리비전 관리로
|
||||
</button>
|
||||
<div className="header-center">
|
||||
<h1>⭕ GASKET 리비전 관리</h1>
|
||||
<span className="header-subtitle">
|
||||
가스켓 타입과 재질을 고려한 GASKET 자재 리비전 처리
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<RevisionStatusIndicator
|
||||
revisionStatus={revisionStatus}
|
||||
onUploadRevision={uploadNewRevision}
|
||||
onNavigateToRevision={navigateToRevision}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컨트롤 섹션 */}
|
||||
<div className="control-section">
|
||||
<div className="section-header">
|
||||
<h3>📊 자재 현황 및 필터</h3>
|
||||
{processingInfo && (
|
||||
<div className="processing-summary">
|
||||
전체: {processingInfo.total_materials}개 |
|
||||
리비전: {processingInfo.by_status.REVISION_MATERIAL || 0}개 |
|
||||
재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0}개 |
|
||||
삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}개
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="control-grid">
|
||||
<div className="control-group">
|
||||
<label>검색:</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="자재명, 가스켓타입, 도면명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>상태 필터:</label>
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
<option value="REVISION_MATERIAL">리비전 자재</option>
|
||||
<option value="INVENTORY_MATERIAL">재고 자재</option>
|
||||
<option value="DELETED_MATERIAL">삭제 자재</option>
|
||||
<option value="NEW_MATERIAL">신규 자재</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>가스켓 타입:</label>
|
||||
<select value={gasketTypeFilter} onChange={(e) => setGasketTypeFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
{uniqueValues.gasketTypes.map(type => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>재질:</label>
|
||||
<select value={materialTypeFilter} onChange={(e) => setMaterialTypeFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
{uniqueValues.materialTypes.map(type => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>압력등급:</label>
|
||||
<select value={pressureRatingFilter} onChange={(e) => setPressureRatingFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
{uniqueValues.pressureRatings.map(rating => (
|
||||
<option key={rating} value={rating}>{rating}#</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>정렬:</label>
|
||||
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
|
||||
<option value="description">자재명</option>
|
||||
<option value="gasket_type">가스켓 타입</option>
|
||||
<option value="nominal_size">크기</option>
|
||||
<option value="material_type">재질</option>
|
||||
<option value="quantity">수량</option>
|
||||
</select>
|
||||
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
|
||||
<option value="asc">오름차순</option>
|
||||
<option value="desc">내림차순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 자재 액션 */}
|
||||
{selectedMaterials.size > 0 && (
|
||||
<div className="selected-actions">
|
||||
<span className="selected-count">
|
||||
{selectedMaterials.size}개 선택됨
|
||||
</span>
|
||||
<div className="action-buttons">
|
||||
<button
|
||||
className="btn-action btn-purchase"
|
||||
onClick={() => executeAction('request_purchase')}
|
||||
>
|
||||
구매 신청
|
||||
</button>
|
||||
<button
|
||||
className="btn-action btn-inventory"
|
||||
onClick={() => executeAction('mark_inventory')}
|
||||
>
|
||||
재고 처리
|
||||
</button>
|
||||
<button
|
||||
className="btn-action btn-delete"
|
||||
onClick={() => executeAction('mark_deleted')}
|
||||
>
|
||||
삭제 처리
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 자재 테이블 */}
|
||||
<div className="materials-table-container">
|
||||
<div className="table-header">
|
||||
<div className="header-cell checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.size === filteredAndSortedMaterials.length && filteredAndSortedMaterials.length > 0}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="header-cell">상태</div>
|
||||
<div className="header-cell">자재명</div>
|
||||
<div className="header-cell">가스켓 타입</div>
|
||||
<div className="header-cell">크기</div>
|
||||
<div className="header-cell">재질</div>
|
||||
<div className="header-cell">두께</div>
|
||||
<div className="header-cell">압력등급</div>
|
||||
<div className="header-cell">수량</div>
|
||||
<div className="header-cell">액션</div>
|
||||
</div>
|
||||
|
||||
<div className="table-body">
|
||||
{filteredAndSortedMaterials.map((material) => (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`table-row ${getStatusClass(material.processing_info?.display_status)}`}
|
||||
>
|
||||
<div className="table-cell checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onChange={(e) => handleMaterialSelect(material.id, e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-cell">
|
||||
<span className={`status-badge ${getStatusClass(material.processing_info?.display_status)}`}>
|
||||
{material.processing_info?.display_status || 'NORMAL'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="table-cell">
|
||||
<div className="material-info">
|
||||
<div className="material-name">{generateGasketDescription(material)}</div>
|
||||
{material.processing_info?.notes && (
|
||||
<div className="material-notes">{material.processing_info.notes}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-cell">{material.gasket_type || '-'}</div>
|
||||
<div className="table-cell">{material.nominal_size || '-'}</div>
|
||||
<div className="table-cell">{material.material_type || '-'}</div>
|
||||
<div className="table-cell">{formatThickness(material)}</div>
|
||||
<div className="table-cell">{material.pressure_rating ? `${material.pressure_rating}#` : '-'}</div>
|
||||
<div className="table-cell quantity-cell">
|
||||
<span className="quantity-value">
|
||||
{formatQuantity(material.quantity)}
|
||||
</span>
|
||||
{material.processing_info?.quantity_change && (
|
||||
<span className="quantity-change">
|
||||
({material.processing_info.quantity_change > 0 ? '+' : ''}
|
||||
{formatQuantity(material.processing_info.quantity_change)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="table-cell">
|
||||
<div className="action-buttons-small">
|
||||
<button
|
||||
className="btn-small btn-view"
|
||||
onClick={() => {/* 상세 보기 로직 */}}
|
||||
title="상세 정보"
|
||||
>
|
||||
👁️
|
||||
</button>
|
||||
<button
|
||||
className="btn-small btn-edit"
|
||||
onClick={() => {/* 편집 로직 */}}
|
||||
title="편집"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
className="btn-small btn-spec"
|
||||
onClick={() => {/* 규격 확인 로직 */}}
|
||||
title="규격 확인"
|
||||
>
|
||||
📏
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredAndSortedMaterials.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>조건에 맞는 GASKET 자재가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 */}
|
||||
{showConfirmDialog && (
|
||||
<ConfirmDialog
|
||||
title="작업 확인"
|
||||
message={`선택된 ${selectedMaterials.size}개 자재에 대해 "${actionType}" 작업을 수행하시겠습니까?`}
|
||||
onConfirm={confirmAction}
|
||||
onCancel={() => setShowConfirmDialog(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GasketRevisionPage;
|
||||
666
frontend/src/pages/revision/PipeCuttingPlanPage.css
Normal file
666
frontend/src/pages/revision/PipeCuttingPlanPage.css
Normal file
@@ -0,0 +1,666 @@
|
||||
/* PIPE Cutting Plan 페이지 전용 스타일 */
|
||||
|
||||
/* PIPE 리비전 상태 표시 */
|
||||
.revision-status-section {
|
||||
margin: 20px 0;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.revision-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border-left: 5px solid;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.revision-alert.pre-cutting {
|
||||
border-left-color: #3b82f6;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
}
|
||||
|
||||
.revision-alert.post-cutting {
|
||||
border-left-color: #f59e0b;
|
||||
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
font-size: 24px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.alert-content h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.alert-content p {
|
||||
margin: 0 0 12px 0;
|
||||
color: #4b5563;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.revision-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.revision-summary span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Cutting Plan 관리 섹션 */
|
||||
.cutting-plan-management-section {
|
||||
margin: 30px 0;
|
||||
padding: 25px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.cutting-plan-management-section .section-header h3 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cutting-plan-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.cutting-plan-actions button {
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.btn-export-temp {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.btn-export-temp:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.btn-finalize-cutting-plan {
|
||||
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.3);
|
||||
}
|
||||
|
||||
.btn-finalize-cutting-plan:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(220, 38, 38, 0.4);
|
||||
}
|
||||
|
||||
.btn-export-finalized {
|
||||
background: linear-gradient(135deg, #059669 0%, #047857 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 4px rgba(5, 150, 105, 0.3);
|
||||
}
|
||||
|
||||
.btn-export-finalized:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #047857 0%, #065f46 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(5, 150, 105, 0.4);
|
||||
}
|
||||
|
||||
.btn-issue-management {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 4px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
.btn-issue-management:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #6d28d9 0%, #5b21b6 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
|
||||
.cutting-plan-actions button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.action-descriptions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 15px;
|
||||
background: rgba(249, 250, 251, 0.8);
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.action-desc {
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.action-desc strong {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.cutting-plan-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cutting-plan-actions button {
|
||||
font-size: 13px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.pipe-cutting-plan-page {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 리비전 경고 섹션 */
|
||||
.revision-warning {
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fbbf24 100%);
|
||||
border: 2px solid #f59e0b;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.25);
|
||||
}
|
||||
|
||||
.warning-content h3 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #92400e;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.warning-content p {
|
||||
margin: 0 0 16px 0;
|
||||
color: #92400e;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-force-upload {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-force-upload:hover {
|
||||
background: #b91c1c;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.3);
|
||||
}
|
||||
|
||||
/* 분류 섹션 */
|
||||
.classification-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.classification-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr 250px;
|
||||
gap: 20px;
|
||||
padding: 20px 24px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.control-group select,
|
||||
.control-group input {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.control-group select:focus,
|
||||
.control-group input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.control-group select:disabled,
|
||||
.control-group input:disabled {
|
||||
background: #f9fafb;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-start-cutting-plan {
|
||||
padding: 12px 20px;
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.btn-start-cutting-plan:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.btn-start-cutting-plan:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 자재 현황 요약 */
|
||||
.materials-summary {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* Cutting Plan 콘텐츠 */
|
||||
.cutting-plan-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 구역 섹션 */
|
||||
.area-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.area-section.unassigned {
|
||||
border-color: #fbbf24;
|
||||
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
|
||||
}
|
||||
|
||||
.area-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.area-section.unassigned .area-header {
|
||||
background: #fbbf24;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.area-header h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.area-section.unassigned .area-header h4 {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.area-count {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.area-section.unassigned .area-count {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* PIPE 테이블 */
|
||||
.pipe-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pipe-table .table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 150px 120px 200px 100px 120px 100px;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.area-section.unassigned .pipe-table .table-header {
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
}
|
||||
|
||||
.pipe-table .table-body {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pipe-table .table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 150px 120px 200px 100px 120px 100px;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
transition: all 0.2s ease;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pipe-table .table-row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.pipe-table .table-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.pipe-table .header-cell,
|
||||
.pipe-table .table-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.pipe-table .table-cell select {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.pipe-table .table-cell select:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
/* 액션 버튼 */
|
||||
.action-buttons-small {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-small:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.btn-small.btn-edit {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.btn-small.btn-delete {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.btn-small.btn-delete:hover {
|
||||
background: #fef2f2;
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.empty-state {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 1400px) {
|
||||
.pipe-table .table-header,
|
||||
.pipe-table .table-row {
|
||||
grid-template-columns: 80px 130px 100px 180px 80px 100px 80px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.classification-controls {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pipe-table .table-header,
|
||||
.pipe-table .table-row {
|
||||
grid-template-columns: 80px 120px 160px 80px 100px 80px;
|
||||
}
|
||||
|
||||
.pipe-table .table-header .header-cell:nth-child(3),
|
||||
.pipe-table .table-row .table-cell:nth-child(3) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pipe-cutting-plan-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.classification-controls {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pipe-table .table-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pipe-table .table-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pipe-table .table-cell {
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.pipe-table .table-cell:last-child {
|
||||
border-bottom: none;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pipe-table .table-cell::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.area-header {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 애니메이션 */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.area-section {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.pipe-table .table-row {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* 스크롤바 스타일 */
|
||||
.pipe-table .table-body::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.pipe-table .table-body::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.pipe-table .table-body::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pipe-table .table-body::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
681
frontend/src/pages/revision/PipeCuttingPlanPage.jsx
Normal file
681
frontend/src/pages/revision/PipeCuttingPlanPage.jsx
Normal file
@@ -0,0 +1,681 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useRevisionLogic } from '../../hooks/useRevisionLogic';
|
||||
import { useRevisionComparison } from '../../hooks/useRevisionComparison';
|
||||
import { useRevisionStatus } from '../../hooks/useRevisionStatus';
|
||||
import { usePipeRevision } from '../../hooks/usePipeRevision';
|
||||
import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
|
||||
import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
|
||||
import './CategoryRevisionPage.css';
|
||||
import './PipeCuttingPlanPage.css';
|
||||
|
||||
const PipeCuttingPlanPage = ({
|
||||
jobNo,
|
||||
fileId,
|
||||
previousFileId,
|
||||
onNavigate,
|
||||
user
|
||||
}) => {
|
||||
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [actionType, setActionType] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedArea, setSelectedArea] = useState('');
|
||||
const [searchDrawing, setSearchDrawing] = useState('');
|
||||
const [cuttingPlanStarted, setCuttingPlanStarted] = useState(false);
|
||||
const [areaAssignments, setAreaAssignments] = useState({});
|
||||
const [endPreparations, setEndPreparations] = useState({});
|
||||
|
||||
// 리비전 로직 훅
|
||||
const {
|
||||
materials,
|
||||
loading: materialsLoading,
|
||||
error: materialsError,
|
||||
processingInfo,
|
||||
handleMaterialSelection,
|
||||
handleBulkAction,
|
||||
refreshMaterials
|
||||
} = useRevisionLogic(fileId, 'PIPE');
|
||||
|
||||
// 리비전 비교 훅
|
||||
const {
|
||||
comparisonResult,
|
||||
loading: comparisonLoading,
|
||||
error: comparisonError,
|
||||
performComparison,
|
||||
getFilteredComparison
|
||||
} = useRevisionComparison(fileId, previousFileId);
|
||||
|
||||
// PIPE 전용 리비전 훅
|
||||
const {
|
||||
revisionStatus: pipeRevisionStatus,
|
||||
comparisonResult: pipeComparisonResult,
|
||||
loading: pipeRevisionLoading,
|
||||
error: pipeRevisionError,
|
||||
checkRevisionStatus,
|
||||
handlePreCuttingPlanRevision,
|
||||
handlePostCuttingPlanRevision,
|
||||
processRevisionAutomatically,
|
||||
finalizeCuttingPlan,
|
||||
getSnapshotStatus,
|
||||
exportFinalizedExcel,
|
||||
checkFinalizationStatus,
|
||||
isPreCuttingPlan,
|
||||
isPostCuttingPlan,
|
||||
requiresAction
|
||||
} = usePipeRevision(jobNo, fileId);
|
||||
|
||||
// 리비전 상태 훅
|
||||
const {
|
||||
revisionStatus,
|
||||
loading: statusLoading,
|
||||
error: statusError,
|
||||
uploadNewRevision,
|
||||
navigateToRevision
|
||||
} = useRevisionStatus(jobNo, fileId);
|
||||
|
||||
// 구역 옵션
|
||||
const areaOptions = ['#01', '#02', '#03', '#04', '#05', '#06', '#07', '#08', '#09', '#10'];
|
||||
|
||||
// 컴포넌트 마운트 시 데이터 로드 및 리비전 처리
|
||||
useEffect(() => {
|
||||
refreshMaterials();
|
||||
|
||||
// PIPE 리비전 자동 처리
|
||||
if (jobNo && fileId && requiresAction) {
|
||||
handlePipeRevisionAutomatically();
|
||||
}
|
||||
}, [refreshMaterials, jobNo, fileId, requiresAction]);
|
||||
|
||||
// PIPE 리비전 자동 처리 함수
|
||||
const handlePipeRevisionAutomatically = async () => {
|
||||
try {
|
||||
const result = await processRevisionAutomatically();
|
||||
|
||||
if (result.success) {
|
||||
if (result.type === 'pre_cutting_plan') {
|
||||
// Cutting Plan 작성 전 리비전 - 기존 데이터 삭제됨
|
||||
alert(`${result.message}\n새로운 Cutting Plan을 작성해주세요.`);
|
||||
setCuttingPlanStarted(false);
|
||||
setAreaAssignments({});
|
||||
} else if (result.type === 'post_cutting_plan') {
|
||||
// Cutting Plan 작성 후 리비전 - 비교 결과 표시
|
||||
alert(`${result.message}\n변경사항을 검토해주세요.`);
|
||||
setCuttingPlanStarted(true);
|
||||
}
|
||||
} else {
|
||||
console.error('PIPE 리비전 자동 처리 실패:', result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('PIPE 리비전 자동 처리 중 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 끝단 처리 옵션
|
||||
const endPrepOptions = [
|
||||
{ value: 'plain', label: '무개선' },
|
||||
{ value: 'single_bevel', label: '한개선' },
|
||||
{ value: 'double_bevel', label: '양개선' }
|
||||
];
|
||||
|
||||
// 필터링된 자재 목록 (도면 검색 적용)
|
||||
const filteredMaterials = useMemo(() => {
|
||||
if (!materials) return [];
|
||||
|
||||
return materials.filter(material => {
|
||||
const matchesDrawing = !searchDrawing ||
|
||||
material.drawing_name?.toLowerCase().includes(searchDrawing.toLowerCase());
|
||||
|
||||
return matchesDrawing;
|
||||
});
|
||||
}, [materials, searchDrawing]);
|
||||
|
||||
// 구역별로 그룹화된 자재
|
||||
const groupedMaterials = useMemo(() => {
|
||||
const grouped = {
|
||||
assigned: {},
|
||||
unassigned: []
|
||||
};
|
||||
|
||||
filteredMaterials.forEach(material => {
|
||||
const assignedArea = areaAssignments[material.id];
|
||||
if (assignedArea) {
|
||||
if (!grouped.assigned[assignedArea]) {
|
||||
grouped.assigned[assignedArea] = [];
|
||||
}
|
||||
grouped.assigned[assignedArea].push(material);
|
||||
} else {
|
||||
grouped.unassigned.push(material);
|
||||
}
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}, [filteredMaterials, areaAssignments]);
|
||||
|
||||
// 초기 비교 수행
|
||||
useEffect(() => {
|
||||
if (fileId && previousFileId && !comparisonResult) {
|
||||
performComparison('PIPE');
|
||||
}
|
||||
}, [fileId, previousFileId, comparisonResult, performComparison]);
|
||||
|
||||
// Cutting Plan 시작
|
||||
const handleStartCuttingPlan = () => {
|
||||
if (!selectedArea) {
|
||||
alert('구역을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 선택된 구역과 검색된 도면에 맞는 자재들을 자동 할당
|
||||
const newAssignments = { ...areaAssignments };
|
||||
filteredMaterials.forEach(material => {
|
||||
if (!newAssignments[material.id]) {
|
||||
newAssignments[material.id] = selectedArea;
|
||||
}
|
||||
});
|
||||
|
||||
setAreaAssignments(newAssignments);
|
||||
setCuttingPlanStarted(true);
|
||||
};
|
||||
|
||||
// 구역 할당 변경
|
||||
const handleAreaAssignment = (materialId, area) => {
|
||||
setAreaAssignments(prev => ({
|
||||
...prev,
|
||||
[materialId]: area
|
||||
}));
|
||||
};
|
||||
|
||||
// 끝단 처리 변경
|
||||
const handleEndPrepChange = (materialId, endPrep) => {
|
||||
setEndPreparations(prev => ({
|
||||
...prev,
|
||||
[materialId]: endPrep
|
||||
}));
|
||||
};
|
||||
|
||||
// 자재 삭제
|
||||
const handleRemoveMaterial = (materialId) => {
|
||||
setSelectedMaterials(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(materialId);
|
||||
return newSet;
|
||||
});
|
||||
setActionType('delete_pipe_segment');
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
// 액션 실행
|
||||
const confirmAction = async () => {
|
||||
try {
|
||||
if (actionType === 'delete_pipe_segment') {
|
||||
// PIPE 세그먼트 삭제 로직
|
||||
console.log('Deleting pipe segments:', Array.from(selectedMaterials));
|
||||
} else if (actionType === 'force_revision_upload') {
|
||||
// 강제 리비전 업로드 로직
|
||||
uploadNewRevision();
|
||||
}
|
||||
|
||||
setSelectedMaterials(new Set());
|
||||
setShowConfirmDialog(false);
|
||||
await refreshMaterials();
|
||||
} catch (error) {
|
||||
console.error('Action failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 파이프 정보 포맷팅
|
||||
const formatPipeInfo = (material) => {
|
||||
const parts = [];
|
||||
if (material.material_grade) parts.push(material.material_grade);
|
||||
if (material.schedule) parts.push(material.schedule);
|
||||
if (material.nominal_size) parts.push(material.nominal_size);
|
||||
|
||||
return parts.join(' ') || '-';
|
||||
};
|
||||
|
||||
// 길이 포맷팅
|
||||
const formatLength = (length) => {
|
||||
if (!length) return '-';
|
||||
return `${parseFloat(length).toFixed(1)}mm`;
|
||||
};
|
||||
|
||||
// 임시 Excel 내보내기 (현재 작업 중인 데이터)
|
||||
const handleExportTempExcel = async () => {
|
||||
try {
|
||||
alert('임시 Excel 내보내기 기능은 구현 예정입니다.\n현재 작업 중인 데이터를 기준으로 생성됩니다.');
|
||||
} catch (error) {
|
||||
console.error('임시 Excel 내보내기 실패:', error);
|
||||
alert('Excel 내보내기에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// Cutting Plan 확정
|
||||
const handleFinalizeCuttingPlan = async () => {
|
||||
try {
|
||||
const confirmed = window.confirm(
|
||||
'⚠️ Cutting Plan을 확정하시겠습니까?\n\n' +
|
||||
'확정 후에는:\n' +
|
||||
'• 데이터가 고정되어 리비전 영향을 받지 않습니다\n' +
|
||||
'• 이슈 관리를 시작할 수 있습니다\n' +
|
||||
'• Excel 내보내기가 고정된 데이터로 제공됩니다'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
const result = await finalizeCuttingPlan();
|
||||
if (result && result.success) {
|
||||
alert(`✅ ${result.message}\n\n스냅샷 ID: ${result.snapshot_id}\n총 단관: ${result.total_segments}개`);
|
||||
// 페이지 새로고침 또는 상태 업데이트
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(`❌ Cutting Plan 확정 실패\n${result?.message || '알 수 없는 오류'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Cutting Plan 확정 실패:', error);
|
||||
alert('Cutting Plan 확정에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 확정된 Excel 내보내기 (고정된 데이터)
|
||||
const handleExportFinalizedExcel = async () => {
|
||||
try {
|
||||
const result = await exportFinalizedExcel();
|
||||
if (result && result.success) {
|
||||
alert('✅ 확정된 Excel 파일이 다운로드되었습니다.\n이 파일은 리비전과 무관하게 고정된 데이터입니다.');
|
||||
} else {
|
||||
alert(`❌ Excel 내보내기 실패\n${result?.message || '알 수 없는 오류'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('확정된 Excel 내보내기 실패:', error);
|
||||
alert('Excel 내보내기에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 이슈 관리 페이지로 이동
|
||||
const handleGoToIssueManagement = async () => {
|
||||
try {
|
||||
const snapshotStatus = await getSnapshotStatus();
|
||||
if (snapshotStatus && snapshotStatus.has_snapshot && snapshotStatus.is_locked) {
|
||||
// 이슈 관리 페이지로 이동
|
||||
onNavigate('pipe-issue-management');
|
||||
} else {
|
||||
alert('❌ 이슈 관리를 시작하려면 먼저 Cutting Plan을 확정해주세요.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('이슈 관리 페이지 이동 실패:', error);
|
||||
alert('이슈 관리 페이지 접근에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
if (materialsLoading || comparisonLoading || statusLoading || pipeRevisionLoading) {
|
||||
return <LoadingSpinner message="PIPE 데이터 로딩 중..." />;
|
||||
}
|
||||
|
||||
const error = materialsError || comparisonError || statusError || pipeRevisionError;
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pipe-cutting-plan-page">
|
||||
{/* PIPE 리비전 상태 표시 */}
|
||||
{pipeRevisionStatus && requiresAction && (
|
||||
<div className="revision-status-section">
|
||||
<div className={`revision-alert ${isPreCuttingPlan ? 'pre-cutting' : 'post-cutting'}`}>
|
||||
<div className="alert-icon">
|
||||
{isPreCuttingPlan ? '🔄' : '⚠️'}
|
||||
</div>
|
||||
<div className="alert-content">
|
||||
<h4>
|
||||
{isPreCuttingPlan ? 'Cutting Plan 작성 전 리비전' : 'Cutting Plan 작성 후 리비전'}
|
||||
</h4>
|
||||
<p>{pipeRevisionStatus.message}</p>
|
||||
{isPostCuttingPlan && pipeComparisonResult && (
|
||||
<div className="revision-summary">
|
||||
<span>변경된 도면: {pipeComparisonResult.summary?.changed_drawings_count || 0}개</span>
|
||||
<span>추가된 단관: {pipeComparisonResult.summary?.added_segments || 0}개</span>
|
||||
<span>삭제된 단관: {pipeComparisonResult.summary?.removed_segments || 0}개</span>
|
||||
<span>수정된 단관: {pipeComparisonResult.summary?.modified_segments || 0}개</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="materials-header">
|
||||
<div className="header-left">
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => onNavigate('enhanced-revision')}
|
||||
>
|
||||
← 리비전 관리로
|
||||
</button>
|
||||
<div className="header-center">
|
||||
<h1>🔧 PIPE Cutting Plan 관리</h1>
|
||||
<span className="header-subtitle">
|
||||
도면-라인번호-길이 기반 파이프 절단 계획 관리
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<RevisionStatusIndicator
|
||||
revisionStatus={revisionStatus}
|
||||
onUploadRevision={uploadNewRevision}
|
||||
onNavigateToRevision={navigateToRevision}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리비전 경고 (Cutting Plan 시작 전) */}
|
||||
{!cuttingPlanStarted && (
|
||||
<div className="revision-warning">
|
||||
<div className="warning-content">
|
||||
<h3>⚠️ PIPE 리비전 처리 안내</h3>
|
||||
<p>
|
||||
<strong>Cutting Plan 작성 전</strong>에 리비전이 발생하면
|
||||
<span className="highlight">기존 단관정보가 전부 삭제</span>되고
|
||||
<span className="highlight">새 BOM 파일 업로드</span>가 필요합니다.
|
||||
</p>
|
||||
{revisionStatus?.has_revision && (
|
||||
<button
|
||||
className="btn-force-upload"
|
||||
onClick={() => {
|
||||
setActionType('force_revision_upload');
|
||||
setShowConfirmDialog(true);
|
||||
}}
|
||||
>
|
||||
🔄 새 BOM 파일 업로드
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 분류 섹션 */}
|
||||
<div className="classification-section">
|
||||
<div className="section-header">
|
||||
<h3>📂 구역 및 도면 분류</h3>
|
||||
</div>
|
||||
|
||||
<div className="classification-controls">
|
||||
<div className="control-group">
|
||||
<label>구역 선택:</label>
|
||||
<select
|
||||
value={selectedArea}
|
||||
onChange={(e) => setSelectedArea(e.target.value)}
|
||||
disabled={cuttingPlanStarted}
|
||||
>
|
||||
<option value="">구역을 선택하세요</option>
|
||||
{areaOptions.map(area => (
|
||||
<option key={area} value={area}>{area}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>도면 검색:</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="도면명으로 검색..."
|
||||
value={searchDrawing}
|
||||
onChange={(e) => setSearchDrawing(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<button
|
||||
className="btn-start-cutting-plan"
|
||||
onClick={handleStartCuttingPlan}
|
||||
disabled={cuttingPlanStarted || !selectedArea}
|
||||
>
|
||||
{cuttingPlanStarted ? '✅ Cutting Plan 작성 중' : '📝 Cutting Plan 작성 시작'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자재 현황 */}
|
||||
<div className="materials-summary">
|
||||
<div className="summary-stats">
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">전체 단관</span>
|
||||
<span className="stat-value">{filteredMaterials.length}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">할당된 단관</span>
|
||||
<span className="stat-value">{Object.keys(areaAssignments).length}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">미할당 단관</span>
|
||||
<span className="stat-value">{groupedMaterials.unassigned.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구역별 자재 테이블 */}
|
||||
<div className="cutting-plan-content">
|
||||
{/* 할당된 구역들 */}
|
||||
{Object.keys(groupedMaterials.assigned).sort().map(area => (
|
||||
<div key={area} className="area-section">
|
||||
<div className="area-header">
|
||||
<h4>📍 구역 {area}</h4>
|
||||
<span className="area-count">{groupedMaterials.assigned[area].length}개 단관</span>
|
||||
</div>
|
||||
|
||||
<div className="pipe-table">
|
||||
<div className="table-header">
|
||||
<div className="header-cell">구역</div>
|
||||
<div className="header-cell">도면</div>
|
||||
<div className="header-cell">라인번호</div>
|
||||
<div className="header-cell">파이프정보(재질)</div>
|
||||
<div className="header-cell">길이</div>
|
||||
<div className="header-cell">끝단정보</div>
|
||||
<div className="header-cell">액션</div>
|
||||
</div>
|
||||
|
||||
<div className="table-body">
|
||||
{groupedMaterials.assigned[area].map(material => (
|
||||
<div key={material.id} className="table-row">
|
||||
<div className="table-cell">
|
||||
<select
|
||||
value={areaAssignments[material.id] || ''}
|
||||
onChange={(e) => handleAreaAssignment(material.id, e.target.value)}
|
||||
>
|
||||
<option value="">미할당</option>
|
||||
{areaOptions.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="table-cell">{material.drawing_name || '-'}</div>
|
||||
<div className="table-cell">{material.line_no || '-'}</div>
|
||||
<div className="table-cell">{formatPipeInfo(material)}</div>
|
||||
<div className="table-cell">{formatLength(material.length || material.total_length)}</div>
|
||||
<div className="table-cell">
|
||||
<select
|
||||
value={endPreparations[material.id] || 'plain'}
|
||||
onChange={(e) => handleEndPrepChange(material.id, e.target.value)}
|
||||
>
|
||||
{endPrepOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="table-cell">
|
||||
<div className="action-buttons-small">
|
||||
<button
|
||||
className="btn-small btn-edit"
|
||||
onClick={() => {/* 편집 로직 */}}
|
||||
title="편집"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
className="btn-small btn-delete"
|
||||
onClick={() => handleRemoveMaterial(material.id)}
|
||||
title="삭제"
|
||||
>
|
||||
❌
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 미할당 단관들 */}
|
||||
{groupedMaterials.unassigned.length > 0 && (
|
||||
<div className="area-section unassigned">
|
||||
<div className="area-header">
|
||||
<h4>❓ 미할당 단관</h4>
|
||||
<span className="area-count">{groupedMaterials.unassigned.length}개 단관</span>
|
||||
</div>
|
||||
|
||||
<div className="pipe-table">
|
||||
<div className="table-header">
|
||||
<div className="header-cell">구역</div>
|
||||
<div className="header-cell">도면</div>
|
||||
<div className="header-cell">라인번호</div>
|
||||
<div className="header-cell">파이프정보(재질)</div>
|
||||
<div className="header-cell">길이</div>
|
||||
<div className="header-cell">끝단정보</div>
|
||||
<div className="header-cell">액션</div>
|
||||
</div>
|
||||
|
||||
<div className="table-body">
|
||||
{groupedMaterials.unassigned.map(material => (
|
||||
<div key={material.id} className="table-row">
|
||||
<div className="table-cell">
|
||||
<select
|
||||
value={areaAssignments[material.id] || ''}
|
||||
onChange={(e) => handleAreaAssignment(material.id, e.target.value)}
|
||||
>
|
||||
<option value="">미할당</option>
|
||||
{areaOptions.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="table-cell">{material.drawing_name || '-'}</div>
|
||||
<div className="table-cell">{material.line_no || '-'}</div>
|
||||
<div className="table-cell">{formatPipeInfo(material)}</div>
|
||||
<div className="table-cell">{formatLength(material.length || material.total_length)}</div>
|
||||
<div className="table-cell">
|
||||
<select
|
||||
value={endPreparations[material.id] || 'plain'}
|
||||
onChange={(e) => handleEndPrepChange(material.id, e.target.value)}
|
||||
>
|
||||
{endPrepOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="table-cell">
|
||||
<div className="action-buttons-small">
|
||||
<button
|
||||
className="btn-small btn-edit"
|
||||
onClick={() => {/* 편집 로직 */}}
|
||||
title="편집"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
className="btn-small btn-delete"
|
||||
onClick={() => handleRemoveMaterial(material.id)}
|
||||
title="삭제"
|
||||
>
|
||||
❌
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{filteredMaterials.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>조건에 맞는 PIPE 자재가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cutting Plan 관리 액션 */}
|
||||
<div className="cutting-plan-management-section">
|
||||
<div className="section-header">
|
||||
<h3>🔧 Cutting Plan 관리</h3>
|
||||
</div>
|
||||
|
||||
<div className="cutting-plan-actions">
|
||||
<button
|
||||
className="btn-export-temp"
|
||||
onClick={handleExportTempExcel}
|
||||
disabled={pipeRevisionLoading}
|
||||
>
|
||||
📊 임시 Excel 내보내기
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-finalize-cutting-plan"
|
||||
onClick={handleFinalizeCuttingPlan}
|
||||
disabled={pipeRevisionLoading}
|
||||
>
|
||||
🔒 Cutting Plan 확정 (이슈 관리 시작)
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-export-finalized"
|
||||
onClick={handleExportFinalizedExcel}
|
||||
disabled={pipeRevisionLoading}
|
||||
>
|
||||
📋 확정된 Excel 내보내기 (고정)
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-issue-management"
|
||||
onClick={handleGoToIssueManagement}
|
||||
disabled={pipeRevisionLoading}
|
||||
>
|
||||
🛠️ 이슈 관리 페이지
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="action-descriptions">
|
||||
<div className="action-desc">
|
||||
<strong>📊 임시 Excel:</strong> 현재 작업 중인 데이터 (리비전 시 변경됨)
|
||||
</div>
|
||||
<div className="action-desc">
|
||||
<strong>🔒 확정:</strong> 데이터 고정 및 이슈 관리 시작 (리비전 보호)
|
||||
</div>
|
||||
<div className="action-desc">
|
||||
<strong>📋 확정된 Excel:</strong> 고정된 데이터 (리비전과 무관)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 */}
|
||||
{showConfirmDialog && (
|
||||
<ConfirmDialog
|
||||
title={actionType === 'force_revision_upload' ? '강제 리비전 업로드' : '작업 확인'}
|
||||
message={
|
||||
actionType === 'force_revision_upload'
|
||||
? '기존 단관정보를 모두 삭제하고 새 BOM 파일을 업로드하시겠습니까?'
|
||||
: `선택된 단관을 삭제하시겠습니까?`
|
||||
}
|
||||
onConfirm={confirmAction}
|
||||
onCancel={() => setShowConfirmDialog(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PipeCuttingPlanPage;
|
||||
460
frontend/src/pages/revision/SpecialRevisionPage.jsx
Normal file
460
frontend/src/pages/revision/SpecialRevisionPage.jsx
Normal file
@@ -0,0 +1,460 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useRevisionLogic } from '../../hooks/useRevisionLogic';
|
||||
import { useRevisionComparison } from '../../hooks/useRevisionComparison';
|
||||
import { useRevisionStatus } from '../../hooks/useRevisionStatus';
|
||||
import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
|
||||
import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
|
||||
import './CategoryRevisionPage.css';
|
||||
|
||||
const SpecialRevisionPage = ({
|
||||
jobNo,
|
||||
fileId,
|
||||
previousFileId,
|
||||
onNavigate,
|
||||
user
|
||||
}) => {
|
||||
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [actionType, setActionType] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('description');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
const [subcategoryFilter, setSubcategoryFilter] = useState('all');
|
||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||
|
||||
// 리비전 로직 훅
|
||||
const {
|
||||
materials,
|
||||
loading: materialsLoading,
|
||||
error: materialsError,
|
||||
processingInfo,
|
||||
handleMaterialSelection,
|
||||
handleBulkAction,
|
||||
refreshMaterials
|
||||
} = useRevisionLogic(fileId, 'SPECIAL');
|
||||
|
||||
// 리비전 비교 훅
|
||||
const {
|
||||
comparisonResult,
|
||||
loading: comparisonLoading,
|
||||
error: comparisonError,
|
||||
performComparison,
|
||||
getFilteredComparison
|
||||
} = useRevisionComparison(fileId, previousFileId);
|
||||
|
||||
// 리비전 상태 훅
|
||||
const {
|
||||
revisionStatus,
|
||||
loading: statusLoading,
|
||||
error: statusError,
|
||||
uploadNewRevision,
|
||||
navigateToRevision
|
||||
} = useRevisionStatus(jobNo, fileId);
|
||||
|
||||
// 필터링 및 정렬된 자재 목록
|
||||
const filteredAndSortedMaterials = useMemo(() => {
|
||||
if (!materials) return [];
|
||||
|
||||
let filtered = materials.filter(material => {
|
||||
const matchesSearch = !searchTerm ||
|
||||
material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.brand?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === 'all' ||
|
||||
material.processing_info?.display_status === statusFilter;
|
||||
|
||||
const matchesSubcategory = subcategoryFilter === 'all' ||
|
||||
material.subcategory === subcategoryFilter;
|
||||
|
||||
const matchesPriority = priorityFilter === 'all' ||
|
||||
material.processing_info?.priority === priorityFilter;
|
||||
|
||||
return matchesSearch && matchesStatus && matchesSubcategory && matchesPriority;
|
||||
});
|
||||
|
||||
// 정렬
|
||||
filtered.sort((a, b) => {
|
||||
let aValue = a[sortBy] || '';
|
||||
let bValue = b[sortBy] || '';
|
||||
|
||||
if (typeof aValue === 'string') {
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
|
||||
} else {
|
||||
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [materials, searchTerm, statusFilter, subcategoryFilter, priorityFilter, sortBy, sortOrder]);
|
||||
|
||||
// 고유 값들 추출 (필터 옵션용)
|
||||
const uniqueValues = useMemo(() => {
|
||||
if (!materials) return { subcategories: [], priorities: [] };
|
||||
|
||||
const subcategories = [...new Set(materials.map(m => m.subcategory).filter(Boolean))];
|
||||
const priorities = [...new Set(materials.map(m => m.processing_info?.priority).filter(Boolean))];
|
||||
|
||||
return { subcategories, priorities };
|
||||
}, [materials]);
|
||||
|
||||
// 초기 비교 수행
|
||||
useEffect(() => {
|
||||
if (fileId && previousFileId && !comparisonResult) {
|
||||
performComparison('SPECIAL');
|
||||
}
|
||||
}, [fileId, previousFileId, comparisonResult, performComparison]);
|
||||
|
||||
// 자재 선택 처리
|
||||
const handleMaterialSelect = (materialId, isSelected) => {
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (isSelected) {
|
||||
newSelected.add(materialId);
|
||||
} else {
|
||||
newSelected.delete(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
handleMaterialSelection(materialId, isSelected);
|
||||
};
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleSelectAll = (isSelected) => {
|
||||
if (isSelected) {
|
||||
const allIds = new Set(filteredAndSortedMaterials.map(m => m.id));
|
||||
setSelectedMaterials(allIds);
|
||||
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true));
|
||||
} else {
|
||||
setSelectedMaterials(new Set());
|
||||
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false));
|
||||
}
|
||||
};
|
||||
|
||||
// 액션 실행
|
||||
const executeAction = async (action) => {
|
||||
setActionType(action);
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const confirmAction = async () => {
|
||||
try {
|
||||
await handleBulkAction(actionType, Array.from(selectedMaterials));
|
||||
setSelectedMaterials(new Set());
|
||||
setShowConfirmDialog(false);
|
||||
await refreshMaterials();
|
||||
} catch (error) {
|
||||
console.error('Action failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 상태별 색상 클래스
|
||||
const getStatusClass = (status) => {
|
||||
switch (status) {
|
||||
case 'REVISION_MATERIAL': return 'status-revision';
|
||||
case 'INVENTORY_MATERIAL': return 'status-inventory';
|
||||
case 'DELETED_MATERIAL': return 'status-deleted';
|
||||
case 'NEW_MATERIAL': return 'status-new';
|
||||
default: return 'status-normal';
|
||||
}
|
||||
};
|
||||
|
||||
// 우선순위별 색상 클래스
|
||||
const getPriorityClass = (priority) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'priority-high';
|
||||
case 'medium': return 'priority-medium';
|
||||
case 'low': return 'priority-low';
|
||||
default: return 'priority-normal';
|
||||
}
|
||||
};
|
||||
|
||||
// 수량 표시 (정수로 변환)
|
||||
const formatQuantity = (quantity) => {
|
||||
if (quantity === null || quantity === undefined) return '-';
|
||||
return Math.round(parseFloat(quantity) || 0).toString();
|
||||
};
|
||||
|
||||
// SPECIAL 자재 설명 생성 (브랜드, 모델 포함)
|
||||
const generateSpecialDescription = (material) => {
|
||||
const parts = [];
|
||||
|
||||
if (material.brand) parts.push(`[${material.brand}]`);
|
||||
if (material.description || material.item_name) {
|
||||
parts.push(material.description || material.item_name);
|
||||
}
|
||||
if (material.model_number) parts.push(`(${material.model_number})`);
|
||||
|
||||
return parts.length > 0 ? parts.join(' ') : 'SPECIAL 자재';
|
||||
};
|
||||
|
||||
if (materialsLoading || comparisonLoading || statusLoading) {
|
||||
return <LoadingSpinner message="SPECIAL 리비전 데이터 로딩 중..." />;
|
||||
}
|
||||
|
||||
const error = materialsError || comparisonError || statusError;
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="category-revision-page">
|
||||
{/* 헤더 */}
|
||||
<div className="materials-header">
|
||||
<div className="header-left">
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => onNavigate('enhanced-revision')}
|
||||
>
|
||||
← 리비전 관리로
|
||||
</button>
|
||||
<div className="header-center">
|
||||
<h1>⭐ SPECIAL 리비전 관리</h1>
|
||||
<span className="header-subtitle">
|
||||
특수 자재 및 브랜드별 SPECIAL 자재 리비전 처리
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<RevisionStatusIndicator
|
||||
revisionStatus={revisionStatus}
|
||||
onUploadRevision={uploadNewRevision}
|
||||
onNavigateToRevision={navigateToRevision}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컨트롤 섹션 */}
|
||||
<div className="control-section">
|
||||
<div className="section-header">
|
||||
<h3>📊 자재 현황 및 필터</h3>
|
||||
{processingInfo && (
|
||||
<div className="processing-summary">
|
||||
전체: {processingInfo.total_materials}개 |
|
||||
리비전: {processingInfo.by_status.REVISION_MATERIAL || 0}개 |
|
||||
재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0}개 |
|
||||
삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}개 |
|
||||
높은 우선순위: {processingInfo.by_priority?.high || 0}개
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="control-grid">
|
||||
<div className="control-group">
|
||||
<label>검색:</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="자재명, 브랜드, 도면명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>상태 필터:</label>
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
<option value="REVISION_MATERIAL">리비전 자재</option>
|
||||
<option value="INVENTORY_MATERIAL">재고 자재</option>
|
||||
<option value="DELETED_MATERIAL">삭제 자재</option>
|
||||
<option value="NEW_MATERIAL">신규 자재</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>서브카테고리:</label>
|
||||
<select value={subcategoryFilter} onChange={(e) => setSubcategoryFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
{uniqueValues.subcategories.map(sub => (
|
||||
<option key={sub} value={sub}>{sub}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>우선순위:</label>
|
||||
<select value={priorityFilter} onChange={(e) => setPriorityFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
<option value="high">높음</option>
|
||||
<option value="medium">보통</option>
|
||||
<option value="low">낮음</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>정렬:</label>
|
||||
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
|
||||
<option value="description">자재명</option>
|
||||
<option value="brand">브랜드</option>
|
||||
<option value="subcategory">서브카테고리</option>
|
||||
<option value="quantity">수량</option>
|
||||
</select>
|
||||
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
|
||||
<option value="asc">오름차순</option>
|
||||
<option value="desc">내림차순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 자재 액션 */}
|
||||
{selectedMaterials.size > 0 && (
|
||||
<div className="selected-actions">
|
||||
<span className="selected-count">
|
||||
{selectedMaterials.size}개 선택됨
|
||||
</span>
|
||||
<div className="action-buttons">
|
||||
<button
|
||||
className="btn-action btn-purchase"
|
||||
onClick={() => executeAction('request_purchase')}
|
||||
>
|
||||
구매 신청
|
||||
</button>
|
||||
<button
|
||||
className="btn-action btn-inventory"
|
||||
onClick={() => executeAction('mark_inventory')}
|
||||
>
|
||||
재고 처리
|
||||
</button>
|
||||
<button
|
||||
className="btn-action btn-delete"
|
||||
onClick={() => executeAction('mark_deleted')}
|
||||
>
|
||||
삭제 처리
|
||||
</button>
|
||||
<button
|
||||
className="btn-action btn-priority"
|
||||
onClick={() => executeAction('set_high_priority')}
|
||||
style={{ background: '#dc2626' }}
|
||||
>
|
||||
높은 우선순위
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 자재 테이블 */}
|
||||
<div className="materials-table-container">
|
||||
<div className="table-header">
|
||||
<div className="header-cell checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.size === filteredAndSortedMaterials.length && filteredAndSortedMaterials.length > 0}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="header-cell">상태</div>
|
||||
<div className="header-cell">우선순위</div>
|
||||
<div className="header-cell">자재명</div>
|
||||
<div className="header-cell">브랜드</div>
|
||||
<div className="header-cell">도면명</div>
|
||||
<div className="header-cell">수량</div>
|
||||
<div className="header-cell">단위</div>
|
||||
<div className="header-cell">액션</div>
|
||||
</div>
|
||||
|
||||
<div className="table-body">
|
||||
{filteredAndSortedMaterials.map((material) => (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`table-row ${getStatusClass(material.processing_info?.display_status)} ${getPriorityClass(material.processing_info?.priority)}`}
|
||||
>
|
||||
<div className="table-cell checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onChange={(e) => handleMaterialSelect(material.id, e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-cell">
|
||||
<span className={`status-badge ${getStatusClass(material.processing_info?.display_status)}`}>
|
||||
{material.processing_info?.display_status || 'NORMAL'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="table-cell">
|
||||
<span className={`priority-badge ${getPriorityClass(material.processing_info?.priority)}`}>
|
||||
{material.processing_info?.priority === 'high' ? '🔴' :
|
||||
material.processing_info?.priority === 'medium' ? '🟡' :
|
||||
material.processing_info?.priority === 'low' ? '🟢' : '⚪'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="table-cell">
|
||||
<div className="material-info">
|
||||
<div className="material-name">{generateSpecialDescription(material)}</div>
|
||||
{material.processing_info?.notes && (
|
||||
<div className="material-notes">{material.processing_info.notes}</div>
|
||||
)}
|
||||
{material.subcategory && (
|
||||
<div className="material-subcategory">📂 {material.subcategory}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-cell">{material.brand || '-'}</div>
|
||||
<div className="table-cell">{material.drawing_name || '-'}</div>
|
||||
<div className="table-cell quantity-cell">
|
||||
<span className="quantity-value">
|
||||
{formatQuantity(material.quantity)}
|
||||
</span>
|
||||
{material.processing_info?.quantity_change && (
|
||||
<span className="quantity-change">
|
||||
({material.processing_info.quantity_change > 0 ? '+' : ''}
|
||||
{formatQuantity(material.processing_info.quantity_change)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="table-cell">{material.unit || 'EA'}</div>
|
||||
<div className="table-cell">
|
||||
<div className="action-buttons-small">
|
||||
<button
|
||||
className="btn-small btn-view"
|
||||
onClick={() => {/* 상세 보기 로직 */}}
|
||||
title="상세 정보"
|
||||
>
|
||||
👁️
|
||||
</button>
|
||||
<button
|
||||
className="btn-small btn-edit"
|
||||
onClick={() => {/* 편집 로직 */}}
|
||||
title="편집"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
className="btn-small btn-priority"
|
||||
onClick={() => {/* 우선순위 변경 로직 */}}
|
||||
title="우선순위 변경"
|
||||
>
|
||||
⭐
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredAndSortedMaterials.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>조건에 맞는 SPECIAL 자재가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 */}
|
||||
{showConfirmDialog && (
|
||||
<ConfirmDialog
|
||||
title="작업 확인"
|
||||
message={`선택된 ${selectedMaterials.size}개 자재에 대해 "${actionType}" 작업을 수행하시겠습니까?`}
|
||||
onConfirm={confirmAction}
|
||||
onCancel={() => setShowConfirmDialog(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpecialRevisionPage;
|
||||
450
frontend/src/pages/revision/SupportRevisionPage.jsx
Normal file
450
frontend/src/pages/revision/SupportRevisionPage.jsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useRevisionLogic } from '../../hooks/useRevisionLogic';
|
||||
import { useRevisionComparison } from '../../hooks/useRevisionComparison';
|
||||
import { useRevisionStatus } from '../../hooks/useRevisionStatus';
|
||||
import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
|
||||
import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
|
||||
import './CategoryRevisionPage.css';
|
||||
|
||||
const SupportRevisionPage = ({
|
||||
jobNo,
|
||||
fileId,
|
||||
previousFileId,
|
||||
onNavigate,
|
||||
user
|
||||
}) => {
|
||||
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [actionType, setActionType] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('description');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
const [supportTypeFilter, setSupportTypeFilter] = useState('all');
|
||||
const [loadRatingFilter, setLoadRatingFilter] = useState('all');
|
||||
|
||||
// 리비전 로직 훅
|
||||
const {
|
||||
materials,
|
||||
loading: materialsLoading,
|
||||
error: materialsError,
|
||||
processingInfo,
|
||||
handleMaterialSelection,
|
||||
handleBulkAction,
|
||||
refreshMaterials
|
||||
} = useRevisionLogic(fileId, 'SUPPORT');
|
||||
|
||||
// 리비전 비교 훅
|
||||
const {
|
||||
comparisonResult,
|
||||
loading: comparisonLoading,
|
||||
error: comparisonError,
|
||||
performComparison,
|
||||
getFilteredComparison
|
||||
} = useRevisionComparison(fileId, previousFileId);
|
||||
|
||||
// 리비전 상태 훅
|
||||
const {
|
||||
revisionStatus,
|
||||
loading: statusLoading,
|
||||
error: statusError,
|
||||
uploadNewRevision,
|
||||
navigateToRevision
|
||||
} = useRevisionStatus(jobNo, fileId);
|
||||
|
||||
// 필터링 및 정렬된 자재 목록
|
||||
const filteredAndSortedMaterials = useMemo(() => {
|
||||
if (!materials) return [];
|
||||
|
||||
let filtered = materials.filter(material => {
|
||||
const matchesSearch = !searchTerm ||
|
||||
material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.support_type?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === 'all' ||
|
||||
material.processing_info?.display_status === statusFilter;
|
||||
|
||||
const matchesSupportType = supportTypeFilter === 'all' ||
|
||||
material.support_type === supportTypeFilter;
|
||||
|
||||
const matchesLoadRating = loadRatingFilter === 'all' ||
|
||||
material.load_rating === loadRatingFilter;
|
||||
|
||||
return matchesSearch && matchesStatus && matchesSupportType && matchesLoadRating;
|
||||
});
|
||||
|
||||
// 정렬
|
||||
filtered.sort((a, b) => {
|
||||
let aValue = a[sortBy] || '';
|
||||
let bValue = b[sortBy] || '';
|
||||
|
||||
if (typeof aValue === 'string') {
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
|
||||
} else {
|
||||
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [materials, searchTerm, statusFilter, supportTypeFilter, loadRatingFilter, sortBy, sortOrder]);
|
||||
|
||||
// 고유 값들 추출 (필터 옵션용)
|
||||
const uniqueValues = useMemo(() => {
|
||||
if (!materials) return { supportTypes: [], loadRatings: [] };
|
||||
|
||||
const supportTypes = [...new Set(materials.map(m => m.support_type).filter(Boolean))];
|
||||
const loadRatings = [...new Set(materials.map(m => m.load_rating).filter(Boolean))];
|
||||
|
||||
return { supportTypes, loadRatings };
|
||||
}, [materials]);
|
||||
|
||||
// 초기 비교 수행
|
||||
useEffect(() => {
|
||||
if (fileId && previousFileId && !comparisonResult) {
|
||||
performComparison('SUPPORT');
|
||||
}
|
||||
}, [fileId, previousFileId, comparisonResult, performComparison]);
|
||||
|
||||
// 자재 선택 처리
|
||||
const handleMaterialSelect = (materialId, isSelected) => {
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (isSelected) {
|
||||
newSelected.add(materialId);
|
||||
} else {
|
||||
newSelected.delete(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
handleMaterialSelection(materialId, isSelected);
|
||||
};
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleSelectAll = (isSelected) => {
|
||||
if (isSelected) {
|
||||
const allIds = new Set(filteredAndSortedMaterials.map(m => m.id));
|
||||
setSelectedMaterials(allIds);
|
||||
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true));
|
||||
} else {
|
||||
setSelectedMaterials(new Set());
|
||||
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false));
|
||||
}
|
||||
};
|
||||
|
||||
// 액션 실행
|
||||
const executeAction = async (action) => {
|
||||
setActionType(action);
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const confirmAction = async () => {
|
||||
try {
|
||||
await handleBulkAction(actionType, Array.from(selectedMaterials));
|
||||
setSelectedMaterials(new Set());
|
||||
setShowConfirmDialog(false);
|
||||
await refreshMaterials();
|
||||
} catch (error) {
|
||||
console.error('Action failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 상태별 색상 클래스
|
||||
const getStatusClass = (status) => {
|
||||
switch (status) {
|
||||
case 'REVISION_MATERIAL': return 'status-revision';
|
||||
case 'INVENTORY_MATERIAL': return 'status-inventory';
|
||||
case 'DELETED_MATERIAL': return 'status-deleted';
|
||||
case 'NEW_MATERIAL': return 'status-new';
|
||||
default: return 'status-normal';
|
||||
}
|
||||
};
|
||||
|
||||
// 수량 표시 (정수로 변환)
|
||||
const formatQuantity = (quantity) => {
|
||||
if (quantity === null || quantity === undefined) return '-';
|
||||
return Math.round(parseFloat(quantity) || 0).toString();
|
||||
};
|
||||
|
||||
// SUPPORT 자재 설명 생성 (지지대 타입과 하중 정보 포함)
|
||||
const generateSupportDescription = (material) => {
|
||||
const parts = [];
|
||||
|
||||
if (material.support_type) parts.push(material.support_type);
|
||||
if (material.pipe_size) parts.push(`${material.pipe_size}"`);
|
||||
if (material.load_rating) parts.push(`${material.load_rating} 등급`);
|
||||
if (material.material_grade) parts.push(material.material_grade);
|
||||
|
||||
const baseDesc = material.description || material.item_name || 'SUPPORT';
|
||||
|
||||
return parts.length > 0 ? `${baseDesc} (${parts.join(', ')})` : baseDesc;
|
||||
};
|
||||
|
||||
// 치수 정보 표시
|
||||
const formatDimensions = (material) => {
|
||||
const dims = [];
|
||||
if (material.length_mm) dims.push(`L${material.length_mm}`);
|
||||
if (material.width_mm) dims.push(`W${material.width_mm}`);
|
||||
if (material.height_mm) dims.push(`H${material.height_mm}`);
|
||||
|
||||
return dims.length > 0 ? dims.join('×') : '-';
|
||||
};
|
||||
|
||||
if (materialsLoading || comparisonLoading || statusLoading) {
|
||||
return <LoadingSpinner message="SUPPORT 리비전 데이터 로딩 중..." />;
|
||||
}
|
||||
|
||||
const error = materialsError || comparisonError || statusError;
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="category-revision-page">
|
||||
{/* 헤더 */}
|
||||
<div className="materials-header">
|
||||
<div className="header-left">
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => onNavigate('enhanced-revision')}
|
||||
>
|
||||
← 리비전 관리로
|
||||
</button>
|
||||
<div className="header-center">
|
||||
<h1>🏗️ SUPPORT 리비전 관리</h1>
|
||||
<span className="header-subtitle">
|
||||
지지대 타입과 하중등급을 고려한 SUPPORT 자재 리비전 처리
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<RevisionStatusIndicator
|
||||
revisionStatus={revisionStatus}
|
||||
onUploadRevision={uploadNewRevision}
|
||||
onNavigateToRevision={navigateToRevision}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컨트롤 섹션 */}
|
||||
<div className="control-section">
|
||||
<div className="section-header">
|
||||
<h3>📊 자재 현황 및 필터</h3>
|
||||
{processingInfo && (
|
||||
<div className="processing-summary">
|
||||
전체: {processingInfo.total_materials}개 |
|
||||
리비전: {processingInfo.by_status.REVISION_MATERIAL || 0}개 |
|
||||
재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0}개 |
|
||||
삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}개
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="control-grid">
|
||||
<div className="control-group">
|
||||
<label>검색:</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="자재명, 지지대 타입, 도면명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>상태 필터:</label>
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
<option value="REVISION_MATERIAL">리비전 자재</option>
|
||||
<option value="INVENTORY_MATERIAL">재고 자재</option>
|
||||
<option value="DELETED_MATERIAL">삭제 자재</option>
|
||||
<option value="NEW_MATERIAL">신규 자재</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>지지대 타입:</label>
|
||||
<select value={supportTypeFilter} onChange={(e) => setSupportTypeFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
{uniqueValues.supportTypes.map(type => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>하중등급:</label>
|
||||
<select value={loadRatingFilter} onChange={(e) => setLoadRatingFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
{uniqueValues.loadRatings.map(rating => (
|
||||
<option key={rating} value={rating}>{rating}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>정렬:</label>
|
||||
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
|
||||
<option value="description">자재명</option>
|
||||
<option value="support_type">지지대 타입</option>
|
||||
<option value="load_rating">하중등급</option>
|
||||
<option value="pipe_size">파이프 크기</option>
|
||||
<option value="quantity">수량</option>
|
||||
</select>
|
||||
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
|
||||
<option value="asc">오름차순</option>
|
||||
<option value="desc">내림차순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 자재 액션 */}
|
||||
{selectedMaterials.size > 0 && (
|
||||
<div className="selected-actions">
|
||||
<span className="selected-count">
|
||||
{selectedMaterials.size}개 선택됨
|
||||
</span>
|
||||
<div className="action-buttons">
|
||||
<button
|
||||
className="btn-action btn-purchase"
|
||||
onClick={() => executeAction('request_purchase')}
|
||||
>
|
||||
구매 신청
|
||||
</button>
|
||||
<button
|
||||
className="btn-action btn-inventory"
|
||||
onClick={() => executeAction('mark_inventory')}
|
||||
>
|
||||
재고 처리
|
||||
</button>
|
||||
<button
|
||||
className="btn-action btn-delete"
|
||||
onClick={() => executeAction('mark_deleted')}
|
||||
>
|
||||
삭제 처리
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 자재 테이블 */}
|
||||
<div className="materials-table-container">
|
||||
<div className="table-header">
|
||||
<div className="header-cell checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.size === filteredAndSortedMaterials.length && filteredAndSortedMaterials.length > 0}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="header-cell">상태</div>
|
||||
<div className="header-cell">자재명</div>
|
||||
<div className="header-cell">지지대 타입</div>
|
||||
<div className="header-cell">파이프 크기</div>
|
||||
<div className="header-cell">하중등급</div>
|
||||
<div className="header-cell">치수</div>
|
||||
<div className="header-cell">수량</div>
|
||||
<div className="header-cell">단위</div>
|
||||
<div className="header-cell">액션</div>
|
||||
</div>
|
||||
|
||||
<div className="table-body">
|
||||
{filteredAndSortedMaterials.map((material) => (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`table-row ${getStatusClass(material.processing_info?.display_status)}`}
|
||||
>
|
||||
<div className="table-cell checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onChange={(e) => handleMaterialSelect(material.id, e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-cell">
|
||||
<span className={`status-badge ${getStatusClass(material.processing_info?.display_status)}`}>
|
||||
{material.processing_info?.display_status || 'NORMAL'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="table-cell">
|
||||
<div className="material-info">
|
||||
<div className="material-name">{generateSupportDescription(material)}</div>
|
||||
{material.processing_info?.notes && (
|
||||
<div className="material-notes">{material.processing_info.notes}</div>
|
||||
)}
|
||||
{material.load_capacity && (
|
||||
<div className="material-capacity">💪 하중용량: {material.load_capacity}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-cell">{material.support_type || '-'}</div>
|
||||
<div className="table-cell">{material.pipe_size ? `${material.pipe_size}"` : '-'}</div>
|
||||
<div className="table-cell">{material.load_rating || '-'}</div>
|
||||
<div className="table-cell">{formatDimensions(material)}</div>
|
||||
<div className="table-cell quantity-cell">
|
||||
<span className="quantity-value">
|
||||
{formatQuantity(material.quantity)}
|
||||
</span>
|
||||
{material.processing_info?.quantity_change && (
|
||||
<span className="quantity-change">
|
||||
({material.processing_info.quantity_change > 0 ? '+' : ''}
|
||||
{formatQuantity(material.processing_info.quantity_change)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="table-cell">{material.unit || 'EA'}</div>
|
||||
<div className="table-cell">
|
||||
<div className="action-buttons-small">
|
||||
<button
|
||||
className="btn-small btn-view"
|
||||
onClick={() => {/* 상세 보기 로직 */}}
|
||||
title="상세 정보"
|
||||
>
|
||||
👁️
|
||||
</button>
|
||||
<button
|
||||
className="btn-small btn-edit"
|
||||
onClick={() => {/* 편집 로직 */}}
|
||||
title="편집"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
className="btn-small btn-calc"
|
||||
onClick={() => {/* 하중 계산 로직 */}}
|
||||
title="하중 계산"
|
||||
>
|
||||
🧮
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredAndSortedMaterials.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>조건에 맞는 SUPPORT 자재가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 */}
|
||||
{showConfirmDialog && (
|
||||
<ConfirmDialog
|
||||
title="작업 확인"
|
||||
message={`선택된 ${selectedMaterials.size}개 자재에 대해 "${actionType}" 작업을 수행하시겠습니까?`}
|
||||
onConfirm={confirmAction}
|
||||
onCancel={() => setShowConfirmDialog(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupportRevisionPage;
|
||||
483
frontend/src/pages/revision/UnclassifiedRevisionPage.jsx
Normal file
483
frontend/src/pages/revision/UnclassifiedRevisionPage.jsx
Normal file
@@ -0,0 +1,483 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useRevisionLogic } from '../../hooks/useRevisionLogic';
|
||||
import { useRevisionComparison } from '../../hooks/useRevisionComparison';
|
||||
import { useRevisionStatus } from '../../hooks/useRevisionStatus';
|
||||
import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
|
||||
import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
|
||||
import './CategoryRevisionPage.css';
|
||||
|
||||
const UnclassifiedRevisionPage = ({
|
||||
jobNo,
|
||||
fileId,
|
||||
previousFileId,
|
||||
onNavigate,
|
||||
user
|
||||
}) => {
|
||||
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [actionType, setActionType] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('description');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
const [classificationFilter, setClassificationFilter] = useState('all');
|
||||
const [showClassificationTools, setShowClassificationTools] = useState(false);
|
||||
|
||||
// 리비전 로직 훅
|
||||
const {
|
||||
materials,
|
||||
loading: materialsLoading,
|
||||
error: materialsError,
|
||||
processingInfo,
|
||||
handleMaterialSelection,
|
||||
handleBulkAction,
|
||||
refreshMaterials
|
||||
} = useRevisionLogic(fileId, 'UNCLASSIFIED');
|
||||
|
||||
// 리비전 비교 훅
|
||||
const {
|
||||
comparisonResult,
|
||||
loading: comparisonLoading,
|
||||
error: comparisonError,
|
||||
performComparison,
|
||||
getFilteredComparison
|
||||
} = useRevisionComparison(fileId, previousFileId);
|
||||
|
||||
// 리비전 상태 훅
|
||||
const {
|
||||
revisionStatus,
|
||||
loading: statusLoading,
|
||||
error: statusError,
|
||||
uploadNewRevision,
|
||||
navigateToRevision
|
||||
} = useRevisionStatus(jobNo, fileId);
|
||||
|
||||
// 필터링 및 정렬된 자재 목록
|
||||
const filteredAndSortedMaterials = useMemo(() => {
|
||||
if (!materials) return [];
|
||||
|
||||
let filtered = materials.filter(material => {
|
||||
const matchesSearch = !searchTerm ||
|
||||
material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === 'all' ||
|
||||
material.processing_info?.display_status === statusFilter;
|
||||
|
||||
const matchesClassification = classificationFilter === 'all' ||
|
||||
(classificationFilter === 'needs_classification' && material.classification_confidence < 0.5) ||
|
||||
(classificationFilter === 'low_confidence' && material.classification_confidence >= 0.5 && material.classification_confidence < 0.8) ||
|
||||
(classificationFilter === 'high_confidence' && material.classification_confidence >= 0.8);
|
||||
|
||||
return matchesSearch && matchesStatus && matchesClassification;
|
||||
});
|
||||
|
||||
// 정렬
|
||||
filtered.sort((a, b) => {
|
||||
let aValue = a[sortBy] || '';
|
||||
let bValue = b[sortBy] || '';
|
||||
|
||||
if (typeof aValue === 'string') {
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
|
||||
} else {
|
||||
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [materials, searchTerm, statusFilter, classificationFilter, sortBy, sortOrder]);
|
||||
|
||||
// 분류 신뢰도별 통계
|
||||
const classificationStats = useMemo(() => {
|
||||
if (!materials) return { needsClassification: 0, lowConfidence: 0, highConfidence: 0 };
|
||||
|
||||
return materials.reduce((stats, material) => {
|
||||
const confidence = material.classification_confidence || 0;
|
||||
if (confidence < 0.5) stats.needsClassification++;
|
||||
else if (confidence < 0.8) stats.lowConfidence++;
|
||||
else stats.highConfidence++;
|
||||
return stats;
|
||||
}, { needsClassification: 0, lowConfidence: 0, highConfidence: 0 });
|
||||
}, [materials]);
|
||||
|
||||
// 초기 비교 수행
|
||||
useEffect(() => {
|
||||
if (fileId && previousFileId && !comparisonResult) {
|
||||
performComparison('UNCLASSIFIED');
|
||||
}
|
||||
}, [fileId, previousFileId, comparisonResult, performComparison]);
|
||||
|
||||
// 자재 선택 처리
|
||||
const handleMaterialSelect = (materialId, isSelected) => {
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (isSelected) {
|
||||
newSelected.add(materialId);
|
||||
} else {
|
||||
newSelected.delete(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
handleMaterialSelection(materialId, isSelected);
|
||||
};
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleSelectAll = (isSelected) => {
|
||||
if (isSelected) {
|
||||
const allIds = new Set(filteredAndSortedMaterials.map(m => m.id));
|
||||
setSelectedMaterials(allIds);
|
||||
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true));
|
||||
} else {
|
||||
setSelectedMaterials(new Set());
|
||||
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false));
|
||||
}
|
||||
};
|
||||
|
||||
// 액션 실행
|
||||
const executeAction = async (action) => {
|
||||
setActionType(action);
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const confirmAction = async () => {
|
||||
try {
|
||||
await handleBulkAction(actionType, Array.from(selectedMaterials));
|
||||
setSelectedMaterials(new Set());
|
||||
setShowConfirmDialog(false);
|
||||
await refreshMaterials();
|
||||
} catch (error) {
|
||||
console.error('Action failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 상태별 색상 클래스
|
||||
const getStatusClass = (status) => {
|
||||
switch (status) {
|
||||
case 'REVISION_MATERIAL': return 'status-revision';
|
||||
case 'INVENTORY_MATERIAL': return 'status-inventory';
|
||||
case 'DELETED_MATERIAL': return 'status-deleted';
|
||||
case 'NEW_MATERIAL': return 'status-new';
|
||||
default: return 'status-normal';
|
||||
}
|
||||
};
|
||||
|
||||
// 분류 신뢰도별 색상 클래스
|
||||
const getConfidenceClass = (confidence) => {
|
||||
if (confidence < 0.5) return 'confidence-low';
|
||||
if (confidence < 0.8) return 'confidence-medium';
|
||||
return 'confidence-high';
|
||||
};
|
||||
|
||||
// 수량 표시 (정수로 변환)
|
||||
const formatQuantity = (quantity) => {
|
||||
if (quantity === null || quantity === undefined) return '-';
|
||||
return Math.round(parseFloat(quantity) || 0).toString();
|
||||
};
|
||||
|
||||
// 분류 신뢰도 표시
|
||||
const formatConfidence = (confidence) => {
|
||||
if (confidence === null || confidence === undefined) return '0%';
|
||||
return `${Math.round(confidence * 100)}%`;
|
||||
};
|
||||
|
||||
// 분류 제안 카테고리 표시
|
||||
const getSuggestedCategory = (material) => {
|
||||
// 간단한 키워드 기반 분류 제안
|
||||
const desc = (material.description || material.item_name || '').toLowerCase();
|
||||
|
||||
if (desc.includes('pipe') || desc.includes('파이프')) return 'PIPE';
|
||||
if (desc.includes('flange') || desc.includes('플랜지')) return 'FLANGE';
|
||||
if (desc.includes('fitting') || desc.includes('피팅')) return 'FITTING';
|
||||
if (desc.includes('support') || desc.includes('지지대')) return 'SUPPORT';
|
||||
if (desc.includes('valve') || desc.includes('밸브')) return 'SPECIAL';
|
||||
|
||||
return '수동 분류 필요';
|
||||
};
|
||||
|
||||
if (materialsLoading || comparisonLoading || statusLoading) {
|
||||
return <LoadingSpinner message="UNCLASSIFIED 리비전 데이터 로딩 중..." />;
|
||||
}
|
||||
|
||||
const error = materialsError || comparisonError || statusError;
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="category-revision-page">
|
||||
{/* 헤더 */}
|
||||
<div className="materials-header">
|
||||
<div className="header-left">
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => onNavigate('enhanced-revision')}
|
||||
>
|
||||
← 리비전 관리로
|
||||
</button>
|
||||
<div className="header-center">
|
||||
<h1>❓ UNCLASSIFIED 리비전 관리</h1>
|
||||
<span className="header-subtitle">
|
||||
미분류 자재의 리비전 처리 및 분류 작업
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<RevisionStatusIndicator
|
||||
revisionStatus={revisionStatus}
|
||||
onUploadRevision={uploadNewRevision}
|
||||
onNavigateToRevision={navigateToRevision}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 분류 통계 카드 */}
|
||||
<div className="classification-stats-card">
|
||||
<div className="stats-header">
|
||||
<h3>🔍 분류 현황</h3>
|
||||
<button
|
||||
className="btn-toggle-tools"
|
||||
onClick={() => setShowClassificationTools(!showClassificationTools)}
|
||||
>
|
||||
{showClassificationTools ? '도구 숨기기' : '분류 도구 보기'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-item needs-classification">
|
||||
<div className="stat-value">{classificationStats.needsClassification}</div>
|
||||
<div className="stat-label">분류 필요</div>
|
||||
</div>
|
||||
<div className="stat-item low-confidence">
|
||||
<div className="stat-value">{classificationStats.lowConfidence}</div>
|
||||
<div className="stat-label">낮은 신뢰도</div>
|
||||
</div>
|
||||
<div className="stat-item high-confidence">
|
||||
<div className="stat-value">{classificationStats.highConfidence}</div>
|
||||
<div className="stat-label">높은 신뢰도</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컨트롤 섹션 */}
|
||||
<div className="control-section">
|
||||
<div className="section-header">
|
||||
<h3>📊 자재 현황 및 필터</h3>
|
||||
{processingInfo && (
|
||||
<div className="processing-summary">
|
||||
전체: {processingInfo.total_materials}개 |
|
||||
리비전: {processingInfo.by_status.REVISION_MATERIAL || 0}개 |
|
||||
재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0}개 |
|
||||
삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}개
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="control-grid">
|
||||
<div className="control-group">
|
||||
<label>검색:</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="자재명, 도면명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>상태 필터:</label>
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
<option value="REVISION_MATERIAL">리비전 자재</option>
|
||||
<option value="INVENTORY_MATERIAL">재고 자재</option>
|
||||
<option value="DELETED_MATERIAL">삭제 자재</option>
|
||||
<option value="NEW_MATERIAL">신규 자재</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>분류 상태:</label>
|
||||
<select value={classificationFilter} onChange={(e) => setClassificationFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
<option value="needs_classification">분류 필요 (<50%)</option>
|
||||
<option value="low_confidence">낮은 신뢰도 (50-80%)</option>
|
||||
<option value="high_confidence">높은 신뢰도 (>80%)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>정렬:</label>
|
||||
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
|
||||
<option value="description">자재명</option>
|
||||
<option value="classification_confidence">분류 신뢰도</option>
|
||||
<option value="quantity">수량</option>
|
||||
<option value="drawing_name">도면명</option>
|
||||
</select>
|
||||
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
|
||||
<option value="asc">오름차순</option>
|
||||
<option value="desc">내림차순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 자재 액션 */}
|
||||
{selectedMaterials.size > 0 && (
|
||||
<div className="selected-actions">
|
||||
<span className="selected-count">
|
||||
{selectedMaterials.size}개 선택됨
|
||||
</span>
|
||||
<div className="action-buttons">
|
||||
<button
|
||||
className="btn-action btn-purchase"
|
||||
onClick={() => executeAction('request_purchase')}
|
||||
>
|
||||
구매 신청
|
||||
</button>
|
||||
<button
|
||||
className="btn-action btn-inventory"
|
||||
onClick={() => executeAction('mark_inventory')}
|
||||
>
|
||||
재고 처리
|
||||
</button>
|
||||
<button
|
||||
className="btn-action btn-delete"
|
||||
onClick={() => executeAction('mark_deleted')}
|
||||
>
|
||||
삭제 처리
|
||||
</button>
|
||||
<button
|
||||
className="btn-action btn-classify"
|
||||
onClick={() => executeAction('auto_classify')}
|
||||
style={{ background: '#8b5cf6' }}
|
||||
>
|
||||
자동 분류
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 자재 테이블 */}
|
||||
<div className="materials-table-container">
|
||||
<div className="table-header">
|
||||
<div className="header-cell checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.size === filteredAndSortedMaterials.length && filteredAndSortedMaterials.length > 0}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="header-cell">상태</div>
|
||||
<div className="header-cell">자재명</div>
|
||||
<div className="header-cell">분류 신뢰도</div>
|
||||
<div className="header-cell">제안 카테고리</div>
|
||||
<div className="header-cell">도면명</div>
|
||||
<div className="header-cell">수량</div>
|
||||
<div className="header-cell">단위</div>
|
||||
<div className="header-cell">액션</div>
|
||||
</div>
|
||||
|
||||
<div className="table-body">
|
||||
{filteredAndSortedMaterials.map((material) => (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`table-row ${getStatusClass(material.processing_info?.display_status)} ${getConfidenceClass(material.classification_confidence)}`}
|
||||
>
|
||||
<div className="table-cell checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onChange={(e) => handleMaterialSelect(material.id, e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-cell">
|
||||
<span className={`status-badge ${getStatusClass(material.processing_info?.display_status)}`}>
|
||||
{material.processing_info?.display_status || 'NORMAL'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="table-cell">
|
||||
<div className="material-info">
|
||||
<div className="material-name">{material.description || material.item_name || '자재명 없음'}</div>
|
||||
{material.processing_info?.notes && (
|
||||
<div className="material-notes">{material.processing_info.notes}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-cell">
|
||||
<span className={`confidence-badge ${getConfidenceClass(material.classification_confidence)}`}>
|
||||
{formatConfidence(material.classification_confidence)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="table-cell">
|
||||
<span className="suggested-category">
|
||||
{getSuggestedCategory(material)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="table-cell">{material.drawing_name || '-'}</div>
|
||||
<div className="table-cell quantity-cell">
|
||||
<span className="quantity-value">
|
||||
{formatQuantity(material.quantity)}
|
||||
</span>
|
||||
{material.processing_info?.quantity_change && (
|
||||
<span className="quantity-change">
|
||||
({material.processing_info.quantity_change > 0 ? '+' : ''}
|
||||
{formatQuantity(material.processing_info.quantity_change)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="table-cell">{material.unit || 'EA'}</div>
|
||||
<div className="table-cell">
|
||||
<div className="action-buttons-small">
|
||||
<button
|
||||
className="btn-small btn-view"
|
||||
onClick={() => {/* 상세 보기 로직 */}}
|
||||
title="상세 정보"
|
||||
>
|
||||
👁️
|
||||
</button>
|
||||
<button
|
||||
className="btn-small btn-classify"
|
||||
onClick={() => {/* 수동 분류 로직 */}}
|
||||
title="수동 분류"
|
||||
>
|
||||
🏷️
|
||||
</button>
|
||||
<button
|
||||
className="btn-small btn-auto-classify"
|
||||
onClick={() => {/* 자동 분류 로직 */}}
|
||||
title="자동 분류"
|
||||
>
|
||||
🤖
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredAndSortedMaterials.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>조건에 맞는 UNCLASSIFIED 자재가 없습니다.</p>
|
||||
{materials && materials.length === 0 && (
|
||||
<p>🎉 모든 자재가 분류되었습니다!</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 */}
|
||||
{showConfirmDialog && (
|
||||
<ConfirmDialog
|
||||
title="작업 확인"
|
||||
message={`선택된 ${selectedMaterials.size}개 자재에 대해 "${actionType}" 작업을 수행하시겠습니까?`}
|
||||
onConfirm={confirmAction}
|
||||
onCancel={() => setShowConfirmDialog(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnclassifiedRevisionPage;
|
||||
453
frontend/src/pages/revision/ValveRevisionPage.jsx
Normal file
453
frontend/src/pages/revision/ValveRevisionPage.jsx
Normal file
@@ -0,0 +1,453 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useRevisionLogic } from '../../hooks/useRevisionLogic';
|
||||
import { useRevisionComparison } from '../../hooks/useRevisionComparison';
|
||||
import { useRevisionStatus } from '../../hooks/useRevisionStatus';
|
||||
import { LoadingSpinner, ErrorMessage, ConfirmDialog } from '../../components/common';
|
||||
import RevisionStatusIndicator from '../../components/revision/RevisionStatusIndicator';
|
||||
import './CategoryRevisionPage.css';
|
||||
|
||||
const ValveRevisionPage = ({
|
||||
jobNo,
|
||||
fileId,
|
||||
previousFileId,
|
||||
onNavigate,
|
||||
user
|
||||
}) => {
|
||||
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [actionType, setActionType] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('description');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
const [valveTypeFilter, setValveTypeFilter] = useState('all');
|
||||
const [pressureRatingFilter, setPressureRatingFilter] = useState('all');
|
||||
const [connectionFilter, setConnectionFilter] = useState('all');
|
||||
|
||||
// 리비전 로직 훅
|
||||
const {
|
||||
materials,
|
||||
loading: materialsLoading,
|
||||
error: materialsError,
|
||||
processingInfo,
|
||||
handleMaterialSelection,
|
||||
handleBulkAction,
|
||||
refreshMaterials
|
||||
} = useRevisionLogic(fileId, 'VALVE');
|
||||
|
||||
// 리비전 비교 훅
|
||||
const {
|
||||
comparisonResult,
|
||||
loading: comparisonLoading,
|
||||
error: comparisonError,
|
||||
performComparison,
|
||||
getFilteredComparison
|
||||
} = useRevisionComparison(fileId, previousFileId);
|
||||
|
||||
// 리비전 상태 훅
|
||||
const {
|
||||
revisionStatus,
|
||||
loading: statusLoading,
|
||||
error: statusError,
|
||||
uploadNewRevision,
|
||||
navigateToRevision
|
||||
} = useRevisionStatus(jobNo, fileId);
|
||||
|
||||
// 필터링 및 정렬된 자재 목록
|
||||
const filteredAndSortedMaterials = useMemo(() => {
|
||||
if (!materials) return [];
|
||||
|
||||
let filtered = materials.filter(material => {
|
||||
const matchesSearch = !searchTerm ||
|
||||
material.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.item_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.drawing_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.valve_type?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === 'all' ||
|
||||
material.processing_info?.display_status === statusFilter;
|
||||
|
||||
const matchesValveType = valveTypeFilter === 'all' ||
|
||||
material.valve_type === valveTypeFilter;
|
||||
|
||||
const matchesPressureRating = pressureRatingFilter === 'all' ||
|
||||
material.pressure_rating === pressureRatingFilter;
|
||||
|
||||
const matchesConnection = connectionFilter === 'all' ||
|
||||
material.connection_method === connectionFilter;
|
||||
|
||||
return matchesSearch && matchesStatus && matchesValveType && matchesPressureRating && matchesConnection;
|
||||
});
|
||||
|
||||
// 정렬
|
||||
filtered.sort((a, b) => {
|
||||
let aValue = a[sortBy] || '';
|
||||
let bValue = b[sortBy] || '';
|
||||
|
||||
if (typeof aValue === 'string') {
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
|
||||
} else {
|
||||
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [materials, searchTerm, statusFilter, valveTypeFilter, pressureRatingFilter, connectionFilter, sortBy, sortOrder]);
|
||||
|
||||
// 고유 값들 추출 (필터 옵션용)
|
||||
const uniqueValues = useMemo(() => {
|
||||
if (!materials) return { valveTypes: [], pressureRatings: [], connections: [] };
|
||||
|
||||
const valveTypes = [...new Set(materials.map(m => m.valve_type).filter(Boolean))];
|
||||
const pressureRatings = [...new Set(materials.map(m => m.pressure_rating).filter(Boolean))];
|
||||
const connections = [...new Set(materials.map(m => m.connection_method).filter(Boolean))];
|
||||
|
||||
return { valveTypes, pressureRatings, connections };
|
||||
}, [materials]);
|
||||
|
||||
// 초기 비교 수행
|
||||
useEffect(() => {
|
||||
if (fileId && previousFileId && !comparisonResult) {
|
||||
performComparison('VALVE');
|
||||
}
|
||||
}, [fileId, previousFileId, comparisonResult, performComparison]);
|
||||
|
||||
// 자재 선택 처리
|
||||
const handleMaterialSelect = (materialId, isSelected) => {
|
||||
const newSelected = new Set(selectedMaterials);
|
||||
if (isSelected) {
|
||||
newSelected.add(materialId);
|
||||
} else {
|
||||
newSelected.delete(materialId);
|
||||
}
|
||||
setSelectedMaterials(newSelected);
|
||||
handleMaterialSelection(materialId, isSelected);
|
||||
};
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleSelectAll = (isSelected) => {
|
||||
if (isSelected) {
|
||||
const allIds = new Set(filteredAndSortedMaterials.map(m => m.id));
|
||||
setSelectedMaterials(allIds);
|
||||
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, true));
|
||||
} else {
|
||||
setSelectedMaterials(new Set());
|
||||
filteredAndSortedMaterials.forEach(m => handleMaterialSelection(m.id, false));
|
||||
}
|
||||
};
|
||||
|
||||
// 액션 실행
|
||||
const executeAction = async (action) => {
|
||||
setActionType(action);
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const confirmAction = async () => {
|
||||
try {
|
||||
await handleBulkAction(actionType, Array.from(selectedMaterials));
|
||||
setSelectedMaterials(new Set());
|
||||
setShowConfirmDialog(false);
|
||||
await refreshMaterials();
|
||||
} catch (error) {
|
||||
console.error('Action failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 상태별 색상 클래스
|
||||
const getStatusClass = (status) => {
|
||||
switch (status) {
|
||||
case 'REVISION_MATERIAL': return 'status-revision';
|
||||
case 'INVENTORY_MATERIAL': return 'status-inventory';
|
||||
case 'DELETED_MATERIAL': return 'status-deleted';
|
||||
case 'NEW_MATERIAL': return 'status-new';
|
||||
default: return 'status-normal';
|
||||
}
|
||||
};
|
||||
|
||||
// 수량 표시 (정수로 변환)
|
||||
const formatQuantity = (quantity) => {
|
||||
if (quantity === null || quantity === undefined) return '-';
|
||||
return Math.round(parseFloat(quantity) || 0).toString();
|
||||
};
|
||||
|
||||
// VALVE 설명 생성 (밸브 타입과 연결 방식 포함)
|
||||
const generateValveDescription = (material) => {
|
||||
const parts = [];
|
||||
|
||||
if (material.valve_type) parts.push(material.valve_type);
|
||||
if (material.nominal_size) parts.push(material.nominal_size);
|
||||
if (material.pressure_rating) parts.push(`${material.pressure_rating}#`);
|
||||
if (material.connection_method) parts.push(material.connection_method);
|
||||
if (material.material_grade) parts.push(material.material_grade);
|
||||
|
||||
const baseDesc = material.description || material.item_name || 'VALVE';
|
||||
|
||||
return parts.length > 0 ? `${baseDesc} (${parts.join(', ')})` : baseDesc;
|
||||
};
|
||||
|
||||
if (materialsLoading || comparisonLoading || statusLoading) {
|
||||
return <LoadingSpinner message="VALVE 리비전 데이터 로딩 중..." />;
|
||||
}
|
||||
|
||||
const error = materialsError || comparisonError || statusError;
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} onClose={() => window.location.reload()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="category-revision-page">
|
||||
{/* 헤더 */}
|
||||
<div className="materials-header">
|
||||
<div className="header-left">
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => onNavigate('enhanced-revision')}
|
||||
>
|
||||
← 리비전 관리로
|
||||
</button>
|
||||
<div className="header-center">
|
||||
<h1>🚰 VALVE 리비전 관리</h1>
|
||||
<span className="header-subtitle">
|
||||
밸브 타입과 연결 방식을 고려한 VALVE 자재 리비전 처리
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<RevisionStatusIndicator
|
||||
revisionStatus={revisionStatus}
|
||||
onUploadRevision={uploadNewRevision}
|
||||
onNavigateToRevision={navigateToRevision}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컨트롤 섹션 */}
|
||||
<div className="control-section">
|
||||
<div className="section-header">
|
||||
<h3>📊 자재 현황 및 필터</h3>
|
||||
{processingInfo && (
|
||||
<div className="processing-summary">
|
||||
전체: {processingInfo.total_materials}개 |
|
||||
리비전: {processingInfo.by_status.REVISION_MATERIAL || 0}개 |
|
||||
재고: {processingInfo.by_status.INVENTORY_MATERIAL || 0}개 |
|
||||
삭제: {processingInfo.by_status.DELETED_MATERIAL || 0}개
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="control-grid">
|
||||
<div className="control-group">
|
||||
<label>검색:</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="자재명, 밸브타입, 도면명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>상태 필터:</label>
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
<option value="REVISION_MATERIAL">리비전 자재</option>
|
||||
<option value="INVENTORY_MATERIAL">재고 자재</option>
|
||||
<option value="DELETED_MATERIAL">삭제 자재</option>
|
||||
<option value="NEW_MATERIAL">신규 자재</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>밸브 타입:</label>
|
||||
<select value={valveTypeFilter} onChange={(e) => setValveTypeFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
{uniqueValues.valveTypes.map(type => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>압력등급:</label>
|
||||
<select value={pressureRatingFilter} onChange={(e) => setPressureRatingFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
{uniqueValues.pressureRatings.map(rating => (
|
||||
<option key={rating} value={rating}>{rating}#</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>연결 방식:</label>
|
||||
<select value={connectionFilter} onChange={(e) => setConnectionFilter(e.target.value)}>
|
||||
<option value="all">전체</option>
|
||||
{uniqueValues.connections.map(conn => (
|
||||
<option key={conn} value={conn}>{conn}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>정렬:</label>
|
||||
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
|
||||
<option value="description">자재명</option>
|
||||
<option value="valve_type">밸브 타입</option>
|
||||
<option value="nominal_size">크기</option>
|
||||
<option value="pressure_rating">압력등급</option>
|
||||
<option value="quantity">수량</option>
|
||||
</select>
|
||||
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
|
||||
<option value="asc">오름차순</option>
|
||||
<option value="desc">내림차순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 자재 액션 */}
|
||||
{selectedMaterials.size > 0 && (
|
||||
<div className="selected-actions">
|
||||
<span className="selected-count">
|
||||
{selectedMaterials.size}개 선택됨
|
||||
</span>
|
||||
<div className="action-buttons">
|
||||
<button
|
||||
className="btn-action btn-purchase"
|
||||
onClick={() => executeAction('request_purchase')}
|
||||
>
|
||||
구매 신청
|
||||
</button>
|
||||
<button
|
||||
className="btn-action btn-inventory"
|
||||
onClick={() => executeAction('mark_inventory')}
|
||||
>
|
||||
재고 처리
|
||||
</button>
|
||||
<button
|
||||
className="btn-action btn-delete"
|
||||
onClick={() => executeAction('mark_deleted')}
|
||||
>
|
||||
삭제 처리
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 자재 테이블 */}
|
||||
<div className="materials-table-container">
|
||||
<div className="table-header">
|
||||
<div className="header-cell checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.size === filteredAndSortedMaterials.length && filteredAndSortedMaterials.length > 0}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="header-cell">상태</div>
|
||||
<div className="header-cell">자재명</div>
|
||||
<div className="header-cell">밸브 타입</div>
|
||||
<div className="header-cell">크기</div>
|
||||
<div className="header-cell">압력등급</div>
|
||||
<div className="header-cell">연결방식</div>
|
||||
<div className="header-cell">수량</div>
|
||||
<div className="header-cell">단위</div>
|
||||
<div className="header-cell">액션</div>
|
||||
</div>
|
||||
|
||||
<div className="table-body">
|
||||
{filteredAndSortedMaterials.map((material) => (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`table-row ${getStatusClass(material.processing_info?.display_status)}`}
|
||||
>
|
||||
<div className="table-cell checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onChange={(e) => handleMaterialSelect(material.id, e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="table-cell">
|
||||
<span className={`status-badge ${getStatusClass(material.processing_info?.display_status)}`}>
|
||||
{material.processing_info?.display_status || 'NORMAL'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="table-cell">
|
||||
<div className="material-info">
|
||||
<div className="material-name">{generateValveDescription(material)}</div>
|
||||
{material.processing_info?.notes && (
|
||||
<div className="material-notes">{material.processing_info.notes}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-cell">{material.valve_type || '-'}</div>
|
||||
<div className="table-cell">{material.nominal_size || '-'}</div>
|
||||
<div className="table-cell">{material.pressure_rating ? `${material.pressure_rating}#` : '-'}</div>
|
||||
<div className="table-cell">{material.connection_method || '-'}</div>
|
||||
<div className="table-cell quantity-cell">
|
||||
<span className="quantity-value">
|
||||
{formatQuantity(material.quantity)}
|
||||
</span>
|
||||
{material.processing_info?.quantity_change && (
|
||||
<span className="quantity-change">
|
||||
({material.processing_info.quantity_change > 0 ? '+' : ''}
|
||||
{formatQuantity(material.processing_info.quantity_change)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="table-cell">{material.unit || 'EA'}</div>
|
||||
<div className="table-cell">
|
||||
<div className="action-buttons-small">
|
||||
<button
|
||||
className="btn-small btn-view"
|
||||
onClick={() => {/* 상세 보기 로직 */}}
|
||||
title="상세 정보"
|
||||
>
|
||||
👁️
|
||||
</button>
|
||||
<button
|
||||
className="btn-small btn-edit"
|
||||
onClick={() => {/* 편집 로직 */}}
|
||||
title="편집"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
className="btn-small btn-test"
|
||||
onClick={() => {/* 밸브 테스트 로직 */}}
|
||||
title="밸브 테스트"
|
||||
>
|
||||
🧪
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredAndSortedMaterials.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>조건에 맞는 VALVE 자재가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 */}
|
||||
{showConfirmDialog && (
|
||||
<ConfirmDialog
|
||||
title="작업 확인"
|
||||
message={`선택된 ${selectedMaterials.size}개 자재에 대해 "${actionType}" 작업을 수행하시겠습니까?`}
|
||||
onConfirm={confirmAction}
|
||||
onCancel={() => setShowConfirmDialog(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValveRevisionPage;
|
||||
592
frontend/src/utils/pipeUtils.js
Normal file
592
frontend/src/utils/pipeUtils.js
Normal file
@@ -0,0 +1,592 @@
|
||||
/**
|
||||
* PIPE 시스템 공통 유틸리티
|
||||
*
|
||||
* 모든 PIPE 관련 컴포넌트에서 공통으로 사용되는 함수들을 모아놓은 유틸리티 모듈
|
||||
*/
|
||||
|
||||
// ========== PIPE 상수 정의 ==========
|
||||
|
||||
export const PIPE_CONSTANTS = {
|
||||
// 길이 관련
|
||||
STANDARD_PIPE_LENGTH_MM: 6000, // 표준 파이프 길이 (6M)
|
||||
CUTTING_LOSS_PER_CUT_MM: 2, // 절단당 손실 (2mm)
|
||||
|
||||
// 분류 관련
|
||||
PIPE_CATEGORY: "PIPE",
|
||||
|
||||
// 끝단 처리 타입
|
||||
END_PREPARATION_TYPES: {
|
||||
"무개선": "PLAIN",
|
||||
"한개선": "SINGLE_BEVEL",
|
||||
"양개선": "DOUBLE_BEVEL"
|
||||
},
|
||||
|
||||
// 상태 관련
|
||||
REVISION_TYPES: {
|
||||
NO_REVISION: "no_revision",
|
||||
PRE_CUTTING_PLAN: "pre_cutting_plan",
|
||||
POST_CUTTING_PLAN: "post_cutting_plan"
|
||||
},
|
||||
|
||||
CHANGE_TYPES: {
|
||||
ADDED: "added",
|
||||
REMOVED: "removed",
|
||||
MODIFIED: "modified",
|
||||
UNCHANGED: "unchanged"
|
||||
},
|
||||
|
||||
// UI 관련
|
||||
STATUS_COLORS: {
|
||||
success: '#28a745',
|
||||
warning: '#ffc107',
|
||||
danger: '#dc3545',
|
||||
info: '#17a2b8',
|
||||
primary: '#007bff'
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 계산 유틸리티 ==========
|
||||
|
||||
export class PipeCalculator {
|
||||
/**
|
||||
* PIPE 구매 수량 계산
|
||||
* @param {Array} materials - PIPE 자재 리스트
|
||||
* @returns {Object} 계산 결과
|
||||
*/
|
||||
static calculatePipePurchaseQuantity(materials) {
|
||||
let totalBomLength = 0;
|
||||
let cuttingCount = 0;
|
||||
const pipeDetails = [];
|
||||
|
||||
materials.forEach(material => {
|
||||
const lengthMm = parseFloat(material.length || material.length_mm || 0);
|
||||
const quantity = parseInt(material.quantity || 1);
|
||||
|
||||
if (lengthMm > 0) {
|
||||
const totalLength = lengthMm * quantity;
|
||||
totalBomLength += totalLength;
|
||||
cuttingCount += quantity;
|
||||
|
||||
pipeDetails.push({
|
||||
description: material.description || '',
|
||||
originalDescription: material.original_description || '',
|
||||
drawingName: material.drawing_name || '',
|
||||
lineNo: material.line_no || '',
|
||||
lengthMm: lengthMm,
|
||||
quantity: quantity,
|
||||
totalLength: totalLength
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 절단 손실 계산
|
||||
const cuttingLoss = cuttingCount * PIPE_CONSTANTS.CUTTING_LOSS_PER_CUT_MM;
|
||||
|
||||
// 총 필요 길이
|
||||
const requiredLength = totalBomLength + cuttingLoss;
|
||||
|
||||
// 6M 단위로 올림 계산
|
||||
const pipesNeeded = requiredLength > 0 ? Math.ceil(requiredLength / PIPE_CONSTANTS.STANDARD_PIPE_LENGTH_MM) : 0;
|
||||
const totalPurchaseLength = pipesNeeded * PIPE_CONSTANTS.STANDARD_PIPE_LENGTH_MM;
|
||||
const wasteLength = pipesNeeded > 0 ? totalPurchaseLength - requiredLength : 0;
|
||||
|
||||
return {
|
||||
bomQuantity: totalBomLength,
|
||||
cuttingCount: cuttingCount,
|
||||
cuttingLoss: cuttingLoss,
|
||||
requiredLength: requiredLength,
|
||||
pipesCount: pipesNeeded,
|
||||
calculatedQty: totalPurchaseLength,
|
||||
wasteLength: wasteLength,
|
||||
utilizationRate: totalPurchaseLength > 0 ? (requiredLength / totalPurchaseLength * 100) : 0,
|
||||
unit: 'mm',
|
||||
pipeDetails: pipeDetails,
|
||||
summary: {
|
||||
totalMaterials: materials.length,
|
||||
totalDrawings: new Set(materials.map(m => m.drawing_name).filter(Boolean)).size,
|
||||
averageLength: materials.length > 0 ? totalBomLength / materials.length : 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 길이 변화량 계산
|
||||
* @param {number} oldLength - 이전 길이
|
||||
* @param {number} newLength - 새로운 길이
|
||||
* @returns {Object} 변화량 정보
|
||||
*/
|
||||
static calculateLengthDifference(oldLength, newLength) {
|
||||
const difference = newLength - oldLength;
|
||||
const percentage = oldLength > 0 ? (difference / oldLength * 100) : 0;
|
||||
|
||||
return {
|
||||
oldLength: oldLength,
|
||||
newLength: newLength,
|
||||
difference: difference,
|
||||
percentage: percentage,
|
||||
changeType: difference > 0 ? 'increased' : difference < 0 ? 'decreased' : 'unchanged'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 비교 유틸리티 ==========
|
||||
|
||||
export class PipeComparator {
|
||||
/**
|
||||
* 단관 데이터 비교
|
||||
* @param {Array} oldSegments - 이전 단관 데이터
|
||||
* @param {Array} newSegments - 새로운 단관 데이터
|
||||
* @returns {Object} 비교 결과
|
||||
*/
|
||||
static comparePipeSegments(oldSegments, newSegments) {
|
||||
// 키 생성 함수
|
||||
const createSegmentKey = (segment) => {
|
||||
return [
|
||||
segment.drawing_name || '',
|
||||
segment.material_grade || '',
|
||||
segment.length || 0,
|
||||
segment.end_preparation || '무개선'
|
||||
].join('|');
|
||||
};
|
||||
|
||||
// 기존 데이터를 키로 매핑
|
||||
const oldMap = {};
|
||||
oldSegments.forEach(segment => {
|
||||
const key = createSegmentKey(segment);
|
||||
if (!oldMap[key]) oldMap[key] = [];
|
||||
oldMap[key].push(segment);
|
||||
});
|
||||
|
||||
// 새로운 데이터를 키로 매핑
|
||||
const newMap = {};
|
||||
newSegments.forEach(segment => {
|
||||
const key = createSegmentKey(segment);
|
||||
if (!newMap[key]) newMap[key] = [];
|
||||
newMap[key].push(segment);
|
||||
});
|
||||
|
||||
// 비교 결과 생성
|
||||
const changes = {
|
||||
added: [],
|
||||
removed: [],
|
||||
modified: [],
|
||||
unchanged: []
|
||||
};
|
||||
|
||||
const allKeys = new Set([...Object.keys(oldMap), ...Object.keys(newMap)]);
|
||||
|
||||
allKeys.forEach(key => {
|
||||
const oldCount = oldMap[key] ? oldMap[key].length : 0;
|
||||
const newCount = newMap[key] ? newMap[key].length : 0;
|
||||
|
||||
if (oldCount === 0) {
|
||||
// 새로 추가된 항목
|
||||
newMap[key].forEach(segment => {
|
||||
changes.added.push({
|
||||
...segment,
|
||||
changeType: 'added',
|
||||
quantityChange: newCount
|
||||
});
|
||||
});
|
||||
} else if (newCount === 0) {
|
||||
// 삭제된 항목
|
||||
oldMap[key].forEach(segment => {
|
||||
changes.removed.push({
|
||||
...segment,
|
||||
changeType: 'removed',
|
||||
quantityChange: -oldCount
|
||||
});
|
||||
});
|
||||
} else if (oldCount !== newCount) {
|
||||
// 수량이 변경된 항목
|
||||
const baseSegment = newMap[key][0] || oldMap[key][0];
|
||||
changes.modified.push({
|
||||
...baseSegment,
|
||||
changeType: 'modified',
|
||||
oldQuantity: oldCount,
|
||||
newQuantity: newCount,
|
||||
quantityChange: newCount - oldCount
|
||||
});
|
||||
} else {
|
||||
// 변경되지 않은 항목
|
||||
const baseSegment = newMap[key][0];
|
||||
changes.unchanged.push({
|
||||
...baseSegment,
|
||||
changeType: 'unchanged',
|
||||
quantity: oldCount
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 통계 생성
|
||||
const stats = {
|
||||
totalOld: oldSegments.length,
|
||||
totalNew: newSegments.length,
|
||||
addedCount: changes.added.length,
|
||||
removedCount: changes.removed.length,
|
||||
modifiedCount: changes.modified.length,
|
||||
unchangedCount: changes.unchanged.length,
|
||||
changedDrawings: new Set([
|
||||
...changes.added,
|
||||
...changes.removed,
|
||||
...changes.modified
|
||||
].map(item => item.drawing_name).filter(Boolean)).size
|
||||
};
|
||||
|
||||
return {
|
||||
changes: changes,
|
||||
statistics: stats,
|
||||
hasChanges: stats.addedCount + stats.removedCount + stats.modifiedCount > 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 검증 유틸리티 ==========
|
||||
|
||||
export class PipeValidator {
|
||||
/**
|
||||
* PIPE 데이터 유효성 검증
|
||||
* @param {Object} pipeData - 검증할 PIPE 데이터
|
||||
* @returns {Object} 검증 결과
|
||||
*/
|
||||
static validatePipeData(pipeData) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
// 필수 필드 검증
|
||||
const requiredFields = ['drawing_name', 'material_grade', 'length'];
|
||||
requiredFields.forEach(field => {
|
||||
if (!pipeData[field]) {
|
||||
errors.push(`필수 필드 누락: ${field}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 길이 검증
|
||||
const length = parseFloat(pipeData.length || 0);
|
||||
if (length <= 0) {
|
||||
errors.push("길이는 0보다 커야 합니다");
|
||||
} else if (length > 20000) { // 20m 초과시 경고
|
||||
warnings.push(`길이가 비정상적으로 큽니다: ${length}mm`);
|
||||
}
|
||||
|
||||
// 수량 검증
|
||||
const quantity = parseInt(pipeData.quantity || 1);
|
||||
if (quantity <= 0) {
|
||||
errors.push("수량은 0보다 커야 합니다");
|
||||
}
|
||||
|
||||
// 도면명 검증
|
||||
const drawingName = pipeData.drawing_name || '';
|
||||
if (drawingName === 'UNKNOWN') {
|
||||
warnings.push("도면명이 지정되지 않았습니다");
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors: errors,
|
||||
warnings: warnings,
|
||||
errorCount: errors.length,
|
||||
warningCount: warnings.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cutting Plan 데이터 전체 검증
|
||||
* @param {Array} cuttingPlan - Cutting Plan 데이터 리스트
|
||||
* @returns {Object} 검증 결과
|
||||
*/
|
||||
static validateCuttingPlanData(cuttingPlan) {
|
||||
const totalErrors = [];
|
||||
const totalWarnings = [];
|
||||
let validItems = 0;
|
||||
|
||||
cuttingPlan.forEach((item, index) => {
|
||||
const validation = PipeValidator.validatePipeData(item);
|
||||
|
||||
if (validation.isValid) {
|
||||
validItems++;
|
||||
} else {
|
||||
validation.errors.forEach(error => {
|
||||
totalErrors.push(`항목 ${index + 1}: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
validation.warnings.forEach(warning => {
|
||||
totalWarnings.push(`항목 ${index + 1}: ${warning}`);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: totalErrors.length === 0,
|
||||
totalItems: cuttingPlan.length,
|
||||
validItems: validItems,
|
||||
invalidItems: cuttingPlan.length - validItems,
|
||||
errors: totalErrors,
|
||||
warnings: totalWarnings,
|
||||
validationRate: cuttingPlan.length > 0 ? (validItems / cuttingPlan.length * 100) : 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 포맷팅 유틸리티 ==========
|
||||
|
||||
export class PipeFormatter {
|
||||
/**
|
||||
* 길이 포맷팅
|
||||
* @param {number} lengthMm - 길이 (mm)
|
||||
* @param {string} unit - 표시 단위
|
||||
* @returns {string} 포맷된 길이 문자열
|
||||
*/
|
||||
static formatLength(lengthMm, unit = 'mm') {
|
||||
if (unit === 'm') {
|
||||
return `${(lengthMm / 1000).toFixed(3)}m`;
|
||||
} else if (unit === 'mm') {
|
||||
return `${Math.round(lengthMm)}mm`;
|
||||
} else {
|
||||
return `${lengthMm}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PIPE 설명 포맷팅
|
||||
* @param {Object} pipeData - PIPE 데이터
|
||||
* @returns {string} 포맷된 설명
|
||||
*/
|
||||
static formatPipeDescription(pipeData) {
|
||||
const parts = [];
|
||||
|
||||
if (pipeData.material_grade) {
|
||||
parts.push(pipeData.material_grade);
|
||||
}
|
||||
|
||||
if (pipeData.main_nom) {
|
||||
parts.push(pipeData.main_nom);
|
||||
}
|
||||
|
||||
if (pipeData.schedule) {
|
||||
parts.push(pipeData.schedule);
|
||||
}
|
||||
|
||||
if (pipeData.length) {
|
||||
parts.push(PipeFormatter.formatLength(pipeData.length));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' ') : 'PIPE';
|
||||
}
|
||||
|
||||
/**
|
||||
* 변경사항 요약 포맷팅
|
||||
* @param {Object} changes - 변경사항 딕셔너리
|
||||
* @returns {string} 포맷된 요약 문자열
|
||||
*/
|
||||
static formatChangeSummary(changes) {
|
||||
const summaryParts = [];
|
||||
|
||||
if (changes.added && changes.added.length > 0) {
|
||||
summaryParts.push(`추가 ${changes.added.length}개`);
|
||||
}
|
||||
|
||||
if (changes.removed && changes.removed.length > 0) {
|
||||
summaryParts.push(`삭제 ${changes.removed.length}개`);
|
||||
}
|
||||
|
||||
if (changes.modified && changes.modified.length > 0) {
|
||||
summaryParts.push(`수정 ${changes.modified.length}개`);
|
||||
}
|
||||
|
||||
if (summaryParts.length === 0) {
|
||||
return "변경사항 없음";
|
||||
}
|
||||
|
||||
return summaryParts.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자를 천 단위 콤마로 포맷팅
|
||||
* @param {number} number - 포맷할 숫자
|
||||
* @returns {string} 포맷된 숫자 문자열
|
||||
*/
|
||||
static formatNumber(number) {
|
||||
return new Intl.NumberFormat('ko-KR').format(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* 백분율 포맷팅
|
||||
* @param {number} value - 백분율 값
|
||||
* @param {number} decimals - 소수점 자리수
|
||||
* @returns {string} 포맷된 백분율 문자열
|
||||
*/
|
||||
static formatPercentage(value, decimals = 1) {
|
||||
return `${value.toFixed(decimals)}%`;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== UI 유틸리티 ==========
|
||||
|
||||
export class PipeUIUtils {
|
||||
/**
|
||||
* 변경 타입에 따른 색상 반환
|
||||
* @param {string} changeType - 변경 타입
|
||||
* @returns {string} CSS 색상 코드
|
||||
*/
|
||||
static getChangeTypeColor(changeType) {
|
||||
const colors = {
|
||||
added: PIPE_CONSTANTS.STATUS_COLORS.success,
|
||||
removed: PIPE_CONSTANTS.STATUS_COLORS.danger,
|
||||
modified: PIPE_CONSTANTS.STATUS_COLORS.warning,
|
||||
unchanged: PIPE_CONSTANTS.STATUS_COLORS.info
|
||||
};
|
||||
|
||||
return colors[changeType] || PIPE_CONSTANTS.STATUS_COLORS.primary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 변경 타입에 따른 아이콘 반환
|
||||
* @param {string} changeType - 변경 타입
|
||||
* @returns {string} 아이콘 클래스명
|
||||
*/
|
||||
static getChangeTypeIcon(changeType) {
|
||||
const icons = {
|
||||
added: '➕',
|
||||
removed: '➖',
|
||||
modified: '🔄',
|
||||
unchanged: '✅'
|
||||
};
|
||||
|
||||
return icons[changeType] || '❓';
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태에 따른 배지 스타일 생성
|
||||
* @param {string} status - 상태
|
||||
* @returns {Object} 스타일 객체
|
||||
*/
|
||||
static createStatusBadge(status) {
|
||||
const styles = {
|
||||
success: {
|
||||
backgroundColor: PIPE_CONSTANTS.STATUS_COLORS.success,
|
||||
color: 'white'
|
||||
},
|
||||
warning: {
|
||||
backgroundColor: PIPE_CONSTANTS.STATUS_COLORS.warning,
|
||||
color: 'black'
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: PIPE_CONSTANTS.STATUS_COLORS.danger,
|
||||
color: 'white'
|
||||
},
|
||||
info: {
|
||||
backgroundColor: PIPE_CONSTANTS.STATUS_COLORS.info,
|
||||
color: 'white'
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
display: 'inline-block',
|
||||
...styles[status]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 데이터 변환 유틸리티 ==========
|
||||
|
||||
export class PipeDataTransformer {
|
||||
/**
|
||||
* API 응답 데이터를 UI용 데이터로 변환
|
||||
* @param {Object} apiData - API 응답 데이터
|
||||
* @returns {Object} 변환된 데이터
|
||||
*/
|
||||
static transformApiDataForUI(apiData) {
|
||||
if (!apiData) return null;
|
||||
|
||||
return {
|
||||
...apiData,
|
||||
formattedLength: PipeFormatter.formatLength(apiData.length || 0),
|
||||
formattedDescription: PipeFormatter.formatPipeDescription(apiData),
|
||||
changeTypeColor: PipeUIUtils.getChangeTypeColor(apiData.change_type),
|
||||
changeTypeIcon: PipeUIUtils.getChangeTypeIcon(apiData.change_type)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 데이터를 API 전송용 데이터로 변환
|
||||
* @param {Object} uiData - UI 데이터
|
||||
* @returns {Object} 변환된 데이터
|
||||
*/
|
||||
static transformUIDataForAPI(uiData) {
|
||||
const apiData = { ...uiData };
|
||||
|
||||
// UI 전용 필드 제거
|
||||
delete apiData.formattedLength;
|
||||
delete apiData.formattedDescription;
|
||||
delete apiData.changeTypeColor;
|
||||
delete apiData.changeTypeIcon;
|
||||
|
||||
// 숫자 타입 변환
|
||||
if (apiData.length) {
|
||||
apiData.length = parseFloat(apiData.length);
|
||||
}
|
||||
if (apiData.quantity) {
|
||||
apiData.quantity = parseInt(apiData.quantity);
|
||||
}
|
||||
|
||||
return apiData;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 로깅 유틸리티 ==========
|
||||
|
||||
export class PipeLogger {
|
||||
/**
|
||||
* PIPE 작업 로깅
|
||||
* @param {string} operation - 작업 유형
|
||||
* @param {string} jobNo - 작업 번호
|
||||
* @param {Object} details - 상세 정보
|
||||
*/
|
||||
static logPipeOperation(operation, jobNo, details = {}) {
|
||||
const message = `🔧 PIPE ${operation} | Job: ${jobNo}`;
|
||||
|
||||
if (Object.keys(details).length > 0) {
|
||||
const detailParts = Object.entries(details).map(([key, value]) => `${key}: ${value}`);
|
||||
console.log(`${message} | ${detailParts.join(', ')}`);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PIPE 오류 로깅
|
||||
* @param {string} operation - 작업 유형
|
||||
* @param {string} jobNo - 작업 번호
|
||||
* @param {Error} error - 오류 객체
|
||||
* @param {Object} context - 컨텍스트 정보
|
||||
*/
|
||||
static logPipeError(operation, jobNo, error, context = {}) {
|
||||
const message = `❌ PIPE ${operation} 실패 | Job: ${jobNo} | Error: ${error.message}`;
|
||||
|
||||
if (Object.keys(context).length > 0) {
|
||||
const contextParts = Object.entries(context).map(([key, value]) => `${key}: ${value}`);
|
||||
console.error(`${message} | Context: ${contextParts.join(', ')}`);
|
||||
} else {
|
||||
console.error(message);
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 export
|
||||
export default {
|
||||
PIPE_CONSTANTS,
|
||||
PipeCalculator,
|
||||
PipeComparator,
|
||||
PipeValidator,
|
||||
PipeFormatter,
|
||||
PipeUIUtils,
|
||||
PipeDataTransformer,
|
||||
PipeLogger
|
||||
};
|
||||
Reference in New Issue
Block a user