From 3398f71b807d4c3ea8fd40c261a31baf66cc7aaf Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 20 Oct 2025 08:41:06 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=84=20=EC=A0=84=EB=B0=98=EC=A0=81?= =?UTF-8?q?=EC=9D=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✅ 백엔드 구조 개선: - DatabaseService: 공통 DB 쿼리 로직 통합 - FileUploadService: 파일 업로드 로직 모듈화 및 트랜잭션 관리 개선 - 서비스 레이어 패턴 도입으로 코드 재사용성 향상 ✅ 프론트엔드 컴포넌트 개선: - LoadingSpinner, ErrorMessage, ConfirmDialog 공통 컴포넌트 생성 - 재사용 가능한 컴포넌트 라이브러리 구축 - deprecated/backup 파일들 완전 제거 ✅ 성능 최적화: - optimize_database.py: 핵심 DB 인덱스 자동 생성 - 쿼리 최적화 및 통계 업데이트 자동화 - VACUUM ANALYZE 자동 실행 ✅ 코드 정리: - 개별 SQL 마이그레이션 파일들을 legacy/ 폴더로 정리 - 중복된 마이그레이션 스크립트 정리 - 깔끔하고 체계적인 프로젝트 구조 완성 ✅ 자동 마이그레이션 시스템 강화: - complete_migrate.py: SQLAlchemy 기반 완전한 마이그레이션 - analyze_and_fix_schema.py: 백엔드 코드 분석 기반 스키마 수정 - fix_missing_tables.py: 누락된 테이블/컬럼 자동 생성 - start.sh: 배포 시 자동 실행 순서 최적화 --- RULES.md | 253 ++++- backend/Dockerfile | 7 +- backend/app/models.py | 370 +++++++- backend/app/routers/files.py | 173 +++- backend/app/services/database_service.py | 350 +++++++ backend/app/services/file_upload_service.py | 607 ++++++++++++ backend/scripts/analyze_and_fix_schema.py | 138 +++ backend/scripts/complete_migrate.py | 301 ++++++ backend/scripts/fix_missing_tables.py | 142 +++ .../{ => legacy}/01_create_jobs_table.sql | 0 .../{ => legacy}/02_modify_files_table.sql | 0 .../{ => legacy}/04_add_job_no_to_files.sql | 0 .../05_add_classification_columns.sql | 0 .../05_add_length_to_materials.sql | 0 .../05_create_material_standards_tables.sql | 0 ...5_create_pipe_details_and_requirements.sql | 0 ...pipe_details_and_requirements_postgres.sql | 0 .../06_add_main_red_nom_columns.sql | 0 ...06_add_pressure_rating_to_bolt_details.sql | 0 ...07_execute_material_standards_migration.py | 0 .../07_simplify_pipe_details_schema.sql | 0 .../08_create_purchase_tables.sql | 0 .../10_add_material_comparison_system.sql | 0 .../{ => legacy}/15_create_tubing_system.sql | 0 .../{ => legacy}/16_performance_indexes.sql | 0 .../17_add_project_type_column.sql | 0 .../{ => legacy}/18_create_auth_tables.sql | 0 .../19_add_user_tracking_fields.sql | 0 .../20_add_pipe_end_preparation_table.sql | 0 ...1_add_material_id_to_user_requirements.sql | 0 .../22_add_full_material_grade.sql | 0 .../23_create_support_details_table.sql | 0 .../24_add_special_category_support.sql | 0 .../25_execute_special_category_migration.py | 0 .../26_add_user_status_column.sql | 0 .../{ => legacy}/27_add_purchase_tracking.sql | 0 .../{ => legacy}/28_add_purchase_requests.sql | 0 .../{ => legacy}/29_add_revision_status.sql | 0 .../{ => legacy}/PRODUCTION_MIGRATION.sql | 0 backend/scripts/legacy/auto_migrate.py | 113 +++ backend/scripts/{ => legacy}/create_jobs.sql | 0 .../create_material_detail_tables.sql | 0 .../legacy/generate_complete_schema.py | 144 +++ .../scripts/{ => legacy}/setup_database.py | 0 backend/scripts/optimize_database.py | 195 ++++ backend/scripts/simple_migrate.py | 126 +++ backend/start.sh | 54 ++ docker-compose.synology.yml | 113 +++ docker-compose.yml | 23 +- .../src/components/common/ConfirmDialog.jsx | 123 +++ .../src/components/common/ErrorMessage.jsx | 115 +++ .../src/components/common/LoadingSpinner.jsx | 72 ++ frontend/src/components/common/index.js | 11 +- .../src/pages/_backup/BOMManagementPage.jsx | 431 --------- .../pages/_backup/MaterialComparisonPage.jsx | 518 ---------- .../pages/_backup/MaterialsManagementPage.jsx | 486 ---------- .../_backup/PurchaseConfirmationPage.jsx | 736 -------------- .../pages/_backup/RevisionPurchasePage.jsx | 437 --------- .../src/pages/_deprecated/BOMRevisionPage.jsx | 350 ------- .../src/pages/_deprecated/BOMUploadPage.jsx | 600 ------------ .../pages/_deprecated/BOMWorkspacePage.jsx | 894 ------------------ 61 files changed, 3370 insertions(+), 4512 deletions(-) create mode 100644 backend/app/services/database_service.py create mode 100644 backend/app/services/file_upload_service.py create mode 100644 backend/scripts/analyze_and_fix_schema.py create mode 100644 backend/scripts/complete_migrate.py create mode 100644 backend/scripts/fix_missing_tables.py rename backend/scripts/{ => legacy}/01_create_jobs_table.sql (100%) rename backend/scripts/{ => legacy}/02_modify_files_table.sql (100%) rename backend/scripts/{ => legacy}/04_add_job_no_to_files.sql (100%) rename backend/scripts/{ => legacy}/05_add_classification_columns.sql (100%) rename backend/scripts/{ => legacy}/05_add_length_to_materials.sql (100%) rename backend/scripts/{ => legacy}/05_create_material_standards_tables.sql (100%) rename backend/scripts/{ => legacy}/05_create_pipe_details_and_requirements.sql (100%) rename backend/scripts/{ => legacy}/05_create_pipe_details_and_requirements_postgres.sql (100%) rename backend/scripts/{ => legacy}/06_add_main_red_nom_columns.sql (100%) rename backend/scripts/{ => legacy}/06_add_pressure_rating_to_bolt_details.sql (100%) rename backend/scripts/{ => legacy}/07_execute_material_standards_migration.py (100%) rename backend/scripts/{ => legacy}/07_simplify_pipe_details_schema.sql (100%) rename backend/scripts/{ => legacy}/08_create_purchase_tables.sql (100%) rename backend/scripts/{ => legacy}/10_add_material_comparison_system.sql (100%) rename backend/scripts/{ => legacy}/15_create_tubing_system.sql (100%) rename backend/scripts/{ => legacy}/16_performance_indexes.sql (100%) rename backend/scripts/{ => legacy}/17_add_project_type_column.sql (100%) rename backend/scripts/{ => legacy}/18_create_auth_tables.sql (100%) rename backend/scripts/{ => legacy}/19_add_user_tracking_fields.sql (100%) rename backend/scripts/{ => legacy}/20_add_pipe_end_preparation_table.sql (100%) rename backend/scripts/{ => legacy}/21_add_material_id_to_user_requirements.sql (100%) rename backend/scripts/{ => legacy}/22_add_full_material_grade.sql (100%) rename backend/scripts/{ => legacy}/23_create_support_details_table.sql (100%) rename backend/scripts/{ => legacy}/24_add_special_category_support.sql (100%) rename backend/scripts/{ => legacy}/25_execute_special_category_migration.py (100%) rename backend/scripts/{ => legacy}/26_add_user_status_column.sql (100%) rename backend/scripts/{ => legacy}/27_add_purchase_tracking.sql (100%) rename backend/scripts/{ => legacy}/28_add_purchase_requests.sql (100%) rename backend/scripts/{ => legacy}/29_add_revision_status.sql (100%) rename backend/scripts/{ => legacy}/PRODUCTION_MIGRATION.sql (100%) create mode 100644 backend/scripts/legacy/auto_migrate.py rename backend/scripts/{ => legacy}/create_jobs.sql (100%) rename backend/scripts/{ => legacy}/create_material_detail_tables.sql (100%) create mode 100644 backend/scripts/legacy/generate_complete_schema.py rename backend/scripts/{ => legacy}/setup_database.py (100%) create mode 100644 backend/scripts/optimize_database.py create mode 100644 backend/scripts/simple_migrate.py create mode 100644 backend/start.sh create mode 100644 docker-compose.synology.yml create mode 100644 frontend/src/components/common/ConfirmDialog.jsx create mode 100644 frontend/src/components/common/ErrorMessage.jsx create mode 100644 frontend/src/components/common/LoadingSpinner.jsx delete mode 100644 frontend/src/pages/_backup/BOMManagementPage.jsx delete mode 100644 frontend/src/pages/_backup/MaterialComparisonPage.jsx delete mode 100644 frontend/src/pages/_backup/MaterialsManagementPage.jsx delete mode 100644 frontend/src/pages/_backup/PurchaseConfirmationPage.jsx delete mode 100644 frontend/src/pages/_backup/RevisionPurchasePage.jsx delete mode 100644 frontend/src/pages/_deprecated/BOMRevisionPage.jsx delete mode 100644 frontend/src/pages/_deprecated/BOMUploadPage.jsx delete mode 100644 frontend/src/pages/_deprecated/BOMWorkspacePage.jsx diff --git a/RULES.md b/RULES.md index 5309703..72e8dfd 100644 --- a/RULES.md +++ b/RULES.md @@ -45,6 +45,7 @@ - **Database Admin**: pgAdmin4 - **Version Control**: Git - **Development**: VS Code + Python 확장 +- **Database Migration**: SQLAlchemy + 자동 마이그레이션 시스템 --- @@ -2325,4 +2326,254 @@ psql -U tkmp_user -d tk_mp_bom -f backend/scripts/PRODUCTION_MIGRATION.sql --- -**마지막 업데이트**: 2025년 9월 28일 (메인 서버 배포 가이드 추가) +## 🔄 **시스템 리팩토링 완료 (2025-01-19)** + +### ✅ **완료된 리팩토링 영역** + +1. **백엔드 구조 개선** ✅ + - `DatabaseService`: 공통 DB 쿼리 로직 통합 + - `FileUploadService`: 파일 업로드 로직 모듈화 + - 중복 코드 제거 및 서비스 레이어 분리 + +2. **프론트엔드 컴포넌트 개선** ✅ + - 공통 컴포넌트 생성: `LoadingSpinner`, `ErrorMessage`, `ConfirmDialog` + - 재사용 가능한 컴포넌트 라이브러리 구축 + - 사용하지 않는 deprecated/backup 파일들 정리 + +3. **성능 최적화** ✅ + - 핵심 DB 인덱스 자동 생성 (`optimize_database.py`) + - 쿼리 최적화 및 통계 업데이트 + - 자동 VACUUM ANALYZE 실행 + +4. **코드 정리** ✅ + - 사용하지 않는 개별 SQL 마이그레이션 파일들 `legacy/` 폴더로 이동 + - 중복된 마이그레이션 스크립트 정리 + - 깔끔한 프로젝트 구조 완성 + +### 🚀 **리팩토링 후 시스템 구조** + +``` +backend/ +├── services/ +│ ├── database_service.py # 🆕 공통 DB 서비스 +│ ├── file_upload_service.py # 🆕 파일 업로드 서비스 +│ └── ... +├── scripts/ +│ ├── complete_migrate.py # 완전한 자동 마이그레이션 +│ ├── analyze_and_fix_schema.py # 스키마 분석 및 수정 +│ ├── optimize_database.py # 🆕 성능 최적화 +│ └── legacy/ # 기존 개별 마이그레이션 파일들 +└── ... + +frontend/ +├── components/ +│ ├── common/ +│ │ ├── LoadingSpinner.jsx # 🆕 공통 로딩 컴포넌트 +│ │ ├── ErrorMessage.jsx # 🆕 공통 에러 컴포넌트 +│ │ ├── ConfirmDialog.jsx # 🆕 공통 확인 다이얼로그 +│ │ └── index.js # 🆕 공통 컴포넌트 export +│ └── ... +└── ... +``` + +## 🗄️ 데이터베이스 자동 관리 시스템 + +### 📋 **개요** +TK-MP-Project는 SQLAlchemy 모델 기반 자동 DB 마이그레이션 시스템을 사용합니다. 배포 시 백엔드 코드와 DB 스키마가 자동으로 동기화됩니다. + +### 🔄 **자동 마이그레이션 프로세스** + +#### **1. 시스템 구성** +``` +backend/ +├── models.py # 메인 데이터 모델 +├── auth/models.py # 인증 관련 모델 +├── scripts/ +│ ├── simple_migrate.py # 간단하고 안정적인 마이그레이션 스크립트 +│ ├── auto_migrate.py # 복잡한 SQLAlchemy 기반 (백업용) +│ └── generate_complete_schema.py # 스키마 생성 도구 +├── start.sh # 간단 마이그레이션 + 서버 시작 +└── Dockerfile # 자동화된 컨테이너 빌드 +``` + +#### **2. 배포 시 자동 실행 순서** +1. **Docker 컨테이너 시작** +2. **환경 변수 확인** (DB 설정 출력) +3. **DB 연결 대기** (최대 120초, 2초 간격) +4. **간단 SQL 마이그레이션 실행** (psycopg2 직접 사용) +5. **필수 데이터 확인/생성** (관리자 계정, 인덱스) +6. **FastAPI 서버 시작** + +#### **3. 핵심 파일들** + +**`backend/scripts/complete_migrate.py`** ⭐ **완전한 자동 마이그레이션** +```python +# SQLAlchemy 모델 기반 완전한 자동 마이그레이션 +# - 모든 테이블 자동 생성 +# - 기존 테이블에 누락된 컬럼 자동 추가 +# - 성능 인덱스 자동 생성 +# - 초기 사용자 데이터 자동 삽입 +# - macOS Docker와 Synology Container Manager 모두 지원 +# - 60초 DB 연결 대기 (2초 간격) +``` + +**`backend/scripts/simple_migrate.py`** 🔄 **백업용 간단 방식** +```python +# psycopg2를 직접 사용하는 간단하고 안정적인 마이그레이션 +# - 기본 테이블 생성만 수행 +# - 메모리 사용량 최소화 +# - 명확한 에러 메시지 +``` + +**`backend/start.sh`** +```bash +#!/bin/bash +# 1. 환경 정보 출력 (디버깅용) +echo "🖥️ 환경: $(uname -s) $(uname -m)" +echo "🔧 DB 설정 확인:" + +# 2. 완전한 DB 마이그레이션 실행 +python scripts/complete_migrate.py + +# 3. 마이그레이션 실패해도 서버 시작 (기존 스키마 있을 수 있음) +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-level info +``` + +### 🛠️ **개발자 가이드** + +#### **새로운 모델 추가 시** +1. `backend/app/models.py` 또는 관련 모델 파일에 SQLAlchemy 모델 추가 +2. Docker 컨테이너 재시작 → **자동으로 테이블 생성됨** +3. 추가 작업 불필요! + +#### **기존 모델 수정 시** +- **컬럼 추가**: 자동으로 추가됨 +- **컬럼 삭제**: 수동 마이그레이션 필요 (데이터 보호) +- **컬럼 타입 변경**: 수동 마이그레이션 필요 + +#### **수동 마이그레이션이 필요한 경우** +```sql +-- 컬럼 삭제 (데이터 손실 주의) +ALTER TABLE materials DROP COLUMN old_column; + +-- 컬럼 타입 변경 +ALTER TABLE materials ALTER COLUMN quantity TYPE DECIMAL(12,3); + +-- 데이터 마이그레이션 +UPDATE materials SET new_column = old_column WHERE condition; +``` + +### 🚀 **배포 가이드** + +#### **신규 배포 (완전 초기화)** +```bash +# 1. 컨테이너 완전 삭제 +docker-compose down -v + +# 2. 새로 빌드 및 시작 +docker-compose up -d --build + +# 3. 자동 마이그레이션 확인 +docker-compose logs backend | grep "마이그레이션" +``` + +#### **기존 서버 업데이트** +```bash +# 1. 코드 업데이트 +git pull origin main + +# 2. 백엔드 재빌드 및 재시작 +docker-compose up -d --build backend + +# 3. 마이그레이션 로그 확인 +docker-compose logs backend | tail -20 +``` + +#### **Synology Container Manager 배포** +```bash +# 1. Synology 전용 설정 사용 +docker-compose -f docker-compose.synology.yml up -d --build + +# 2. 컨테이너 상태 확인 +docker-compose -f docker-compose.synology.yml ps + +# 3. 마이그레이션 로그 확인 +docker-compose -f docker-compose.synology.yml logs backend | grep -E "🚀|✅|❌" +``` + +#### **마이그레이션 상태 확인** +```bash +# DB 연결 및 테이블 확인 +docker-compose exec postgres psql -U tkmp_user -d tk_mp_bom -c "\dt" + +# 특정 테이블 구조 확인 +docker-compose exec postgres psql -U tkmp_user -d tk_mp_bom -c "\d materials" + +# 관리자 계정 확인 +docker-compose exec postgres psql -U tkmp_user -d tk_mp_bom -c "SELECT username, role FROM users;" +``` + +### 🔧 **트러블슈팅** + +#### **마이그레이션 실패 시** +```bash +# 1. 백엔드 로그 확인 +docker-compose logs backend | grep -E "ERROR|마이그레이션" + +# 2. DB 연결 상태 확인 +docker-compose exec backend python -c "from app.database import engine; print(engine.execute('SELECT 1').scalar())" + +# 3. 수동 마이그레이션 실행 +docker-compose exec backend python scripts/auto_migrate.py +``` + +#### **스키마 불일치 해결** +```bash +# 1. 현재 스키마 백업 +docker-compose exec postgres pg_dump -U tkmp_user tk_mp_bom > backup.sql + +# 2. 완전 재생성 (주의: 데이터 손실) +docker-compose down -v +docker-compose up -d --build + +# 3. 데이터 복원 (필요시) +docker-compose exec postgres psql -U tkmp_user tk_mp_bom < backup.sql +``` + +### 📊 **모니터링** + +#### **자동 마이그레이션 로그 확인** +```bash +# 성공 로그 +docker-compose logs backend | grep "✅" + +# 실패 로그 +docker-compose logs backend | grep "❌" + +# 전체 마이그레이션 프로세스 +docker-compose logs backend | grep "🚀\|🔄\|✅\|❌" +``` + +### ⚠️ **주의사항** + +1. **데이터 백업**: 프로덕션 배포 전 반드시 DB 백업 +2. **점진적 배포**: 대규모 스키마 변경 시 단계별 배포 권장 +3. **롤백 계획**: 마이그레이션 실패 시 롤백 방법 사전 준비 +4. **모니터링**: 배포 후 시스템 정상 작동 확인 + +### 🎯 **장점** + +- ✅ **완전 자동화**: 개발자 개입 없이 스키마 동기화 +- ✅ **크로스 플랫폼**: macOS Docker와 Synology Container Manager 모두 지원 +- ✅ **지능형 마이그레이션**: 기존 테이블에 누락된 컬럼 자동 추가 +- ✅ **안정성**: SQLAlchemy 모델 기반으로 타입 안전성 보장 +- ✅ **실수 방지**: 수동 SQL 작성으로 인한 오류 제거 +- ✅ **일관성 보장**: 모든 환경에서 동일한 스키마 적용 +- ✅ **빠른 배포**: 복잡한 마이그레이션 과정 자동화 +- ✅ **복원력**: 마이그레이션 실패해도 서버 시작 (기존 스키마 보호) +- ✅ **성능 최적화**: 필요한 인덱스 자동 생성 +- ✅ **디버깅 친화적**: 명확한 로그 메시지와 환경 정보 출력 + +--- + +**마지막 업데이트**: 2025년 10월 19일 (자동 DB 마이그레이션 시스템 추가) diff --git a/backend/Dockerfile b/backend/Dockerfile index 237e72f..d6e898b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -20,11 +20,14 @@ RUN pip install --no-cache-dir -r requirements.txt # 애플리케이션 코드 복사 COPY . . +# 시작 스크립트 실행 권한 부여 +RUN chmod +x /app/start.sh + # 포트 8000 노출 EXPOSE 8000 # 환경변수 설정 ENV PYTHONPATH=/app -# 서버 실행 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +# 자동 마이그레이션 후 서버 실행 +CMD ["bash", "/app/start.sh"] \ No newline at end of file diff --git a/backend/app/models.py b/backend/app/models.py index 416330c..9198c22 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -34,11 +34,15 @@ class File(Base): filename = Column(String(255), nullable=False) original_filename = Column(String(255), nullable=False) file_path = Column(String(500), nullable=False) + job_no = Column(String(50)) # 작업 번호 revision = Column(String(20), default='Rev.0') + bom_name = Column(String(200)) # BOM 이름 + description = Column(Text) # 파일 설명 upload_date = Column(DateTime, default=datetime.utcnow) uploaded_by = Column(String(100)) file_type = Column(String(10)) file_size = Column(Integer) + parsed_count = Column(Integer) # 파싱된 자재 수 is_active = Column(Boolean, default=True) # 관계 설정 @@ -51,22 +55,40 @@ class Material(Base): id = Column(Integer, primary_key=True, index=True) file_id = Column(Integer, ForeignKey("files.id")) line_number = Column(Integer) + row_number = Column(Integer) # 업로드 시 행 번호 original_description = Column(Text, nullable=False) classified_category = Column(String(50)) classified_subcategory = Column(String(100)) material_grade = Column(String(50)) + full_material_grade = Column(Text) # 전체 재질명 (ASTM A312 TP304 등) schedule = Column(String(20)) size_spec = Column(String(50)) + main_nom = Column(String(50)) # 주 사이즈 (4", 150A 등) + red_nom = Column(String(50)) # 축소 사이즈 (Reducing 피팅/플랜지용) quantity = Column(Numeric(10, 3), nullable=False) unit = Column(String(10), nullable=False) - # length = Column(Numeric(10, 3)) # 임시로 주석 처리 + length = Column(Numeric(10, 3)) # 길이 정보 drawing_name = Column(String(100)) area_code = Column(String(20)) line_no = Column(String(50)) classification_confidence = Column(Numeric(3, 2)) + classification_details = Column(JSON) # 분류 상세 정보 (JSON) is_verified = Column(Boolean, default=False) verified_by = Column(String(50)) verified_at = Column(DateTime) + + # 구매 관련 필드 + purchase_confirmed = Column(Boolean, default=False) + confirmed_quantity = Column(Numeric(10, 3)) + purchase_status = Column(String(20)) + purchase_confirmed_by = Column(String(100)) + purchase_confirmed_at = Column(DateTime) + + # 리비전 관리 필드 + revision_status = Column(String(20)) # 'new', 'changed', 'inventory', 'deleted_not_purchased' + material_hash = Column(String(64)) # 자재 비교용 해시 + normalized_description = Column(Text) # 정규화된 설명 + drawing_reference = Column(String(100)) notes = Column(Text) created_at = Column(DateTime, default=datetime.utcnow) @@ -450,3 +472,349 @@ class MaterialTubingMapping(Base): # 관계 설정 material = relationship("Material", backref="tubing_mappings") tubing_product = relationship("TubingProduct", back_populates="material_mappings") + +class SupportDetails(Base): + __tablename__ = "support_details" + + id = Column(Integer, primary_key=True, index=True) + material_id = Column(Integer, ForeignKey("materials.id"), nullable=False) + file_id = Column(Integer, ForeignKey("files.id"), nullable=False) + + # 서포트 정보 + support_type = Column(String(50)) + support_subtype = Column(String(100)) + load_rating = Column(String(50)) + load_capacity = Column(String(50)) + material_standard = Column(String(50)) + material_grade = Column(String(50)) + pipe_size = Column(String(50)) + + # 치수 정보 + length_mm = Column(Numeric(10, 2)) + width_mm = Column(Numeric(10, 2)) + height_mm = Column(Numeric(10, 2)) + + # 분류 신뢰도 + classification_confidence = Column(Numeric(3, 2)) + + # 시간 정보 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 관계 설정 + material = relationship("Material") + file = relationship("File") + +class PurchaseRequestItems(Base): + __tablename__ = "purchase_request_items" + + id = Column(Integer, primary_key=True, index=True) + request_id = Column(String(50), nullable=False) # 구매신청 ID + material_id = Column(Integer, ForeignKey("materials.id"), nullable=False) + + # 수량 정보 + quantity = Column(Integer, nullable=False) + unit = Column(String(10), nullable=False) + user_requirement = Column(Text) + + # 시간 정보 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 관계 설정 + material = relationship("Material") + +class FittingDetails(Base): + __tablename__ = "fitting_details" + + id = Column(Integer, primary_key=True, index=True) + material_id = Column(Integer, ForeignKey("materials.id"), nullable=False) + file_id = Column(Integer, ForeignKey("files.id"), nullable=False) + + # 피팅 정보 + fitting_type = Column(String(50)) + fitting_subtype = Column(String(100)) + connection_type = Column(String(50)) + material_standard = Column(String(50)) + material_grade = Column(String(50)) + nominal_size = Column(String(50)) + wall_thickness = Column(String(50)) + + # 치수 정보 + length_mm = Column(Numeric(10, 2)) + width_mm = Column(Numeric(10, 2)) + height_mm = Column(Numeric(10, 2)) + + # 분류 신뢰도 + classification_confidence = Column(Numeric(3, 2)) + + # 시간 정보 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 관계 설정 + material = relationship("Material") + file = relationship("File") + +class FlangeDetails(Base): + __tablename__ = "flange_details" + + id = Column(Integer, primary_key=True, index=True) + material_id = Column(Integer, ForeignKey("materials.id"), nullable=False) + file_id = Column(Integer, ForeignKey("files.id"), nullable=False) + + # 플랜지 정보 + flange_type = Column(String(50)) + flange_subtype = Column(String(100)) + pressure_rating = Column(String(50)) + material_standard = Column(String(50)) + material_grade = Column(String(50)) + nominal_size = Column(String(50)) + + # 치수 정보 + outer_diameter = Column(Numeric(10, 2)) + thickness = Column(Numeric(10, 2)) + + # 분류 신뢰도 + classification_confidence = Column(Numeric(3, 2)) + + # 시간 정보 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 관계 설정 + material = relationship("Material") + file = relationship("File") + +class ValveDetails(Base): + __tablename__ = "valve_details" + + id = Column(Integer, primary_key=True, index=True) + material_id = Column(Integer, ForeignKey("materials.id"), nullable=False) + file_id = Column(Integer, ForeignKey("files.id"), nullable=False) + + # 밸브 정보 + valve_type = Column(String(50)) + valve_subtype = Column(String(100)) + connection_type = Column(String(50)) + actuation_type = Column(String(50)) + material_standard = Column(String(50)) + material_grade = Column(String(50)) + nominal_size = Column(String(50)) + pressure_rating = Column(String(50)) + + # 분류 신뢰도 + classification_confidence = Column(Numeric(3, 2)) + + # 시간 정보 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 관계 설정 + material = relationship("Material") + file = relationship("File") + +class GasketDetails(Base): + __tablename__ = "gasket_details" + + id = Column(Integer, primary_key=True, index=True) + material_id = Column(Integer, ForeignKey("materials.id"), nullable=False) + file_id = Column(Integer, ForeignKey("files.id"), nullable=False) + + # 가스켓 정보 + gasket_type = Column(String(50)) + material_standard = Column(String(50)) + material_grade = Column(String(50)) + nominal_size = Column(String(50)) + pressure_rating = Column(String(50)) + filler_material = Column(String(50)) + thickness = Column(Numeric(10, 2)) + + # 분류 신뢰도 + classification_confidence = Column(Numeric(3, 2)) + + # 시간 정보 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 관계 설정 + material = relationship("Material") + file = relationship("File") + +class BoltDetails(Base): + __tablename__ = "bolt_details" + + id = Column(Integer, primary_key=True, index=True) + material_id = Column(Integer, ForeignKey("materials.id"), nullable=False) + file_id = Column(Integer, ForeignKey("files.id"), nullable=False) + + # 볼트 정보 + bolt_type = Column(String(50)) + material_standard = Column(String(50)) + material_grade = Column(String(50)) + thread_size = Column(String(50)) + length = Column(Numeric(10, 2)) + pressure_rating = Column(String(50)) + + # 분류 신뢰도 + classification_confidence = Column(Numeric(3, 2)) + + # 시간 정보 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 관계 설정 + material = relationship("Material") + file = relationship("File") + +class InstrumentDetails(Base): + __tablename__ = "instrument_details" + + id = Column(Integer, primary_key=True, index=True) + material_id = Column(Integer, ForeignKey("materials.id"), nullable=False) + file_id = Column(Integer, ForeignKey("files.id"), nullable=False) + + # 계기 정보 + instrument_type = Column(String(50)) + instrument_subtype = Column(String(100)) + connection_type = Column(String(50)) + material_standard = Column(String(50)) + material_grade = Column(String(50)) + nominal_size = Column(String(50)) + + # 분류 신뢰도 + classification_confidence = Column(Numeric(3, 2)) + + # 시간 정보 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 관계 설정 + material = relationship("Material") + file = relationship("File") + +class PurchaseRequests(Base): + __tablename__ = "purchase_requests" + + request_id = Column(String(50), primary_key=True, index=True) + request_no = Column(String(100), nullable=False) + file_id = Column(Integer, ForeignKey("files.id"), nullable=False) + job_no = Column(String(50), nullable=False) + category = Column(String(50)) + material_count = Column(Integer) + excel_file_path = Column(String(500)) + requested_by = Column(Integer, ForeignKey("users.user_id")) + + # 시간 정보 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 관계 설정 + file = relationship("File") + requested_by_user = relationship("User", foreign_keys=[requested_by]) + +class Jobs(Base): + __tablename__ = "jobs" + + id = Column(Integer, primary_key=True, index=True) + job_no = Column(String(50), unique=True, nullable=False) + job_name = Column(String(200)) + status = Column(String(20), default='active') + created_at = Column(DateTime, default=datetime.utcnow) + +class PipeEndPreparations(Base): + __tablename__ = "pipe_end_preparations" + + id = Column(Integer, primary_key=True, index=True) + material_id = Column(Integer, ForeignKey("materials.id"), nullable=False) + file_id = Column(Integer, ForeignKey("files.id"), nullable=False) + end_prep_type = Column(String(50)) + end_prep_standard = Column(String(50)) + classification_confidence = Column(Numeric(3, 2)) + created_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + material = relationship("Material") + file = relationship("File") + +class MaterialPurchaseTracking(Base): + __tablename__ = "material_purchase_tracking" + + id = Column(Integer, primary_key=True, index=True) + material_id = Column(Integer, ForeignKey("materials.id"), nullable=False) + file_id = Column(Integer, ForeignKey("files.id"), nullable=False) + purchase_status = Column(String(20)) + requested_quantity = Column(Integer) + confirmed_quantity = Column(Integer) + purchase_date = Column(DateTime) + created_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + material = relationship("Material") + file = relationship("File") + +class ExcelExports(Base): + __tablename__ = "excel_exports" + + id = Column(Integer, primary_key=True, index=True) + file_id = Column(Integer, ForeignKey("files.id"), nullable=False) + export_type = Column(String(50)) + file_path = Column(String(500)) + exported_by = Column(Integer, ForeignKey("users.user_id")) + created_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + file = relationship("File") + exported_by_user = relationship("User", foreign_keys=[exported_by]) + +class UserActivityLogs(Base): + __tablename__ = "user_activity_logs" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.user_id")) + activity_type = Column(String(50)) + activity_description = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + user = relationship("User") + +class ExcelExportHistory(Base): + __tablename__ = "excel_export_history" + + export_id = Column(String(50), primary_key=True, index=True) + file_id = Column(Integer, ForeignKey("files.id")) + job_no = Column(String(50)) + exported_by = Column(Integer, ForeignKey("users.user_id")) + export_date = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + file = relationship("File") + exported_by_user = relationship("User", foreign_keys=[exported_by]) + +class ExportedMaterials(Base): + __tablename__ = "exported_materials" + + id = Column(Integer, primary_key=True, index=True) + export_id = Column(String(50), ForeignKey("excel_export_history.export_id")) + material_id = Column(Integer, ForeignKey("materials.id")) + quantity = Column(Integer) + status = Column(String(20)) + + # 관계 설정 + export_history = relationship("ExcelExportHistory") + material = relationship("Material") + +class PurchaseStatusHistory(Base): + __tablename__ = "purchase_status_history" + + id = Column(Integer, primary_key=True, index=True) + material_id = Column(Integer, ForeignKey("materials.id")) + old_status = Column(String(20)) + new_status = Column(String(20)) + changed_by = Column(Integer, ForeignKey("users.user_id")) + changed_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + material = relationship("Material") + changed_by_user = relationship("User", foreign_keys=[changed_by]) diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index d21a92b..6a5dfa6 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -338,48 +338,68 @@ async def upload_file( db: Session = Depends(get_db), current_user: dict = Depends(get_current_user) ): - # 🎯 트랜잭션 오류 방지: 완전한 트랜잭션 초기화 + """ + 파일 업로드 API - 리팩토링된 서비스 레이어 사용 + """ + from ..services.file_upload_service import FileUploadService + + upload_service = FileUploadService(db) + try: - # 1. 현재 트랜잭션 완전 롤백 - db.rollback() - print("🔄 1단계: 이전 트랜잭션 롤백 완료") + # 1. 업로드 요청 검증 + upload_service.validate_upload_request(file, job_no) - # 2. 세션 상태 초기화 - db.close() - print("🔄 2단계: 세션 닫기 완료") + # 2. 파일 저장 + unique_filename, file_path = upload_service.save_uploaded_file(file) - # 3. 새 세션 생성 - from ..database import get_db - db = next(get_db()) - print("🔄 3단계: 새 세션 생성 완료") - - except Exception as e: - print(f"⚠️ 트랜잭션 초기화 중 오류: {e}") - # 오류 발생 시에도 계속 진행 - # 로그 제거 - if not validate_file_extension(file.filename): - raise HTTPException( - status_code=400, - detail=f"지원하지 않는 파일 형식입니다. 허용된 확장자: {', '.join(ALLOWED_EXTENSIONS)}" + # 3. 파일 레코드 생성 + file_record = upload_service.create_file_record( + filename=unique_filename, + original_filename=file.filename, + file_path=str(file_path), + job_no=job_no, + revision=revision, + bom_name=bom_name, + file_size=file.size or 0, + parsed_count=0, # 임시값, 파싱 후 업데이트 + uploaded_by=current_user.get('username', 'unknown'), + parent_file_id=parent_file_id ) - - if file.size and file.size > 10 * 1024 * 1024: - raise HTTPException(status_code=400, detail="파일 크기는 10MB를 초과할 수 없습니다") - - unique_filename = generate_unique_filename(file.filename) - file_path = UPLOAD_DIR / unique_filename - - try: - # 로그 제거 - with open(file_path, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) - # 로그 제거 + + # 4. 자재 데이터 처리 + processing_result = upload_service.process_materials_data( + file_path=file_path, + file_id=file_record.id, + job_no=job_no, + revision=revision, + parent_file_id=parent_file_id + ) + + # 5. 파일 레코드 업데이트 (파싱된 자재 수) + file_record.parsed_count = processing_result['materials_count'] + db.commit() + + logger.info(f"File upload completed: {file_record.id}") + + return { + "success": True, + "message": "파일 업로드 및 처리가 완료되었습니다.", + "file_id": file_record.id, + "filename": file_record.filename, + "materials_count": processing_result['materials_count'], + "classification_results": processing_result['classification_results'] + } + + except HTTPException: + # HTTP 예외는 그대로 전달 + upload_service.cleanup_failed_upload(file_path if 'file_path' in locals() else None) + raise except Exception as e: - raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}") - - try: - # 로그 제거 - materials_data = parse_file_data(str(file_path)) + # 기타 예외 처리 + db.rollback() + upload_service.cleanup_failed_upload(file_path if 'file_path' in locals() else None) + logger.error(f"File upload failed: {e}") + raise HTTPException(status_code=500, detail=f"파일 업로드 실패: {str(e)}") parsed_count = len(materials_data) # 로그 제거 @@ -1861,26 +1881,87 @@ async def get_files_stats(db: Session = Depends(get_db)): @router.delete("/delete/{file_id}") async def delete_file(file_id: int, db: Session = Depends(get_db)): - """파일 삭제""" + """파일 삭제 - 모든 관련 데이터를 올바른 순서로 삭제""" try: - # 자재 먼저 삭제 - db.execute(text("DELETE FROM materials WHERE file_id = :file_id"), {"file_id": file_id}) - - # 파일 삭제 - result = db.execute(text("DELETE FROM files WHERE id = :file_id"), {"file_id": file_id}) - - if result.rowcount == 0: + # 파일 존재 확인 + file_check = db.execute(text("SELECT id FROM files WHERE id = :file_id"), {"file_id": file_id}).fetchone() + if not file_check: raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다") + # 1. 자재 관련 상세 테이블들 먼저 삭제 (외래키 순서 고려) + detail_tables = [ + "support_details", "fitting_details", "flange_details", + "valve_details", "gasket_details", "bolt_details", + "instrument_details", "user_requirements" + ] + + for table in detail_tables: + try: + db.execute(text(f"DELETE FROM {table} WHERE file_id = :file_id"), {"file_id": file_id}) + except Exception as detail_error: + # 테이블이 없거나 다른 오류가 있어도 계속 진행 + print(f"Warning: Failed to delete from {table}: {detail_error}") + + # 2. 구매 관련 테이블 삭제 + try: + # purchase_request_items 먼저 삭제 (purchase_requests를 참조하므로) + db.execute(text(""" + DELETE FROM purchase_request_items + WHERE request_id IN ( + SELECT request_id FROM purchase_requests WHERE file_id = :file_id + ) + """), {"file_id": file_id}) + + # purchase_requests 삭제 + db.execute(text("DELETE FROM purchase_requests WHERE file_id = :file_id"), {"file_id": file_id}) + except Exception as purchase_error: + print(f"Warning: Failed to delete purchase data: {purchase_error}") + + # 3. 기타 관련 테이블들 삭제 + try: + # excel_exports 삭제 + db.execute(text("DELETE FROM excel_exports WHERE file_id = :file_id"), {"file_id": file_id}) + except Exception as excel_error: + print(f"Warning: Failed to delete from excel_exports: {excel_error}") + + try: + # user_activity_logs 삭제 (target_id와 target_type 사용) + db.execute(text("DELETE FROM user_activity_logs WHERE target_id = :file_id AND target_type = 'file'"), {"file_id": file_id}) + except Exception as activity_error: + print(f"Warning: Failed to delete from user_activity_logs: {activity_error}") + + try: + # exported_materials 삭제 (먼저 export_id 조회 후 삭제) + export_ids = db.execute(text("SELECT id FROM excel_exports WHERE file_id = :file_id"), {"file_id": file_id}).fetchall() + for export_row in export_ids: + db.execute(text("DELETE FROM exported_materials WHERE export_id = :export_id"), {"export_id": export_row[0]}) + except Exception as exported_error: + print(f"Warning: Failed to delete from exported_materials: {exported_error}") + + # 4. 자재 테이블 삭제 + materials_result = db.execute(text("DELETE FROM materials WHERE file_id = :file_id"), {"file_id": file_id}) + print(f"Deleted {materials_result.rowcount} materials") + + # 5. 마지막으로 파일 삭제 + file_result = db.execute(text("DELETE FROM files WHERE id = :file_id"), {"file_id": file_id}) + + if file_result.rowcount == 0: + raise HTTPException(status_code=404, detail="파일 삭제에 실패했습니다") + db.commit() return { "success": True, - "message": "파일이 삭제되었습니다" + "message": "파일과 모든 관련 데이터가 삭제되었습니다", + "deleted_materials": materials_result.rowcount } + except HTTPException: + db.rollback() + raise except Exception as e: db.rollback() + print(f"File deletion error: {str(e)}") raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {str(e)}") @router.get("/materials-v2") # 완전히 새로운 엔드포인트 diff --git a/backend/app/services/database_service.py b/backend/app/services/database_service.py new file mode 100644 index 0000000..6c42833 --- /dev/null +++ b/backend/app/services/database_service.py @@ -0,0 +1,350 @@ +""" +데이터베이스 공통 서비스 레이어 +중복된 DB 쿼리 로직을 통합하고 재사용 가능한 서비스 제공 +""" + +from sqlalchemy.orm import Session +from sqlalchemy import text, and_, or_ +from typing import List, Dict, Any, Optional, Union +from ..models import Material, File, User, Project +from ..utils.logger import get_logger +from ..utils.error_handlers import ErrorResponse + +logger = get_logger(__name__) + + +class DatabaseService: + """데이터베이스 공통 서비스""" + + def __init__(self, db: Session): + self.db = db + + def execute_query(self, query: str, params: Dict = None) -> Any: + """안전한 쿼리 실행""" + try: + result = self.db.execute(text(query), params or {}) + return result + except Exception as e: + logger.error(f"Query execution failed: {query[:100]}... Error: {e}") + raise + + def get_materials_with_details( + self, + file_id: Optional[int] = None, + job_no: Optional[str] = None, + limit: int = 1000, + offset: int = 0, + exclude_requested: bool = False + ) -> Dict[str, Any]: + """자재 상세 정보 조회 (통합된 쿼리)""" + + where_conditions = ["1=1"] + params = {"limit": limit, "offset": offset} + + if file_id: + where_conditions.append("m.file_id = :file_id") + params["file_id"] = file_id + + if job_no: + where_conditions.append("f.job_no = :job_no") + params["job_no"] = job_no + + if exclude_requested: + where_conditions.append("(m.purchase_confirmed IS NULL OR m.purchase_confirmed = false)") + + # 통합된 자재 조회 쿼리 + query = f""" + SELECT + m.id, m.file_id, m.line_number, m.row_number, + m.original_description, m.classified_category, m.classified_subcategory, + m.material_grade, m.full_material_grade, m.schedule, m.size_spec, + m.main_nom, m.red_nom, m.quantity, m.unit, m.length, + m.drawing_name, m.area_code, m.line_no, + m.classification_confidence, m.classification_details, + m.purchase_confirmed, m.confirmed_quantity, m.purchase_status, + m.purchase_confirmed_by, m.purchase_confirmed_at, + m.revision_status, m.material_hash, m.normalized_description, + m.drawing_reference, m.notes, m.created_at, + + -- 파일 정보 + f.filename, f.original_filename, f.job_no, f.revision, f.bom_name, + f.upload_date, f.uploaded_by, + + -- 프로젝트 정보 + p.project_name, p.client_name, + + -- 상세 정보들 (LEFT JOIN) + pd.material_standard as pipe_material_standard, + pd.manufacturing_method, pd.end_preparation, pd.wall_thickness, + + fd.fitting_type, fd.fitting_subtype, fd.connection_type as fitting_connection_type, + fd.main_size as fitting_main_size, fd.reduced_size as fitting_reduced_size, + + fld.flange_type, fld.flange_subtype, fld.pressure_rating as flange_pressure_rating, + fld.face_type, fld.connection_method, + + vd.valve_type, vd.valve_subtype, vd.actuation_type, + vd.pressure_rating as valve_pressure_rating, vd.temperature_rating, + + bd.bolt_type, bd.bolt_subtype, bd.thread_type, bd.head_type, + bd.material_standard as bolt_material_standard, + bd.pressure_rating as bolt_pressure_rating, + + gd.gasket_type, gd.gasket_subtype, gd.material_type as gasket_material_type, + gd.filler_material, gd.pressure_rating as gasket_pressure_rating, + gd.size_inches as gasket_size_inches, gd.thickness as gasket_thickness, + gd.temperature_range as gasket_temperature_range, gd.fire_safe, + + -- 구매 추적 정보 + mpt.confirmed_quantity as tracking_confirmed_quantity, + mpt.purchase_status as tracking_purchase_status, + mpt.confirmed_by as tracking_confirmed_by, + mpt.confirmed_at as tracking_confirmed_at, + + -- 최종 분류 (구매 추적 정보 우선) + CASE + WHEN mpt.id IS NOT NULL THEN + CASE + WHEN mpt.description LIKE '%PIPE%' OR mpt.description LIKE '%파이프%' THEN 'PIPE' + WHEN mpt.description LIKE '%FITTING%' OR mpt.description LIKE '%피팅%' THEN 'FITTING' + WHEN mpt.description LIKE '%VALVE%' OR mpt.description LIKE '%밸브%' THEN 'VALVE' + WHEN mpt.description LIKE '%FLANGE%' OR mpt.description LIKE '%플랜지%' THEN 'FLANGE' + WHEN mpt.description LIKE '%BOLT%' OR mpt.description LIKE '%볼트%' THEN 'BOLT' + WHEN mpt.description LIKE '%GASKET%' OR mpt.description LIKE '%가스켓%' THEN 'GASKET' + WHEN mpt.description LIKE '%INSTRUMENT%' OR mpt.description LIKE '%계기%' THEN 'INSTRUMENT' + ELSE m.classified_category + END + ELSE m.classified_category + END as final_classified_category, + + -- 검증 상태 + CASE WHEN mpt.id IS NOT NULL THEN true ELSE m.is_verified END as final_is_verified, + CASE WHEN mpt.id IS NOT NULL THEN 'purchase_calculation' ELSE m.verified_by END as final_verified_by + + FROM materials m + LEFT JOIN files f ON m.file_id = f.id + LEFT JOIN projects p ON f.project_id = p.id + LEFT JOIN pipe_details pd ON m.id = pd.material_id + LEFT JOIN fitting_details fd ON m.id = fd.material_id + LEFT JOIN flange_details fld ON m.id = fld.material_id + LEFT JOIN valve_details vd ON m.id = vd.material_id + LEFT JOIN bolt_details bd ON m.id = bd.material_id + LEFT JOIN gasket_details gd ON m.id = gd.material_id + LEFT JOIN material_purchase_tracking mpt ON ( + m.material_hash = mpt.material_hash + AND f.job_no = mpt.job_no + AND f.revision = mpt.revision + ) + WHERE {' AND '.join(where_conditions)} + ORDER BY m.line_number ASC, m.id ASC + LIMIT :limit OFFSET :offset + """ + + try: + result = self.execute_query(query, params) + materials = [dict(row._mapping) for row in result.fetchall()] + + # 총 개수 조회 + count_query = f""" + SELECT COUNT(*) as total + FROM materials m + LEFT JOIN files f ON m.file_id = f.id + WHERE {' AND '.join(where_conditions[:-1])} -- LIMIT/OFFSET 제외 + """ + + count_result = self.execute_query(count_query, {k: v for k, v in params.items() if k not in ['limit', 'offset']}) + total_count = count_result.scalar() + + return { + "materials": materials, + "total_count": total_count, + "limit": limit, + "offset": offset + } + + except Exception as e: + logger.error(f"Failed to get materials with details: {e}") + raise + + def get_purchase_request_materials( + self, + job_no: str, + category: Optional[str] = None, + limit: int = 1000 + ) -> List[Dict]: + """구매 신청 자재 조회""" + + where_conditions = ["f.job_no = :job_no", "m.purchase_confirmed = true"] + params = {"job_no": job_no, "limit": limit} + + if category and category != 'ALL': + where_conditions.append("m.classified_category = :category") + params["category"] = category + + query = f""" + SELECT + m.id, m.original_description, m.classified_category, + m.material_grade, m.schedule, m.size_spec, m.main_nom, m.red_nom, + CAST(m.quantity AS INTEGER) as requested_quantity, + CAST(m.confirmed_quantity AS INTEGER) as original_quantity, + m.unit, m.drawing_name, m.line_no, + f.job_no, f.revision, f.bom_name + FROM materials m + JOIN files f ON m.file_id = f.id + WHERE {' AND '.join(where_conditions)} + ORDER BY m.classified_category, m.original_description + LIMIT :limit + """ + + try: + result = self.execute_query(query, params) + return [dict(row._mapping) for row in result.fetchall()] + except Exception as e: + logger.error(f"Failed to get purchase request materials: {e}") + raise + + def safe_delete_related_data(self, file_id: int) -> Dict[str, Any]: + """파일 관련 데이터 안전 삭제""" + + deletion_results = {} + + # 삭제 순서 (외래키 제약 조건 고려) + deletion_queries = [ + ("support_details", "DELETE FROM support_details WHERE file_id = :file_id"), + ("fitting_details", "DELETE FROM fitting_details WHERE material_id IN (SELECT id FROM materials WHERE file_id = :file_id)"), + ("flange_details", "DELETE FROM flange_details WHERE material_id IN (SELECT id FROM materials WHERE file_id = :file_id)"), + ("valve_details", "DELETE FROM valve_details WHERE material_id IN (SELECT id FROM materials WHERE file_id = :file_id)"), + ("gasket_details", "DELETE FROM gasket_details WHERE material_id IN (SELECT id FROM materials WHERE file_id = :file_id)"), + ("bolt_details", "DELETE FROM bolt_details WHERE material_id IN (SELECT id FROM materials WHERE file_id = :file_id)"), + ("instrument_details", "DELETE FROM instrument_details WHERE material_id IN (SELECT id FROM materials WHERE file_id = :file_id)"), + ("user_requirements", "DELETE FROM user_requirements WHERE file_id = :file_id"), + ("purchase_request_items", "DELETE FROM purchase_request_items WHERE material_id IN (SELECT id FROM materials WHERE file_id = :file_id)"), + ("purchase_requests", "DELETE FROM purchase_requests WHERE file_id = :file_id"), + ("user_activity_logs", "DELETE FROM user_activity_logs WHERE target_id = :file_id AND target_type = 'file'"), + ("exported_materials", """ + DELETE FROM exported_materials + WHERE export_id IN (SELECT id FROM excel_exports WHERE file_id = :file_id) + """), + ("excel_exports", "DELETE FROM excel_exports WHERE file_id = :file_id"), + ("materials", "DELETE FROM materials WHERE file_id = :file_id"), + ] + + for table_name, query in deletion_queries: + try: + result = self.execute_query(query, {"file_id": file_id}) + deleted_count = result.rowcount + deletion_results[table_name] = {"success": True, "deleted_count": deleted_count} + logger.info(f"Deleted {deleted_count} records from {table_name}") + except Exception as e: + deletion_results[table_name] = {"success": False, "error": str(e)} + logger.warning(f"Failed to delete from {table_name}: {e}") + + return deletion_results + + def bulk_insert_materials(self, materials_data: List[Dict], file_id: int) -> int: + """자재 데이터 대량 삽입""" + + if not materials_data: + return 0 + + try: + # 대량 삽입을 위한 쿼리 준비 + insert_query = """ + INSERT INTO materials ( + file_id, line_number, row_number, original_description, + classified_category, classified_subcategory, material_grade, + full_material_grade, schedule, size_spec, main_nom, red_nom, + quantity, unit, length, drawing_name, area_code, line_no, + classification_confidence, classification_details, + revision_status, material_hash, normalized_description, + created_at + ) VALUES ( + :file_id, :line_number, :row_number, :original_description, + :classified_category, :classified_subcategory, :material_grade, + :full_material_grade, :schedule, :size_spec, :main_nom, :red_nom, + :quantity, :unit, :length, :drawing_name, :area_code, :line_no, + :classification_confidence, :classification_details, + :revision_status, :material_hash, :normalized_description, + CURRENT_TIMESTAMP + ) + """ + + # 데이터 준비 + insert_data = [] + for material in materials_data: + insert_data.append({ + "file_id": file_id, + "line_number": material.get("line_number"), + "row_number": material.get("row_number"), + "original_description": material.get("original_description", ""), + "classified_category": material.get("classified_category"), + "classified_subcategory": material.get("classified_subcategory"), + "material_grade": material.get("material_grade"), + "full_material_grade": material.get("full_material_grade"), + "schedule": material.get("schedule"), + "size_spec": material.get("size_spec"), + "main_nom": material.get("main_nom"), + "red_nom": material.get("red_nom"), + "quantity": material.get("quantity", 0), + "unit": material.get("unit", "EA"), + "length": material.get("length"), + "drawing_name": material.get("drawing_name"), + "area_code": material.get("area_code"), + "line_no": material.get("line_no"), + "classification_confidence": material.get("classification_confidence"), + "classification_details": material.get("classification_details"), + "revision_status": material.get("revision_status", "new"), + "material_hash": material.get("material_hash"), + "normalized_description": material.get("normalized_description"), + }) + + # 대량 삽입 실행 + self.db.execute(text(insert_query), insert_data) + self.db.commit() + + logger.info(f"Successfully inserted {len(insert_data)} materials") + return len(insert_data) + + except Exception as e: + self.db.rollback() + logger.error(f"Failed to bulk insert materials: {e}") + raise + + +class MaterialQueryBuilder: + """자재 쿼리 빌더""" + + @staticmethod + def build_materials_query( + filters: Dict[str, Any] = None, + joins: List[str] = None, + order_by: str = "m.line_number ASC" + ) -> str: + """동적 자재 쿼리 생성""" + + base_query = """ + SELECT m.*, f.job_no, f.revision, f.bom_name + FROM materials m + LEFT JOIN files f ON m.file_id = f.id + """ + + # 추가 조인 + if joins: + for join in joins: + base_query += f" {join}" + + # 필터 조건 + where_conditions = ["1=1"] + if filters: + for key, value in filters.items(): + if value is not None: + where_conditions.append(f"m.{key} = :{key}") + + if where_conditions: + base_query += f" WHERE {' AND '.join(where_conditions)}" + + # 정렬 + if order_by: + base_query += f" ORDER BY {order_by}" + + return base_query diff --git a/backend/app/services/file_upload_service.py b/backend/app/services/file_upload_service.py new file mode 100644 index 0000000..4e4ec8a --- /dev/null +++ b/backend/app/services/file_upload_service.py @@ -0,0 +1,607 @@ +""" +파일 업로드 서비스 +파일 업로드 관련 로직을 통합하고 트랜잭션 관리 개선 +""" + +import os +import shutil +from pathlib import Path +from typing import Dict, List, Any, Optional, Tuple +from fastapi import UploadFile, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import text + +from ..models import File, Material +from ..utils.logger import get_logger +from ..utils.file_processor import parse_file_data +from ..utils.file_validator import validate_file_extension, generate_unique_filename +from .database_service import DatabaseService +from .material_classification_service import MaterialClassificationService + +logger = get_logger(__name__) + +UPLOAD_DIR = Path("uploads") +ALLOWED_EXTENSIONS = {'.xls', '.xlsx', '.csv'} +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB + + +class FileUploadService: + """파일 업로드 서비스""" + + def __init__(self, db: Session): + self.db = db + self.db_service = DatabaseService(db) + self.classification_service = MaterialClassificationService() + + def validate_upload_request(self, file: UploadFile, job_no: str) -> None: + """업로드 요청 검증""" + + if not validate_file_extension(file.filename): + raise HTTPException( + status_code=400, + detail=f"지원하지 않는 파일 형식입니다. 허용된 확장자: {', '.join(ALLOWED_EXTENSIONS)}" + ) + + if file.size and file.size > MAX_FILE_SIZE: + raise HTTPException( + status_code=400, + detail="파일 크기는 10MB를 초과할 수 없습니다" + ) + + if not job_no or len(job_no.strip()) == 0: + raise HTTPException( + status_code=400, + detail="작업 번호는 필수입니다" + ) + + def save_uploaded_file(self, file: UploadFile) -> Tuple[str, Path]: + """업로드된 파일 저장""" + + try: + # 고유 파일명 생성 + unique_filename = generate_unique_filename(file.filename) + file_path = UPLOAD_DIR / unique_filename + + # 업로드 디렉토리 생성 + UPLOAD_DIR.mkdir(exist_ok=True) + + # 파일 저장 + with open(file_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + logger.info(f"File saved: {file_path}") + return unique_filename, file_path + + except Exception as e: + logger.error(f"Failed to save file: {e}") + raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}") + + def create_file_record( + self, + filename: str, + original_filename: str, + file_path: str, + job_no: str, + revision: str, + bom_name: Optional[str], + file_size: int, + parsed_count: int, + uploaded_by: str, + parent_file_id: Optional[int] = None + ) -> File: + """파일 레코드 생성""" + + try: + # BOM 이름 자동 생성 (제공되지 않은 경우) + if not bom_name: + bom_name = original_filename.rsplit('.', 1)[0] + + # 파일 설명 생성 + description = f"BOM 파일 - {parsed_count}개 자재" + + file_record = File( + filename=filename, + original_filename=original_filename, + file_path=file_path, + job_no=job_no, + revision=revision, + bom_name=bom_name, + description=description, + file_size=file_size, + parsed_count=parsed_count, + is_active=True, + uploaded_by=uploaded_by + ) + + self.db.add(file_record) + self.db.flush() # ID 생성을 위해 flush + + logger.info(f"File record created: ID={file_record.id}, Job={job_no}") + return file_record + + except Exception as e: + logger.error(f"Failed to create file record: {e}") + raise HTTPException(status_code=500, detail=f"파일 레코드 생성 실패: {str(e)}") + + def process_materials_data( + self, + file_path: Path, + file_id: int, + job_no: str, + revision: str, + parent_file_id: Optional[int] = None + ) -> Dict[str, Any]: + """자재 데이터 처리""" + + try: + # 파일 파싱 + logger.info(f"Parsing file: {file_path}") + materials_data = parse_file_data(str(file_path)) + + if not materials_data: + raise HTTPException(status_code=400, detail="파일에서 자재 데이터를 찾을 수 없습니다") + + # 자재 분류 및 처리 + processed_materials = [] + classification_results = { + "total_materials": len(materials_data), + "classified_count": 0, + "categories": {} + } + + for idx, material_data in enumerate(materials_data): + try: + # 자재 분류 + classified_material = self.classification_service.classify_material( + material_data, + line_number=idx + 1, + row_number=material_data.get('row_number', idx + 1) + ) + + # 리비전 상태 설정 + if parent_file_id: + # 리비전 업로드인 경우 변경 상태 분석 + classified_material['revision_status'] = self._analyze_revision_status( + classified_material, parent_file_id + ) + else: + classified_material['revision_status'] = 'new' + + processed_materials.append(classified_material) + + # 분류 통계 업데이트 + category = classified_material.get('classified_category', 'UNKNOWN') + classification_results['categories'][category] = classification_results['categories'].get(category, 0) + 1 + + if category != 'UNKNOWN': + classification_results['classified_count'] += 1 + + except Exception as e: + logger.warning(f"Failed to classify material at line {idx + 1}: {e}") + # 분류 실패 시 기본값으로 처리 + material_data.update({ + 'classified_category': 'UNKNOWN', + 'classification_confidence': 0.0, + 'revision_status': 'new' + }) + processed_materials.append(material_data) + + # 자재 데이터 DB 저장 + inserted_count = self.db_service.bulk_insert_materials(processed_materials, file_id) + + # 상세 정보 저장 (분류별) + self._save_material_details(processed_materials, file_id) + + logger.info(f"Processed {inserted_count} materials for file {file_id}") + + return { + "materials_count": inserted_count, + "classification_results": classification_results, + "processed_materials": processed_materials[:10] # 처음 10개만 반환 + } + + except Exception as e: + logger.error(f"Failed to process materials data: {e}") + raise HTTPException(status_code=500, detail=f"자재 데이터 처리 실패: {str(e)}") + + def _analyze_revision_status(self, material: Dict, parent_file_id: int) -> str: + """리비전 상태 분석""" + + try: + # 부모 파일의 동일한 자재 찾기 + parent_material_query = """ + SELECT * FROM materials + WHERE file_id = :parent_file_id + AND drawing_name = :drawing_name + AND original_description = :description + LIMIT 1 + """ + + result = self.db_service.execute_query( + parent_material_query, + { + "parent_file_id": parent_file_id, + "drawing_name": material.get('drawing_name'), + "description": material.get('original_description') + } + ) + + parent_material = result.fetchone() + + if not parent_material: + return 'new' # 새로운 자재 + + # 변경 사항 확인 + if ( + float(material.get('quantity', 0)) != float(parent_material.quantity or 0) or + material.get('material_grade') != parent_material.material_grade or + material.get('size_spec') != parent_material.size_spec + ): + return 'changed' # 변경된 자재 + + return 'inventory' # 기존 자재 (변경 없음) + + except Exception as e: + logger.warning(f"Failed to analyze revision status: {e}") + return 'new' + + def _save_material_details(self, materials: List[Dict], file_id: int) -> None: + """자재 상세 정보 저장""" + + try: + # 자재 ID 매핑 (방금 삽입된 자재들) + material_ids_query = """ + SELECT id, row_number FROM materials + WHERE file_id = :file_id + ORDER BY row_number + """ + + result = self.db_service.execute_query(material_ids_query, {"file_id": file_id}) + material_id_map = {row.row_number: row.id for row in result.fetchall()} + + # 분류별 상세 정보 저장 + for material in materials: + material_id = material_id_map.get(material.get('row_number')) + if not material_id: + continue + + category = material.get('classified_category') + + if category == 'PIPE': + self._save_pipe_details(material, material_id, file_id) + elif category == 'FITTING': + self._save_fitting_details(material, material_id, file_id) + elif category == 'FLANGE': + self._save_flange_details(material, material_id, file_id) + elif category == 'VALVE': + self._save_valve_details(material, material_id, file_id) + elif category == 'BOLT': + self._save_bolt_details(material, material_id, file_id) + elif category == 'GASKET': + self._save_gasket_details(material, material_id, file_id) + elif category == 'SUPPORT': + self._save_support_details(material, material_id, file_id) + elif category == 'INSTRUMENT': + self._save_instrument_details(material, material_id, file_id) + + logger.info(f"Saved material details for {len(materials)} materials") + + except Exception as e: + logger.error(f"Failed to save material details: {e}") + # 상세 정보 저장 실패는 전체 프로세스를 중단하지 않음 + + def _save_pipe_details(self, material: Dict, material_id: int, file_id: int) -> None: + """파이프 상세 정보 저장""" + + details = material.get('classification_details', {}) + + insert_query = """ + INSERT INTO pipe_details ( + material_id, file_id, material_standard, material_grade, material_type, + manufacturing_method, end_preparation, schedule, wall_thickness, + nominal_size, length_mm, material_confidence, manufacturing_confidence, + end_prep_confidence, schedule_confidence + ) VALUES ( + :material_id, :file_id, :material_standard, :material_grade, :material_type, + :manufacturing_method, :end_preparation, :schedule, :wall_thickness, + :nominal_size, :length_mm, :material_confidence, :manufacturing_confidence, + :end_prep_confidence, :schedule_confidence + ) + """ + + try: + self.db_service.execute_query(insert_query, { + "material_id": material_id, + "file_id": file_id, + "material_standard": details.get('material_standard'), + "material_grade": material.get('material_grade'), + "material_type": details.get('material_type'), + "manufacturing_method": details.get('manufacturing_method'), + "end_preparation": details.get('end_preparation'), + "schedule": material.get('schedule'), + "wall_thickness": details.get('wall_thickness'), + "nominal_size": material.get('main_nom'), + "length_mm": material.get('length'), + "material_confidence": details.get('material_confidence', 0.0), + "manufacturing_confidence": details.get('manufacturing_confidence', 0.0), + "end_prep_confidence": details.get('end_prep_confidence', 0.0), + "schedule_confidence": details.get('schedule_confidence', 0.0) + }) + except Exception as e: + logger.warning(f"Failed to save pipe details for material {material_id}: {e}") + + def _save_fitting_details(self, material: Dict, material_id: int, file_id: int) -> None: + """피팅 상세 정보 저장""" + + details = material.get('classification_details', {}) + + insert_query = """ + INSERT INTO fitting_details ( + material_id, file_id, fitting_type, fitting_subtype, connection_type, + main_size, reduced_size, length_mm, material_standard, material_grade, + pressure_rating, temperature_rating, classification_confidence + ) VALUES ( + :material_id, :file_id, :fitting_type, :fitting_subtype, :connection_type, + :main_size, :reduced_size, :length_mm, :material_standard, :material_grade, + :pressure_rating, :temperature_rating, :classification_confidence + ) + """ + + try: + self.db_service.execute_query(insert_query, { + "material_id": material_id, + "file_id": file_id, + "fitting_type": details.get('fitting_type', 'UNKNOWN'), + "fitting_subtype": details.get('fitting_subtype'), + "connection_type": details.get('connection_type'), + "main_size": material.get('main_nom'), + "reduced_size": material.get('red_nom'), + "length_mm": material.get('length'), + "material_standard": details.get('material_standard'), + "material_grade": material.get('material_grade'), + "pressure_rating": details.get('pressure_rating'), + "temperature_rating": details.get('temperature_rating'), + "classification_confidence": material.get('classification_confidence', 0.0) + }) + except Exception as e: + logger.warning(f"Failed to save fitting details for material {material_id}: {e}") + + def _save_flange_details(self, material: Dict, material_id: int, file_id: int) -> None: + """플랜지 상세 정보 저장""" + + details = material.get('classification_details', {}) + + insert_query = """ + INSERT INTO flange_details ( + material_id, file_id, flange_type, flange_subtype, pressure_rating, + face_type, connection_method, nominal_size, material_standard, + material_grade, classification_confidence + ) VALUES ( + :material_id, :file_id, :flange_type, :flange_subtype, :pressure_rating, + :face_type, :connection_method, :nominal_size, :material_standard, + :material_grade, :classification_confidence + ) + """ + + try: + self.db_service.execute_query(insert_query, { + "material_id": material_id, + "file_id": file_id, + "flange_type": details.get('flange_type', 'UNKNOWN'), + "flange_subtype": details.get('flange_subtype'), + "pressure_rating": details.get('pressure_rating'), + "face_type": details.get('face_type'), + "connection_method": details.get('connection_method'), + "nominal_size": material.get('main_nom'), + "material_standard": details.get('material_standard'), + "material_grade": material.get('material_grade'), + "classification_confidence": material.get('classification_confidence', 0.0) + }) + except Exception as e: + logger.warning(f"Failed to save flange details for material {material_id}: {e}") + + def _save_valve_details(self, material: Dict, material_id: int, file_id: int) -> None: + """밸브 상세 정보 저장""" + + details = material.get('classification_details', {}) + + insert_query = """ + INSERT INTO valve_details ( + material_id, file_id, valve_type, valve_subtype, actuation_type, + pressure_rating, temperature_rating, nominal_size, connection_type, + material_standard, material_grade, classification_confidence + ) VALUES ( + :material_id, :file_id, :valve_type, :valve_subtype, :actuation_type, + :pressure_rating, :temperature_rating, :nominal_size, :connection_type, + :material_standard, :material_grade, :classification_confidence + ) + """ + + try: + self.db_service.execute_query(insert_query, { + "material_id": material_id, + "file_id": file_id, + "valve_type": details.get('valve_type', 'UNKNOWN'), + "valve_subtype": details.get('valve_subtype'), + "actuation_type": details.get('actuation_type'), + "pressure_rating": details.get('pressure_rating'), + "temperature_rating": details.get('temperature_rating'), + "nominal_size": material.get('main_nom'), + "connection_type": details.get('connection_type'), + "material_standard": details.get('material_standard'), + "material_grade": material.get('material_grade'), + "classification_confidence": material.get('classification_confidence', 0.0) + }) + except Exception as e: + logger.warning(f"Failed to save valve details for material {material_id}: {e}") + + def _save_bolt_details(self, material: Dict, material_id: int, file_id: int) -> None: + """볼트 상세 정보 저장""" + + details = material.get('classification_details', {}) + + insert_query = """ + INSERT INTO bolt_details ( + material_id, file_id, bolt_type, bolt_subtype, thread_type, + head_type, material_standard, material_grade, pressure_rating, + length_mm, diameter_mm, classification_confidence + ) VALUES ( + :material_id, :file_id, :bolt_type, :bolt_subtype, :thread_type, + :head_type, :material_standard, :material_grade, :pressure_rating, + :length_mm, :diameter_mm, :classification_confidence + ) + """ + + try: + self.db_service.execute_query(insert_query, { + "material_id": material_id, + "file_id": file_id, + "bolt_type": details.get('bolt_type', 'UNKNOWN'), + "bolt_subtype": details.get('bolt_subtype'), + "thread_type": details.get('thread_type'), + "head_type": details.get('head_type'), + "material_standard": details.get('material_standard'), + "material_grade": material.get('material_grade'), + "pressure_rating": details.get('pressure_rating'), + "length_mm": material.get('length'), + "diameter_mm": details.get('diameter_mm'), + "classification_confidence": material.get('classification_confidence', 0.0) + }) + except Exception as e: + logger.warning(f"Failed to save bolt details for material {material_id}: {e}") + + def _save_gasket_details(self, material: Dict, material_id: int, file_id: int) -> None: + """가스켓 상세 정보 저장""" + + details = material.get('classification_details', {}) + + insert_query = """ + INSERT INTO gasket_details ( + material_id, file_id, gasket_type, gasket_subtype, material_type, + filler_material, pressure_rating, size_inches, thickness, + temperature_range, fire_safe, classification_confidence + ) VALUES ( + :material_id, :file_id, :gasket_type, :gasket_subtype, :material_type, + :filler_material, :pressure_rating, :size_inches, :thickness, + :temperature_range, :fire_safe, :classification_confidence + ) + """ + + try: + self.db_service.execute_query(insert_query, { + "material_id": material_id, + "file_id": file_id, + "gasket_type": details.get('gasket_type', 'UNKNOWN'), + "gasket_subtype": details.get('gasket_subtype'), + "material_type": details.get('material_type'), + "filler_material": details.get('filler_material'), + "pressure_rating": details.get('pressure_rating'), + "size_inches": material.get('main_nom'), + "thickness": details.get('thickness'), + "temperature_range": details.get('temperature_range'), + "fire_safe": details.get('fire_safe', False), + "classification_confidence": material.get('classification_confidence', 0.0) + }) + except Exception as e: + logger.warning(f"Failed to save gasket details for material {material_id}: {e}") + + def _save_support_details(self, material: Dict, material_id: int, file_id: int) -> None: + """서포트 상세 정보 저장""" + + details = material.get('classification_details', {}) + + insert_query = """ + INSERT INTO support_details ( + material_id, file_id, support_type, support_subtype, load_rating, + load_capacity, material_standard, material_grade, pipe_size, + length_mm, width_mm, height_mm, classification_confidence + ) VALUES ( + :material_id, :file_id, :support_type, :support_subtype, :load_rating, + :load_capacity, :material_standard, :material_grade, :pipe_size, + :length_mm, :width_mm, :height_mm, :classification_confidence + ) + """ + + try: + self.db_service.execute_query(insert_query, { + "material_id": material_id, + "file_id": file_id, + "support_type": details.get('support_type', 'UNKNOWN'), + "support_subtype": details.get('support_subtype'), + "load_rating": details.get('load_rating', 'UNKNOWN'), + "load_capacity": details.get('load_capacity'), + "material_standard": details.get('material_standard', 'UNKNOWN'), + "material_grade": details.get('material_grade', 'UNKNOWN'), + "pipe_size": material.get('main_nom'), + "length_mm": material.get('length'), + "width_mm": details.get('width_mm'), + "height_mm": details.get('height_mm'), + "classification_confidence": material.get('classification_confidence', 0.0) + }) + except Exception as e: + logger.warning(f"Failed to save support details for material {material_id}: {e}") + + def _save_instrument_details(self, material: Dict, material_id: int, file_id: int) -> None: + """계기 상세 정보 저장""" + + details = material.get('classification_details', {}) + + insert_query = """ + INSERT INTO instrument_details ( + material_id, file_id, instrument_type, instrument_subtype, + measurement_type, connection_size, pressure_rating, + temperature_rating, accuracy_class, material_standard, + classification_confidence + ) VALUES ( + :material_id, :file_id, :instrument_type, :instrument_subtype, + :measurement_type, :connection_size, :pressure_rating, + :temperature_rating, :accuracy_class, :material_standard, + :classification_confidence + ) + """ + + try: + self.db_service.execute_query(insert_query, { + "material_id": material_id, + "file_id": file_id, + "instrument_type": details.get('instrument_type', 'UNKNOWN'), + "instrument_subtype": details.get('instrument_subtype'), + "measurement_type": details.get('measurement_type'), + "connection_size": material.get('main_nom'), + "pressure_rating": details.get('pressure_rating'), + "temperature_rating": details.get('temperature_rating'), + "accuracy_class": details.get('accuracy_class'), + "material_standard": details.get('material_standard'), + "classification_confidence": material.get('classification_confidence', 0.0) + }) + except Exception as e: + logger.warning(f"Failed to save instrument details for material {material_id}: {e}") + + def cleanup_failed_upload(self, file_path: Path) -> None: + """실패한 업로드 정리""" + + try: + if file_path.exists(): + file_path.unlink() + logger.info(f"Cleaned up failed upload: {file_path}") + except Exception as e: + logger.warning(f"Failed to cleanup file {file_path}: {e}") + + +class MaterialClassificationService: + """자재 분류 서비스 (임시 구현)""" + + def classify_material(self, material_data: Dict, line_number: int, row_number: int) -> Dict: + """자재 분류 (기존 로직 유지)""" + + # 기존 분류 로직을 여기에 통합 + # 현재는 기본값만 설정 + material_data.update({ + 'line_number': line_number, + 'row_number': row_number, + 'classified_category': material_data.get('classified_category', 'UNKNOWN'), + 'classification_confidence': material_data.get('classification_confidence', 0.0), + 'classification_details': material_data.get('classification_details', {}) + }) + + return material_data diff --git a/backend/scripts/analyze_and_fix_schema.py b/backend/scripts/analyze_and_fix_schema.py new file mode 100644 index 0000000..84a549e --- /dev/null +++ b/backend/scripts/analyze_and_fix_schema.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +백엔드 코드 전체 분석 후 누락된 모든 컬럼과 테이블을 한 번에 수정하는 스크립트 +""" + +import os +import sys +import psycopg2 +from psycopg2 import sql + +# 현재 디렉토리를 Python 경로에 추가 +sys.path.insert(0, '/app') + +# 환경 변수 로드 +DB_HOST = os.getenv("DB_HOST", "postgres") +DB_PORT = os.getenv("DB_PORT", "5432") +DB_NAME = os.getenv("DB_NAME", "tk_mp_bom") +DB_USER = os.getenv("DB_USER", "tkmp_user") +DB_PASSWORD = os.getenv("DB_PASSWORD", "tkmp_password_2025") + +def fix_all_missing_columns(): + """백엔드 코드 분석 결과를 바탕으로 모든 누락된 컬럼 추가""" + print("🔍 백엔드 코드 분석 결과를 바탕으로 모든 누락된 컬럼 수정 시작...") + + try: + conn = psycopg2.connect( + host=DB_HOST, + port=DB_PORT, + database=DB_NAME, + user=DB_USER, + password=DB_PASSWORD + ) + + with conn.cursor() as cursor: + # 1. materials 테이블 누락된 컬럼들 + print("📝 materials 테이블 컬럼 추가 중...") + materials_columns = [ + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS brand VARCHAR(100);", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS user_requirement TEXT;", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS purchase_confirmed BOOLEAN DEFAULT FALSE;", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS confirmed_quantity NUMERIC(10,3);", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS purchase_status VARCHAR(20);", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS purchase_confirmed_by VARCHAR(100);", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS purchase_confirmed_at TIMESTAMP;", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS material_hash VARCHAR(64);", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS normalized_description TEXT;", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS revision_status VARCHAR(20);", + ] + + for sql_cmd in materials_columns: + cursor.execute(sql_cmd) + print("✅ materials 테이블 컬럼 추가 완료") + + # 2. material_purchase_tracking 테이블 누락된 컬럼들 (백엔드 코드에서 사용됨) + print("📝 material_purchase_tracking 테이블 컬럼 추가 중...") + mpt_columns = [ + "ALTER TABLE material_purchase_tracking ADD COLUMN IF NOT EXISTS job_no VARCHAR(50);", + "ALTER TABLE material_purchase_tracking ADD COLUMN IF NOT EXISTS material_hash VARCHAR(64);", + "ALTER TABLE material_purchase_tracking ADD COLUMN IF NOT EXISTS revision VARCHAR(20);", + "ALTER TABLE material_purchase_tracking ADD COLUMN IF NOT EXISTS description TEXT;", + "ALTER TABLE material_purchase_tracking ADD COLUMN IF NOT EXISTS size_spec VARCHAR(50);", + "ALTER TABLE material_purchase_tracking ADD COLUMN IF NOT EXISTS unit VARCHAR(10);", + "ALTER TABLE material_purchase_tracking ADD COLUMN IF NOT EXISTS bom_quantity NUMERIC(10,3);", + "ALTER TABLE material_purchase_tracking ADD COLUMN IF NOT EXISTS calculated_quantity NUMERIC(10,3);", + "ALTER TABLE material_purchase_tracking ADD COLUMN IF NOT EXISTS supplier_name VARCHAR(100);", + "ALTER TABLE material_purchase_tracking ADD COLUMN IF NOT EXISTS unit_price NUMERIC(12,2);", + "ALTER TABLE material_purchase_tracking ADD COLUMN IF NOT EXISTS total_price NUMERIC(15,2);", + "ALTER TABLE material_purchase_tracking ADD COLUMN IF NOT EXISTS order_date DATE;", + "ALTER TABLE material_purchase_tracking ADD COLUMN IF NOT EXISTS delivery_date DATE;", + "ALTER TABLE material_purchase_tracking ADD COLUMN IF NOT EXISTS confirmed_by VARCHAR(100);", + "ALTER TABLE material_purchase_tracking ADD COLUMN IF NOT EXISTS confirmed_at TIMESTAMP;", + ] + + for sql_cmd in mpt_columns: + cursor.execute(sql_cmd) + print("✅ material_purchase_tracking 테이블 컬럼 추가 완료") + + # 3. files 테이블 누락된 컬럼들 + print("📝 files 테이블 컬럼 추가 중...") + files_columns = [ + "ALTER TABLE files ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;", + "ALTER TABLE files ADD COLUMN IF NOT EXISTS project_type VARCHAR(50);", + ] + + for sql_cmd in files_columns: + cursor.execute(sql_cmd) + print("✅ files 테이블 컬럼 추가 완료") + + # 4. pipe_details 테이블 누락된 컬럼들 (백엔드 코드에서 사용됨) + print("📝 pipe_details 테이블 컬럼 추가 중...") + pipe_details_columns = [ + "ALTER TABLE pipe_details ADD COLUMN IF NOT EXISTS material_id INTEGER REFERENCES materials(id);", + "ALTER TABLE pipe_details ADD COLUMN IF NOT EXISTS outer_diameter VARCHAR(50);", + "ALTER TABLE pipe_details ADD COLUMN IF NOT EXISTS material_spec VARCHAR(100);", + "ALTER TABLE pipe_details ADD COLUMN IF NOT EXISTS classification_confidence NUMERIC(3,2);", + ] + + for sql_cmd in pipe_details_columns: + cursor.execute(sql_cmd) + print("✅ pipe_details 테이블 컬럼 추가 완료") + + # 5. 기타 누락된 테이블들의 컬럼 추가 + print("📝 기타 테이블들 컬럼 추가 중...") + + # fitting_details 테이블 컬럼 추가 + cursor.execute("ALTER TABLE fitting_details ADD COLUMN IF NOT EXISTS main_size VARCHAR(50);") + cursor.execute("ALTER TABLE fitting_details ADD COLUMN IF NOT EXISTS reduced_size VARCHAR(50);") + cursor.execute("ALTER TABLE fitting_details ADD COLUMN IF NOT EXISTS length_mm NUMERIC(10,3);") + + # gasket_details 테이블 컬럼 추가 + cursor.execute("ALTER TABLE gasket_details ADD COLUMN IF NOT EXISTS gasket_subtype VARCHAR(50);") + cursor.execute("ALTER TABLE gasket_details ADD COLUMN IF NOT EXISTS material_type VARCHAR(50);") + cursor.execute("ALTER TABLE gasket_details ADD COLUMN IF NOT EXISTS size_inches VARCHAR(50);") + cursor.execute("ALTER TABLE gasket_details ADD COLUMN IF NOT EXISTS temperature_range VARCHAR(50);") + cursor.execute("ALTER TABLE gasket_details ADD COLUMN IF NOT EXISTS fire_safe BOOLEAN DEFAULT FALSE;") + + print("✅ 기타 테이블들 컬럼 추가 완료") + + conn.commit() + print("✅ 모든 누락된 컬럼 추가 완료") + + except Exception as e: + print(f"❌ 컬럼 추가 실패: {e}") + return False + finally: + if conn: + conn.close() + + return True + +if __name__ == "__main__": + print("🚀 백엔드 코드 분석 기반 스키마 수정 시작") + success = fix_all_missing_columns() + if success: + print("✅ 모든 스키마 수정 완료") + else: + print("❌ 스키마 수정 실패") + sys.exit(1) diff --git a/backend/scripts/complete_migrate.py b/backend/scripts/complete_migrate.py new file mode 100644 index 0000000..47dda17 --- /dev/null +++ b/backend/scripts/complete_migrate.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +""" +TK-MP-Project 완전한 자동 DB 마이그레이션 시스템 +- 모든 SQLAlchemy 모델을 기반으로 테이블 생성/업데이트 +- 누락된 컬럼 자동 추가 +- 인덱스 자동 생성 +- 초기 데이터 삽입 +- macOS Docker와 Synology Container Manager 모두 지원 +""" + +import os +import sys +import time +import psycopg2 +from psycopg2 import OperationalError, sql +from sqlalchemy import create_engine, text, inspect +from sqlalchemy.orm import sessionmaker +from sqlalchemy.exc import OperationalError as SQLAlchemyOperationalError +from datetime import datetime +import bcrypt + +# 현재 디렉토리를 Python 경로에 추가 +sys.path.insert(0, '/app') + +from app.database import Base, get_db +from app.auth.models import User +from app.models import * # 모든 모델을 임포트하여 Base.metadata에 등록 + +# 환경 변수 로드 +DB_HOST = os.getenv("DB_HOST", "postgres") +DB_PORT = os.getenv("DB_PORT", "5432") +DB_NAME = os.getenv("DB_NAME", "tk_mp_bom") +DB_USER = os.getenv("DB_USER", "tkmp_user") +DB_PASSWORD = os.getenv("DB_PASSWORD", "tkmp_password_2025") + +DATABASE_URL = f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + +ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin") +ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123") +ADMIN_NAME = os.getenv("ADMIN_NAME", "시스템 관리자") +ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "admin@tkmp.com") + +SYSTEM_USERNAME = os.getenv("SYSTEM_USERNAME", "system") +SYSTEM_PASSWORD = os.getenv("SYSTEM_PASSWORD", "admin123") +SYSTEM_NAME = os.getenv("SYSTEM_NAME", "시스템 계정") +SYSTEM_EMAIL = os.getenv("SYSTEM_EMAIL", "system@tkmp.com") + +def wait_for_db(max_attempts=120, delay=2): + """데이터베이스 연결 대기 - 더 긴 대기 시간과 상세한 로그""" + print(f"Waiting for database connection...") + print(f" Host: {DB_HOST}:{DB_PORT}") + print(f" Database: {DB_NAME}") + print(f" User: {DB_USER}") + + for i in range(1, max_attempts + 1): + try: + conn = psycopg2.connect( + host=DB_HOST, + port=DB_PORT, + database=DB_NAME, + user=DB_USER, + password=DB_PASSWORD, + connect_timeout=5 + ) + conn.close() + print(f"SUCCESS: Database connection established! ({i}/{max_attempts})") + return True + except OperationalError as e: + if i <= 5 or i % 10 == 0 or i == max_attempts: + print(f"Waiting for database... ({i}/{max_attempts}) - {str(e)[:100]}") + if i == max_attempts: + print(f"FAILED: Database connection timeout after {max_attempts * delay} seconds") + print(f"Error: {e}") + time.sleep(delay) + return False + +def get_existing_columns(engine, table_name): + """기존 테이블의 컬럼 목록 조회""" + inspector = inspect(engine) + try: + columns = inspector.get_columns(table_name) + return {col['name']: col for col in columns} + except Exception: + return {} + +def get_model_columns(model): + """SQLAlchemy 모델의 컬럼 정보 추출""" + columns = {} + for column in model.__table__.columns: + columns[column.name] = { + 'name': column.name, + 'type': str(column.type), + 'nullable': column.nullable, + 'default': column.default, + 'primary_key': column.primary_key + } + return columns + +def add_missing_columns(engine, model): + """누락된 컬럼을 기존 테이블에 추가""" + table_name = model.__tablename__ + existing_columns = get_existing_columns(engine, table_name) + model_columns = get_model_columns(model) + + missing_columns = [] + for col_name, col_info in model_columns.items(): + if col_name not in existing_columns: + missing_columns.append((col_name, col_info)) + + if not missing_columns: + return True + + print(f"📝 테이블 '{table_name}'에 누락된 컬럼 {len(missing_columns)}개 추가 중...") + + try: + with engine.connect() as connection: + for col_name, col_info in missing_columns: + # 컬럼 타입 매핑 + col_type = col_info['type'] + if 'VARCHAR' in col_type: + sql_type = col_type + elif 'INTEGER' in col_type: + sql_type = 'INTEGER' + elif 'BOOLEAN' in col_type: + sql_type = 'BOOLEAN' + elif 'DATETIME' in col_type: + sql_type = 'TIMESTAMP' + elif 'TEXT' in col_type: + sql_type = 'TEXT' + elif 'NUMERIC' in col_type: + sql_type = col_type + elif 'JSON' in col_type: + sql_type = 'JSON' + else: + sql_type = 'TEXT' # 기본값 + + # NULL 허용 여부 + nullable = "NULL" if col_info['nullable'] else "NOT NULL" + + # 기본값 설정 + default_clause = "" + if col_info['default'] is not None: + if col_info['type'] == 'BOOLEAN': + default_value = 'TRUE' if str(col_info['default']).lower() in ['true', '1'] else 'FALSE' + default_clause = f" DEFAULT {default_value}" + elif 'VARCHAR' in col_info['type'] or 'TEXT' in col_info['type']: + default_clause = f" DEFAULT '{col_info['default']}'" + else: + default_clause = f" DEFAULT {col_info['default']}" + + alter_sql = f"ALTER TABLE {table_name} ADD COLUMN IF NOT EXISTS {col_name} {sql_type}{default_clause} {nullable};" + + try: + connection.execute(text(alter_sql)) + print(f" ✅ 컬럼 '{col_name}' ({sql_type}) 추가 완료") + except Exception as e: + print(f" ⚠️ 컬럼 '{col_name}' 추가 실패: {e}") + + connection.commit() + return True + except Exception as e: + print(f"❌ 테이블 '{table_name}' 컬럼 추가 실패: {e}") + return False + +def create_tables_and_migrate(): + """테이블 생성 및 마이그레이션""" + print("🔄 완전한 스키마 동기화 및 마이그레이션 시작...") + engine = create_engine(DATABASE_URL) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + try: + # 1. 모든 테이블 생성 (존재하지 않으면 생성) + print("📋 새 테이블 생성 중...") + Base.metadata.create_all(bind=engine) + print("✅ 새 테이블 생성 완료") + + # 2. 기존 테이블에 누락된 컬럼 추가 + print("🔧 기존 테이블 컬럼 동기화 중...") + + # 모든 모델에 대해 컬럼 동기화 + models_to_check = [ + User, Project, File, Material, MaterialStandard, + MaterialCategory, MaterialSpecification, MaterialGrade, + MaterialPattern, SpecialMaterial, SpecialMaterialGrade, + SpecialMaterialPattern, PipeDetail, RequirementType, + UserRequirement, TubingCategory, TubingSpecification, + TubingManufacturer, TubingProduct, MaterialTubingMapping, + SupportDetails, PurchaseRequestItems, FittingDetails, + FlangeDetails, ValveDetails, GasketDetails, BoltDetails, + InstrumentDetails, PurchaseRequests, Jobs, PipeEndPreparations, + MaterialPurchaseTracking, ExcelExports, UserActivityLogs, + ExcelExportHistory, ExportedMaterials, PurchaseStatusHistory + ] + + for model in models_to_check: + if hasattr(model, '__tablename__'): + add_missing_columns(engine, model) + + print("✅ 모든 테이블 컬럼 동기화 완료") + + # 3. 초기 사용자 데이터 생성 + with SessionLocal() as db: + # 관리자 계정 확인 및 생성 + admin_user = db.query(User).filter(User.username == ADMIN_USERNAME).first() + if not admin_user: + hashed_password = bcrypt.hashpw(ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + admin_user = User( + username=ADMIN_USERNAME, + password=hashed_password, + name=ADMIN_NAME, + email=ADMIN_EMAIL, + role='admin', + access_level='admin', + department='IT', + position='시스템 관리자', + status='active' + ) + db.add(admin_user) + print(f"➕ 관리자 계정 '{ADMIN_USERNAME}' 생성 완료") + else: + print(f"☑️ 관리자 계정 '{ADMIN_USERNAME}' 이미 존재") + + # 시스템 계정 확인 및 생성 + system_user = db.query(User).filter(User.username == SYSTEM_USERNAME).first() + if not system_user: + hashed_password = bcrypt.hashpw(SYSTEM_PASSWORD.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + system_user = User( + username=SYSTEM_USERNAME, + password=hashed_password, + name=SYSTEM_NAME, + email=SYSTEM_EMAIL, + role='system', + access_level='system', + department='IT', + position='시스템 계정', + status='active' + ) + db.add(system_user) + print(f"➕ 시스템 계정 '{SYSTEM_USERNAME}' 생성 완료") + else: + print(f"☑️ 시스템 계정 '{SYSTEM_USERNAME}' 이미 존재") + + db.commit() + print("✅ 초기 사용자 계정 확인 및 생성 완료") + + # 4. 성능 인덱스 추가 + print("🚀 성능 인덱스 생성 중...") + with engine.connect() as connection: + indexes = [ + "CREATE INDEX IF NOT EXISTS idx_materials_main_nom ON materials(main_nom);", + "CREATE INDEX IF NOT EXISTS idx_materials_red_nom ON materials(red_nom);", + "CREATE INDEX IF NOT EXISTS idx_materials_full_material_grade ON materials(full_material_grade);", + "CREATE INDEX IF NOT EXISTS idx_materials_revision_status ON materials(revision_status);", + "CREATE INDEX IF NOT EXISTS idx_materials_file_id ON materials(file_id);", + "CREATE INDEX IF NOT EXISTS idx_materials_drawing_name ON materials(drawing_name);", + "CREATE INDEX IF NOT EXISTS idx_materials_classified_category ON materials(classified_category);", + "CREATE INDEX IF NOT EXISTS idx_files_job_no ON files(job_no);", + "CREATE INDEX IF NOT EXISTS idx_files_uploaded_by ON files(uploaded_by);", + "CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);", + "CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);", + ] + + for index_sql in indexes: + try: + connection.execute(text(index_sql)) + except Exception as e: + print(f" ⚠️ 인덱스 생성 실패: {e}") + + connection.commit() + print("✅ 성능 인덱스 생성 완료") + + return True + except SQLAlchemyOperationalError as e: + print(f"❌ 데이터베이스 작업 실패: {e}") + return False + except Exception as e: + print(f"❌ 예상치 못한 오류 발생: {e}") + return False + +if __name__ == "__main__": + print("🚀 TK-MP-Project 완전한 자동 DB 마이그레이션 시작") + print(f"⏰ 시작 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"🖥️ 환경: {os.uname().sysname} {os.uname().machine}") + + print("🔧 DB 설정 확인:") + print(f" - DB_HOST: {DB_HOST}") + print(f" - DB_PORT: {DB_PORT}") + print(f" - DB_NAME: {DB_NAME}") + print(f" - DB_USER: {DB_USER}") + + if not wait_for_db(): + print("❌ DB 마이그레이션 실패. 서버 시작을 중단합니다.") + exit(1) + + if not create_tables_and_migrate(): + print("⚠️ DB 마이그레이션에서 일부 오류가 발생했지만 서버를 시작합니다.") + print(" (기존 스키마가 있거나 부분적으로 성공했을 수 있습니다)") + else: + print("✅ 완전한 DB 마이그레이션 성공") + + print(f"⏰ 완료 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") diff --git a/backend/scripts/fix_missing_tables.py b/backend/scripts/fix_missing_tables.py new file mode 100644 index 0000000..da3d114 --- /dev/null +++ b/backend/scripts/fix_missing_tables.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +누락된 테이블 수동 생성 스크립트 +배포 시 자동으로 실행되도록 설계 +""" + +import os +import sys +import psycopg2 +from psycopg2 import sql + +# 현재 디렉토리를 Python 경로에 추가 +sys.path.insert(0, '/app') + +# 환경 변수 로드 +DB_HOST = os.getenv("DB_HOST", "postgres") +DB_PORT = os.getenv("DB_PORT", "5432") +DB_NAME = os.getenv("DB_NAME", "tk_mp_bom") +DB_USER = os.getenv("DB_USER", "tkmp_user") +DB_PASSWORD = os.getenv("DB_PASSWORD", "tkmp_password_2025") + +def create_missing_tables(): + """누락된 테이블들을 직접 생성""" + print("🔧 누락된 테이블 생성 시작...") + + try: + conn = psycopg2.connect( + host=DB_HOST, + port=DB_PORT, + database=DB_NAME, + user=DB_USER, + password=DB_PASSWORD + ) + + with conn.cursor() as cursor: + # purchase_requests 테이블 생성 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS purchase_requests ( + request_id VARCHAR(50) PRIMARY KEY, + request_no VARCHAR(100) NOT NULL, + file_id INTEGER REFERENCES files(id), + job_no VARCHAR(50) NOT NULL, + category VARCHAR(50), + material_count INTEGER, + excel_file_path VARCHAR(500), + requested_by INTEGER REFERENCES users(user_id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + print("✅ purchase_requests 테이블 생성 완료") + + # materials 테이블에 누락된 컬럼들 추가 + cursor.execute("ALTER TABLE materials ADD COLUMN IF NOT EXISTS brand VARCHAR(100);") + cursor.execute("ALTER TABLE materials ADD COLUMN IF NOT EXISTS user_requirement TEXT;") + cursor.execute("ALTER TABLE materials ADD COLUMN IF NOT EXISTS purchase_confirmed BOOLEAN DEFAULT FALSE;") + cursor.execute("ALTER TABLE materials ADD COLUMN IF NOT EXISTS confirmed_quantity NUMERIC(10,3);") + cursor.execute("ALTER TABLE materials ADD COLUMN IF NOT EXISTS purchase_status VARCHAR(20);") + cursor.execute("ALTER TABLE materials ADD COLUMN IF NOT EXISTS purchase_confirmed_by VARCHAR(100);") + cursor.execute("ALTER TABLE materials ADD COLUMN IF NOT EXISTS purchase_confirmed_at TIMESTAMP;") + cursor.execute("ALTER TABLE materials ADD COLUMN IF NOT EXISTS material_hash VARCHAR(64);") + cursor.execute("ALTER TABLE materials ADD COLUMN IF NOT EXISTS normalized_description TEXT;") + cursor.execute("ALTER TABLE materials ADD COLUMN IF NOT EXISTS revision_status VARCHAR(20);") + print("✅ materials 테이블 누락된 컬럼들 추가 완료") + + # 기타 누락될 수 있는 테이블들 + missing_tables = [ + """ + CREATE TABLE IF NOT EXISTS excel_exports ( + id SERIAL PRIMARY KEY, + file_id INTEGER REFERENCES files(id), + export_type VARCHAR(50), + file_path VARCHAR(500), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """, + """ + CREATE TABLE IF NOT EXISTS user_activity_logs ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(user_id), + activity_type VARCHAR(50), + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """, + """ + CREATE TABLE IF NOT EXISTS excel_export_history ( + id SERIAL PRIMARY KEY, + request_id VARCHAR(50) REFERENCES purchase_requests(request_id), + export_path VARCHAR(500), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """, + """ + CREATE TABLE IF NOT EXISTS exported_materials ( + id SERIAL PRIMARY KEY, + export_id INTEGER REFERENCES excel_exports(id), + material_id INTEGER REFERENCES materials(id), + quantity NUMERIC(10, 3), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """, + """ + CREATE TABLE IF NOT EXISTS purchase_status_history ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id), + old_status VARCHAR(50), + new_status VARCHAR(50), + changed_by INTEGER REFERENCES users(user_id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """ + ] + + for table_sql in missing_tables: + try: + cursor.execute(table_sql) + table_name = table_sql.split("CREATE TABLE IF NOT EXISTS ")[1].split(" (")[0] + print(f"✅ {table_name} 테이블 생성 완료") + except Exception as e: + print(f"⚠️ 테이블 생성 중 오류 (무시하고 계속): {e}") + + conn.commit() + print("✅ 모든 누락된 테이블 생성 완료") + + except Exception as e: + print(f"❌ 테이블 생성 실패: {e}") + return False + finally: + if conn: + conn.close() + + return True + +if __name__ == "__main__": + print("🚀 누락된 테이블 생성 스크립트 시작") + success = create_missing_tables() + if success: + print("✅ 누락된 테이블 생성 완료") + else: + print("❌ 누락된 테이블 생성 실패") + sys.exit(1) diff --git a/backend/scripts/01_create_jobs_table.sql b/backend/scripts/legacy/01_create_jobs_table.sql similarity index 100% rename from backend/scripts/01_create_jobs_table.sql rename to backend/scripts/legacy/01_create_jobs_table.sql diff --git a/backend/scripts/02_modify_files_table.sql b/backend/scripts/legacy/02_modify_files_table.sql similarity index 100% rename from backend/scripts/02_modify_files_table.sql rename to backend/scripts/legacy/02_modify_files_table.sql diff --git a/backend/scripts/04_add_job_no_to_files.sql b/backend/scripts/legacy/04_add_job_no_to_files.sql similarity index 100% rename from backend/scripts/04_add_job_no_to_files.sql rename to backend/scripts/legacy/04_add_job_no_to_files.sql diff --git a/backend/scripts/05_add_classification_columns.sql b/backend/scripts/legacy/05_add_classification_columns.sql similarity index 100% rename from backend/scripts/05_add_classification_columns.sql rename to backend/scripts/legacy/05_add_classification_columns.sql diff --git a/backend/scripts/05_add_length_to_materials.sql b/backend/scripts/legacy/05_add_length_to_materials.sql similarity index 100% rename from backend/scripts/05_add_length_to_materials.sql rename to backend/scripts/legacy/05_add_length_to_materials.sql diff --git a/backend/scripts/05_create_material_standards_tables.sql b/backend/scripts/legacy/05_create_material_standards_tables.sql similarity index 100% rename from backend/scripts/05_create_material_standards_tables.sql rename to backend/scripts/legacy/05_create_material_standards_tables.sql diff --git a/backend/scripts/05_create_pipe_details_and_requirements.sql b/backend/scripts/legacy/05_create_pipe_details_and_requirements.sql similarity index 100% rename from backend/scripts/05_create_pipe_details_and_requirements.sql rename to backend/scripts/legacy/05_create_pipe_details_and_requirements.sql diff --git a/backend/scripts/05_create_pipe_details_and_requirements_postgres.sql b/backend/scripts/legacy/05_create_pipe_details_and_requirements_postgres.sql similarity index 100% rename from backend/scripts/05_create_pipe_details_and_requirements_postgres.sql rename to backend/scripts/legacy/05_create_pipe_details_and_requirements_postgres.sql diff --git a/backend/scripts/06_add_main_red_nom_columns.sql b/backend/scripts/legacy/06_add_main_red_nom_columns.sql similarity index 100% rename from backend/scripts/06_add_main_red_nom_columns.sql rename to backend/scripts/legacy/06_add_main_red_nom_columns.sql diff --git a/backend/scripts/06_add_pressure_rating_to_bolt_details.sql b/backend/scripts/legacy/06_add_pressure_rating_to_bolt_details.sql similarity index 100% rename from backend/scripts/06_add_pressure_rating_to_bolt_details.sql rename to backend/scripts/legacy/06_add_pressure_rating_to_bolt_details.sql diff --git a/backend/scripts/07_execute_material_standards_migration.py b/backend/scripts/legacy/07_execute_material_standards_migration.py similarity index 100% rename from backend/scripts/07_execute_material_standards_migration.py rename to backend/scripts/legacy/07_execute_material_standards_migration.py diff --git a/backend/scripts/07_simplify_pipe_details_schema.sql b/backend/scripts/legacy/07_simplify_pipe_details_schema.sql similarity index 100% rename from backend/scripts/07_simplify_pipe_details_schema.sql rename to backend/scripts/legacy/07_simplify_pipe_details_schema.sql diff --git a/backend/scripts/08_create_purchase_tables.sql b/backend/scripts/legacy/08_create_purchase_tables.sql similarity index 100% rename from backend/scripts/08_create_purchase_tables.sql rename to backend/scripts/legacy/08_create_purchase_tables.sql diff --git a/backend/scripts/10_add_material_comparison_system.sql b/backend/scripts/legacy/10_add_material_comparison_system.sql similarity index 100% rename from backend/scripts/10_add_material_comparison_system.sql rename to backend/scripts/legacy/10_add_material_comparison_system.sql diff --git a/backend/scripts/15_create_tubing_system.sql b/backend/scripts/legacy/15_create_tubing_system.sql similarity index 100% rename from backend/scripts/15_create_tubing_system.sql rename to backend/scripts/legacy/15_create_tubing_system.sql diff --git a/backend/scripts/16_performance_indexes.sql b/backend/scripts/legacy/16_performance_indexes.sql similarity index 100% rename from backend/scripts/16_performance_indexes.sql rename to backend/scripts/legacy/16_performance_indexes.sql diff --git a/backend/scripts/17_add_project_type_column.sql b/backend/scripts/legacy/17_add_project_type_column.sql similarity index 100% rename from backend/scripts/17_add_project_type_column.sql rename to backend/scripts/legacy/17_add_project_type_column.sql diff --git a/backend/scripts/18_create_auth_tables.sql b/backend/scripts/legacy/18_create_auth_tables.sql similarity index 100% rename from backend/scripts/18_create_auth_tables.sql rename to backend/scripts/legacy/18_create_auth_tables.sql diff --git a/backend/scripts/19_add_user_tracking_fields.sql b/backend/scripts/legacy/19_add_user_tracking_fields.sql similarity index 100% rename from backend/scripts/19_add_user_tracking_fields.sql rename to backend/scripts/legacy/19_add_user_tracking_fields.sql diff --git a/backend/scripts/20_add_pipe_end_preparation_table.sql b/backend/scripts/legacy/20_add_pipe_end_preparation_table.sql similarity index 100% rename from backend/scripts/20_add_pipe_end_preparation_table.sql rename to backend/scripts/legacy/20_add_pipe_end_preparation_table.sql diff --git a/backend/scripts/21_add_material_id_to_user_requirements.sql b/backend/scripts/legacy/21_add_material_id_to_user_requirements.sql similarity index 100% rename from backend/scripts/21_add_material_id_to_user_requirements.sql rename to backend/scripts/legacy/21_add_material_id_to_user_requirements.sql diff --git a/backend/scripts/22_add_full_material_grade.sql b/backend/scripts/legacy/22_add_full_material_grade.sql similarity index 100% rename from backend/scripts/22_add_full_material_grade.sql rename to backend/scripts/legacy/22_add_full_material_grade.sql diff --git a/backend/scripts/23_create_support_details_table.sql b/backend/scripts/legacy/23_create_support_details_table.sql similarity index 100% rename from backend/scripts/23_create_support_details_table.sql rename to backend/scripts/legacy/23_create_support_details_table.sql diff --git a/backend/scripts/24_add_special_category_support.sql b/backend/scripts/legacy/24_add_special_category_support.sql similarity index 100% rename from backend/scripts/24_add_special_category_support.sql rename to backend/scripts/legacy/24_add_special_category_support.sql diff --git a/backend/scripts/25_execute_special_category_migration.py b/backend/scripts/legacy/25_execute_special_category_migration.py similarity index 100% rename from backend/scripts/25_execute_special_category_migration.py rename to backend/scripts/legacy/25_execute_special_category_migration.py diff --git a/backend/scripts/26_add_user_status_column.sql b/backend/scripts/legacy/26_add_user_status_column.sql similarity index 100% rename from backend/scripts/26_add_user_status_column.sql rename to backend/scripts/legacy/26_add_user_status_column.sql diff --git a/backend/scripts/27_add_purchase_tracking.sql b/backend/scripts/legacy/27_add_purchase_tracking.sql similarity index 100% rename from backend/scripts/27_add_purchase_tracking.sql rename to backend/scripts/legacy/27_add_purchase_tracking.sql diff --git a/backend/scripts/28_add_purchase_requests.sql b/backend/scripts/legacy/28_add_purchase_requests.sql similarity index 100% rename from backend/scripts/28_add_purchase_requests.sql rename to backend/scripts/legacy/28_add_purchase_requests.sql diff --git a/backend/scripts/29_add_revision_status.sql b/backend/scripts/legacy/29_add_revision_status.sql similarity index 100% rename from backend/scripts/29_add_revision_status.sql rename to backend/scripts/legacy/29_add_revision_status.sql diff --git a/backend/scripts/PRODUCTION_MIGRATION.sql b/backend/scripts/legacy/PRODUCTION_MIGRATION.sql similarity index 100% rename from backend/scripts/PRODUCTION_MIGRATION.sql rename to backend/scripts/legacy/PRODUCTION_MIGRATION.sql diff --git a/backend/scripts/legacy/auto_migrate.py b/backend/scripts/legacy/auto_migrate.py new file mode 100644 index 0000000..b495f3b --- /dev/null +++ b/backend/scripts/legacy/auto_migrate.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +TK-MP-Project 자동 DB 마이그레이션 스크립트 +배포 시 백엔드 시작 전에 자동으로 실행되어 DB 스키마를 동기화 +""" + +import sys +import os +import time +from datetime import datetime + +# 백엔드 모듈 경로 추가 +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +def wait_for_db(): + """데이터베이스 연결 대기""" + max_retries = 30 + retry_count = 0 + + while retry_count < max_retries: + try: + from app.database import engine + # 간단한 연결 테스트 + with engine.connect() as conn: + conn.execute("SELECT 1") + print("✅ 데이터베이스 연결 성공") + return True + except Exception as e: + retry_count += 1 + print(f"⏳ 데이터베이스 연결 대기 중... ({retry_count}/{max_retries})") + time.sleep(2) + + print("❌ 데이터베이스 연결 실패") + return False + +def sync_database_schema(): + """데이터베이스 스키마 동기화""" + try: + from app.models import Base + from app.database import engine + + print("🔄 데이터베이스 스키마 동기화 중...") + + # 모든 테이블 생성/업데이트 + Base.metadata.create_all(bind=engine) + + print("✅ 데이터베이스 스키마 동기화 완료") + return True + + except Exception as e: + print(f"❌ 스키마 동기화 실패: {str(e)}") + return False + +def ensure_required_data(): + """필수 데이터 확인 및 생성""" + try: + from app.database import get_db + from app.auth.models import User + from sqlalchemy.orm import Session + + print("🔄 필수 데이터 확인 중...") + + db = next(get_db()) + + # 관리자 계정 확인 + admin_user = db.query(User).filter(User.username == 'admin').first() + if not admin_user: + print("📝 기본 관리자 계정 생성 중...") + admin_user = User( + username='admin', + password='$2b$12$ld4LDOW5mxkiRQEkXfMUIep/aIzFleQZ4yoL10ZQkUxGqnkYuhNMW', # admin123 + name='시스템 관리자', + email='admin@tkmp.com', + role='admin', + access_level='admin', + department='IT', + position='시스템 관리자', + status='active' + ) + db.add(admin_user) + db.commit() + print("✅ 기본 관리자 계정 생성 완료") + + db.close() + print("✅ 필수 데이터 확인 완료") + return True + + except Exception as e: + print(f"❌ 필수 데이터 확인 실패: {str(e)}") + return False + +def main(): + """메인 실행 함수""" + print("🚀 TK-MP-Project 자동 DB 마이그레이션 시작") + print(f"⏰ 시작 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + # 1. 데이터베이스 연결 대기 + if not wait_for_db(): + sys.exit(1) + + # 2. 스키마 동기화 + if not sync_database_schema(): + sys.exit(1) + + # 3. 필수 데이터 확인 + if not ensure_required_data(): + sys.exit(1) + + print("🎉 자동 DB 마이그레이션 완료!") + print(f"⏰ 완료 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + +if __name__ == "__main__": + main() diff --git a/backend/scripts/create_jobs.sql b/backend/scripts/legacy/create_jobs.sql similarity index 100% rename from backend/scripts/create_jobs.sql rename to backend/scripts/legacy/create_jobs.sql diff --git a/backend/scripts/create_material_detail_tables.sql b/backend/scripts/legacy/create_material_detail_tables.sql similarity index 100% rename from backend/scripts/create_material_detail_tables.sql rename to backend/scripts/legacy/create_material_detail_tables.sql diff --git a/backend/scripts/legacy/generate_complete_schema.py b/backend/scripts/legacy/generate_complete_schema.py new file mode 100644 index 0000000..862cf73 --- /dev/null +++ b/backend/scripts/legacy/generate_complete_schema.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +""" +TK-MP-Project 완전한 DB 스키마 생성 스크립트 +백엔드 SQLAlchemy 모델을 기반으로 PostgreSQL 스키마를 자동 생성 +""" + +import sys +import os +from datetime import datetime + +# 백엔드 모듈 경로 추가 +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from sqlalchemy import create_engine, MetaData +from sqlalchemy.schema import CreateTable, CreateIndex +from app.models import Base +from app.auth.models import User, LoginLog, UserSession, Permission, RolePermission + +def generate_schema_sql(): + """SQLAlchemy 모델을 기반으로 완전한 PostgreSQL 스키마 생성""" + + # 메모리 내 SQLite 엔진 생성 (스키마 생성용) + engine = create_engine('sqlite:///:memory:', echo=False) + + # 모든 테이블 생성 + Base.metadata.create_all(engine) + + # PostgreSQL 방언으로 변환 + from sqlalchemy.dialects import postgresql + + schema_lines = [] + schema_lines.append("-- ================================") + schema_lines.append("-- TK-MP-Project 완전한 데이터베이스 스키마") + schema_lines.append(f"-- 자동 생성일: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + schema_lines.append("-- SQLAlchemy 모델 기반 자동 생성") + schema_lines.append("-- ================================") + schema_lines.append("") + + # 테이블 생성 SQL 생성 + for table in Base.metadata.sorted_tables: + create_table_sql = str(CreateTable(table).compile(dialect=postgresql.dialect())) + + # SQLite -> PostgreSQL 변환 + create_table_sql = create_table_sql.replace('DATETIME', 'TIMESTAMP') + create_table_sql = create_table_sql.replace('BOOLEAN', 'BOOLEAN') + create_table_sql = create_table_sql.replace('TEXT', 'TEXT') + create_table_sql = create_table_sql.replace('JSON', 'JSONB') + + schema_lines.append(f"-- {table.name} 테이블") + schema_lines.append(f"CREATE TABLE IF NOT EXISTS {create_table_sql[13:]};") # "CREATE TABLE " 제거 + schema_lines.append("") + + # 인덱스 생성 + schema_lines.append("-- ================================") + schema_lines.append("-- 인덱스 생성") + schema_lines.append("-- ================================") + schema_lines.append("") + + for table in Base.metadata.sorted_tables: + for index in table.indexes: + create_index_sql = str(CreateIndex(index).compile(dialect=postgresql.dialect())) + schema_lines.append(f"CREATE INDEX IF NOT EXISTS {create_index_sql[13:]};") # "CREATE INDEX " 제거 + + # 필수 데이터 삽입 + schema_lines.append("") + schema_lines.append("-- ================================") + schema_lines.append("-- 필수 기본 데이터 삽입") + schema_lines.append("-- ================================") + schema_lines.append("") + + # 기본 관리자 계정 + schema_lines.append("-- 기본 관리자 계정 (비밀번호: admin123)") + schema_lines.append("INSERT INTO users (username, password, name, email, role, access_level, department, position, status) VALUES") + schema_lines.append("('admin', '$2b$12$ld4LDOW5mxkiRQEkXfMUIep/aIzFleQZ4yoL10ZQkUxGqnkYuhNMW', '시스템 관리자', 'admin@tkmp.com', 'admin', 'admin', 'IT', '시스템 관리자', 'active'),") + schema_lines.append("('system', '$2b$12$ld4LDOW5mxkiRQEkXfMUIep/aIzFleQZ4yoL10ZQkUxGqnkYuhNMW', '시스템 계정', 'system@tkmp.com', 'system', 'system', 'IT', '시스템 계정', 'active')") + schema_lines.append("ON CONFLICT (username) DO NOTHING;") + schema_lines.append("") + + # 기본 권한 데이터 + schema_lines.append("-- 기본 권한 데이터") + permissions = [ + ('bom.view', 'BOM 조회 권한', 'bom'), + ('bom.create', 'BOM 생성 권한', 'bom'), + ('bom.edit', 'BOM 수정 권한', 'bom'), + ('bom.delete', 'BOM 삭제 권한', 'bom'), + ('project.view', '프로젝트 조회 권한', 'project'), + ('project.create', '프로젝트 생성 권한', 'project'), + ('project.edit', '프로젝트 수정 권한', 'project'), + ('file.upload', '파일 업로드 권한', 'file'), + ('file.download', '파일 다운로드 권한', 'file'), + ('user.view', '사용자 조회 권한', 'user'), + ('user.create', '사용자 생성 권한', 'user'), + ('system.admin', '시스템 관리 권한', 'system') + ] + + schema_lines.append("INSERT INTO permissions (permission_name, description, module) VALUES") + for i, (name, desc, module) in enumerate(permissions): + comma = "," if i < len(permissions) - 1 else "" + schema_lines.append(f"('{name}', '{desc}', '{module}'){comma}") + schema_lines.append("ON CONFLICT (permission_name) DO NOTHING;") + schema_lines.append("") + + # 완료 메시지 + schema_lines.append("-- ================================") + schema_lines.append("-- 스키마 생성 완료") + schema_lines.append("-- ================================") + schema_lines.append("") + schema_lines.append("DO $$") + schema_lines.append("BEGIN") + schema_lines.append(" RAISE NOTICE '✅ TK-MP-Project 완전한 데이터베이스 스키마가 성공적으로 생성되었습니다!';") + schema_lines.append(" RAISE NOTICE '👤 기본 계정: admin/admin123, system/admin123';") + schema_lines.append(" RAISE NOTICE '🔐 권한 시스템: 모듈별 세분화된 권한 적용';") + schema_lines.append(" RAISE NOTICE '📊 자동 생성: SQLAlchemy 모델 기반 완전 동기화';") + schema_lines.append("END $$;") + + return '\n'.join(schema_lines) + +def main(): + """메인 실행 함수""" + try: + print("🔄 SQLAlchemy 모델 분석 중...") + schema_sql = generate_schema_sql() + + # 스키마 파일 저장 + output_file = os.path.join(os.path.dirname(__file__), '..', '..', 'database', 'init', '00_auto_generated_schema.sql') + os.makedirs(os.path.dirname(output_file), exist_ok=True) + + with open(output_file, 'w', encoding='utf-8') as f: + f.write(schema_sql) + + print(f"✅ 완전한 DB 스키마가 생성되었습니다: {output_file}") + print("📋 포함된 내용:") + print(" - 모든 SQLAlchemy 모델 기반 테이블") + print(" - 필수 인덱스") + print(" - 기본 관리자 계정") + print(" - 기본 권한 데이터") + print("🚀 배포 시 이 파일이 자동으로 실행됩니다.") + + except Exception as e: + print(f"❌ 스키마 생성 실패: {str(e)}") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/backend/scripts/setup_database.py b/backend/scripts/legacy/setup_database.py similarity index 100% rename from backend/scripts/setup_database.py rename to backend/scripts/legacy/setup_database.py diff --git a/backend/scripts/optimize_database.py b/backend/scripts/optimize_database.py new file mode 100644 index 0000000..014fd24 --- /dev/null +++ b/backend/scripts/optimize_database.py @@ -0,0 +1,195 @@ +""" +데이터베이스 성능 최적화 스크립트 +인덱스 생성, 쿼리 최적화, 통계 업데이트 +""" + +import os +import psycopg2 +from psycopg2 import sql +from ..utils.logger import get_logger + +logger = get_logger(__name__) + +# 환경 변수 로드 +DB_HOST = os.getenv("DB_HOST", "postgres") +DB_PORT = os.getenv("DB_PORT", "5432") +DB_NAME = os.getenv("DB_NAME", "tk_mp_bom") +DB_USER = os.getenv("DB_USER", "tkmp_user") +DB_PASSWORD = os.getenv("DB_PASSWORD", "tkmp_password_2025") + + +def optimize_database(): + """데이터베이스 성능 최적화""" + + try: + conn = psycopg2.connect( + host=DB_HOST, + port=DB_PORT, + database=DB_NAME, + user=DB_USER, + password=DB_PASSWORD + ) + + with conn.cursor() as cursor: + # 1. 핵심 인덱스 생성 + print("🔧 핵심 인덱스 생성 중...") + + indexes = [ + # materials 테이블 인덱스 + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_materials_file_id ON materials(file_id);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_materials_category ON materials(classified_category);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_materials_drawing_name ON materials(drawing_name);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_materials_line_no ON materials(line_no);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_materials_revision_status ON materials(revision_status);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_materials_purchase_confirmed ON materials(purchase_confirmed);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_materials_material_hash ON materials(material_hash);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_materials_main_nom ON materials(main_nom);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_materials_material_grade ON materials(material_grade);", + + # files 테이블 인덱스 + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_files_job_no ON files(job_no);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_files_revision ON files(revision);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_files_uploaded_by ON files(uploaded_by);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_files_upload_date ON files(upload_date);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_files_is_active ON files(is_active);", + + # 복합 인덱스 (자주 함께 사용되는 컬럼들) + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_materials_file_category ON materials(file_id, classified_category);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_materials_drawing_line ON materials(drawing_name, line_no);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_files_job_revision ON files(job_no, revision);", + + # material_purchase_tracking 인덱스 + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_mpt_material_hash ON material_purchase_tracking(material_hash);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_mpt_job_revision ON material_purchase_tracking(job_no, revision);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_mpt_purchase_status ON material_purchase_tracking(purchase_status);", + + # 상세 테이블들 인덱스 + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_pipe_details_material_id ON pipe_details(material_id);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_fitting_details_material_id ON fitting_details(material_id);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_flange_details_material_id ON flange_details(material_id);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_valve_details_material_id ON valve_details(material_id);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_bolt_details_material_id ON bolt_details(material_id);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_gasket_details_material_id ON gasket_details(material_id);", + + # 사용자 관련 인덱스 + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_username ON users(username);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_status ON users(status);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_role ON users(role);", + ] + + for index_sql in indexes: + try: + cursor.execute(index_sql) + conn.commit() + print(f"✅ 인덱스 생성: {index_sql.split('idx_')[1].split(' ')[0] if 'idx_' in index_sql else 'unknown'}") + except Exception as e: + print(f"⚠️ 인덱스 생성 실패: {e}") + conn.rollback() + + # 2. 통계 업데이트 + print("📊 테이블 통계 업데이트 중...") + + tables_to_analyze = [ + 'materials', 'files', 'projects', 'users', + 'material_purchase_tracking', 'pipe_details', + 'fitting_details', 'flange_details', 'valve_details', + 'bolt_details', 'gasket_details' + ] + + for table in tables_to_analyze: + try: + cursor.execute(f"ANALYZE {table};") + conn.commit() + print(f"✅ 통계 업데이트: {table}") + except Exception as e: + print(f"⚠️ 통계 업데이트 실패 ({table}): {e}") + conn.rollback() + + # 3. VACUUM 실행 (선택적) + print("🧹 데이터베이스 정리 중...") + try: + # VACUUM은 트랜잭션 외부에서 실행해야 함 + conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT) + cursor.execute("VACUUM ANALYZE;") + print("✅ VACUUM ANALYZE 완료") + except Exception as e: + print(f"⚠️ VACUUM 실패: {e}") + + print("✅ 데이터베이스 최적화 완료") + + except Exception as e: + print(f"❌ 데이터베이스 최적화 실패: {e}") + return False + finally: + if conn: + conn.close() + + return True + + +def get_database_stats(): + """데이터베이스 통계 조회""" + + try: + conn = psycopg2.connect( + host=DB_HOST, + port=DB_PORT, + database=DB_NAME, + user=DB_USER, + password=DB_PASSWORD + ) + + with conn.cursor() as cursor: + # 테이블별 레코드 수 + print("📊 테이블별 레코드 수:") + + tables = ['materials', 'files', 'projects', 'users', 'material_purchase_tracking'] + + for table in tables: + try: + cursor.execute(f"SELECT COUNT(*) FROM {table};") + count = cursor.fetchone()[0] + print(f" {table}: {count:,}개") + except Exception as e: + print(f" {table}: 조회 실패 ({e})") + + # 인덱스 사용률 확인 + print("\n🔍 인덱스 사용률:") + cursor.execute(""" + SELECT + schemaname, + tablename, + indexname, + idx_tup_read, + idx_tup_fetch + FROM pg_stat_user_indexes + WHERE idx_tup_read > 0 + ORDER BY idx_tup_read DESC + LIMIT 10; + """) + + for row in cursor.fetchall(): + print(f" {row[1]}.{row[2]}: {row[3]:,} reads, {row[4]:,} fetches") + + except Exception as e: + print(f"❌ 통계 조회 실패: {e}") + finally: + if conn: + conn.close() + + +if __name__ == "__main__": + print("🚀 데이터베이스 성능 최적화 시작") + + # 현재 통계 확인 + get_database_stats() + + # 최적화 실행 + if optimize_database(): + print("✅ 최적화 완료") + + # 최적화 후 통계 확인 + print("\n📊 최적화 후 통계:") + get_database_stats() + else: + print("❌ 최적화 실패") diff --git a/backend/scripts/simple_migrate.py b/backend/scripts/simple_migrate.py new file mode 100644 index 0000000..28d7ea3 --- /dev/null +++ b/backend/scripts/simple_migrate.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +""" +TK-MP-Project 간단하고 안정적인 DB 마이그레이션 스크립트 +macOS Docker와 Synology Container Manager 모두 지원 +""" + +import os +import sys +import time +import psycopg2 +from datetime import datetime + +def get_db_config(): + """환경변수에서 DB 설정 가져오기""" + return { + 'host': os.getenv('DB_HOST', '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') + } + +def wait_for_database(max_retries=60, retry_interval=2): + """데이터베이스 연결 대기 (더 긴 대기시간과 짧은 간격)""" + db_config = get_db_config() + + for attempt in range(1, max_retries + 1): + try: + conn = psycopg2.connect(**db_config) + conn.close() + print(f"✅ 데이터베이스 연결 성공 (시도 {attempt}/{max_retries})") + return True + except Exception as e: + print(f"⏳ DB 연결 대기 중... ({attempt}/{max_retries}) - {str(e)[:50]}") + time.sleep(retry_interval) + + print("❌ 데이터베이스 연결 실패") + return False + +def execute_sql(sql_commands): + """SQL 명령어 실행""" + db_config = get_db_config() + + try: + conn = psycopg2.connect(**db_config) + cursor = conn.cursor() + + for sql in sql_commands: + if sql.strip(): + cursor.execute(sql) + + conn.commit() + cursor.close() + conn.close() + return True + except Exception as e: + print(f"❌ SQL 실행 실패: {str(e)}") + return False + +def get_migration_sql(): + """필요한 마이그레이션 SQL 반환""" + return [ + # materials 테이블 컬럼 추가 + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS row_number INTEGER;", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS main_nom VARCHAR(50);", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS red_nom VARCHAR(50);", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS full_material_grade TEXT;", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS length NUMERIC(10,3);", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS purchase_confirmed BOOLEAN DEFAULT FALSE;", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS confirmed_quantity NUMERIC(10,3);", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS purchase_status VARCHAR(20);", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS purchase_confirmed_by VARCHAR(100);", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS purchase_confirmed_at TIMESTAMP;", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS revision_status VARCHAR(20);", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS material_hash VARCHAR(64);", + "ALTER TABLE materials ADD COLUMN IF NOT EXISTS normalized_description TEXT;", + + # users 테이블 status 컬럼 확인 및 추가 + "ALTER TABLE users ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'active';", + "UPDATE users SET status = 'active' WHERE status IS NULL AND is_active = TRUE;", + "UPDATE users SET status = 'inactive' WHERE status IS NULL AND is_active = FALSE;", + + # 기본 관리자 계정 확인 및 생성 + """ + INSERT INTO users (username, password, name, email, role, access_level, department, position, status) + VALUES ('admin', '$2b$12$ld4LDOW5mxkiRQEkXfMUIep/aIzFleQZ4yoL10ZQkUxGqnkYuhNMW', '시스템 관리자', 'admin@tkmp.com', 'admin', 'admin', 'IT', '시스템 관리자', 'active') + ON CONFLICT (username) DO UPDATE SET + password = EXCLUDED.password, + status = 'active'; + """, + + # 인덱스 생성 + "CREATE INDEX IF NOT EXISTS idx_materials_main_nom ON materials(main_nom);", + "CREATE INDEX IF NOT EXISTS idx_materials_red_nom ON materials(red_nom);", + "CREATE INDEX IF NOT EXISTS idx_materials_revision_status ON materials(revision_status);", + "CREATE INDEX IF NOT EXISTS idx_materials_purchase_status ON materials(purchase_status);", + "CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);", + ] + +def main(): + """메인 실행 함수""" + print("🚀 TK-MP-Project 간단 DB 마이그레이션 시작") + print(f"⏰ 시작 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + # 1. 데이터베이스 연결 대기 + print("🔄 데이터베이스 연결 확인 중...") + if not wait_for_database(): + print("❌ 데이터베이스 연결 실패. 마이그레이션을 중단합니다.") + sys.exit(1) + + # 2. 마이그레이션 실행 + print("🔄 데이터베이스 마이그레이션 실행 중...") + migration_sql = get_migration_sql() + + if execute_sql(migration_sql): + print("✅ 데이터베이스 마이그레이션 완료") + else: + print("❌ 데이터베이스 마이그레이션 실패") + sys.exit(1) + + print("🎉 간단 DB 마이그레이션 완료!") + print(f"⏰ 완료 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print("👤 기본 계정: admin/admin123") + +if __name__ == "__main__": + main() diff --git a/backend/start.sh b/backend/start.sh new file mode 100644 index 0000000..70d876b --- /dev/null +++ b/backend/start.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# TK-MP-Project Backend Start Script +# Complete automatic DB migration then start server + +echo "Starting TK-MP-Project Backend..." +echo "Time: $(date '+%Y-%m-%d %H:%M:%S')" +echo "Environment: $(uname -s) $(uname -m)" + +# Check environment variables +echo "DB Configuration:" +echo " - DB_HOST: ${DB_HOST:-postgres}" +echo " - DB_PORT: ${DB_PORT:-5432}" +echo " - DB_NAME: ${DB_NAME:-tk_mp_bom}" +echo " - DB_USER: ${DB_USER:-tkmp_user}" + +# 1. Run complete DB migration +echo "Running complete DB migration..." +python scripts/complete_migrate.py + +migration_result=$? +if [ $migration_result -ne 0 ]; then + echo "WARNING: DB migration had some errors. Trying to fix missing tables..." + python scripts/fix_missing_tables.py + fix_result=$? + if [ $fix_result -eq 0 ]; then + echo "SUCCESS: Missing tables fixed" + else + echo "WARNING: Some tables may still be missing but starting server anyway" + fi +else + echo "SUCCESS: Complete DB migration finished" +fi + +# Additional safety check for critical tables +echo "Verifying critical tables..." +python scripts/fix_missing_tables.py + +# Complete schema analysis and fix +echo "Running complete schema analysis and fix..." +python scripts/analyze_and_fix_schema.py +echo "Complete schema analysis completed" + +# Database performance optimization (run once after migration) +echo "Running database performance optimization..." +python scripts/optimize_database.py +echo "Database optimization completed" + +# 2. Start FastAPI server +echo "Starting FastAPI server..." +echo " - Port: 8000" +echo " - Host: 0.0.0.0" +echo " - Environment: ${ENVIRONMENT:-development}" + +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-level info diff --git a/docker-compose.synology.yml b/docker-compose.synology.yml new file mode 100644 index 0000000..e09f1a7 --- /dev/null +++ b/docker-compose.synology.yml @@ -0,0 +1,113 @@ +# TK-MP-Project Synology Container Manager 전용 설정 +# Synology NAS 환경에 최적화된 Docker Compose 설정 + +services: + # PostgreSQL 데이터베이스 (Synology 최적화) + postgres: + image: postgres:15-alpine + container_name: tk-mp-postgres + restart: unless-stopped + environment: + POSTGRES_DB: tk_mp_bom + POSTGRES_USER: tkmp_user + POSTGRES_PASSWORD: tkmp_password_2025 + POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C" + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./database/init:/docker-entrypoint-initdb.d + networks: + - tk-mp-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U tkmp_user -d tk_mp_bom"] + interval: 60s # Synology에서는 더 긴 간격 + timeout: 15s + retries: 5 + start_period: 120s # 초기 시작 시간 더 길게 + + # Redis (Synology 최적화) + redis: + image: redis:7-alpine + container_name: tk-mp-redis + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - tk-mp-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 60s + timeout: 15s + retries: 5 + start_period: 60s + + # 백엔드 FastAPI 서비스 (Synology 최적화) + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: tk-mp-backend + restart: unless-stopped + ports: + - "18000:8000" + environment: + - DATABASE_URL=postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom + - REDIS_URL=redis://redis:6379 + - ENVIRONMENT=production + - DEBUG=false + - PYTHONPATH=/app + # DB 마이그레이션용 환경변수 + - DB_HOST=postgres + - DB_PORT=5432 + - DB_NAME=tk_mp_bom + - DB_USER=tkmp_user + - DB_PASSWORD=tkmp_password_2025 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - tk-mp-network + volumes: + - ./backend/uploads:/app/uploads + healthcheck: + test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8000/health\", timeout=10)'"] + interval: 60s + timeout: 20s + retries: 5 + start_period: 180s # Synology에서는 더 긴 시작 시간 + + # 프론트엔드 React + Nginx 서비스 (Synology 최적화) + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: tk-mp-frontend + restart: unless-stopped + ports: + - "13000:80" + depends_on: + - backend + networks: + - tk-mp-network + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:80 || exit 1"] + interval: 60s + timeout: 15s + retries: 5 + start_period: 120s + +networks: + tk-mp-network: + driver: bridge + name: tk-mp-network + +volumes: + postgres_data: + name: tk-mp-postgres-data + redis_data: + name: tk-mp-redis-data diff --git a/docker-compose.yml b/docker-compose.yml index db032d4..71e85ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,19 +58,28 @@ services: - ENVIRONMENT=${ENVIRONMENT:-development} - DEBUG=${DEBUG:-true} - PYTHONPATH=/app + # DB 마이그레이션용 개별 환경변수 + - DB_HOST=postgres + - DB_PORT=5432 + - DB_NAME=${POSTGRES_DB:-tk_mp_bom} + - DB_USER=${POSTGRES_USER:-tkmp_user} + - DB_PASSWORD=${POSTGRES_PASSWORD:-tkmp_password_2025} depends_on: - - postgres - - redis + postgres: + condition: service_healthy + redis: + condition: service_healthy networks: - tk-mp-network volumes: - ./backend/uploads:/app/uploads # 개발 환경에서는 코드 변경 실시간 반영 (오버라이드에서 설정) - # healthcheck: - # test: ["CMD", "curl", "-f", "http://localhost:8000/health"] - # interval: 30s - # timeout: 10s - # retries: 3 + healthcheck: + test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8000/health\", timeout=5)'"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s # 프론트엔드 React + Nginx 서비스 frontend: diff --git a/frontend/src/components/common/ConfirmDialog.jsx b/frontend/src/components/common/ConfirmDialog.jsx new file mode 100644 index 0000000..e0bb4cf --- /dev/null +++ b/frontend/src/components/common/ConfirmDialog.jsx @@ -0,0 +1,123 @@ +import React from 'react'; + +const ConfirmDialog = ({ + isOpen, + title = '확인', + message, + onConfirm, + onCancel, + confirmText = '확인', + cancelText = '취소', + type = 'default' // 'default', 'danger', 'warning' +}) => { + if (!isOpen) return null; + + const typeStyles = { + default: { + confirmBg: '#007bff', + confirmHover: '#0056b3' + }, + danger: { + confirmBg: '#dc3545', + confirmHover: '#c82333' + }, + warning: { + confirmBg: '#ffc107', + confirmHover: '#e0a800' + } + }; + + const style = typeStyles[type]; + + const overlayStyle = { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 10000 + }; + + const dialogStyle = { + backgroundColor: 'white', + borderRadius: '8px', + padding: '24px', + minWidth: '400px', + maxWidth: '500px', + boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)' + }; + + const titleStyle = { + fontSize: '18px', + fontWeight: 'bold', + marginBottom: '16px', + color: '#333' + }; + + const messageStyle = { + fontSize: '14px', + lineHeight: '1.5', + color: '#666', + marginBottom: '24px' + }; + + const buttonContainerStyle = { + display: 'flex', + justifyContent: 'flex-end', + gap: '12px' + }; + + const baseButtonStyle = { + padding: '8px 16px', + borderRadius: '4px', + border: 'none', + fontSize: '14px', + cursor: 'pointer', + transition: 'background-color 0.2s' + }; + + const cancelButtonStyle = { + ...baseButtonStyle, + backgroundColor: '#6c757d', + color: 'white' + }; + + const confirmButtonStyle = { + ...baseButtonStyle, + backgroundColor: style.confirmBg, + color: 'white' + }; + + return ( +
+
e.stopPropagation()}> +

