✅ 백엔드 구조 개선: - 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: 배포 시 자동 실행 순서 최적화
This commit is contained in:
253
RULES.md
253
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 마이그레이션 시스템 추가)
|
||||
|
||||
@@ -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"]
|
||||
# 자동 마이그레이션 후 서버 실행
|
||||
CMD ["bash", "/app/start.sh"]
|
||||
@@ -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])
|
||||
|
||||
@@ -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") # 완전히 새로운 엔드포인트
|
||||
|
||||
350
backend/app/services/database_service.py
Normal file
350
backend/app/services/database_service.py
Normal file
@@ -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
|
||||
607
backend/app/services/file_upload_service.py
Normal file
607
backend/app/services/file_upload_service.py
Normal file
@@ -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
|
||||
138
backend/scripts/analyze_and_fix_schema.py
Normal file
138
backend/scripts/analyze_and_fix_schema.py
Normal file
@@ -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)
|
||||
301
backend/scripts/complete_migrate.py
Normal file
301
backend/scripts/complete_migrate.py
Normal file
@@ -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')}")
|
||||
142
backend/scripts/fix_missing_tables.py
Normal file
142
backend/scripts/fix_missing_tables.py
Normal file
@@ -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)
|
||||
113
backend/scripts/legacy/auto_migrate.py
Normal file
113
backend/scripts/legacy/auto_migrate.py
Normal file
@@ -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()
|
||||
144
backend/scripts/legacy/generate_complete_schema.py
Normal file
144
backend/scripts/legacy/generate_complete_schema.py
Normal file
@@ -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()
|
||||
195
backend/scripts/optimize_database.py
Normal file
195
backend/scripts/optimize_database.py
Normal file
@@ -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("❌ 최적화 실패")
|
||||
126
backend/scripts/simple_migrate.py
Normal file
126
backend/scripts/simple_migrate.py
Normal file
@@ -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()
|
||||
54
backend/start.sh
Normal file
54
backend/start.sh
Normal file
@@ -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
|
||||
113
docker-compose.synology.yml
Normal file
113
docker-compose.synology.yml
Normal file
@@ -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
|
||||
@@ -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:
|
||||
|
||||
123
frontend/src/components/common/ConfirmDialog.jsx
Normal file
123
frontend/src/components/common/ConfirmDialog.jsx
Normal file
@@ -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 (
|
||||
<div style={overlayStyle} onClick={onCancel}>
|
||||
<div style={dialogStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<h3 style={titleStyle}>{title}</h3>
|
||||
<p style={messageStyle}>{message}</p>
|
||||
<div style={buttonContainerStyle}>
|
||||
<button
|
||||
style={cancelButtonStyle}
|
||||
onClick={onCancel}
|
||||
onMouseOver={(e) => e.target.style.backgroundColor = '#5a6268'}
|
||||
onMouseOut={(e) => e.target.style.backgroundColor = '#6c757d'}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
style={confirmButtonStyle}
|
||||
onClick={onConfirm}
|
||||
onMouseOver={(e) => e.target.style.backgroundColor = style.confirmHover}
|
||||
onMouseOut={(e) => e.target.style.backgroundColor = style.confirmBg}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmDialog;
|
||||
115
frontend/src/components/common/ErrorMessage.jsx
Normal file
115
frontend/src/components/common/ErrorMessage.jsx
Normal file
@@ -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 (
|
||||
<div style={containerStyle}>
|
||||
<span style={{ fontSize: '16px' }}>{style.icon}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={messageStyle}>
|
||||
{getErrorMessage(error)}
|
||||
</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
style={buttonStyle}
|
||||
onMouseOver={(e) => e.target.style.opacity = '0.8'}
|
||||
onMouseOut={(e) => e.target.style.opacity = '1'}
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
backgroundColor: 'transparent',
|
||||
color: style.color,
|
||||
border: `1px solid ${style.color}`
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.target.style.backgroundColor = style.color;
|
||||
e.target.style.color = 'white';
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.target.style.backgroundColor = 'transparent';
|
||||
e.target.style.color = style.color;
|
||||
}}
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorMessage;
|
||||
72
frontend/src/components/common/LoadingSpinner.jsx
Normal file
72
frontend/src/components/common/LoadingSpinner.jsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div style={containerStyle}>
|
||||
<div
|
||||
className={sizeClasses[size]}
|
||||
style={spinnerStyle}
|
||||
></div>
|
||||
{message && (
|
||||
<p style={{
|
||||
marginTop: '10px',
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
||||
@@ -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';
|
||||
@@ -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' }) => (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e2e8f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '12px',
|
||||
background: color + '20',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '24px'
|
||||
}}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{value}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
🔧 BOM 관리
|
||||
</h1>
|
||||
<p style={{
|
||||
color: '#718096',
|
||||
fontSize: '16px',
|
||||
margin: '0'
|
||||
}}>
|
||||
Bill of Materials 업로드, 분석 및 관리를 수행하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드들 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<StatCard
|
||||
title="총 업로드 파일"
|
||||
value={stats.totalFiles}
|
||||
icon="📄"
|
||||
color="#667eea"
|
||||
/>
|
||||
<StatCard
|
||||
title="분석된 자재"
|
||||
value={stats.totalMaterials}
|
||||
icon="🔧"
|
||||
color="#48bb78"
|
||||
/>
|
||||
<StatCard
|
||||
title="활성 프로젝트"
|
||||
value={projects.length}
|
||||
icon="📋"
|
||||
color="#ed8936"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
overflow: 'hidden',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
borderBottom: '1px solid #e2e8f0'
|
||||
}}>
|
||||
{[
|
||||
{ id: 'upload', label: '📤 파일 업로드', icon: '📤' },
|
||||
{ id: 'files', label: '📁 파일 관리', icon: '📁' },
|
||||
{ id: 'materials', label: '🔧 자재 목록', icon: '🔧' },
|
||||
{ id: 'analysis', label: '📊 분석 결과', icon: '📊' }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '16px 20px',
|
||||
background: activeTab === tab.id ? '#f7fafc' : 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === tab.id ? '2px solid #667eea' : '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: activeTab === tab.id ? '600' : '500',
|
||||
color: activeTab === tab.id ? '#667eea' : '#4a5568',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
<div style={{ padding: '24px' }}>
|
||||
{activeTab === 'upload' && (
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
📤 BOM 파일 업로드
|
||||
</h3>
|
||||
|
||||
{/* 프로젝트 선택 */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
프로젝트 선택
|
||||
</label>
|
||||
<select
|
||||
value={selectedProject?.job_no || ''}
|
||||
onChange={(e) => {
|
||||
const project = projects.find(p => p.job_no === e.target.value);
|
||||
setSelectedProject(project);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
background: 'white'
|
||||
}}
|
||||
>
|
||||
<option value="">프로젝트를 선택하세요</option>
|
||||
{projects.map(project => (
|
||||
<option key={project.job_no} value={project.job_no}>
|
||||
{project.job_no} - {project.job_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedProject ? (
|
||||
<div>
|
||||
<div style={{
|
||||
background: '#f7fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 8px 0', color: '#2d3748' }}>
|
||||
선택된 프로젝트: {selectedProject.job_name}
|
||||
</h4>
|
||||
<p style={{ margin: '0', fontSize: '14px', color: '#718096' }}>
|
||||
Job No: {selectedProject.job_no} |
|
||||
고객사: {selectedProject.client_name} |
|
||||
상태: {selectedProject.status}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SimpleFileUpload
|
||||
selectedProject={selectedProject}
|
||||
onUploadComplete={handleFileUpload}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
먼저 프로젝트를 선택해주세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'files' && (
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
📁 업로드된 파일 목록
|
||||
</h3>
|
||||
|
||||
{selectedProject ? (
|
||||
loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
|
||||
파일 목록을 불러오는 중...
|
||||
</div>
|
||||
) : files.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{files.map((file, index) => (
|
||||
<div key={index} style={{
|
||||
background: '#f7fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontWeight: '600', color: '#2d3748' }}>
|
||||
{file.original_filename || file.filename}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#718096' }}>
|
||||
업로드: {new Date(file.created_at).toLocaleString()} |
|
||||
자재 수: {file.parsed_count || 0}개
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '4px 8px',
|
||||
background: '#48bb78',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
{file.revision || 'Rev.0'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
|
||||
업로드된 파일이 없습니다.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
|
||||
프로젝트를 선택해주세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'materials' && (
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
🔧 자재 목록
|
||||
</h3>
|
||||
|
||||
{selectedProject ? (
|
||||
<MaterialList
|
||||
selectedProject={selectedProject}
|
||||
key={selectedProject.job_no} // 프로젝트 변경 시 컴포넌트 재렌더링
|
||||
/>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
|
||||
프로젝트를 선택해주세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'analysis' && (
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
📊 분석 결과
|
||||
</h3>
|
||||
|
||||
<div style={{
|
||||
background: '#fff3cd',
|
||||
border: '1px solid #ffeaa7',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '16px', color: '#856404' }}>
|
||||
🚧 분석 결과 페이지는 곧 구현될 예정입니다.
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#856404', marginTop: '8px' }}>
|
||||
자재 분류, 통계, 비교 분석 기능이 추가됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMManagementPage;
|
||||
@@ -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 (
|
||||
<Box>
|
||||
{/* 요약 통계 카드 */}
|
||||
<Grid container spacing={3} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="primary" gutterBottom>
|
||||
{summary?.new_items_count || 0}
|
||||
</Typography>
|
||||
<Typography variant="h6">신규 자재</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
새로 추가된 자재
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="warning.main" gutterBottom>
|
||||
{summary?.modified_items_count || 0}
|
||||
</Typography>
|
||||
<Typography variant="h6">변경 자재</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
수량이 변경된 자재
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="error.main" gutterBottom>
|
||||
{summary?.removed_items_count || 0}
|
||||
</Typography>
|
||||
<Typography variant="h6">삭제 자재</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
제거된 자재
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="success.main" gutterBottom>
|
||||
{summary?.total_current_items || 0}
|
||||
</Typography>
|
||||
<Typography variant="h6">총 자재</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
현재 리비전 전체
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* 탭으로 구분된 자재 목록 */}
|
||||
<Card>
|
||||
<Tabs
|
||||
value={selectedTab}
|
||||
onChange={(e, newValue) => setSelectedTab(newValue)}
|
||||
variant="fullWidth"
|
||||
>
|
||||
<Tab label={`신규 자재 (${new_items.length})`} />
|
||||
<Tab label={`변경 자재 (${modified_items.length})`} />
|
||||
<Tab label={`삭제 자재 (${removed_items.length})`} />
|
||||
</Tabs>
|
||||
|
||||
<CardContent>
|
||||
{selectedTab === 0 && renderMaterialTable(new_items, 'new')}
|
||||
{selectedTab === 1 && renderMaterialTable(modified_items, 'modified')}
|
||||
{selectedTab === 2 && renderMaterialTable(removed_items, 'removed')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMaterialTable = (items, type) => {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Alert severity="info">
|
||||
{type === 'new' && '새로 추가된 자재가 없습니다.'}
|
||||
{type === 'modified' && '수량이 변경된 자재가 없습니다.'}
|
||||
{type === 'removed' && '삭제된 자재가 없습니다.'}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`🔍 ${type} 테이블 렌더링:`, items); // 디버깅용
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>카테고리</TableCell>
|
||||
<TableCell>자재 설명</TableCell>
|
||||
<TableCell>사이즈</TableCell>
|
||||
<TableCell>재질</TableCell>
|
||||
{type === 'modified' && (
|
||||
<>
|
||||
<TableCell align="center">이전 수량</TableCell>
|
||||
<TableCell align="center">현재 수량</TableCell>
|
||||
<TableCell align="center">변경량</TableCell>
|
||||
</>
|
||||
)}
|
||||
{type !== 'modified' && <TableCell align="center">수량</TableCell>}
|
||||
<TableCell>단위</TableCell>
|
||||
<TableCell>길이(mm)</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{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 = (
|
||||
<Box>
|
||||
<Typography variant="body2">
|
||||
이전: {Math.round(prevTotalLength).toLocaleString()}mm → 현재: {Math.round(currentTotalLength).toLocaleString()}mm
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight="bold"
|
||||
color={lengthChange > 0 ? 'success.main' : lengthChange < 0 ? 'error.main' : 'text.primary'}
|
||||
>
|
||||
변화: {lengthChange > 0 ? '+' : ''}{Math.round(lengthChange).toLocaleString()}mm
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
// 신규/삭제된 파이프: 실제 총길이 사용
|
||||
lengthInfo = (
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
총 길이: {Math.round(currentTotalLength).toLocaleString()}mm
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
} else if (item.category === 'PIPE') {
|
||||
lengthInfo = '길이 정보 없음';
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={item.category}
|
||||
size="small"
|
||||
color={type === 'new' ? 'primary' : type === 'modified' ? 'warning' : 'error'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{item.description}</TableCell>
|
||||
<TableCell>{item.size_spec || '-'}</TableCell>
|
||||
<TableCell>{item.material_grade || '-'}</TableCell>
|
||||
{type === 'modified' && (
|
||||
<>
|
||||
<TableCell align="center">{item.previous_quantity}</TableCell>
|
||||
<TableCell align="center">{item.current_quantity}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight="bold"
|
||||
color={item.quantity_change > 0 ? 'success.main' : 'error.main'}
|
||||
>
|
||||
{item.quantity_change > 0 ? '+' : ''}{item.quantity_change}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
{type !== 'modified' && (
|
||||
<TableCell align="center">
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{item.quantity}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>{item.unit || 'EA'}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Typography variant="body2" color={lengthInfo !== '-' ? 'primary.main' : 'text.secondary'}>
|
||||
{lengthInfo}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Breadcrumbs sx={{ mb: 2 }}>
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
onClick={() => navigate('/jobs')}
|
||||
sx={{ textDecoration: 'none' }}
|
||||
>
|
||||
프로젝트 목록
|
||||
</Link>
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
onClick={() => navigate(`/materials?job_no=${jobNo}`)}
|
||||
sx={{ textDecoration: 'none' }}
|
||||
>
|
||||
{jobNo}
|
||||
</Link>
|
||||
<Typography variant="body2" color="textPrimary">
|
||||
자재 비교
|
||||
</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
자재 리비전 비교
|
||||
</Typography>
|
||||
<Typography variant="body1" color="textSecondary">
|
||||
{filename && `파일: ${filename}`}
|
||||
<br />
|
||||
{previousRevision ?
|
||||
`${previousRevision} → ${currentRevision} 비교` :
|
||||
`${currentRevision} (이전 리비전 없음)`
|
||||
}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon={<Download />}
|
||||
onClick={handleExportToExcel}
|
||||
disabled={!comparisonResult}
|
||||
>
|
||||
엑셀 내보내기
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={handleGoBack}
|
||||
>
|
||||
BOM 목록으로
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
{renderHeader()}
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||
<Stack alignItems="center" spacing={2}>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
자재 비교 중...
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
리비전간 차이점을 분석하고 있습니다
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
{renderHeader()}
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
자재 비교 실패
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{error}
|
||||
</Typography>
|
||||
</Alert>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Refresh />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
다시 시도
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
{renderHeader()}
|
||||
|
||||
{comparisonResult && renderComparisonResults()}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialComparisonPage;
|
||||
@@ -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 }) => (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '10px',
|
||||
background: color + '20',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px'
|
||||
}}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
{value.toLocaleString()}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#718096'
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#718096',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px',
|
||||
background: '#f7fafc',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
📦 자재 관리
|
||||
</h1>
|
||||
<p style={{
|
||||
color: '#718096',
|
||||
fontSize: '16px',
|
||||
margin: '0'
|
||||
}}>
|
||||
전체 프로젝트의 자재 정보를 통합 관리하고 분석하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드들 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<StatCard
|
||||
title="전체 자재"
|
||||
value={stats.totalMaterials}
|
||||
icon="📦"
|
||||
color="#667eea"
|
||||
subtitle={`${projects.length}개 프로젝트`}
|
||||
/>
|
||||
<StatCard
|
||||
title="분류 완료"
|
||||
value={stats.categorizedMaterials}
|
||||
icon="✅"
|
||||
color="#48bb78"
|
||||
subtitle={`${Math.round((stats.categorizedMaterials / stats.totalMaterials) * 100) || 0}% 완료`}
|
||||
/>
|
||||
<StatCard
|
||||
title="미분류"
|
||||
value={stats.uncategorizedMaterials}
|
||||
icon="⚠️"
|
||||
color="#ed8936"
|
||||
subtitle="분류 작업 필요"
|
||||
/>
|
||||
<StatCard
|
||||
title="카테고리"
|
||||
value={Object.keys(stats.categories).length}
|
||||
icon="🏷️"
|
||||
color="#9f7aea"
|
||||
subtitle="자재 분류 유형"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 필터 섹션 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: '24px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
margin: '0'
|
||||
}}>
|
||||
🔍 필터 및 검색
|
||||
</h3>
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#e2e8f0',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#4a5568'
|
||||
}}
|
||||
>
|
||||
필터 초기화
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '16px'
|
||||
}}>
|
||||
{/* 프로젝트 필터 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
프로젝트
|
||||
</label>
|
||||
<select
|
||||
value={filters.project}
|
||||
onChange={(e) => handleFilterChange('project', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value="">전체 프로젝트</option>
|
||||
{projects.map(project => (
|
||||
<option key={project.job_no} value={project.job_no}>
|
||||
{project.job_no} - {project.job_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
카테고리
|
||||
</label>
|
||||
<select
|
||||
value={filters.category}
|
||||
onChange={(e) => handleFilterChange('category', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value="">전체 카테고리</option>
|
||||
{Object.keys(stats.categories).map(category => (
|
||||
<option key={category} value={category}>
|
||||
{category} ({stats.categories[category]})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
분류 상태
|
||||
</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="categorized">분류 완료</option>
|
||||
<option value="uncategorized">미분류</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#4a5568',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
검색
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="자재명, 코드, 카테고리 검색..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 결과 요약 */}
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
background: '#f7fafc',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
color: '#4a5568'
|
||||
}}>
|
||||
<strong>{filteredMaterials.length.toLocaleString()}</strong>개의 자재가 검색되었습니다.
|
||||
{filters.project && ` (프로젝트: ${filters.project})`}
|
||||
{filters.category && ` (카테고리: ${filters.category})`}
|
||||
{filters.status && ` (상태: ${filters.status === 'categorized' ? '분류완료' : '미분류'})`}
|
||||
{filters.search && ` (검색: "${filters.search}")`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자재 목록 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
background: '#f7fafc',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: '0',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748'
|
||||
}}>
|
||||
자재 목록 ({filteredMaterials.length.toLocaleString()}개)
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#667eea',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
📊 분석 리포트
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#48bb78',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
📤 Excel 내보내기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MaterialList
|
||||
selectedProject={null} // 전체 자재 보기
|
||||
showProjectInfo={true}
|
||||
enableSelection={true}
|
||||
key="all-materials" // 전체 자재 모드
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialsManagementPage;
|
||||
@@ -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 (
|
||||
<div style={{ marginTop: '8px', fontSize: '12px', color: '#666' }}>
|
||||
절단손실: {item.cutting_loss || 0}mm |
|
||||
구매: {item.pipes_count || 0}본 |
|
||||
여유분: {item.waste_length || 0}mm
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||
분수 사이즈: {(item.size_fraction || item.size_spec || '').replace(/"/g, '')} |
|
||||
표면처리: {item.surface_treatment || '없음'}
|
||||
</div>
|
||||
|
||||
{/* 특수 용도 볼트 정보 */}
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
padding: '8px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', fontWeight: 'bold', color: '#0288d1' }}>
|
||||
특수 용도 볼트 현황:
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: '8px',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', color: psvCount > 0 ? '#d32f2f' : '#666' }}>
|
||||
PSV용: {psvCount}개
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: ltCount > 0 ? '#ed6c02' : '#666' }}>
|
||||
저온용: {ltCount}개
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: ckCount > 0 ? '#0288d1' : '#666' }}>
|
||||
체크밸브용: {ckCount}개
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: oriCount > 0 ? '#9c27b0' : '#666' }}>
|
||||
오리피스용: {oriCount}개
|
||||
</div>
|
||||
</div>
|
||||
{(psvCount + ltCount + ckCount + oriCount) === 0 && (
|
||||
<div style={{ fontSize: '12px', color: '#2e7d32', fontStyle: 'italic' }}>
|
||||
특수 용도 볼트 없음 (일반 볼트만 포함)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '200px'
|
||||
}}>
|
||||
<div>로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
marginRight: '16px',
|
||||
padding: '8px'
|
||||
}}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h1 style={{ margin: '0 0 8px 0', fontSize: '28px', fontWeight: 'bold' }}>
|
||||
🛒 구매 확정
|
||||
</h1>
|
||||
<h2 style={{ margin: 0, fontSize: '18px', color: '#1976d2' }}>
|
||||
Job: {jobNo} | {filename} | {revision}
|
||||
</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={exportToExcel}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
backgroundColor: '#2e7d32',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
📊 엑셀 내보내기
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDialog(true)}
|
||||
disabled={purchaseItems.length === 0}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
backgroundColor: purchaseItems.length === 0 ? '#ccc' : '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: purchaseItems.length === 0 ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
🛒 구매 주문 생성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리비전 비교 알림 */}
|
||||
{revisionComparison && (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
backgroundColor: revisionComparison.has_changes ? '#fff3e0' : '#e3f2fd',
|
||||
border: `1px solid ${revisionComparison.has_changes ? '#ed6c02' : '#0288d1'}`,
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span style={{ marginRight: '8px', fontSize: '20px' }}>🔄</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
|
||||
리비전 변경사항: {revisionComparison.summary}
|
||||
</div>
|
||||
{revisionComparison.additional_items && (
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
추가 구매 필요: {revisionComparison.additional_items}개 품목
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구매 품목 목록 */}
|
||||
{purchaseItems.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '48px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '8px'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📦</div>
|
||||
<div style={{ fontSize: '18px', color: '#666' }}>
|
||||
구매할 품목이 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{purchaseItems.map(item => (
|
||||
<div key={item.id} style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '4px 12px',
|
||||
backgroundColor: getCategoryColor(item.category),
|
||||
color: 'white',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
marginRight: '16px'
|
||||
}}>
|
||||
{item.category}
|
||||
</span>
|
||||
<h3 style={{ margin: 0, flex: 1, fontSize: '18px' }}>
|
||||
{item.specification}
|
||||
</h3>
|
||||
{item.is_additional && (
|
||||
<span style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#fff3e0',
|
||||
color: '#ed6c02',
|
||||
border: '1px solid #ed6c02',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
추가 구매
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '24px'
|
||||
}}>
|
||||
{/* BOM 수량 */}
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
BOM 필요량
|
||||
</div>
|
||||
<div style={{ fontSize: '20px', fontWeight: 'bold' }}>
|
||||
{item.bom_quantity} {item.unit}
|
||||
</div>
|
||||
{formatPipeInfo(item)}
|
||||
{formatBoltInfo(item)}
|
||||
</div>
|
||||
|
||||
{/* 구매 수량 */}
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
구매 수량
|
||||
</div>
|
||||
{editingItem === item.id ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="number"
|
||||
value={item.calculated_qty}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateItemQuantity(item.id, 'calculated_qty', item.calculated_qty)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#1976d2',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingItem(null)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#1976d2' }}>
|
||||
{item.calculated_qty} {item.unit}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditingItem(item.id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이미 구매한 수량 */}
|
||||
{previousRevision && (
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
기구매 수량
|
||||
</div>
|
||||
<div style={{ fontSize: '20px', fontWeight: 'bold' }}>
|
||||
{item.purchased_quantity || 0} {item.unit}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가 구매 필요량 */}
|
||||
{previousRevision && (
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
추가 구매 필요
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: item.additional_needed > 0 ? '#d32f2f' : '#2e7d32'
|
||||
}}>
|
||||
{Math.max(item.additional_needed || 0, 0)} {item.unit}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 여유율 및 최소 주문 정보 */}
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>여유율</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
|
||||
{((item.safety_factor || 1) - 1) * 100}%
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>최소 주문</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
|
||||
{item.min_order_qty || 0} {item.unit}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>예상 여유분</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
|
||||
{(item.calculated_qty - item.bom_quantity).toFixed(1)} {item.unit}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>활용률</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
|
||||
{((item.bom_quantity / item.calculated_qty) * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구매 주문 확인 다이얼로그 */}
|
||||
{confirmDialog && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
minWidth: '400px',
|
||||
maxWidth: '500px'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0' }}>구매 주문 생성 확인</h3>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
총 {purchaseItems.length}개 품목에 대한 구매 주문을 생성하시겠습니까?
|
||||
</div>
|
||||
|
||||
{revisionComparison && revisionComparison.has_changes && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
marginBottom: '16px',
|
||||
backgroundColor: '#fff3e0',
|
||||
border: '1px solid #ed6c02',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
⚠️ 리비전 변경으로 인한 추가 구매가 포함되어 있습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '24px' }}>
|
||||
구매 주문 생성 후에는 수량 변경이 제한됩니다.
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||
<button
|
||||
onClick={() => setConfirmDialog(false)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'white',
|
||||
color: '#666',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmPurchase}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
주문 생성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseConfirmationPage;
|
||||
@@ -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 (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||
<CircularProgress />
|
||||
<Typography variant="h6" sx={{ ml: 2 }}>
|
||||
리비전 비교 분석 중...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ maxWidth: 1200, mx: 'auto', mt: 4, px: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate(`/bom-status?job_no=${jobNo}`)}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
BOM 목록으로
|
||||
</Button>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const { newItems, increasedItems } = calculatePurchaseNeeds();
|
||||
const totalNewItems = newItems.length;
|
||||
const totalIncreasedItems = increasedItems.length;
|
||||
const totalPurchaseItems = totalNewItems + totalIncreasedItems;
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 1400, mx: 'auto', mt: 4, px: 2 }}>
|
||||
{/* 헤더 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate(`/bom-status?job_no=${jobNo}`)}
|
||||
>
|
||||
BOM 목록으로
|
||||
</Button>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Assessment />}
|
||||
onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${currentRevision}&prev_revision=${previousRevision}`)}
|
||||
>
|
||||
상세 비교 보기
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={<ShoppingCart />}
|
||||
disabled={totalPurchaseItems === 0}
|
||||
>
|
||||
구매 목록 생성
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h4" gutterBottom>
|
||||
🛒 리비전간 추가 구매 필요 자재
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
Job No: {jobNo} | {currentRevision} vs {previousRevision || '이전 리비전'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 리비전 선택 카드 */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
리비전 비교 설정
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>현재 리비전</InputLabel>
|
||||
<Select
|
||||
value={currentRevision}
|
||||
label="현재 리비전"
|
||||
onChange={(e) => handleRevisionChange('current', e.target.value)}
|
||||
>
|
||||
{availableRevisions.map((file) => (
|
||||
<MenuItem key={file.id} value={file.revision}>
|
||||
{file.revision} ({file.parsed_count || 0}개 자재)
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>이전 리비전</InputLabel>
|
||||
<Select
|
||||
value={previousRevision || ''}
|
||||
label="이전 리비전"
|
||||
onChange={(e) => handleRevisionChange('previous', e.target.value)}
|
||||
>
|
||||
<MenuItem value="">자동 선택 (직전 리비전)</MenuItem>
|
||||
{availableRevisions
|
||||
.filter(file => file.revision !== currentRevision)
|
||||
.map((file) => (
|
||||
<MenuItem key={file.id} value={file.revision}>
|
||||
{file.revision} ({file.parsed_count || 0}개 자재)
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 구매 요약 */}
|
||||
<Grid container spacing={3} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<AddIcon color="primary" sx={{ fontSize: 48, mb: 1 }} />
|
||||
<Typography variant="h4" color="primary">
|
||||
{totalNewItems}
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
신규 자재
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<TrendingUp color="warning" sx={{ fontSize: 48, mb: 1 }} />
|
||||
<Typography variant="h4" color="warning.main">
|
||||
{totalIncreasedItems}
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
수량 증가
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<ShoppingCart color="success" sx={{ fontSize: 48, mb: 1 }} />
|
||||
<Typography variant="h4" color="success.main">
|
||||
{totalPurchaseItems}
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
총 구매 항목
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* 탭으로 구분된 자재 목록 */}
|
||||
<Card>
|
||||
<Tabs
|
||||
value={selectedTab}
|
||||
onChange={(e, newValue) => setSelectedTab(newValue)}
|
||||
variant="fullWidth"
|
||||
>
|
||||
<Tab label={`신규 자재 (${totalNewItems})`} />
|
||||
<Tab label={`수량 증가 (${totalIncreasedItems})`} />
|
||||
</Tabs>
|
||||
|
||||
<CardContent>
|
||||
{selectedTab === 0 && (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom color="primary">
|
||||
🆕 신규 추가 자재
|
||||
</Typography>
|
||||
{newItems.length === 0 ? (
|
||||
<Alert severity="info">새로 추가된 자재가 없습니다.</Alert>
|
||||
) : (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>카테고리</TableCell>
|
||||
<TableCell>자재 설명</TableCell>
|
||||
<TableCell>사이즈</TableCell>
|
||||
<TableCell>재질</TableCell>
|
||||
<TableCell>수량</TableCell>
|
||||
<TableCell>단위</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{newItems.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={item.category}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{item.description}</TableCell>
|
||||
<TableCell>{item.size_spec || '-'}</TableCell>
|
||||
<TableCell>{item.material_grade || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight="bold" color="primary">
|
||||
+{item.quantity}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{item.unit || 'EA'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{selectedTab === 1 && (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom color="warning.main">
|
||||
📈 수량 증가 자재
|
||||
</Typography>
|
||||
{increasedItems.length === 0 ? (
|
||||
<Alert severity="info">수량이 증가한 자재가 없습니다.</Alert>
|
||||
) : (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>카테고리</TableCell>
|
||||
<TableCell>자재 설명</TableCell>
|
||||
<TableCell>사이즈</TableCell>
|
||||
<TableCell>재질</TableCell>
|
||||
<TableCell>이전 수량</TableCell>
|
||||
<TableCell>현재 수량</TableCell>
|
||||
<TableCell>증가량</TableCell>
|
||||
<TableCell>단위</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{increasedItems.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={item.category}
|
||||
size="small"
|
||||
color="warning"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{item.description}</TableCell>
|
||||
<TableCell>{item.size_spec || '-'}</TableCell>
|
||||
<TableCell>{item.material_grade || '-'}</TableCell>
|
||||
<TableCell>{item.previous_quantity}</TableCell>
|
||||
<TableCell>{item.current_quantity}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight="bold" color="warning.main">
|
||||
+{item.quantity_change}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{item.unit || 'EA'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{totalPurchaseItems === 0 && (
|
||||
<Alert severity="success" sx={{ mt: 3 }}>
|
||||
🎉 추가로 구매가 필요한 자재가 없습니다!
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevisionPurchasePage;
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
padding: '40px',
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '20px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
marginBottom: '40px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0',
|
||||
letterSpacing: '-0.025em'
|
||||
}}>
|
||||
BOM Revision Management
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#64748b',
|
||||
margin: 0,
|
||||
fontWeight: '400'
|
||||
}}>
|
||||
Project: {selectedProject?.job_name || 'No Project Selected'}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={() => onNavigate('bom-upload')}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
New Upload
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 정보 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '20px'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#1d4ed8', marginBottom: '4px' }}>
|
||||
{selectedProject?.official_project_code || selectedProject?.job_no || 'N/A'}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1d4ed8', fontWeight: '500' }}>
|
||||
Project Code
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#d97706', marginBottom: '4px' }}>
|
||||
{bomFiles.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#d97706', fontWeight: '500' }}>
|
||||
BOM Files
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#7c3aed', marginBottom: '4px' }}>
|
||||
{bomFiles.reduce((total, bom) => total + bom.revisions.length, 0)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#7c3aed', fontWeight: '500' }}>
|
||||
Total Revisions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 개발 예정 배너 */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '20px',
|
||||
padding: '60px 40px',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
textAlign: 'center',
|
||||
marginBottom: '40px'
|
||||
}}>
|
||||
<div style={{ fontSize: '64px', marginBottom: '24px' }}>🚧</div>
|
||||
<h2 style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 16px 0'
|
||||
}}>
|
||||
Advanced Revision Management
|
||||
</h2>
|
||||
<p style={{
|
||||
fontSize: '18px',
|
||||
color: '#64748b',
|
||||
margin: '0 0 32px 0',
|
||||
maxWidth: '600px',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
lineHeight: '1.6'
|
||||
}}>
|
||||
고급 리비전 관리 기능이 개발 중입니다. 업로드 기능 완료 후 본격적인 개발이 시작됩니다.
|
||||
</p>
|
||||
|
||||
{/* 예정 기능 미리보기 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: '24px',
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
padding: '24px',
|
||||
borderRadius: '16px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '32px', marginBottom: '12px' }}>📊</div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#92400e', margin: '0 0 8px 0' }}>
|
||||
Revision Timeline
|
||||
</h3>
|
||||
<p style={{ fontSize: '14px', color: '#92400e', margin: 0 }}>
|
||||
시각적 리비전 히스토리
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
|
||||
padding: '24px',
|
||||
borderRadius: '16px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '32px', marginBottom: '12px' }}>🔍</div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#1d4ed8', margin: '0 0 8px 0' }}>
|
||||
Diff Comparison
|
||||
</h3>
|
||||
<p style={{ fontSize: '14px', color: '#1d4ed8', margin: 0 }}>
|
||||
리비전 간 변경사항 비교
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)',
|
||||
padding: '24px',
|
||||
borderRadius: '16px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '32px', marginBottom: '12px' }}>⏪</div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#059669', margin: '0 0 8px 0' }}>
|
||||
Rollback System
|
||||
</h3>
|
||||
<p style={{ fontSize: '14px', color: '#059669', margin: 0 }}>
|
||||
이전 리비전으로 롤백
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 임시 BOM 파일 목록 (기본 구조) */}
|
||||
{bomFiles.length > 0 && (
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '20px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
Current BOM Files (Preview)
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
{bomFiles.map((bom) => (
|
||||
<div key={bom.id} style={{
|
||||
background: 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<h4 style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
{bom.bom_name}
|
||||
</h4>
|
||||
<div style={{ display: 'flex', gap: '16px', fontSize: '14px', color: '#6b7280' }}>
|
||||
<span>Latest: {bom.latest_revision}</span>
|
||||
<span>Revisions: {bom.revisions.length}</span>
|
||||
<span>Uploaded: {bom.upload_date}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => onNavigate('bom-management', { bomId: bom.id })}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Manage BOM
|
||||
</button>
|
||||
<button
|
||||
disabled
|
||||
style={{
|
||||
background: '#f3f4f6',
|
||||
color: '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
cursor: 'not-allowed',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
View History (Soon)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMRevisionPage;
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
padding: '40px',
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '20px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
marginBottom: '40px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
margin: '0 0 8px 0',
|
||||
letterSpacing: '-0.025em'
|
||||
}}>
|
||||
BOM File Upload
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#64748b',
|
||||
margin: 0,
|
||||
fontWeight: '400'
|
||||
}}>
|
||||
Project: {selectedProject?.job_name || 'No Project Selected'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 정보 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '20px'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#1d4ed8', marginBottom: '4px' }}>
|
||||
{selectedProject?.official_project_code || selectedProject?.job_no || 'N/A'}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1d4ed8', fontWeight: '500' }}>
|
||||
Project Code
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#059669', marginBottom: '4px' }}>
|
||||
{user?.username || 'Unknown'}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#059669', fontWeight: '500' }}>
|
||||
Uploaded by
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 업로드 영역 */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '20px',
|
||||
padding: '40px',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
marginBottom: '40px'
|
||||
}}>
|
||||
{/* BOM 이름 입력 */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
BOM Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bomName}
|
||||
onChange={(e) => setBomName(e.target.value)}
|
||||
placeholder="Enter BOM name..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
fontSize: '16px',
|
||||
transition: 'border-color 0.2s ease',
|
||||
outline: 'none'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = '#e5e7eb'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 파일 드롭 영역 */}
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
border: `3px dashed ${dragOver ? '#3b82f6' : '#d1d5db'}`,
|
||||
borderRadius: '16px',
|
||||
padding: '60px 40px',
|
||||
textAlign: 'center',
|
||||
background: dragOver ? '#eff6ff' : '#f9fafb',
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '24px'
|
||||
}}
|
||||
onClick={handleFileButtonClick}
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
|
||||
{dragOver ? '📁' : '📄'}
|
||||
</div>
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
margin: '0 0 8px 0'
|
||||
}}>
|
||||
{dragOver ? 'Drop files here' : 'Upload BOM Files'}
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#6b7280',
|
||||
margin: '0 0 16px 0'
|
||||
}}>
|
||||
Drag and drop your Excel or CSV files here, or click to browse
|
||||
</p>
|
||||
<div style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 16px',
|
||||
background: 'rgba(59, 130, 246, 0.1)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#3b82f6'
|
||||
}}>
|
||||
<span>📋</span>
|
||||
Supported: .xlsx, .xls, .csv (Max 50MB)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 숨겨진 파일 입력 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{/* 선택된 파일 목록 */}
|
||||
{selectedFiles.length > 0 && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
Selected Files ({selectedFiles.length})
|
||||
</h4>
|
||||
<div style={{
|
||||
background: '#f8fafc',
|
||||
borderRadius: '12px',
|
||||
padding: '16px'
|
||||
}}>
|
||||
{selectedFiles.map((file, index) => (
|
||||
<div key={index} style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
marginBottom: index < selectedFiles.length - 1 ? '8px' : '0',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontSize: '20px' }}>📄</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: '500', color: '#374151' }}>
|
||||
{file.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280' }}>
|
||||
{formatFileSize(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFile(index)}
|
||||
style={{
|
||||
background: '#fee2e2',
|
||||
color: '#dc2626',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드 진행률 */}
|
||||
{uploading && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
|
||||
Uploading...
|
||||
</span>
|
||||
<span style={{ fontSize: '14px', fontWeight: '500', color: '#3b82f6' }}>
|
||||
{uploadProgress}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '8px',
|
||||
background: '#e5e7eb',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${uploadProgress}%`,
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fee2e2',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '16px' }}>⚠️</span>
|
||||
<div style={{ fontSize: '14px', color: '#dc2626', whiteSpace: 'pre-line' }}>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 성공 메시지 */}
|
||||
{success && (
|
||||
<div style={{
|
||||
background: '#dcfce7',
|
||||
border: '1px solid #bbf7d0',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '16px' }}>✅</span>
|
||||
<div style={{ fontSize: '14px', color: '#059669' }}>
|
||||
{success}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드 버튼 */}
|
||||
<div style={{ display: 'flex', gap: '16px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
disabled={uploading}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: 'white',
|
||||
color: '#6b7280',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '12px',
|
||||
cursor: uploading ? 'not-allowed' : 'pointer',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
opacity: uploading ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading || selectedFiles.length === 0 || !bomName.trim()}
|
||||
style={{
|
||||
padding: '12px 32px',
|
||||
background: (uploading || selectedFiles.length === 0 || !bomName.trim())
|
||||
? '#d1d5db'
|
||||
: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
cursor: (uploading || selectedFiles.length === 0 || !bomName.trim())
|
||||
? 'not-allowed'
|
||||
: 'pointer',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload BOM'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 가이드 정보 */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: '20px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)'
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
📋 Upload Guidelines
|
||||
</h3>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
gap: '24px'
|
||||
}}>
|
||||
<div>
|
||||
<h4 style={{ fontSize: '16px', fontWeight: '600', color: '#059669', marginBottom: '8px' }}>
|
||||
✅ Supported Formats
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', color: '#6b7280' }}>
|
||||
<li>Excel files (.xlsx, .xls)</li>
|
||||
<li>CSV files (.csv)</li>
|
||||
<li>Maximum file size: 50MB</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ fontSize: '16px', fontWeight: '600', color: '#3b82f6', marginBottom: '8px' }}>
|
||||
📊 Required Columns
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', color: '#6b7280' }}>
|
||||
<li>Description (자재명/품명)</li>
|
||||
<li>Quantity (수량)</li>
|
||||
<li>Size information (사이즈)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ fontSize: '16px', fontWeight: '600', color: '#f59e0b', marginBottom: '8px' }}>
|
||||
⚡ Auto Processing
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', color: '#6b7280' }}>
|
||||
<li>Automatic material classification</li>
|
||||
<li>WELD GAP items excluded</li>
|
||||
<li>Ready for BOM management</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMUploadPage;
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
height: '100vh',
|
||||
background: '#f5f5f5',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
}}>
|
||||
{/* 사이드바 - 프로젝트 정보 */}
|
||||
<div style={{
|
||||
width: `${sidebarWidth}px`,
|
||||
background: '#ffffff',
|
||||
borderRight: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
background: '#fafafa'
|
||||
}}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
marginBottom: '8px'
|
||||
}}
|
||||
>
|
||||
← 대시보드로
|
||||
</button>
|
||||
<h2 style={{
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#333'
|
||||
}}>
|
||||
{project?.project_name}
|
||||
</h2>
|
||||
<p style={{
|
||||
margin: '4px 0 0 0',
|
||||
fontSize: '14px',
|
||||
color: '#666'
|
||||
}}>
|
||||
{project?.official_project_code || project?.job_no}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 통계 */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '8px' }}>
|
||||
프로젝트 현황
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '8px', background: '#f8f9fa', borderRadius: '4px' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '600', color: '#4299e1' }}>
|
||||
{files.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>BOM 파일</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '8px', background: '#f8f9fa', borderRadius: '4px' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '600', color: '#48bb78' }}>
|
||||
{files.reduce((sum, f) => sum + (f.parsed_count || 0), 0)}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>총 자재</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 업로드 영역 */}
|
||||
<div
|
||||
style={{
|
||||
margin: '16px',
|
||||
padding: '20px',
|
||||
border: dragOver ? '2px dashed #4299e1' : '2px dashed #ddd',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center',
|
||||
background: dragOver ? '#f0f9ff' : '#fafafa',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{uploading ? (
|
||||
<div style={{ color: '#4299e1' }}>
|
||||
📤 업로드 중...
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ fontSize: '24px', marginBottom: '8px' }}>📁</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Excel 파일을 드래그하거나<br />클릭하여 업로드
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 패널 - 파일 목록 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#ffffff'
|
||||
}}>
|
||||
{/* 툴바 */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
background: '#fafafa',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600' }}>
|
||||
BOM 파일 목록 ({Object.keys(groupedFiles).length})
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={loadFiles}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: loading ? '#a0aec0' : '#48bb78',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
{loading ? '🔄 로딩중...' : '🔄 새로고침'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#4299e1',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
+ 파일 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 파일 목록 */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#666' }}>
|
||||
로딩 중...
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#666' }}>
|
||||
업로드된 BOM 파일이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{Object.entries(groupedFiles).map(([bomName, bomFiles]) => {
|
||||
// 최신 리비전 파일을 대표로 선택
|
||||
const latestFile = bomFiles[0]; // 이미 최신순으로 정렬됨
|
||||
|
||||
return (
|
||||
<div
|
||||
key={latestFile.id}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
cursor: 'pointer',
|
||||
background: selectedFile?.id === latestFile.id ? '#f0f9ff' : 'transparent',
|
||||
transition: 'background-color 0.2s ease'
|
||||
}}
|
||||
onClick={() => 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';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{/* BOM 이름 (인라인 편집) */}
|
||||
{editingFile === latestFile.id && editingField === 'bom_name' ? (
|
||||
<input
|
||||
value={editValue}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
cursor: 'text'
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEdit(latestFile, 'bom_name');
|
||||
}}
|
||||
>
|
||||
{bomName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '2px' }}>
|
||||
📄 {bomFiles.length > 1 ? `${bomFiles.length}개 리비전` : latestFile.revision || 'Rev.0'} •
|
||||
{bomFiles.reduce((sum, f) => sum + (f.parsed_count || 0), 0)}개 자재 (최신: {latestFile.revision || 'Rev.0'})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '4px', marginLeft: '12px' }}>
|
||||
{bomFiles.length === 1 ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
viewMaterials(latestFile);
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: '#48bb78',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px'
|
||||
}}
|
||||
>
|
||||
📋 자재
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const selectedFileId = e.target.value;
|
||||
const selectedFile = bomFiles.find(f => f.id.toString() === selectedFileId);
|
||||
if (selectedFile) {
|
||||
viewMaterials(selectedFile);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: '#48bb78',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px'
|
||||
}}
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>📋 자재 선택</option>
|
||||
{bomFiles.map(file => (
|
||||
<option key={file.id} value={file.id} style={{ color: 'black' }}>
|
||||
{file.revision || 'Rev.0'} ({file.parsed_count || 0}개)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRevisionUpload(latestFile);
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: 'white',
|
||||
color: '#4299e1',
|
||||
border: '1px solid #4299e1',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px'
|
||||
}}
|
||||
>
|
||||
📝 리비전
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm(`${bomName} BOM을 삭제하시겠습니까? (모든 리비전이 삭제됩니다)`)) {
|
||||
// 모든 리비전 파일 삭제
|
||||
bomFiles.forEach(file => handleDelete(file.id));
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: '#f56565',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px'
|
||||
}}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 패널 - 상세 정보 */}
|
||||
{selectedFile && (
|
||||
<div style={{
|
||||
width: `${previewWidth}px`,
|
||||
background: '#ffffff',
|
||||
borderLeft: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
{/* 상세 정보 헤더 */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
background: '#fafafa'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: '600' }}>
|
||||
파일 상세 정보
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 내용 */}
|
||||
<div style={{ padding: '16px', flex: 1, overflow: 'auto' }}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
|
||||
BOM 이름
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '4px',
|
||||
cursor: 'text',
|
||||
background: '#fafafa'
|
||||
}}
|
||||
onClick={() => startEdit(selectedFile, 'bom_name')}
|
||||
>
|
||||
{selectedFile.bom_name || selectedFile.original_filename}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
|
||||
파일명
|
||||
</label>
|
||||
<div style={{ fontSize: '14px', color: '#333' }}>
|
||||
{selectedFile.original_filename}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
|
||||
리비전
|
||||
</label>
|
||||
<div style={{ fontSize: '14px', color: '#333' }}>
|
||||
{selectedFile.revision || 'Rev.0'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
|
||||
자재 수
|
||||
</label>
|
||||
<div style={{ fontSize: '14px', color: '#333' }}>
|
||||
{selectedFile.parsed_count || 0}개
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '12px', color: '#666', display: 'block', marginBottom: '4px' }}>
|
||||
업로드 일시
|
||||
</label>
|
||||
<div style={{ fontSize: '14px', color: '#333' }}>
|
||||
{new Date(selectedFile.created_at).toLocaleString('ko-KR')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<div style={{ marginTop: '24px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => viewMaterials(selectedFile)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
background: '#4299e1',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
📋 자재 목록 보기
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleRevisionUpload(selectedFile)}
|
||||
disabled={uploading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
background: 'white',
|
||||
color: '#4299e1',
|
||||
border: '1px solid #4299e1',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
📝 새 리비전 업로드
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(selectedFile.id)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
background: '#f56565',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
🗑️ 파일 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
background: '#fed7d7',
|
||||
color: '#c53030',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #fc8181',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
{error}
|
||||
<button
|
||||
onClick={() => setError('')}
|
||||
style={{
|
||||
marginLeft: '12px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#c53030',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMWorkspacePage;
|
||||
Reference in New Issue
Block a user