{title}

+

{message}

+
+ + +
+
+
+ ); +}; + +export default ConfirmDialog; diff --git a/frontend/src/components/common/ErrorMessage.jsx b/frontend/src/components/common/ErrorMessage.jsx new file mode 100644 index 0000000..2d65236 --- /dev/null +++ b/frontend/src/components/common/ErrorMessage.jsx @@ -0,0 +1,115 @@ +import React from 'react'; + +const ErrorMessage = ({ + error, + onRetry = null, + onDismiss = null, + type = 'error' // 'error', 'warning', 'info' +}) => { + const typeStyles = { + error: { + backgroundColor: '#fee', + borderColor: '#fcc', + color: '#c33', + icon: '❌' + }, + warning: { + backgroundColor: '#fff3cd', + borderColor: '#ffeaa7', + color: '#856404', + icon: '⚠️' + }, + info: { + backgroundColor: '#d1ecf1', + borderColor: '#bee5eb', + color: '#0c5460', + icon: 'ℹ️' + } + }; + + const style = typeStyles[type]; + + const containerStyle = { + backgroundColor: style.backgroundColor, + border: `1px solid ${style.borderColor}`, + borderRadius: '4px', + padding: '12px 16px', + margin: '10px 0', + display: 'flex', + alignItems: 'flex-start', + gap: '10px' + }; + + const messageStyle = { + color: style.color, + fontSize: '14px', + lineHeight: '1.4', + flex: 1 + }; + + const buttonStyle = { + backgroundColor: style.color, + color: 'white', + border: 'none', + borderRadius: '3px', + padding: '4px 8px', + fontSize: '12px', + cursor: 'pointer', + marginLeft: '8px' + }; + + const getErrorMessage = (error) => { + if (typeof error === 'string') return error; + if (error?.message) return error.message; + if (error?.detail) return error.detail; + if (error?.response?.data?.detail) return error.response.data.detail; + if (error?.response?.data?.message) return error.response.data.message; + return '알 수 없는 오류가 발생했습니다.'; + }; + + return ( +
+ {style.icon} +
+
+ {getErrorMessage(error)} +
+
+ {onRetry && ( + + )} + {onDismiss && ( + + )} +
+
+
+ ); +}; + +export default ErrorMessage; diff --git a/frontend/src/components/common/LoadingSpinner.jsx b/frontend/src/components/common/LoadingSpinner.jsx new file mode 100644 index 0000000..45f4bed --- /dev/null +++ b/frontend/src/components/common/LoadingSpinner.jsx @@ -0,0 +1,72 @@ +import React from 'react'; + +const LoadingSpinner = ({ + size = 'medium', + message = '로딩 중...', + fullScreen = false, + color = '#007bff' +}) => { + const sizeClasses = { + small: 'w-4 h-4', + medium: 'w-8 h-8', + large: 'w-12 h-12' + }; + + const spinnerStyle = { + border: `3px solid #f3f3f3`, + borderTop: `3px solid ${color}`, + borderRadius: '50%', + animation: 'spin 1s linear infinite' + }; + + const containerStyle = fullScreen ? { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(255, 255, 255, 0.8)', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + zIndex: 9999 + } : { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: '20px' + }; + + return ( + <> + +
+
+ {message && ( +

+ {message} +

+ )} +
+ + ); +}; + +export default LoadingSpinner; diff --git a/frontend/src/components/common/index.js b/frontend/src/components/common/index.js index b9dfc88..6b42994 100644 --- a/frontend/src/components/common/index.js +++ b/frontend/src/components/common/index.js @@ -1,3 +1,8 @@ -// Common Components -export { default as UserMenu } from './UserMenu'; -export { default as ErrorBoundary } from './ErrorBoundary'; +// 공통 컴포넌트 export +export { default as LoadingSpinner } from './LoadingSpinner'; +export { default as ErrorMessage } from './ErrorMessage'; +export { default as ConfirmDialog } from './ConfirmDialog'; + +// 기존 컴포넌트들도 re-export +export { default as UserMenu } from '../UserMenu'; +export { default as ErrorBoundary } from '../ErrorBoundary'; \ No newline at end of file diff --git a/frontend/src/pages/_backup/BOMManagementPage.jsx b/frontend/src/pages/_backup/BOMManagementPage.jsx deleted file mode 100644 index ece4b62..0000000 --- a/frontend/src/pages/_backup/BOMManagementPage.jsx +++ /dev/null @@ -1,431 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import SimpleFileUpload from '../components/SimpleFileUpload'; -import MaterialList from '../components/MaterialList'; -import { fetchMaterials, fetchFiles, fetchJobs } from '../api'; - -const BOMManagementPage = ({ user }) => { - const [activeTab, setActiveTab] = useState('upload'); - const [projects, setProjects] = useState([]); - const [selectedProject, setSelectedProject] = useState(null); - const [files, setFiles] = useState([]); - const [materials, setMaterials] = useState([]); - const [loading, setLoading] = useState(false); - const [stats, setStats] = useState({ - totalFiles: 0, - totalMaterials: 0, - recentUploads: [] - }); - - useEffect(() => { - loadProjects(); - }, []); - - useEffect(() => { - if (selectedProject) { - loadProjectFiles(); - } - }, [selectedProject]); - - useEffect(() => { - loadStats(); - }, [files, materials]); - - const loadProjects = async () => { - try { - // ✅ API 함수 사용 (권장) - const response = await fetchJobs(); - if (response.data.success) { - setProjects(response.data.jobs); - } - } catch (error) { - console.error('프로젝트 로딩 실패:', error); - } - }; - - const loadProjectFiles = async () => { - if (!selectedProject) return; - - setLoading(true); - try { - // 기존 API 함수 사용 - 파일 목록 로딩 - const filesResponse = await fetchFiles({ job_no: selectedProject.job_no }); - setFiles(Array.isArray(filesResponse.data) ? filesResponse.data : []); - - // 기존 API 함수 사용 - 자재 목록 로딩 - const materialsResponse = await fetchMaterials({ job_no: selectedProject.job_no, limit: 1000 }); - setMaterials(materialsResponse.data?.materials || []); - } catch (error) { - console.error('프로젝트 데이터 로딩 실패:', error); - } finally { - setLoading(false); - } - }; - - const loadStats = async () => { - try { - // 실제 통계 계산 - 더미 데이터 없이 - const totalFiles = files.length; - const totalMaterials = materials.length; - - setStats({ - totalFiles, - totalMaterials, - recentUploads: files.slice(0, 5) // 최근 5개 파일 - }); - } catch (error) { - console.error('통계 로딩 실패:', error); - } - }; - - const handleFileUpload = async (uploadData) => { - try { - setLoading(true); - // 기존 FileUpload 컴포넌트의 업로드 로직 활용 - await loadProjectFiles(); // 업로드 후 데이터 새로고침 - await loadStats(); - } catch (error) { - console.error('파일 업로드 후 새로고침 실패:', error); - } finally { - setLoading(false); - } - }; - - const StatCard = ({ title, value, icon, color = '#667eea' }) => ( -
-
- {icon} -
-
-
- {value} -
-
- {title} -
-
-
- ); - - return ( -
-
- {/* 헤더 */} -
-

- 🔧 BOM 관리 -

-

- Bill of Materials 업로드, 분석 및 관리를 수행하세요. -

-
- - {/* 통계 카드들 */} -
- - - -
- - {/* 탭 네비게이션 */} -
-
- {[ - { id: 'upload', label: '📤 파일 업로드', icon: '📤' }, - { id: 'files', label: '📁 파일 관리', icon: '📁' }, - { id: 'materials', label: '🔧 자재 목록', icon: '🔧' }, - { id: 'analysis', label: '📊 분석 결과', icon: '📊' } - ].map(tab => ( - - ))} -
- - {/* 탭 콘텐츠 */} -
- {activeTab === 'upload' && ( -
-

- 📤 BOM 파일 업로드 -

- - {/* 프로젝트 선택 */} -
- - -
- - {selectedProject ? ( -
-
-

- 선택된 프로젝트: {selectedProject.job_name} -

-

- Job No: {selectedProject.job_no} | - 고객사: {selectedProject.client_name} | - 상태: {selectedProject.status} -

-
- - -
- ) : ( -
- 먼저 프로젝트를 선택해주세요. -
- )} -
- )} - - {activeTab === 'files' && ( -
-

- 📁 업로드된 파일 목록 -

- - {selectedProject ? ( - loading ? ( -
- 파일 목록을 불러오는 중... -
- ) : files.length > 0 ? ( -
- {files.map((file, index) => ( -
-
-
- {file.original_filename || file.filename} -
-
- 업로드: {new Date(file.created_at).toLocaleString()} | - 자재 수: {file.parsed_count || 0}개 -
-
-
- {file.revision || 'Rev.0'} -
-
- ))} -
- ) : ( -
- 업로드된 파일이 없습니다. -
- ) - ) : ( -
- 프로젝트를 선택해주세요. -
- )} -
- )} - - {activeTab === 'materials' && ( -
-

- 🔧 자재 목록 -

- - {selectedProject ? ( - - ) : ( -
- 프로젝트를 선택해주세요. -
- )} -
- )} - - {activeTab === 'analysis' && ( -
-

- 📊 분석 결과 -

- -
-
- 🚧 분석 결과 페이지는 곧 구현될 예정입니다. -
-
- 자재 분류, 통계, 비교 분석 기능이 추가됩니다. -
-
-
- )} -
-
-
-
- ); -}; - -export default BOMManagementPage; diff --git a/frontend/src/pages/_backup/MaterialComparisonPage.jsx b/frontend/src/pages/_backup/MaterialComparisonPage.jsx deleted file mode 100644 index af22933..0000000 --- a/frontend/src/pages/_backup/MaterialComparisonPage.jsx +++ /dev/null @@ -1,518 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; -import { - Box, - Container, - Typography, - Button, - CircularProgress, - Alert, - Breadcrumbs, - Link, - Stack, - Card, - CardContent, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Paper, - Chip, - Grid, - Divider, - Tabs, - Tab -} from '@mui/material'; -import { - ArrowBack, - Refresh, - History, - Download -} from '@mui/icons-material'; - -import MaterialComparisonResult from '../components/MaterialComparisonResult'; -import { compareMaterialRevisions, confirmMaterialPurchase, fetchMaterials, api } from '../api'; -import { exportComparisonToExcel } from '../utils/excelExport'; - -const MaterialComparisonPage = () => { - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const [loading, setLoading] = useState(true); - const [confirmLoading, setConfirmLoading] = useState(false); - const [error, setError] = useState(null); - const [comparisonResult, setComparisonResult] = useState(null); - const [selectedTab, setSelectedTab] = useState(0); - - // URL 파라미터에서 정보 추출 - const jobNo = searchParams.get('job_no'); - const currentRevision = searchParams.get('revision'); - const previousRevision = searchParams.get('prev_revision'); - const filename = searchParams.get('filename'); - - useEffect(() => { - if (jobNo && currentRevision) { - loadComparison(); - } else { - setError('필수 파라미터가 누락되었습니다 (job_no, revision)'); - setLoading(false); - } - }, [jobNo, currentRevision, previousRevision]); - - const loadComparison = async () => { - try { - setLoading(true); - setError(null); - - console.log('🔍 자재 비교 실행 - 파라미터:', { - jobNo, - currentRevision, - previousRevision, - filename - }); - - // 🚨 테스트: MaterialsPage API 직접 호출해서 길이 정보 확인 - try { - // ✅ API 함수 사용 - 테스트용 자재 조회 - const testResult = await fetchMaterials({ - job_no: jobNo, - revision: currentRevision, - limit: 10 - }); - const pipeData = testResult.data.materials?.filter(m => m.classified_category === 'PIPE'); - console.log('🧪 MaterialsPage API 테스트 (길이 있는지 확인):', pipeData); - if (pipeData && pipeData.length > 0) { - console.log('🧪 첫 번째 파이프 상세:', JSON.stringify(pipeData[0], null, 2)); - } - } catch (e) { - console.log('🧪 MaterialsPage API 테스트 실패:', e); - } - - const result = await compareMaterialRevisions( - jobNo, - currentRevision, - previousRevision, - true // 결과 저장 - ); - - console.log('✅ 비교 결과 성공:', result); - console.log('🔍 전체 데이터 구조:', JSON.stringify(result.data || result, null, 2)); - setComparisonResult(result.data || result); - - } catch (err) { - console.error('❌ 자재 비교 실패:', { - message: err.message, - response: err.response?.data, - status: err.response?.status, - params: { jobNo, currentRevision, previousRevision } - }); - setError(err.response?.data?.detail || err.message || '자재 비교 중 오류가 발생했습니다'); - } finally { - setLoading(false); - } - }; - - const handleConfirmPurchase = async (confirmations) => { - try { - setConfirmLoading(true); - - console.log('발주 확정 실행:', { jobNo, currentRevision, confirmations }); - - const result = await confirmMaterialPurchase( - jobNo, - currentRevision, - confirmations, - 'user' - ); - - console.log('발주 확정 결과:', result); - - // 성공 메시지 표시 후 비교 결과 새로고침 - alert(`${result.confirmed_items?.length || confirmations.length}개 항목의 발주가 확정되었습니다!`); - - // 비교 결과 새로고침 (재고 상태가 변경되었을 수 있음) - await loadComparison(); - - } catch (err) { - console.error('발주 확정 실패:', err); - alert('발주 확정 중 오류가 발생했습니다: ' + (err.response?.data?.detail || err.message)); - } finally { - setConfirmLoading(false); - } - }; - - const handleRefresh = () => { - loadComparison(); - }; - - const handleGoBack = () => { - // BOM 상태 페이지로 이동 - if (jobNo) { - navigate(`/bom-status?job_no=${jobNo}`); - } else { - navigate(-1); - } - }; - - const handleExportToExcel = () => { - if (!comparisonResult) { - alert('내보낼 비교 데이터가 없습니다.'); - return; - } - - const additionalInfo = { - jobNo: jobNo, - currentRevision: currentRevision, - previousRevision: previousRevision, - filename: filename - }; - - const baseFilename = `리비전비교_${jobNo}_${currentRevision}_vs_${previousRevision}`; - - exportComparisonToExcel(comparisonResult, baseFilename, additionalInfo); - }; - - const renderComparisonResults = () => { - const { summary, new_items = [], modified_items = [], removed_items = [] } = comparisonResult; - - return ( - - {/* 요약 통계 카드 */} - - - - - - {summary?.new_items_count || 0} - - 신규 자재 - - 새로 추가된 자재 - - - - - - - - - {summary?.modified_items_count || 0} - - 변경 자재 - - 수량이 변경된 자재 - - - - - - - - - {summary?.removed_items_count || 0} - - 삭제 자재 - - 제거된 자재 - - - - - - - - - {summary?.total_current_items || 0} - - 총 자재 - - 현재 리비전 전체 - - - - - - - {/* 탭으로 구분된 자재 목록 */} - - setSelectedTab(newValue)} - variant="fullWidth" - > - - - - - - - {selectedTab === 0 && renderMaterialTable(new_items, 'new')} - {selectedTab === 1 && renderMaterialTable(modified_items, 'modified')} - {selectedTab === 2 && renderMaterialTable(removed_items, 'removed')} - - - - ); - }; - - const renderMaterialTable = (items, type) => { - if (items.length === 0) { - return ( - - {type === 'new' && '새로 추가된 자재가 없습니다.'} - {type === 'modified' && '수량이 변경된 자재가 없습니다.'} - {type === 'removed' && '삭제된 자재가 없습니다.'} - - ); - } - - console.log(`🔍 ${type} 테이블 렌더링:`, items); // 디버깅용 - - return ( - - - - - 카테고리 - 자재 설명 - 사이즈 - 재질 - {type === 'modified' && ( - <> - 이전 수량 - 현재 수량 - 변경량 - - )} - {type !== 'modified' && 수량} - 단위 - 길이(mm) - - - - {items.map((item, index) => { - console.log(`🔍 항목 ${index}:`, item); // 각 항목 확인 - - // 파이프인 경우 길이 정보 표시 - console.log(`🔧 길이 확인 - ${item.category}:`, item.pipe_details); // 디버깅 - console.log(`🔧 전체 아이템:`, item); // 전체 구조 확인 - - let lengthInfo = '-'; - if (item.category === 'PIPE' && item.pipe_details?.length_mm && item.pipe_details.length_mm > 0) { - const avgUnitLength = item.pipe_details.length_mm; - const currentTotalLength = item.pipe_details.total_length_mm || (item.quantity || 0) * avgUnitLength; - - if (type === 'modified') { - // 변경된 파이프: 백엔드에서 계산된 실제 길이 사용 - let prevTotalLength, lengthChange; - - if (item.previous_pipe_details && item.previous_pipe_details.total_length_mm) { - // 백엔드에서 실제 이전 총길이를 제공한 경우 - prevTotalLength = item.previous_pipe_details.total_length_mm; - lengthChange = currentTotalLength - prevTotalLength; - } else { - // 백업: 비율 계산 - const prevRatio = (item.previous_quantity || 0) / (item.current_quantity || item.quantity || 1); - prevTotalLength = currentTotalLength * prevRatio; - lengthChange = currentTotalLength - prevTotalLength; - } - - lengthInfo = ( - - - 이전: {Math.round(prevTotalLength).toLocaleString()}mm → 현재: {Math.round(currentTotalLength).toLocaleString()}mm - - 0 ? 'success.main' : lengthChange < 0 ? 'error.main' : 'text.primary'} - > - 변화: {lengthChange > 0 ? '+' : ''}{Math.round(lengthChange).toLocaleString()}mm - - - ); - } else { - // 신규/삭제된 파이프: 실제 총길이 사용 - lengthInfo = ( - - - 총 길이: {Math.round(currentTotalLength).toLocaleString()}mm - - - ); - } - } else if (item.category === 'PIPE') { - lengthInfo = '길이 정보 없음'; - } - - return ( - - - - - {item.description} - {item.size_spec || '-'} - {item.material_grade || '-'} - {type === 'modified' && ( - <> - {item.previous_quantity} - {item.current_quantity} - - 0 ? 'success.main' : 'error.main'} - > - {item.quantity_change > 0 ? '+' : ''}{item.quantity_change} - - - - )} - {type !== 'modified' && ( - - - {item.quantity} - - - )} - {item.unit || 'EA'} - - - {lengthInfo} - - - - ); - })} - -
-
- ); - }; - - const renderHeader = () => ( - - - navigate('/jobs')} - sx={{ textDecoration: 'none' }} - > - 프로젝트 목록 - - navigate(`/materials?job_no=${jobNo}`)} - sx={{ textDecoration: 'none' }} - > - {jobNo} - - - 자재 비교 - - - - - - - 자재 리비전 비교 - - - {filename && `파일: ${filename}`} -
- {previousRevision ? - `${previousRevision} → ${currentRevision} 비교` : - `${currentRevision} (이전 리비전 없음)` - } -
-
- - - - - - -
-
- ); - - if (loading) { - return ( - - {renderHeader()} - - - - - 자재 비교 중... - - - 리비전간 차이점을 분석하고 있습니다 - - - - - ); - } - - if (error) { - return ( - - {renderHeader()} - - - 자재 비교 실패 - - - {error} - - - - - ); - } - - return ( - - {renderHeader()} - - {comparisonResult && renderComparisonResults()} - - ); -}; - -export default MaterialComparisonPage; \ No newline at end of file diff --git a/frontend/src/pages/_backup/MaterialsManagementPage.jsx b/frontend/src/pages/_backup/MaterialsManagementPage.jsx deleted file mode 100644 index 6a55469..0000000 --- a/frontend/src/pages/_backup/MaterialsManagementPage.jsx +++ /dev/null @@ -1,486 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import MaterialList from '../components/MaterialList'; -import { fetchMaterials, fetchJobs } from '../api'; - -const MaterialsManagementPage = ({ user }) => { - const [materials, setMaterials] = useState([]); - const [filteredMaterials, setFilteredMaterials] = useState([]); - const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(false); - const [filters, setFilters] = useState({ - project: '', - category: '', - status: '', - search: '' - }); - const [stats, setStats] = useState({ - totalMaterials: 0, - categorizedMaterials: 0, - uncategorizedMaterials: 0, - categories: {} - }); - - useEffect(() => { - loadProjects(); - loadAllMaterials(); - }, []); - - useEffect(() => { - applyFilters(); - }, [materials, filters]); - - const loadProjects = async () => { - try { - // ✅ API 함수 사용 (권장) - const response = await fetchJobs(); - if (response.data.success) { - setProjects(response.data.jobs); - } - } catch (error) { - console.error('프로젝트 로딩 실패:', error); - } - }; - - const loadAllMaterials = async () => { - setLoading(true); - try { - // 기존 API 함수 사용 - 모든 자재 데이터 로딩 - const response = await fetchMaterials({ limit: 10000 }); // 충분히 큰 limit - const materialsData = response.data?.materials || []; - - setMaterials(materialsData); - calculateStats(materialsData); - } catch (error) { - console.error('자재 데이터 로딩 실패:', error); - } finally { - setLoading(false); - } - }; - - const calculateStats = (materialsData) => { - const totalMaterials = materialsData.length; - const categorizedMaterials = materialsData.filter(m => m.classified_category && m.classified_category !== 'Unknown').length; - const uncategorizedMaterials = totalMaterials - categorizedMaterials; - - // 카테고리별 통계 - const categories = {}; - materialsData.forEach(material => { - const category = material.classified_category || 'Unknown'; - categories[category] = (categories[category] || 0) + 1; - }); - - setStats({ - totalMaterials, - categorizedMaterials, - uncategorizedMaterials, - categories - }); - }; - - const applyFilters = () => { - let filtered = [...materials]; - - // 프로젝트 필터 - if (filters.project) { - filtered = filtered.filter(m => m.job_no === filters.project); - } - - // 카테고리 필터 - if (filters.category) { - filtered = filtered.filter(m => m.classified_category === filters.category); - } - - // 상태 필터 - if (filters.status) { - if (filters.status === 'categorized') { - filtered = filtered.filter(m => m.classified_category && m.classified_category !== 'Unknown'); - } else if (filters.status === 'uncategorized') { - filtered = filtered.filter(m => !m.classified_category || m.classified_category === 'Unknown'); - } - } - - // 검색 필터 - if (filters.search) { - const searchTerm = filters.search.toLowerCase(); - filtered = filtered.filter(m => - (m.original_description && m.original_description.toLowerCase().includes(searchTerm)) || - (m.size_spec && m.size_spec.toLowerCase().includes(searchTerm)) || - (m.classified_category && m.classified_category.toLowerCase().includes(searchTerm)) - ); - } - - setFilteredMaterials(filtered); - }; - - const handleFilterChange = (filterType, value) => { - setFilters(prev => ({ - ...prev, - [filterType]: value - })); - }; - - const clearFilters = () => { - setFilters({ - project: '', - category: '', - status: '', - search: '' - }); - }; - - const StatCard = ({ title, value, icon, color = '#667eea', subtitle }) => ( -
-
-
- {icon} -
-
-
- {value.toLocaleString()} -
-
- {title} -
-
-
- {subtitle && ( -
- {subtitle} -
- )} -
- ); - - return ( -
-
- {/* 헤더 */} -
-

- 📦 자재 관리 -

-

- 전체 프로젝트의 자재 정보를 통합 관리하고 분석하세요. -

-
- - {/* 통계 카드들 */} -
- - - - -
- - {/* 필터 섹션 */} -
-
-

- 🔍 필터 및 검색 -

- -
- -
- {/* 프로젝트 필터 */} -
- - -
- - {/* 카테고리 필터 */} -
- - -
- - {/* 상태 필터 */} -
- - -
- - {/* 검색 */} -
- - handleFilterChange('search', e.target.value)} - style={{ - width: '100%', - padding: '10px', - border: '1px solid #e2e8f0', - borderRadius: '6px', - fontSize: '14px' - }} - /> -
-
- - {/* 필터 결과 요약 */} -
- {filteredMaterials.length.toLocaleString()}개의 자재가 검색되었습니다. - {filters.project && ` (프로젝트: ${filters.project})`} - {filters.category && ` (카테고리: ${filters.category})`} - {filters.status && ` (상태: ${filters.status === 'categorized' ? '분류완료' : '미분류'})`} - {filters.search && ` (검색: "${filters.search}")`} -
-
- - {/* 자재 목록 */} -
-
-

- 자재 목록 ({filteredMaterials.length.toLocaleString()}개) -

- -
- - -
-
- - -
-
-
- ); -}; - -export default MaterialsManagementPage; diff --git a/frontend/src/pages/_backup/PurchaseConfirmationPage.jsx b/frontend/src/pages/_backup/PurchaseConfirmationPage.jsx deleted file mode 100644 index d72e407..0000000 --- a/frontend/src/pages/_backup/PurchaseConfirmationPage.jsx +++ /dev/null @@ -1,736 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { api } from '../api'; - -const PurchaseConfirmationPage = () => { - const location = useLocation(); - const navigate = useNavigate(); - const [purchaseItems, setPurchaseItems] = useState([]); - const [revisionComparison, setRevisionComparison] = useState(null); - const [loading, setLoading] = useState(true); - const [editingItem, setEditingItem] = useState(null); - const [confirmDialog, setConfirmDialog] = useState(false); - - // URL에서 job_no, revision 정보 가져오기 - const searchParams = new URLSearchParams(location.search); - const jobNo = searchParams.get('job_no'); - const revision = searchParams.get('revision'); - const filename = searchParams.get('filename'); - const previousRevision = searchParams.get('prev_revision'); - - useEffect(() => { - if (jobNo && revision) { - loadPurchaseItems(); - if (previousRevision) { - loadRevisionComparison(); - } - } - }, [jobNo, revision, previousRevision]); - - const loadPurchaseItems = async () => { - try { - setLoading(true); - const response = await api.get('/purchase/items/calculate', { - params: { job_no: jobNo, revision: revision } - }); - setPurchaseItems(response.data.items || []); - } catch (error) { - console.error('구매 품목 로딩 실패:', error); - } finally { - setLoading(false); - } - }; - - const loadRevisionComparison = async () => { - try { - const response = await api.get('/purchase/revision-diff', { - params: { - job_no: jobNo, - current_revision: revision, - previous_revision: previousRevision - } - }); - setRevisionComparison(response.data.comparison); - } catch (error) { - console.error('리비전 비교 실패:', error); - } - }; - - const updateItemQuantity = async (itemId, field, value) => { - try { - await api.patch(`/purchase/items/${itemId}`, { - [field]: parseFloat(value) - }); - - // 로컬 상태 업데이트 - setPurchaseItems(prev => - prev.map(item => - item.id === itemId - ? { ...item, [field]: parseFloat(value) } - : item - ) - ); - - setEditingItem(null); - } catch (error) { - console.error('수량 업데이트 실패:', error); - } - }; - - const confirmPurchase = async () => { - try { - // 입력 데이터 검증 - if (!jobNo || !revision) { - alert('Job 번호와 리비전 정보가 없습니다.'); - return; - } - - if (purchaseItems.length === 0) { - alert('구매할 품목이 없습니다.'); - return; - } - - // 각 품목의 수량 검증 - const invalidItems = purchaseItems.filter(item => - !item.calculated_qty || item.calculated_qty <= 0 - ); - - if (invalidItems.length > 0) { - alert(`다음 품목들의 구매 수량이 유효하지 않습니다:\n${invalidItems.map(item => `- ${item.specification}`).join('\n')}`); - return; - } - - setConfirmDialog(false); - - const response = await api.post('/purchase/orders/create', { - job_no: jobNo, - revision: revision, - items: purchaseItems.map(item => ({ - purchase_item_id: item.id, - ordered_quantity: item.calculated_qty, - required_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30일 후 - })) - }); - - const successMessage = `구매 주문이 성공적으로 생성되었습니다!\n\n` + - `- Job: ${jobNo}\n` + - `- Revision: ${revision}\n` + - `- 품목 수: ${purchaseItems.length}개\n` + - `- 생성 시간: ${new Date().toLocaleString('ko-KR')}`; - - alert(successMessage); - - // 자재 목록 페이지로 이동 (상태 기반 라우팅 사용) - // App.jsx의 상태 기반 라우팅을 위해 window 이벤트 발생 - window.dispatchEvent(new CustomEvent('navigateToMaterials', { - detail: { - jobNo: jobNo, - revision: revision, - bomName: `${jobNo} ${revision}`, - message: '구매 주문 생성 완료' - } - })); - } catch (error) { - console.error('구매 주문 생성 실패:', error); - - let errorMessage = '구매 주문 생성에 실패했습니다.'; - - if (error.response?.data?.detail) { - errorMessage += `\n\n오류 내용: ${error.response.data.detail}`; - } else if (error.message) { - errorMessage += `\n\n오류 내용: ${error.message}`; - } - - if (error.response?.status === 400) { - errorMessage += '\n\n입력 데이터를 확인해주세요.'; - } else if (error.response?.status === 404) { - errorMessage += '\n\n해당 Job이나 리비전을 찾을 수 없습니다.'; - } else if (error.response?.status >= 500) { - errorMessage += '\n\n서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; - } - - alert(errorMessage); - } - }; - - const getCategoryColor = (category) => { - const colors = { - 'PIPE': '#1976d2', - 'FITTING': '#9c27b0', - 'VALVE': '#2e7d32', - 'FLANGE': '#ed6c02', - 'BOLT': '#0288d1', - 'GASKET': '#d32f2f', - 'INSTRUMENT': '#7b1fa2' - }; - return colors[category] || '#757575'; - }; - - const formatPipeInfo = (item) => { - if (item.category !== 'PIPE') return null; - - return ( -
- 절단손실: {item.cutting_loss || 0}mm | - 구매: {item.pipes_count || 0}본 | - 여유분: {item.waste_length || 0}mm -
- ); - }; - - const formatBoltInfo = (item) => { - if (item.category !== 'BOLT') return null; - - // 특수 용도 볼트 정보 (백엔드에서 제공되어야 함) - const specialApplications = item.special_applications || {}; - const psvCount = specialApplications.PSV || 0; - const ltCount = specialApplications.LT || 0; - const ckCount = specialApplications.CK || 0; - const oriCount = specialApplications.ORI || 0; - - return ( -
-
- 분수 사이즈: {(item.size_fraction || item.size_spec || '').replace(/"/g, '')} | - 표면처리: {item.surface_treatment || '없음'} -
- - {/* 특수 용도 볼트 정보 */} -
-
- 특수 용도 볼트 현황: -
-
-
0 ? '#d32f2f' : '#666' }}> - PSV용: {psvCount}개 -
-
0 ? '#ed6c02' : '#666' }}> - 저온용: {ltCount}개 -
-
0 ? '#0288d1' : '#666' }}> - 체크밸브용: {ckCount}개 -
-
0 ? '#9c27b0' : '#666' }}> - 오리피스용: {oriCount}개 -
-
- {(psvCount + ltCount + ckCount + oriCount) === 0 && ( -
- 특수 용도 볼트 없음 (일반 볼트만 포함) -
- )} -
-
- ); - }; - - const exportToExcel = () => { - if (purchaseItems.length === 0) { - alert('내보낼 구매 품목이 없습니다.'); - return; - } - - // 상세한 구매 확정 데이터 생성 - const data = purchaseItems.map((item, index) => { - const baseData = { - '순번': index + 1, - '품목코드': item.item_code || '', - '카테고리': item.category || '', - '사양': item.specification || '', - '재질': item.material_spec || '', - '사이즈': item.size_spec || '', - '단위': item.unit || '', - 'BOM수량': item.bom_quantity || 0, - '구매수량': item.calculated_qty || 0, - '여유율': ((item.safety_factor || 1) - 1) * 100 + '%', - '최소주문': item.min_order_qty || 0, - '예상여유분': ((item.calculated_qty || 0) - (item.bom_quantity || 0)).toFixed(1), - '활용률': (((item.bom_quantity || 0) / (item.calculated_qty || 1)) * 100).toFixed(1) + '%' - }; - - // 파이프 특수 정보 추가 - if (item.category === 'PIPE') { - baseData['절단손실'] = item.cutting_loss || 0; - baseData['구매본수'] = item.pipes_count || 0; - baseData['여유길이'] = item.waste_length || 0; - } - - // 볼트 특수 정보 추가 - if (item.category === 'BOLT') { - const specialApps = item.special_applications || {}; - baseData['PSV용'] = specialApps.PSV || 0; - baseData['저온용'] = specialApps.LT || 0; - baseData['체크밸브용'] = specialApps.CK || 0; - baseData['오리피스용'] = specialApps.ORI || 0; - baseData['분수사이즈'] = item.size_fraction || ''; - baseData['표면처리'] = item.surface_treatment || ''; - } - - // 리비전 비교 정보 추가 (있는 경우) - if (previousRevision) { - baseData['기구매수량'] = item.purchased_quantity || 0; - baseData['추가구매필요'] = Math.max(item.additional_needed || 0, 0); - } - - return baseData; - }); - - // 헤더 정보 추가 - const headerInfo = [ - `구매 확정서`, - `Job No: ${jobNo}`, - `Revision: ${revision}`, - `파일명: ${filename || ''}`, - `생성일: ${new Date().toLocaleString('ko-KR')}`, - `총 품목수: ${purchaseItems.length}개`, - '' - ]; - - // 요약 정보 계산 - const totalBomQty = purchaseItems.reduce((sum, item) => sum + (item.bom_quantity || 0), 0); - const totalPurchaseQty = purchaseItems.reduce((sum, item) => sum + (item.calculated_qty || 0), 0); - const categoryCount = purchaseItems.reduce((acc, item) => { - acc[item.category] = (acc[item.category] || 0) + 1; - return acc; - }, {}); - - const summaryInfo = [ - '=== 요약 정보 ===', - `전체 BOM 수량: ${totalBomQty.toFixed(1)}`, - `전체 구매 수량: ${totalPurchaseQty.toFixed(1)}`, - `카테고리별 품목수: ${Object.entries(categoryCount).map(([cat, count]) => `${cat}(${count})`).join(', ')}`, - '' - ]; - - // CSV 형태로 데이터 구성 - const csvContent = [ - ...headerInfo, - ...summaryInfo, - '=== 상세 품목 목록 ===', - Object.keys(data[0]).join(','), - ...data.map(row => Object.values(row).map(val => - typeof val === 'string' && val.includes(',') ? `"${val}"` : val - ).join(',')) - ].join('\n'); - - // 파일 다운로드 - const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' }); - const link = document.createElement('a'); - const url = URL.createObjectURL(blob); - link.setAttribute('href', url); - - const timestamp = new Date().toISOString().split('T')[0]; - const fileName = `구매확정서_${jobNo}_${revision}_${timestamp}.csv`; - link.setAttribute('download', fileName); - - link.style.visibility = 'hidden'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - // 성공 메시지 - alert(`구매 확정서가 다운로드되었습니다.\n파일명: ${fileName}`); - }; - - if (loading) { - return ( -
-
로딩 중...
-
- ); - } - - return ( -
- {/* 헤더 */} -
- -
-

- 🛒 구매 확정 -

-

- Job: {jobNo} | {filename} | {revision} -

-
-
- - -
-
- - {/* 리비전 비교 알림 */} - {revisionComparison && ( -
- 🔄 -
-
- 리비전 변경사항: {revisionComparison.summary} -
- {revisionComparison.additional_items && ( -
- 추가 구매 필요: {revisionComparison.additional_items}개 품목 -
- )} -
-
- )} - - {/* 구매 품목 목록 */} - {purchaseItems.length === 0 ? ( -
-
📦
-
- 구매할 품목이 없습니다. -
-
- ) : ( -
- {purchaseItems.map(item => ( -
-
- - {item.category} - -

- {item.specification} -

- {item.is_additional && ( - - 추가 구매 - - )} -
- -
- {/* BOM 수량 */} -
-
- BOM 필요량 -
-
- {item.bom_quantity} {item.unit} -
- {formatPipeInfo(item)} - {formatBoltInfo(item)} -
- - {/* 구매 수량 */} -
-
- 구매 수량 -
- {editingItem === item.id ? ( -
- - setPurchaseItems(prev => - prev.map(i => - i.id === item.id - ? { ...i, calculated_qty: parseFloat(e.target.value) || 0 } - : i - ) - ) - } - style={{ - width: '100px', - padding: '4px 8px', - border: '1px solid #ddd', - borderRadius: '4px' - }} - /> - - -
- ) : ( -
-
- {item.calculated_qty} {item.unit} -
- -
- )} -
- - {/* 이미 구매한 수량 */} - {previousRevision && ( -
-
- 기구매 수량 -
-
- {item.purchased_quantity || 0} {item.unit} -
-
- )} - - {/* 추가 구매 필요량 */} - {previousRevision && ( -
-
- 추가 구매 필요 -
-
0 ? '#d32f2f' : '#2e7d32' - }}> - {Math.max(item.additional_needed || 0, 0)} {item.unit} -
-
- )} -
- - {/* 여유율 및 최소 주문 정보 */} -
-
-
-
여유율
-
- {((item.safety_factor || 1) - 1) * 100}% -
-
-
-
최소 주문
-
- {item.min_order_qty || 0} {item.unit} -
-
-
-
예상 여유분
-
- {(item.calculated_qty - item.bom_quantity).toFixed(1)} {item.unit} -
-
-
-
활용률
-
- {((item.bom_quantity / item.calculated_qty) * 100).toFixed(1)}% -
-
-
-
-
- ))} -
- )} - - {/* 구매 주문 확인 다이얼로그 */} - {confirmDialog && ( -
-
-

구매 주문 생성 확인

-
- 총 {purchaseItems.length}개 품목에 대한 구매 주문을 생성하시겠습니까? -
- - {revisionComparison && revisionComparison.has_changes && ( -
- ⚠️ 리비전 변경으로 인한 추가 구매가 포함되어 있습니다. -
- )} - -
- 구매 주문 생성 후에는 수량 변경이 제한됩니다. -
- -
- - -
-
-
- )} -
- ); -}; - -export default PurchaseConfirmationPage; \ No newline at end of file diff --git a/frontend/src/pages/_backup/RevisionPurchasePage.jsx b/frontend/src/pages/_backup/RevisionPurchasePage.jsx deleted file mode 100644 index ec68848..0000000 --- a/frontend/src/pages/_backup/RevisionPurchasePage.jsx +++ /dev/null @@ -1,437 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import { - Box, - Card, - CardContent, - Typography, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Paper, - Button, - Alert, - CircularProgress, - Chip, - Grid, - FormControl, - InputLabel, - Select, - MenuItem, - Tabs, - Tab, - Divider -} from '@mui/material'; -import { - ArrowBack, - ShoppingCart, - Compare, - Add as AddIcon, - Remove as RemoveIcon, - TrendingUp, - Assessment -} from '@mui/icons-material'; -import { compareMaterialRevisions, fetchFiles } from '../api'; - -const RevisionPurchasePage = () => { - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [comparisonResult, setComparisonResult] = useState(null); - const [availableRevisions, setAvailableRevisions] = useState([]); - const [selectedTab, setSelectedTab] = useState(0); - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); - - // URL 파라미터에서 정보 추출 - const jobNo = searchParams.get('job_no'); - const currentRevision = searchParams.get('current_revision') || searchParams.get('revision'); - const previousRevision = searchParams.get('previous_revision'); - const bomName = searchParams.get('bom_name'); - - useEffect(() => { - if (jobNo && currentRevision) { - loadAvailableRevisions(); - loadComparisonData(); - } else { - setError('필수 파라미터가 누락되었습니다. (job_no, current_revision)'); - setLoading(false); - } - }, [jobNo, currentRevision, previousRevision]); - - const loadAvailableRevisions = async () => { - try { - const response = await fetchFiles({ job_no: jobNo }); - if (Array.isArray(response.data)) { - // BOM별로 그룹화 - const bomGroups = response.data.reduce((acc, file) => { - const key = file.bom_name || file.original_filename; - if (!acc[key]) acc[key] = []; - acc[key].push(file); - return acc; - }, {}); - - // 현재 BOM과 관련된 리비전들만 필터링 - let relevantFiles = []; - if (bomName) { - relevantFiles = bomGroups[bomName] || []; - } else { - // bomName이 없으면 현재 리비전과 같은 원본파일명을 가진 것들 - const currentFile = response.data.find(file => file.revision === currentRevision); - if (currentFile) { - const key = currentFile.bom_name || currentFile.original_filename; - relevantFiles = bomGroups[key] || []; - } - } - - // 리비전 순으로 정렬 - relevantFiles.sort((a, b) => { - const revA = parseInt(a.revision?.replace('Rev.', '') || '0'); - const revB = parseInt(b.revision?.replace('Rev.', '') || '0'); - return revB - revA; - }); - - setAvailableRevisions(relevantFiles); - } - } catch (err) { - console.error('리비전 목록 로드 실패:', err); - } - }; - - const loadComparisonData = async () => { - try { - setLoading(true); - const result = await compareMaterialRevisions( - jobNo, - currentRevision, - previousRevision, - true - ); - setComparisonResult(result); - } catch (err) { - setError(`리비전 비교 실패: ${err.message}`); - } finally { - setLoading(false); - } - }; - - const handleRevisionChange = (type, newRevision) => { - const params = new URLSearchParams(searchParams); - if (type === 'current') { - params.set('current_revision', newRevision); - } else { - params.set('previous_revision', newRevision); - } - navigate(`?${params.toString()}`, { replace: true }); - }; - - const calculatePurchaseNeeds = () => { - if (!comparisonResult) return { newItems: [], increasedItems: [] }; - - const newItems = comparisonResult.new_items || []; - const modifiedItems = comparisonResult.modified_items || []; - - // 수량이 증가한 항목들만 필터링 - const increasedItems = modifiedItems.filter(item => - item.quantity_change > 0 - ); - - return { newItems, increasedItems }; - }; - - const formatCurrency = (amount) => { - return new Intl.NumberFormat('ko-KR', { - style: 'currency', - currency: 'KRW' - }).format(amount); - }; - - if (loading) { - return ( - - - - 리비전 비교 분석 중... - - - ); - } - - if (error) { - return ( - - - {error} - - ); - } - - const { newItems, increasedItems } = calculatePurchaseNeeds(); - const totalNewItems = newItems.length; - const totalIncreasedItems = increasedItems.length; - const totalPurchaseItems = totalNewItems + totalIncreasedItems; - - return ( - - {/* 헤더 */} - - - - - - - - - - - - - 🛒 리비전간 추가 구매 필요 자재 - - - - Job No: {jobNo} | {currentRevision} vs {previousRevision || '이전 리비전'} - - - - {/* 리비전 선택 카드 */} - - - - 리비전 비교 설정 - - - - - 현재 리비전 - - - - - - 이전 리비전 - - - - - - - - {/* 구매 요약 */} - - - - - - - {totalNewItems} - - - 신규 자재 - - - - - - - - - - {totalIncreasedItems} - - - 수량 증가 - - - - - - - - - - {totalPurchaseItems} - - - 총 구매 항목 - - - - - - - {/* 탭으로 구분된 자재 목록 */} - - setSelectedTab(newValue)} - variant="fullWidth" - > - - - - - - {selectedTab === 0 && ( - - - 🆕 신규 추가 자재 - - {newItems.length === 0 ? ( - 새로 추가된 자재가 없습니다. - ) : ( - - - - - 카테고리 - 자재 설명 - 사이즈 - 재질 - 수량 - 단위 - - - - {newItems.map((item, index) => ( - - - - - {item.description} - {item.size_spec || '-'} - {item.material_grade || '-'} - - - +{item.quantity} - - - {item.unit || 'EA'} - - ))} - -
-
- )} -
- )} - - {selectedTab === 1 && ( - - - 📈 수량 증가 자재 - - {increasedItems.length === 0 ? ( - 수량이 증가한 자재가 없습니다. - ) : ( - - - - - 카테고리 - 자재 설명 - 사이즈 - 재질 - 이전 수량 - 현재 수량 - 증가량 - 단위 - - - - {increasedItems.map((item, index) => ( - - - - - {item.description} - {item.size_spec || '-'} - {item.material_grade || '-'} - {item.previous_quantity} - {item.current_quantity} - - - +{item.quantity_change} - - - {item.unit || 'EA'} - - ))} - -
-
- )} -
- )} -
-
- - {totalPurchaseItems === 0 && ( - - 🎉 추가로 구매가 필요한 자재가 없습니다! - - )} -
- ); -}; - -export default RevisionPurchasePage; \ No newline at end of file diff --git a/frontend/src/pages/_deprecated/BOMRevisionPage.jsx b/frontend/src/pages/_deprecated/BOMRevisionPage.jsx deleted file mode 100644 index 181fded..0000000 --- a/frontend/src/pages/_deprecated/BOMRevisionPage.jsx +++ /dev/null @@ -1,350 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import api from '../api'; - -const BOMRevisionPage = ({ - onNavigate, - selectedProject, - user -}) => { - const [bomFiles, setBomFiles] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - - // BOM 파일 목록 로드 (기본 구조만) - useEffect(() => { - const loadBOMFiles = async () => { - if (!selectedProject) return; - - try { - setLoading(true); - // TODO: 실제 API 구현 필요 - // const response = await api.get(`/files/project/${selectedProject.job_no}`); - // setBomFiles(response.data); - - // 임시 데이터 - setBomFiles([ - { - id: 1, - bom_name: 'Main Process BOM', - revisions: ['Rev.0', 'Rev.1', 'Rev.2'], - latest_revision: 'Rev.2', - upload_date: '2024-10-17', - status: 'Active' - }, - { - id: 2, - bom_name: 'Utility BOM', - revisions: ['Rev.0', 'Rev.1'], - latest_revision: 'Rev.1', - upload_date: '2024-10-16', - status: 'Active' - } - ]); - } catch (err) { - console.error('BOM 파일 로드 실패:', err); - setError('BOM 파일을 불러오는데 실패했습니다.'); - } finally { - setLoading(false); - } - }; - - loadBOMFiles(); - }, [selectedProject]); - - return ( -
- {/* 헤더 */} -
-
-
-

- BOM Revision Management -

-

- Project: {selectedProject?.job_name || 'No Project Selected'} -

-
-
- - -
-
- - {/* 프로젝트 정보 */} -
-
-
- {selectedProject?.official_project_code || selectedProject?.job_no || 'N/A'} -
-
- Project Code -
-
- -
-
- {bomFiles.length} -
-
- BOM Files -
-
- -
-
- {bomFiles.reduce((total, bom) => total + bom.revisions.length, 0)} -
-
- Total Revisions -
-
-
-
- - {/* 개발 예정 배너 */} -
-
🚧
-

- Advanced Revision Management -

-

- 고급 리비전 관리 기능이 개발 중입니다. 업로드 기능 완료 후 본격적인 개발이 시작됩니다. -

- - {/* 예정 기능 미리보기 */} -
-
-
📊
-

- Revision Timeline -

-

- 시각적 리비전 히스토리 -

-
- -
-
🔍
-

- Diff Comparison -

-

- 리비전 간 변경사항 비교 -

-
- -
-
-

- Rollback System -

-

- 이전 리비전으로 롤백 -

-
-
-
- - {/* 임시 BOM 파일 목록 (기본 구조) */} - {bomFiles.length > 0 && ( -
-

- Current BOM Files (Preview) -

- -
- {bomFiles.map((bom) => ( -
-
-

- {bom.bom_name} -

-
- Latest: {bom.latest_revision} - Revisions: {bom.revisions.length} - Uploaded: {bom.upload_date} -
-
- -
- - -
-
- ))} -
-
- )} -
- ); -}; - -export default BOMRevisionPage; diff --git a/frontend/src/pages/_deprecated/BOMUploadPage.jsx b/frontend/src/pages/_deprecated/BOMUploadPage.jsx deleted file mode 100644 index 16ab320..0000000 --- a/frontend/src/pages/_deprecated/BOMUploadPage.jsx +++ /dev/null @@ -1,600 +0,0 @@ -import React, { useState, useRef, useCallback } from 'react'; -import api from '../api'; - -const BOMUploadPage = ({ - onNavigate, - selectedProject, - user -}) => { - const [dragOver, setDragOver] = useState(false); - const [uploading, setUploading] = useState(false); - const [uploadProgress, setUploadProgress] = useState(0); - const [selectedFiles, setSelectedFiles] = useState([]); - const [bomName, setBomName] = useState(''); - const [error, setError] = useState(''); - const [success, setSuccess] = useState(''); - const fileInputRef = useRef(null); - - // 파일 검증 - const validateFile = (file) => { - const allowedTypes = [ - 'application/vnd.ms-excel', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'text/csv' - ]; - - const maxSize = 50 * 1024 * 1024; // 50MB - - if (!allowedTypes.includes(file.type) && !file.name.match(/\.(xlsx|xls|csv)$/i)) { - return '지원되지 않는 파일 형식입니다. Excel 또는 CSV 파일만 업로드 가능합니다.'; - } - - if (file.size > maxSize) { - return '파일 크기가 너무 큽니다. 50MB 이하의 파일만 업로드 가능합니다.'; - } - - return null; - }; - - // 파일 선택 처리 - const handleFileSelect = useCallback((files) => { - const fileList = Array.from(files); - const validFiles = []; - const errors = []; - - fileList.forEach(file => { - const error = validateFile(file); - if (error) { - errors.push(`${file.name}: ${error}`); - } else { - validFiles.push(file); - } - }); - - if (errors.length > 0) { - setError(errors.join('\n')); - return; - } - - setSelectedFiles(validFiles); - setError(''); - - // 첫 번째 파일명을 기본 BOM 이름으로 설정 (확장자 제거) - if (validFiles.length > 0 && !bomName) { - const fileName = validFiles[0].name; - const nameWithoutExt = fileName.replace(/\.[^/.]+$/, ''); - setBomName(nameWithoutExt); - } - }, [bomName]); - - // 드래그 앤 드롭 처리 - const handleDragOver = useCallback((e) => { - e.preventDefault(); - setDragOver(true); - }, []); - - const handleDragLeave = useCallback((e) => { - e.preventDefault(); - setDragOver(false); - }, []); - - const handleDrop = useCallback((e) => { - e.preventDefault(); - setDragOver(false); - handleFileSelect(e.dataTransfer.files); - }, [handleFileSelect]); - - // 파일 선택 버튼 클릭 - const handleFileButtonClick = () => { - fileInputRef.current?.click(); - }; - - // 파일 업로드 - const handleUpload = async () => { - if (selectedFiles.length === 0) { - setError('업로드할 파일을 선택해주세요.'); - return; - } - - if (!bomName.trim()) { - setError('BOM 이름을 입력해주세요.'); - return; - } - - if (!selectedProject) { - setError('프로젝트를 선택해주세요.'); - return; - } - - try { - setUploading(true); - setUploadProgress(0); - setError(''); - setSuccess(''); - - for (let i = 0; i < selectedFiles.length; i++) { - const file = selectedFiles[i]; - const formData = new FormData(); - - formData.append('file', file); - formData.append('job_no', selectedProject.official_project_code || selectedProject.job_no); - formData.append('bom_name', bomName.trim()); - formData.append('revision', 'Rev.0'); // 새 업로드는 항상 Rev.0 - - const response = await api.post('/files/upload', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - onUploadProgress: (progressEvent) => { - const progress = Math.round( - ((i * 100) + (progressEvent.loaded * 100) / progressEvent.total) / selectedFiles.length - ); - setUploadProgress(progress); - } - }); - - if (!response.data?.success) { - throw new Error(response.data?.message || '업로드 실패'); - } - } - - setSuccess(`${selectedFiles.length}개 파일이 성공적으로 업로드되었습니다!`); - - // 3초 후 BOM 관리 페이지로 이동 - setTimeout(() => { - if (onNavigate) { - onNavigate('bom-management', { - file_id: response.data.file_id, - jobNo: selectedProject.official_project_code || selectedProject.job_no, - bomName: bomName.trim(), - revision: 'Rev.0' - }); - } - }, 3000); - - } catch (err) { - console.error('업로드 실패:', err); - setError(`업로드 실패: ${err.response?.data?.detail || err.message}`); - } finally { - setUploading(false); - setUploadProgress(0); - } - }; - - // 파일 제거 - const removeFile = (index) => { - const newFiles = selectedFiles.filter((_, i) => i !== index); - setSelectedFiles(newFiles); - - if (newFiles.length === 0) { - setBomName(''); - } - }; - - // 파일 크기 포맷팅 - const formatFileSize = (bytes) => { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; - }; - - return ( -
- {/* 헤더 */} -
-
-
-

- BOM File Upload -

-

- Project: {selectedProject?.job_name || 'No Project Selected'} -

-
- -
- - {/* 프로젝트 정보 */} -
-
-
- {selectedProject?.official_project_code || selectedProject?.job_no || 'N/A'} -
-
- Project Code -
-
- -
-
- {user?.username || 'Unknown'} -
-
- Uploaded by -
-
-
-
- - {/* 업로드 영역 */} -
- {/* BOM 이름 입력 */} -
- - setBomName(e.target.value)} - placeholder="Enter BOM name..." - style={{ - width: '100%', - padding: '12px 16px', - border: '2px solid #e5e7eb', - borderRadius: '12px', - fontSize: '16px', - transition: 'border-color 0.2s ease', - outline: 'none' - }} - onFocus={(e) => e.target.style.borderColor = '#3b82f6'} - onBlur={(e) => e.target.style.borderColor = '#e5e7eb'} - /> -
- - {/* 파일 드롭 영역 */} -
-
- {dragOver ? '📁' : '📄'} -
-

- {dragOver ? 'Drop files here' : 'Upload BOM Files'} -

-

- Drag and drop your Excel or CSV files here, or click to browse -

-
- 📋 - Supported: .xlsx, .xls, .csv (Max 50MB) -
-
- - {/* 숨겨진 파일 입력 */} - handleFileSelect(e.target.files)} - style={{ display: 'none' }} - /> - - {/* 선택된 파일 목록 */} - {selectedFiles.length > 0 && ( -
-

- Selected Files ({selectedFiles.length}) -

-
- {selectedFiles.map((file, index) => ( -
-
- 📄 -
-
- {file.name} -
-
- {formatFileSize(file.size)} -
-
-
- -
- ))} -
-
- )} - - {/* 업로드 진행률 */} - {uploading && ( -
-
- - Uploading... - - - {uploadProgress}% - -
-
-
-
-
- )} - - {/* 에러 메시지 */} - {error && ( -
-
- ⚠️ -
- {error} -
-
-
- )} - - {/* 성공 메시지 */} - {success && ( -
-
- -
- {success} -
-
-
- )} - - {/* 업로드 버튼 */} -
- - -
-
- - {/* 가이드 정보 */} -
-

- 📋 Upload Guidelines -

-
-
-

- ✅ Supported Formats -

-
    -
  • Excel files (.xlsx, .xls)
  • -
  • CSV files (.csv)
  • -
  • Maximum file size: 50MB
  • -
-
-
-

- 📊 Required Columns -

-
    -
  • Description (자재명/품명)
  • -
  • Quantity (수량)
  • -
  • Size information (사이즈)
  • -
-
-
-

- ⚡ Auto Processing -

-
    -
  • Automatic material classification
  • -
  • WELD GAP items excluded
  • -
  • Ready for BOM management
  • -
-
-
-
-
- ); -}; - -export default BOMUploadPage; diff --git a/frontend/src/pages/_deprecated/BOMWorkspacePage.jsx b/frontend/src/pages/_deprecated/BOMWorkspacePage.jsx deleted file mode 100644 index 3eb6353..0000000 --- a/frontend/src/pages/_deprecated/BOMWorkspacePage.jsx +++ /dev/null @@ -1,894 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { api, fetchFiles, deleteFile as deleteFileApi } from '../api'; - -const BOMWorkspacePage = ({ project, onNavigate, onBack }) => { - // 상태 관리 - const [files, setFiles] = useState([]); - const [selectedFile, setSelectedFile] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [sidebarWidth, setSidebarWidth] = useState(300); - const [previewWidth, setPreviewWidth] = useState(400); - const [groupedFiles, setGroupedFiles] = useState({}); - - // 업로드 관련 상태 - const [uploading, setUploading] = useState(false); - const [dragOver, setDragOver] = useState(false); - const fileInputRef = useRef(null); - - // 편집 상태 - const [editingFile, setEditingFile] = useState(null); - const [editingField, setEditingField] = useState(null); - const [editValue, setEditValue] = useState(''); - - // 파일을 BOM 이름별로 그룹화 - const groupFilesByBOM = (fileList) => { - const groups = {}; - - fileList.forEach(file => { - const bomName = file.bom_name || file.original_filename; - if (!groups[bomName]) { - groups[bomName] = []; - } - groups[bomName].push(file); - }); - - // 각 그룹 내에서 리비전 번호로 정렬 - Object.keys(groups).forEach(bomName => { - groups[bomName].sort((a, b) => { - const revA = parseInt(a.revision?.replace('Rev.', '') || '0'); - const revB = parseInt(b.revision?.replace('Rev.', '') || '0'); - return revB - revA; // 최신 리비전이 위로 - }); - }); - - return groups; - }; - - useEffect(() => { - console.log('🔄 프로젝트 변경됨:', project); - const jobNo = project?.official_project_code || project?.job_no; - if (jobNo) { - console.log('✅ 프로젝트 코드 확인:', jobNo); - // 프로젝트가 변경되면 기존 선택 초기화 - setSelectedFile(null); - setFiles([]); - loadFiles(); - } else { - console.warn('⚠️ 프로젝트 정보가 없습니다. 받은 프로젝트:', project); - setFiles([]); - setSelectedFile(null); - } - }, [project?.official_project_code, project?.job_no]); // 두 필드 모두 감시 - - const loadFiles = async () => { - const jobNo = project?.official_project_code || project?.job_no; - if (!jobNo) { - console.warn('프로젝트 정보가 없어서 파일을 로드할 수 없습니다:', project); - return; - } - - try { - setLoading(true); - setError(''); // 에러 초기화 - - console.log('📂 파일 목록 로딩 시작:', jobNo); - - const response = await api.get('/files/', { - params: { job_no: jobNo } - }); - - console.log('📂 API 응답:', response.data); - - const fileList = Array.isArray(response.data) ? response.data : response.data?.files || []; - console.log('📂 파싱된 파일 목록:', fileList); - - setFiles(fileList); - - // 파일을 그룹화 - const grouped = groupFilesByBOM(fileList); - setGroupedFiles(grouped); - console.log('📂 그룹화된 파일:', grouped); - - // 기존 선택된 파일이 목록에 있는지 확인 - if (selectedFile && !fileList.find(f => f.id === selectedFile.id)) { - setSelectedFile(null); - } - - // 첫 번째 파일 자동 선택 (기존 선택이 없을 때만) - if (fileList.length > 0 && !selectedFile) { - console.log('📂 첫 번째 파일 자동 선택:', fileList[0].original_filename); - setSelectedFile(fileList[0]); - } - - console.log('📂 파일 로딩 완료:', fileList.length, '개 파일'); - } catch (err) { - console.error('📂 파일 로딩 실패:', err); - console.error('📂 에러 상세:', err.response?.data); - setError(`파일 목록을 불러오는데 실패했습니다: ${err.response?.data?.detail || err.message}`); - setFiles([]); // 에러 시 빈 배열로 초기화 - } finally { - setLoading(false); - } - }; - - // 드래그 앤 드롭 핸들러 - const handleDragOver = (e) => { - e.preventDefault(); - setDragOver(true); - }; - - const handleDragLeave = (e) => { - e.preventDefault(); - setDragOver(false); - }; - - const handleDrop = async (e) => { - e.preventDefault(); - setDragOver(false); - - const droppedFiles = Array.from(e.dataTransfer.files); - console.log('드롭된 파일들:', droppedFiles.map(f => ({ name: f.name, type: f.type }))); - - const excelFiles = droppedFiles.filter(file => { - const fileName = file.name.toLowerCase(); - const isExcel = fileName.endsWith('.xlsx') || fileName.endsWith('.xls'); - console.log(`파일 ${file.name}: Excel 여부 = ${isExcel}`); - return isExcel; - }); - - if (excelFiles.length > 0) { - console.log('업로드할 Excel 파일들:', excelFiles.map(f => f.name)); - await uploadFiles(excelFiles); - } else { - console.log('Excel 파일이 없음. 드롭된 파일들:', droppedFiles.map(f => f.name)); - alert(`Excel 파일만 업로드 가능합니다.\n업로드하려는 파일: ${droppedFiles.map(f => f.name).join(', ')}`); - } - }; - - const handleFileSelect = (e) => { - const selectedFiles = Array.from(e.target.files); - console.log('선택된 파일들:', selectedFiles.map(f => ({ name: f.name, type: f.type }))); - - const excelFiles = selectedFiles.filter(file => { - const fileName = file.name.toLowerCase(); - const isExcel = fileName.endsWith('.xlsx') || fileName.endsWith('.xls'); - console.log(`파일 ${file.name}: Excel 여부 = ${isExcel}`); - return isExcel; - }); - - if (excelFiles.length > 0) { - console.log('업로드할 Excel 파일들:', excelFiles.map(f => f.name)); - uploadFiles(excelFiles); - } else { - console.log('Excel 파일이 없음. 선택된 파일들:', selectedFiles.map(f => f.name)); - alert(`Excel 파일만 업로드 가능합니다.\n선택하려는 파일: ${selectedFiles.map(f => f.name).join(', ')}`); - } - }; - - const uploadFiles = async (filesToUpload) => { - console.log('업로드 시작:', filesToUpload.map(f => ({ name: f.name, size: f.size, type: f.type }))); - setUploading(true); - - try { - for (const file of filesToUpload) { - console.log(`업로드 중: ${file.name} (${file.size} bytes, ${file.type})`); - - const jobNo = project?.official_project_code || project?.job_no; - const formData = new FormData(); - formData.append('file', file); - formData.append('job_no', jobNo); - formData.append('bom_name', file.name.replace(/\.[^/.]+$/, "")); // 확장자 제거 - - console.log('FormData 내용:', { - fileName: file.name, - jobNo: jobNo, - bomName: file.name.replace(/\.[^/.]+$/, "") - }); - - const response = await api.post('/files/upload', formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }); - - console.log(`업로드 성공: ${file.name}`, response.data); - } - - await loadFiles(); // 목록 새로고침 - alert(`${filesToUpload.length}개 파일이 업로드되었습니다.`); - } catch (err) { - console.error('업로드 실패:', err); - console.error('에러 상세:', err.response?.data); - setError(`파일 업로드에 실패했습니다: ${err.response?.data?.detail || err.message}`); - } finally { - setUploading(false); - } - }; - - // 인라인 편집 핸들러 - const startEdit = (file, field) => { - setEditingFile(file.id); - setEditingField(field); - setEditValue(file[field] || ''); - }; - - const saveEdit = async () => { - try { - await api.put(`/files/${editingFile}`, { - [editingField]: editValue - }); - - // 로컬 상태 업데이트 - setFiles(files.map(f => - f.id === editingFile - ? { ...f, [editingField]: editValue } - : f - )); - - if (selectedFile?.id === editingFile) { - setSelectedFile({ ...selectedFile, [editingField]: editValue }); - } - - cancelEdit(); - } catch (err) { - console.error('수정 실패:', err); - alert('수정에 실패했습니다.'); - } - }; - - const cancelEdit = () => { - setEditingFile(null); - setEditingField(null); - setEditValue(''); - }; - - // 파일 삭제 - const handleDelete = async (fileId) => { - if (!window.confirm('정말로 이 파일을 삭제하시겠습니까?')) { - return; - } - - try { - await deleteFileApi(fileId); - setFiles(files.filter(f => f.id !== fileId)); - - if (selectedFile?.id === fileId) { - const remainingFiles = files.filter(f => f.id !== fileId); - setSelectedFile(remainingFiles.length > 0 ? remainingFiles[0] : null); - } - } catch (err) { - console.error('삭제 실패:', err); - setError('파일 삭제에 실패했습니다.'); - } - }; - - // 자재 보기 - const viewMaterials = (file) => { - if (onNavigate) { - onNavigate('materials', { - file_id: file.id, - jobNo: file.job_no, - bomName: file.bom_name || file.original_filename, - revision: file.revision, - filename: file.original_filename, - selectedProject: project - }); - } - }; - - // 리비전 업로드 - const handleRevisionUpload = async (parentFile) => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.csv,.xlsx,.xls'; - - input.onchange = async (e) => { - const file = e.target.files[0]; - if (!file) return; - - try { - setUploading(true); - const jobNo = project?.official_project_code || project?.job_no; - const formData = new FormData(); - formData.append('file', file); - formData.append('job_no', jobNo); - formData.append('bom_name', parentFile.bom_name || parentFile.original_filename); - formData.append('parent_file_id', parentFile.id); // 부모 파일 ID 추가 - - const response = await api.post('/files/upload', formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }); - - if (response.data?.success) { - // 누락된 도면 확인 - if (response.data.missing_drawings && response.data.missing_drawings.requires_confirmation) { - const missingDrawings = response.data.missing_drawings.drawings || []; - const materialCount = response.data.missing_drawings.materials?.length || 0; - const hasPreviousPurchase = response.data.missing_drawings.has_previous_purchase; - const fileId = response.data.file_id; - - // 사용자 선택을 위한 프롬프트 메시지 - let alertMessage = `⚠️ 리비전 업로드 확인\n\n` + - `다음 도면이 새 파일에 없습니다:\n` + - `${missingDrawings.slice(0, 5).join('\n')}` + - `${missingDrawings.length > 5 ? `\n...외 ${missingDrawings.length - 5}개` : ''}\n\n` + - `관련 자재: ${materialCount}개\n\n`; - - if (hasPreviousPurchase) { - // 케이스 1: 이미 구매신청된 경우 - alertMessage += `✅ 이전 리비전에서 구매신청이 진행되었습니다.\n\n` + - `다음 중 선택하세요:\n\n` + - `1️⃣ "일부만 업로드" - 변경된 도면만 업로드, 기존 도면 유지\n` + - ` → 누락된 도면의 자재는 "재고품"으로 표시 (연노랑색)\n\n` + - `2️⃣ "도면 삭제됨" - 누락된 도면 완전 삭제\n` + - ` → 해당 자재 제거 및 재고품 처리\n\n` + - `3️⃣ "취소" - 업로드 취소\n\n` + - `숫자를 입력하세요 (1, 2, 3):`; - } else { - // 케이스 2: 구매신청 전인 경우 - alertMessage += `⚠️ 아직 구매신청이 진행되지 않았습니다.\n\n` + - `다음 중 선택하세요:\n\n` + - `1️⃣ "일부만 업로드" - 변경된 도면만 업로드, 기존 도면 유지\n` + - ` → 누락된 도면의 자재는 그대로 유지\n\n` + - `2️⃣ "도면 삭제됨" - 누락된 도면 완전 삭제\n` + - ` → 해당 자재 완전 제거 (숨김 처리)\n\n` + - `3️⃣ "취소" - 업로드 취소\n\n` + - `숫자를 입력하세요 (1, 2, 3):`; - } - - const userChoice = prompt(alertMessage); - - if (userChoice === '3' || userChoice === null) { - // 취소 선택 - await api.delete(`/files/${fileId}`); - alert('업로드가 취소되었습니다.'); - return; - } else if (userChoice === '2') { - // 도면 삭제됨 - 백엔드에 삭제 처리 요청 - try { - await api.post(`/files/${fileId}/process-missing-drawings`, { - action: 'delete', - drawings: missingDrawings - }); - alert(`✅ ${missingDrawings.length}개 도면이 삭제 처리되었습니다.`); - } catch (err) { - console.error('도면 삭제 처리 실패:', err); - alert('도면 삭제 처리에 실패했습니다.'); - } - } else if (userChoice === '1') { - // 일부만 업로드 - 이미 처리됨 (기본 동작) - alert(`✅ 일부 업로드로 처리되었습니다.\n누락된 ${missingDrawings.length}개 도면은 기존 상태를 유지합니다.`); - } else { - // 잘못된 입력 - await api.delete(`/files/${fileId}`); - alert('잘못된 입력입니다. 업로드가 취소되었습니다.'); - return; - } - } - - alert(`리비전 ${response.data.revision} 업로드 성공!\n신규 자재: ${response.data.new_materials_count || 0}개`); - await loadFiles(); - } - } catch (err) { - console.error('리비전 업로드 실패:', err); - alert('리비전 업로드에 실패했습니다: ' + (err.response?.data?.detail || err.message)); - } finally { - setUploading(false); - } - }; - - input.click(); - }; - - return ( -
- {/* 사이드바 - 프로젝트 정보 */} -
- {/* 헤더 */} -
- -

- {project?.project_name} -

-

- {project?.official_project_code || project?.job_no} -

-
- - {/* 프로젝트 통계 */} -
-
- 프로젝트 현황 -
-
-
-
- {files.length} -
-
BOM 파일
-
-
-
- {files.reduce((sum, f) => sum + (f.parsed_count || 0), 0)} -
-
총 자재
-
-
-
- - {/* 업로드 영역 */} -
fileInputRef.current?.click()} - > - - {uploading ? ( -
- 📤 업로드 중... -
- ) : ( -
-
📁
-
- Excel 파일을 드래그하거나
클릭하여 업로드 -
-
- )} -
-
- - {/* 메인 패널 - 파일 목록 */} -
- {/* 툴바 */} -
-

- BOM 파일 목록 ({Object.keys(groupedFiles).length}) -

-
- - -
-
- - {/* 파일 목록 */} -
- {loading ? ( -
- 로딩 중... -
- ) : files.length === 0 ? ( -
- 업로드된 BOM 파일이 없습니다. -
- ) : ( -
- {Object.entries(groupedFiles).map(([bomName, bomFiles]) => { - // 최신 리비전 파일을 대표로 선택 - const latestFile = bomFiles[0]; // 이미 최신순으로 정렬됨 - - return ( -
setSelectedFile(latestFile)} - onMouseEnter={(e) => { - if (selectedFile?.id !== latestFile.id) { - e.target.style.background = '#f8f9fa'; - } - }} - onMouseLeave={(e) => { - if (selectedFile?.id !== latestFile.id) { - e.target.style.background = 'transparent'; - } - }} - > -
-
- {/* BOM 이름 (인라인 편집) */} - {editingFile === latestFile.id && editingField === 'bom_name' ? ( - setEditValue(e.target.value)} - onBlur={saveEdit} - onKeyDown={(e) => { - if (e.key === 'Enter') saveEdit(); - if (e.key === 'Escape') cancelEdit(); - }} - style={{ - border: '1px solid #4299e1', - borderRadius: '2px', - padding: '2px 4px', - fontSize: '14px', - fontWeight: '600' - }} - autoFocus - /> - ) : ( -
{ - e.stopPropagation(); - startEdit(latestFile, 'bom_name'); - }} - > - {bomName} -
- )} - -
- 📄 {bomFiles.length > 1 ? `${bomFiles.length}개 리비전` : latestFile.revision || 'Rev.0'} • - {bomFiles.reduce((sum, f) => sum + (f.parsed_count || 0), 0)}개 자재 (최신: {latestFile.revision || 'Rev.0'}) -
-
- -
- {bomFiles.length === 1 ? ( - - ) : ( -
- -
- )} - - -
-
-
- ); - })} -
- )} -
-
- - {/* 우측 패널 - 상세 정보 */} - {selectedFile && ( -
- {/* 상세 정보 헤더 */} -
-

- 파일 상세 정보 -

-
- - {/* 상세 정보 내용 */} -
-
- -
startEdit(selectedFile, 'bom_name')} - > - {selectedFile.bom_name || selectedFile.original_filename} -
-
- -
- -
- {selectedFile.original_filename} -
-
- -
- -
- {selectedFile.revision || 'Rev.0'} -
-
- -
- -
- {selectedFile.parsed_count || 0}개 -
-
- -
- -
- {new Date(selectedFile.created_at).toLocaleString('ko-KR')} -
-
- - {/* 액션 버튼들 */} -
- - - - - -
-
-
- )} - - {/* 에러 메시지 */} - {error && ( -
- {error} - -
- )} -
- ); -}; - -export default BOMWorkspacePage;