Compare commits
88 Commits
0d31d8b3fc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee99586a2f | ||
|
|
f16bc662ad | ||
|
|
afea8428b2 | ||
|
|
6ad1ef7aad | ||
|
|
a6868b129e | ||
|
|
17843e285f | ||
|
|
c258303bb7 | ||
|
|
edfe1bdf78 | ||
|
|
ab607dfa9a | ||
|
|
e0ad21bfad | ||
|
|
f336b5a4a8 | ||
|
|
6b6360ecd5 | ||
|
|
a27213e0e5 | ||
|
|
379af6b1e3 | ||
|
|
a5bfeec9aa | ||
|
|
c7297c6fb7 | ||
|
|
22baea38e1 | ||
|
|
64fd9ad3d2 | ||
|
|
5aef867110 | ||
|
|
ee13e92b61 | ||
|
|
5e995d1208 | ||
|
|
f1e1fb6475 | ||
|
|
c3ebb38669 | ||
|
|
b9442928da | ||
|
|
e799aae71b | ||
|
|
b10bd8d01c | ||
|
|
9725331af0 | ||
|
|
8f5330a008 | ||
|
|
72126ef78d | ||
|
|
5a21ef8f6c | ||
|
|
e27020ae9b | ||
|
|
e468663386 | ||
|
|
745ecaf3a3 | ||
|
|
5a3ee33e9b | ||
|
|
b1bfd1a4c0 | ||
|
|
39917be585 | ||
|
|
50eab5ac5f | ||
|
|
fb46902b85 | ||
|
|
e50f0887ad | ||
|
|
e14f8b69c7 | ||
|
|
dfb6c7e8a4 | ||
|
|
6d8bb468c3 | ||
|
|
003983872c | ||
|
|
a55e2e1c37 | ||
|
|
433d894175 | ||
|
|
aca00cf3cb | ||
|
|
ca0336d627 | ||
|
|
9325d36031 | ||
|
|
07ca79f376 | ||
|
|
e8fb531fdb | ||
|
|
e45bcf12c0 | ||
|
|
48b100d0d4 | ||
|
|
f1fe614977 | ||
|
|
e3d0c4d9a0 | ||
|
|
5a9274d42b | ||
|
|
1d2ab35d18 | ||
|
|
805d164124 | ||
|
|
f09e494bd4 | ||
|
|
a0d22508be | ||
|
|
fa032e95c6 | ||
|
|
0ed1047839 | ||
|
|
511f5c4f19 | ||
|
|
2ea7f2879f | ||
|
|
573f145f50 | ||
|
|
cde930c263 | ||
|
|
2e0d91cf59 | ||
|
|
50570e4624 | ||
|
|
0f9a5ad2ea | ||
|
|
af4ad25a54 | ||
|
|
04299542b5 | ||
|
|
fe3fd76112 | ||
|
|
389a4c2026 | ||
|
|
f674f3b350 | ||
|
|
529777aa14 | ||
|
|
881fc13580 | ||
|
|
83b90ef05c | ||
|
|
4f8e395f87 | ||
|
|
78d90c7a8f | ||
|
|
28a1302cae | ||
|
|
7dbb742981 | ||
|
|
a7e4c0158e | ||
|
|
f34eb0e210 | ||
|
|
48f8f634d1 | ||
|
|
fc925974bb | ||
|
|
501daf7360 | ||
|
|
4439c88d00 | ||
|
|
407d1cede6 | ||
|
|
9e5250a8f9 |
26
.env.example
Normal file
26
.env.example
Normal file
@@ -0,0 +1,26 @@
|
||||
# TK-MP-Project 환경 변수 설정 예시
|
||||
# 사용법: 이 파일을 .env로 복사한 후 필요한 값을 수정하세요
|
||||
# cp .env.example .env
|
||||
|
||||
# PostgreSQL 설정
|
||||
POSTGRES_DB=tk_mp_bom
|
||||
POSTGRES_USER=tkmp_user
|
||||
POSTGRES_PASSWORD=your_password_here
|
||||
POSTGRES_PORT=15432
|
||||
|
||||
# Redis 설정
|
||||
REDIS_PORT=16379
|
||||
|
||||
# 백엔드 설정
|
||||
BACKEND_PORT=18000
|
||||
ENVIRONMENT=development
|
||||
DEBUG=true
|
||||
|
||||
# 프론트엔드 설정
|
||||
FRONTEND_PORT=13000
|
||||
VITE_API_URL=http://localhost:18000
|
||||
|
||||
# pgAdmin 설정
|
||||
PGADMIN_EMAIL=admin@example.com
|
||||
PGADMIN_PASSWORD=admin_password_here
|
||||
PGADMIN_PORT=15050
|
||||
35
.gitea/workflows/sonarqube.yml
Normal file
35
.gitea/workflows/sonarqube.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
name: SonarQube Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master, develop ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
|
||||
jobs:
|
||||
sonarqube:
|
||||
name: SonarQube Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
run: |
|
||||
git clone http://172.17.0.2:3000/${{ gitea.repository }}.git .
|
||||
|
||||
- name: Test SonarQube connection
|
||||
run: |
|
||||
echo "Testing connection to SonarQube..."
|
||||
curl -f http://172.17.0.3:9000/api/system/ping || echo "Connection failed"
|
||||
|
||||
- name: Run SonarQube scan
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
run: |
|
||||
docker run \
|
||||
--rm \
|
||||
--network bridge \
|
||||
-e SONAR_HOST_URL="http://172.17.0.3:9000" \
|
||||
-e SONAR_SCANNER_OPTS="-Dsonar.projectKey=my-project" \
|
||||
-e SONAR_TOKEN="${SONAR_TOKEN}" \
|
||||
-v "$(pwd):/usr/src" \
|
||||
sonarsource/sonar-scanner-cli
|
||||
193
DOCKER-GUIDE.md
Normal file
193
DOCKER-GUIDE.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# TK-MP-Project Docker 가이드
|
||||
|
||||
## 🚀 빠른 시작
|
||||
|
||||
### 1. 개발 환경 실행
|
||||
```bash
|
||||
./docker-run.sh dev up
|
||||
```
|
||||
|
||||
### 2. 프로덕션 환경 실행
|
||||
```bash
|
||||
./docker-run.sh prod up
|
||||
```
|
||||
|
||||
### 3. 시놀로지 NAS 환경 실행
|
||||
```bash
|
||||
./docker-run.sh synology up
|
||||
```
|
||||
|
||||
## 📋 사용 가능한 명령어
|
||||
|
||||
| 명령어 | 설명 |
|
||||
|--------|------|
|
||||
| `up` | 컨테이너 시작 (기본값) |
|
||||
| `down` | 컨테이너 중지 |
|
||||
| `build` | 이미지 빌드 |
|
||||
| `rebuild` | 이미지 재빌드 (캐시 무시) |
|
||||
| `logs` | 로그 실시간 확인 |
|
||||
| `ps` 또는 `status` | 서비스 상태 확인 |
|
||||
| `restart` | 컨테이너 재시작 |
|
||||
|
||||
## 🌍 환경별 설정
|
||||
|
||||
### 개발 환경 (dev)
|
||||
- **포트**: 모든 서비스 외부 노출
|
||||
- Frontend: http://localhost:13000
|
||||
- Backend API: http://localhost:18000
|
||||
- PostgreSQL: localhost:5432
|
||||
- Redis: localhost:6379
|
||||
- pgAdmin: http://localhost:5050
|
||||
- **특징**:
|
||||
- 코드 실시간 반영 (Hot Reload)
|
||||
- 디버그 모드 활성화
|
||||
- 모든 로그 레벨 출력
|
||||
|
||||
### 프로덕션 환경 (prod)
|
||||
- **포트**: Nginx를 통한 리버스 프록시
|
||||
- Web: http://localhost (Nginx)
|
||||
- HTTPS: https://localhost (SSL 설정 필요)
|
||||
- **특징**:
|
||||
- 내부 서비스 포트 비노출
|
||||
- 최적화된 빌드
|
||||
- 로그 레벨 INFO
|
||||
- pgAdmin 비활성화
|
||||
|
||||
### 시놀로지 NAS 환경 (synology)
|
||||
- **포트**: 포트 충돌 방지를 위한 커스텀 포트
|
||||
- Frontend: http://localhost:10173
|
||||
- Backend API: http://localhost:10080
|
||||
- PostgreSQL: localhost:15432
|
||||
- Redis: localhost:16379
|
||||
- pgAdmin: http://localhost:15050
|
||||
- **특징**:
|
||||
- 명명된 볼륨 사용
|
||||
- 시놀로지 Container Manager 호환
|
||||
|
||||
## 🔧 환경 설정 파일
|
||||
|
||||
각 환경별 설정은 다음 파일에서 관리됩니다:
|
||||
|
||||
- `env.development` - 개발 환경 설정
|
||||
- `env.production` - 프로덕션 환경 설정
|
||||
- `env.synology` - 시놀로지 환경 설정
|
||||
|
||||
### 주요 환경 변수
|
||||
|
||||
```bash
|
||||
# 배포 환경
|
||||
DEPLOY_ENV=development|production|synology
|
||||
|
||||
# 포트 설정
|
||||
FRONTEND_EXTERNAL_PORT=13000
|
||||
BACKEND_EXTERNAL_PORT=18000
|
||||
POSTGRES_EXTERNAL_PORT=5432
|
||||
|
||||
# 데이터베이스 설정
|
||||
POSTGRES_DB=tk_mp_bom
|
||||
POSTGRES_USER=tkmp_user
|
||||
POSTGRES_PASSWORD=tkmp_password_2025
|
||||
|
||||
# 디버그 설정
|
||||
DEBUG=true|false
|
||||
LOG_LEVEL=DEBUG|INFO|WARNING|ERROR
|
||||
```
|
||||
|
||||
## 🛠️ 사용 예시
|
||||
|
||||
### 개발 시작
|
||||
```bash
|
||||
# 개발 환경 시작
|
||||
./docker-run.sh dev up
|
||||
|
||||
# 로그 확인
|
||||
./docker-run.sh dev logs
|
||||
|
||||
# 상태 확인
|
||||
./docker-run.sh dev ps
|
||||
```
|
||||
|
||||
### 프로덕션 배포
|
||||
```bash
|
||||
# 이미지 빌드
|
||||
./docker-run.sh prod build
|
||||
|
||||
# 프로덕션 시작
|
||||
./docker-run.sh prod up
|
||||
|
||||
# 상태 확인
|
||||
./docker-run.sh prod ps
|
||||
```
|
||||
|
||||
### 시놀로지 NAS 배포
|
||||
```bash
|
||||
# 시놀로지 환경 시작
|
||||
./docker-run.sh synology up
|
||||
|
||||
# 로그 확인
|
||||
./docker-run.sh synology logs
|
||||
```
|
||||
|
||||
### 컨테이너 관리
|
||||
```bash
|
||||
# 컨테이너 중지
|
||||
./docker-run.sh dev down
|
||||
|
||||
# 컨테이너 재시작
|
||||
./docker-run.sh dev restart
|
||||
|
||||
# 이미지 재빌드 (캐시 무시)
|
||||
./docker-run.sh dev rebuild
|
||||
```
|
||||
|
||||
## 🔍 트러블슈팅
|
||||
|
||||
### 포트 충돌 해결
|
||||
환경 설정 파일에서 `*_EXTERNAL_PORT` 변수를 수정하세요.
|
||||
|
||||
### 볼륨 권한 문제
|
||||
```bash
|
||||
# 볼륨 삭제 후 재생성
|
||||
docker volume prune
|
||||
./docker-run.sh dev up
|
||||
```
|
||||
|
||||
### 이미지 빌드 문제
|
||||
```bash
|
||||
# 캐시 없이 재빌드
|
||||
./docker-run.sh dev rebuild
|
||||
```
|
||||
|
||||
## 📁 파일 구조
|
||||
|
||||
```
|
||||
TK-MP-Project/
|
||||
├── docker-compose.yml # 통합 Docker Compose 파일
|
||||
├── docker-run.sh # 실행 스크립트
|
||||
├── env.development # 개발 환경 설정
|
||||
├── env.production # 프로덕션 환경 설정
|
||||
├── env.synology # 시놀로지 환경 설정
|
||||
├── docker-backup/ # 기존 파일 백업
|
||||
│ ├── docker-compose.yml
|
||||
│ ├── docker-compose.prod.yml
|
||||
│ ├── docker-compose.synology.yml
|
||||
│ └── docker-compose.override.yml
|
||||
└── DOCKER-GUIDE.md # 이 가이드 파일
|
||||
```
|
||||
|
||||
## 🎯 마이그레이션 가이드
|
||||
|
||||
기존 Docker Compose 파일을 사용하던 경우:
|
||||
|
||||
1. **기존 컨테이너 중지**
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
2. **새로운 방식으로 시작**
|
||||
```bash
|
||||
./docker-run.sh dev up
|
||||
```
|
||||
|
||||
3. **기존 파일은 `docker-backup/` 폴더에 보관됨**
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# TK-MP-Project Backend 개선/확장/운영 권장사항
|
||||
|
||||
## 1. 코드 구조/품질
|
||||
- ResponseModel(Pydantic) 적용: API 반환값의 타입 안정성 및 문서화 강화
|
||||
- 로깅/에러 처리: print → logging 모듈, 운영 환경에 맞는 에러/이벤트 기록
|
||||
- 환경변수/설정 분리: CORS, DB, 포트 등 환경별 관리 용이하게 분리
|
||||
- 라우터 자동 등록/동적 관리: 라우터가 많아질 경우 코드 중복 최소화
|
||||
|
||||
## 2. 보안/운영
|
||||
- CORS 제한: 운영 환경에서는 허용 origin을 제한
|
||||
- 업로드 파일 검증 강화: 경로, 파일명, 크기 등 보안 검증 추가
|
||||
|
||||
## 3. 성능/확장성
|
||||
- 대용량 파일/데이터 처리: 비동기/청크 처리, 인덱스 튜닝 등
|
||||
- DB 트랜잭션 명확화: 파일/자재 저장 등에서 트랜잭션 관리 강화
|
||||
|
||||
## 4. 테스트/CI
|
||||
- 자동화 테스트(assert 기반): print 위주 → assert 기반 자동화로 CI/CD 연동
|
||||
- 테스트 커버리지 확대: 다양한 예외/경계 케이스 추가
|
||||
|
||||
## 5. 기타
|
||||
- 코드/유틸 함수 분리: 중복 유틸 함수는 별도 모듈로 분리
|
||||
- 상태/활성화 관리 enum화: status 등은 enum으로 관리
|
||||
- 삭제/수정 API 추가: Job 등 주요 엔티티의 논리적 삭제/수정 지원
|
||||
|
||||
---
|
||||
|
||||
*2024-07-15 기준, backend 코드 리뷰 기반 개선/확장/운영 권장사항 정리*
|
||||
123
README.md
123
README.md
@@ -1,123 +0,0 @@
|
||||
아! 이해했습니다! 😅
|
||||
cat > README.md << 'EOF' 명령어에서 EOF까지의 모든 내용을 한 번에 입력하라는 뜻이에요.
|
||||
즉, 이 전체 부분을 한 번에 복사해서 터미널에 붙여넣기하면 됩니다:
|
||||
bashcat > README.md << 'EOF'
|
||||
# 🚀 TK-MP-Project: BOM 시스템 개발 프로젝트
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
BOM (Bill of Materials) 시스템의 기능 이상을 해결하고, 도면 완성 후 자재 관리의 모든 프로세스를 자동화하는 종합 시스템 개발 프로젝트입니다.
|
||||
|
||||
## 🎯 프로젝트 목표
|
||||
|
||||
### 핵심 미션
|
||||
**"도면 완성 후 자재 관리의 모든 번거로움을 해결"**
|
||||
|
||||
### 주요 해결 과제
|
||||
- 📄 **파일 분석 자동화**: 엑셀/CSV 자재 목록의 자동 분류 및 정제
|
||||
- 🔍 **정확한 분류 체계**: 파이프/피팅/볼트/밸브/계기류의 4단계 자동 분류
|
||||
- 💾 **체계적 데이터 관리**: 프로젝트별 버전 관리 및 이력 추적
|
||||
- 📊 **업무별 맞춤 출력**: 구매/생산/품질 각 팀의 필요에 맞는 자료 생성
|
||||
- 🔄 **리비전 변화 추적**: 도면 변경 시 자재 변경사항 자동 비교
|
||||
|
||||
## 💻 기술 스택
|
||||
|
||||
### Backend
|
||||
- **Language**: Python 3.9+
|
||||
- **Framework**: FastAPI (고성능 API 서버)
|
||||
- **Database**: PostgreSQL 15 (복잡한 관계형 데이터 처리)
|
||||
- **ORM**: SQLAlchemy (데이터베이스 모델링)
|
||||
- **Data Processing**: Pandas, openpyxl (파일 처리)
|
||||
|
||||
### DevOps & Tools
|
||||
- **Containerization**: Docker & Docker Compose
|
||||
- **Version Control**: Git (Gitea 호스팅)
|
||||
- **Development**: VS Code + Python 확장
|
||||
|
||||
## 🌐 개발 환경 설정
|
||||
|
||||
### Git 저장소 접속
|
||||
```bash
|
||||
# VPN 연결 필요: vpn.hyungi.net:21194
|
||||
git clone http://192.168.1.227:10300/hyungi/TK-MP-Project.git
|
||||
cd TK-MP-Project
|
||||
데이터베이스 실행
|
||||
bash# PostgreSQL 및 pgAdmin 실행
|
||||
docker-compose up -d postgres pgadmin redis
|
||||
|
||||
# 접속 확인
|
||||
# pgAdmin: http://localhost:5050 (admin@tkmp.local / admin2025)
|
||||
Python 개발 환경
|
||||
bash# 가상환경 생성
|
||||
python -m venv venv
|
||||
source venv/bin/activate # macOS/Linux
|
||||
|
||||
# 의존성 설치
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
# 개발 서버 실행
|
||||
cd backend
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
📁 프로젝트 구조
|
||||
TK-MP-Project/
|
||||
├── README.md
|
||||
├── docker-compose.yml
|
||||
├── backend/ # FastAPI 백엔드
|
||||
│ ├── app/
|
||||
│ │ ├── models/ # SQLAlchemy 모델
|
||||
│ │ ├── schemas/ # Pydantic 스키마
|
||||
│ │ ├── api/ # API 라우터
|
||||
│ │ ├── core/ # 설정 및 유틸리티
|
||||
│ │ ├── services/ # 비즈니스 로직
|
||||
│ │ └── database/ # DB 연결 설정
|
||||
│ └── requirements.txt
|
||||
├── database/ # DB 스키마 및 초기 데이터
|
||||
│ └── init/
|
||||
│ └── 01_schema.sql
|
||||
└── docs/ # 프로젝트 문서
|
||||
🚀 개발 로드맵
|
||||
Phase 1: 기반 시스템 구축 (진행중)
|
||||
|
||||
Git 환경 구축 ✅
|
||||
데이터베이스 스키마 설계 ✅
|
||||
Docker 개발 환경 설정 ✅
|
||||
FastAPI 기본 구조 구현
|
||||
파일 업로드 및 파싱 기능
|
||||
|
||||
Phase 2: 핵심 기능 개발
|
||||
|
||||
자재 분류 알고리즘 구현
|
||||
웹 인터페이스 구축
|
||||
구매 BOM 생성 기능
|
||||
|
||||
Phase 3: 고도화
|
||||
|
||||
리비전 비교 기능
|
||||
파이프 cutting 자료 생성
|
||||
사용자 테스트 및 최적화
|
||||
|
||||
🗄️ 데이터베이스 스키마
|
||||
핵심 테이블
|
||||
|
||||
projects: 프로젝트 관리 (코드 매칭, 버전 관리)
|
||||
files: 업로드된 자재 목록 파일들
|
||||
materials: 개별 자재 상세 정보 (분류 결과 포함)
|
||||
|
||||
주요 기능
|
||||
|
||||
프로젝트별 파일 버전 관리 (Rev.0, Rev.1, Rev.2)
|
||||
자재 자동 분류 시스템 (카테고리, 재질, 사이즈)
|
||||
분류 신뢰도 및 사용자 검증 시스템
|
||||
|
||||
📞 개발팀
|
||||
|
||||
Lead Developer: hyungi
|
||||
Gitea Repository: http://192.168.1.227:10300/hyungi/TK-MP-Project
|
||||
|
||||
🎯 다음 단계
|
||||
|
||||
데이터베이스 실행: docker-compose up -d postgres pgadmin
|
||||
Python 환경 구축: 가상환경 생성 및 패키지 설치
|
||||
FastAPI 구조 구현: 기본 API 서버 및 모델 생성
|
||||
|
||||
|
||||
Last Updated: 2025.07.14
|
||||
33
REVIEW.md
33
REVIEW.md
@@ -1,33 +0,0 @@
|
||||
# TK-MP-Project Backend 코드 리뷰 요약
|
||||
|
||||
## 1. 전체 구조
|
||||
- FastAPI + SQLAlchemy 기반 백엔드
|
||||
- models, schemas, routers, services, api, uploads 등 역할별 디렉토리 분리
|
||||
- 자재/BOM/스풀/계장 등 플랜트/조선/기계 실무에 특화된 구조
|
||||
|
||||
## 2. 주요 코드 검토
|
||||
- **main.py**: 앱 진입점, CORS, 라우터 등록, 헬스체크 등
|
||||
- **routers/**: 파일, 작업(Job) 등 API 엔드포인트 구현
|
||||
- **services/**: 품목별 분류기(볼트, 밸브, 플랜지, 피팅, 가스켓, 파이프, 계장 등), 스풀 관리, 테스트 코드
|
||||
- **material_classifier.py**: 재질 분류 공통 모듈, 규격/패턴/키워드 기반 robust 분류
|
||||
- **spool_manager.py/v2**: 도면-에어리어-스풀 넘버링, 유효성 검증, 자동 추천 등
|
||||
- **api/**: 과거 버전/백업/보조 코드(실제 서비스는 routers/가 메인)
|
||||
- **테스트 코드**: 다양한 실무 케이스를 print 기반으로 커버(자동화는 미흡)
|
||||
- **materials_schema.py**: 분류기에서 사용하는 규격/패턴/키워드/등급 등 데이터 정의
|
||||
|
||||
## 3. 품목별 분류기 구조
|
||||
- 볼트/밸브/플랜지/피팅/가스켓/파이프/계장 등 각 품목별로 dict 기반 패턴/키워드/규격 관리
|
||||
- material_classifier와 연동, 신뢰도/구매정보 등 실무적 정보 제공
|
||||
- 구조/로직은 유사하나, 각 품목별 실무 특성에 맞는 분류 포인트 반영
|
||||
|
||||
## 4. 테스트 코드
|
||||
- 다양한 실무 케이스를 print 기반으로 커버
|
||||
- 자동화(assert) 기반 테스트는 미흡(추후 개선 필요)
|
||||
|
||||
## 5. materials_schema.py
|
||||
- 분류기에서 사용하는 규격/패턴/키워드/등급 등 실무적 데이터가 체계적으로 구조화
|
||||
- 신규 규격/등급/패턴 추가/수정이 용이
|
||||
|
||||
---
|
||||
|
||||
*2024-07-15 기준, 전체 backend 코드 리뷰 및 구조 요약*
|
||||
25
backend/.dockerignore
Normal file
25
backend/.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
env
|
||||
venv
|
||||
.venv
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
.tox
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.log
|
||||
.git
|
||||
.mypy_cache
|
||||
.pytest_cache
|
||||
.hypothesis
|
||||
.DS_Store
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
31
backend/Dockerfile
Normal file
31
backend/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
# Python 3.9 베이스 이미지 사용
|
||||
FROM python:3.9-slim
|
||||
|
||||
# 작업 디렉토리 설정
|
||||
WORKDIR /app
|
||||
|
||||
# 시스템 패키지 업데이트 및 필요한 패키지 설치
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
g++ \
|
||||
libpq-dev \
|
||||
libmagic1 \
|
||||
libmagic-dev \
|
||||
netcat-openbsd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# requirements.txt 복사 및 의존성 설치
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 애플리케이션 코드 복사
|
||||
COPY . .
|
||||
|
||||
# 포트 8000 노출
|
||||
EXPOSE 8000
|
||||
|
||||
# 환경변수 설정
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
# 서버 실행
|
||||
CMD ["bash", "start.sh"]
|
||||
116
backend/alembic.ini
Normal file
116
backend/alembic.ini
Normal file
@@ -0,0 +1,116 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
1
backend/alembic/README
Normal file
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
116
backend/alembic/env.py
Normal file
116
backend/alembic/env.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Backend root directory adding to path to allow imports
|
||||
backend_path = Path(__file__).parent.parent
|
||||
sys.path.append(str(backend_path))
|
||||
|
||||
from app.models import Base
|
||||
from app.config import get_settings
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# Update config with app settings
|
||||
# Update config with app settings
|
||||
settings = get_settings()
|
||||
|
||||
# 우선적으로 환경변수에서 DB URL을 확인하여 설정 (로컬 마이그레이션용)
|
||||
env_db_url = os.getenv("DATABASE_URL")
|
||||
if env_db_url:
|
||||
config.set_main_option("sqlalchemy.url", env_db_url)
|
||||
else:
|
||||
config.set_main_option("sqlalchemy.url", settings.get_database_url())
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
# Debug: Check what URL is actually being used
|
||||
# 우선적으로 환경변수에서 DB URL을 확인하여 설정 (로컬 마이그레이션용)
|
||||
env_db_url = os.getenv("DATABASE_URL")
|
||||
if env_db_url:
|
||||
print(f"DEBUG: Using DATABASE_URL from environment: {env_db_url}")
|
||||
url = env_db_url
|
||||
else:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
print(f"DEBUG: Using default configuration URL: {url}")
|
||||
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
# Debug: Check what URL is actually being used
|
||||
env_db_url = os.getenv("DATABASE_URL")
|
||||
if env_db_url:
|
||||
print(f"DEBUG: Using DATABASE_URL from environment: {env_db_url}")
|
||||
config.set_main_option("sqlalchemy.url", env_db_url)
|
||||
else:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
print(f"DEBUG: Using default configuration URL: {url}")
|
||||
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
872
backend/alembic/versions/8905071fdd15_initial_baseline.py
Normal file
872
backend/alembic/versions/8905071fdd15_initial_baseline.py
Normal file
@@ -0,0 +1,872 @@
|
||||
"""Initial baseline
|
||||
|
||||
Revision ID: 8905071fdd15
|
||||
Revises:
|
||||
Create Date: 2026-01-09 09:29:05.123731
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '8905071fdd15'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('role_permissions')
|
||||
op.drop_table('user_activity_logs')
|
||||
op.drop_index('idx_support_details_file_id', table_name='support_details')
|
||||
op.drop_index('idx_support_details_material_id', table_name='support_details')
|
||||
op.drop_table('support_details')
|
||||
op.drop_table('material_purchase_tracking')
|
||||
op.drop_index('idx_purchase_confirmations_job_revision', table_name='purchase_confirmations')
|
||||
op.drop_table('purchase_confirmations')
|
||||
op.drop_table('valve_details')
|
||||
op.drop_table('purchase_items')
|
||||
op.drop_index('idx_revision_sessions_job_no', table_name='revision_sessions')
|
||||
op.drop_index('idx_revision_sessions_status', table_name='revision_sessions')
|
||||
op.drop_table('revision_sessions')
|
||||
op.drop_table('instrument_details')
|
||||
op.drop_table('fitting_details')
|
||||
op.drop_table('flange_details')
|
||||
op.drop_index('idx_special_material_details_file_id', table_name='special_material_details')
|
||||
op.drop_index('idx_special_material_details_material_id', table_name='special_material_details')
|
||||
op.drop_table('special_material_details')
|
||||
op.drop_table('pipe_end_preparations')
|
||||
op.drop_table('user_sessions')
|
||||
op.drop_table('login_logs')
|
||||
op.drop_table('material_revisions_comparison')
|
||||
op.drop_index('idx_purchase_request_items_category', table_name='purchase_request_items')
|
||||
op.drop_index('idx_purchase_request_items_material_id', table_name='purchase_request_items')
|
||||
op.drop_index('idx_purchase_request_items_request_id', table_name='purchase_request_items')
|
||||
op.drop_table('purchase_request_items')
|
||||
op.drop_table('material_comparison_details')
|
||||
op.drop_index('idx_confirmed_purchase_items_category', table_name='confirmed_purchase_items')
|
||||
op.drop_index('idx_confirmed_purchase_items_confirmation', table_name='confirmed_purchase_items')
|
||||
op.drop_table('confirmed_purchase_items')
|
||||
op.drop_table('material_purchase_mapping')
|
||||
op.drop_table('bolt_details')
|
||||
op.drop_table('permissions')
|
||||
op.drop_index('idx_purchase_requests_job_no', table_name='purchase_requests')
|
||||
op.drop_index('idx_purchase_requests_requested_by', table_name='purchase_requests')
|
||||
op.drop_index('idx_purchase_requests_status', table_name='purchase_requests')
|
||||
op.drop_table('purchase_requests')
|
||||
op.drop_table('gasket_details')
|
||||
op.drop_index('idx_revision_changes_action', table_name='revision_material_changes')
|
||||
op.drop_index('idx_revision_changes_session', table_name='revision_material_changes')
|
||||
op.drop_index('idx_revision_changes_status', table_name='revision_material_changes')
|
||||
op.drop_table('revision_material_changes')
|
||||
op.drop_index('idx_revision_logs_date', table_name='revision_action_logs')
|
||||
op.drop_index('idx_revision_logs_session', table_name='revision_action_logs')
|
||||
op.drop_index('idx_revision_logs_type', table_name='revision_action_logs')
|
||||
op.drop_table('revision_action_logs')
|
||||
op.drop_index('idx_inventory_transfers_date', table_name='inventory_transfers')
|
||||
op.drop_index('idx_inventory_transfers_material', table_name='inventory_transfers')
|
||||
op.drop_table('inventory_transfers')
|
||||
op.drop_table('users')
|
||||
op.drop_table('jobs')
|
||||
op.drop_index('idx_files_active', table_name='files')
|
||||
op.drop_index('idx_files_project', table_name='files')
|
||||
op.drop_index('idx_files_purchase_confirmed', table_name='files')
|
||||
op.drop_index('idx_files_uploaded_by', table_name='files')
|
||||
op.create_index(op.f('ix_files_id'), 'files', ['id'], unique=False)
|
||||
op.drop_constraint('files_project_id_fkey', 'files', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'files', 'projects', ['project_id'], ['id'])
|
||||
op.drop_column('files', 'description')
|
||||
op.drop_column('files', 'classification_completed')
|
||||
op.drop_column('files', 'purchase_confirmed')
|
||||
op.drop_column('files', 'bom_name')
|
||||
op.drop_column('files', 'confirmed_at')
|
||||
op.drop_column('files', 'confirmed_by')
|
||||
op.drop_column('files', 'job_no')
|
||||
op.drop_column('files', 'parsed_count')
|
||||
op.create_index(op.f('ix_material_categories_id'), 'material_categories', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_material_grades_id'), 'material_grades', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_material_patterns_id'), 'material_patterns', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_material_specifications_id'), 'material_specifications', ['id'], unique=False)
|
||||
op.drop_constraint('material_standards_standard_code_key', 'material_standards', type_='unique')
|
||||
op.create_index(op.f('ix_material_standards_id'), 'material_standards', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_material_standards_standard_code'), 'material_standards', ['standard_code'], unique=True)
|
||||
op.create_index(op.f('ix_material_tubing_mapping_id'), 'material_tubing_mapping', ['id'], unique=False)
|
||||
op.alter_column('materials', 'verified_by',
|
||||
existing_type=sa.VARCHAR(length=100),
|
||||
type_=sa.String(length=50),
|
||||
existing_nullable=True)
|
||||
op.alter_column('materials', 'material_hash',
|
||||
existing_type=sa.VARCHAR(length=64),
|
||||
type_=sa.String(length=100),
|
||||
existing_nullable=True)
|
||||
op.alter_column('materials', 'full_material_grade',
|
||||
existing_type=sa.TEXT(),
|
||||
type_=sa.String(length=100),
|
||||
existing_nullable=True)
|
||||
op.drop_index('idx_materials_category', table_name='materials')
|
||||
op.drop_index('idx_materials_classification_details', table_name='materials', postgresql_using='gin')
|
||||
op.drop_index('idx_materials_file', table_name='materials')
|
||||
op.drop_index('idx_materials_material_size', table_name='materials')
|
||||
op.create_index(op.f('ix_materials_id'), 'materials', ['id'], unique=False)
|
||||
op.drop_constraint('materials_file_id_fkey', 'materials', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'materials', 'files', ['file_id'], ['id'])
|
||||
op.drop_column('materials', 'classification_details')
|
||||
op.add_column('pipe_details', sa.Column('material_standard', sa.String(length=50), nullable=True))
|
||||
op.add_column('pipe_details', sa.Column('material_grade', sa.String(length=50), nullable=True))
|
||||
op.add_column('pipe_details', sa.Column('material_type', sa.String(length=50), nullable=True))
|
||||
op.add_column('pipe_details', sa.Column('wall_thickness', sa.String(length=50), nullable=True))
|
||||
op.add_column('pipe_details', sa.Column('nominal_size', sa.String(length=50), nullable=True))
|
||||
op.add_column('pipe_details', sa.Column('material_confidence', sa.Numeric(precision=3, scale=2), nullable=True))
|
||||
op.add_column('pipe_details', sa.Column('manufacturing_confidence', sa.Numeric(precision=3, scale=2), nullable=True))
|
||||
op.add_column('pipe_details', sa.Column('end_prep_confidence', sa.Numeric(precision=3, scale=2), nullable=True))
|
||||
op.add_column('pipe_details', sa.Column('schedule_confidence', sa.Numeric(precision=3, scale=2), nullable=True))
|
||||
op.alter_column('pipe_details', 'file_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False)
|
||||
op.create_index(op.f('ix_pipe_details_id'), 'pipe_details', ['id'], unique=False)
|
||||
op.drop_constraint('pipe_details_material_id_fkey', 'pipe_details', type_='foreignkey')
|
||||
op.drop_constraint('pipe_details_file_id_fkey', 'pipe_details', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'pipe_details', 'files', ['file_id'], ['id'])
|
||||
op.drop_column('pipe_details', 'outer_diameter')
|
||||
op.drop_column('pipe_details', 'additional_info')
|
||||
op.drop_column('pipe_details', 'classification_confidence')
|
||||
op.drop_column('pipe_details', 'material_id')
|
||||
op.drop_column('pipe_details', 'material_spec')
|
||||
op.drop_index('idx_projects_design_code', table_name='projects')
|
||||
op.drop_index('idx_projects_official_code', table_name='projects')
|
||||
op.drop_constraint('projects_official_project_code_key', 'projects', type_='unique')
|
||||
op.create_index(op.f('ix_projects_id'), 'projects', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_projects_official_project_code'), 'projects', ['official_project_code'], unique=True)
|
||||
op.create_index(op.f('ix_requirement_types_id'), 'requirement_types', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_special_material_grades_id'), 'special_material_grades', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_special_material_patterns_id'), 'special_material_patterns', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_special_materials_id'), 'special_materials', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tubing_categories_id'), 'tubing_categories', ['id'], unique=False)
|
||||
op.alter_column('tubing_manufacturers', 'contact_info',
|
||||
existing_type=postgresql.JSONB(astext_type=sa.Text()),
|
||||
type_=sa.JSON(),
|
||||
existing_nullable=True)
|
||||
op.alter_column('tubing_manufacturers', 'quality_certs',
|
||||
existing_type=postgresql.JSONB(astext_type=sa.Text()),
|
||||
type_=sa.JSON(),
|
||||
existing_nullable=True)
|
||||
op.create_index(op.f('ix_tubing_manufacturers_id'), 'tubing_manufacturers', ['id'], unique=False)
|
||||
op.alter_column('tubing_products', 'last_price_update',
|
||||
existing_type=sa.DATE(),
|
||||
type_=sa.DateTime(),
|
||||
existing_nullable=True)
|
||||
op.drop_constraint('tubing_products_specification_id_manufacturer_id_manufactur_key', 'tubing_products', type_='unique')
|
||||
op.create_index(op.f('ix_tubing_products_id'), 'tubing_products', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tubing_specifications_id'), 'tubing_specifications', ['id'], unique=False)
|
||||
op.alter_column('user_requirements', 'due_date',
|
||||
existing_type=sa.DATE(),
|
||||
type_=sa.DateTime(),
|
||||
existing_nullable=True)
|
||||
op.create_index(op.f('ix_user_requirements_id'), 'user_requirements', ['id'], unique=False)
|
||||
op.drop_constraint('user_requirements_material_id_fkey', 'user_requirements', type_='foreignkey')
|
||||
op.drop_constraint('user_requirements_file_id_fkey', 'user_requirements', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'user_requirements', 'materials', ['material_id'], ['id'])
|
||||
op.create_foreign_key(None, 'user_requirements', 'files', ['file_id'], ['id'])
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'user_requirements', type_='foreignkey')
|
||||
op.drop_constraint(None, 'user_requirements', type_='foreignkey')
|
||||
op.create_foreign_key('user_requirements_file_id_fkey', 'user_requirements', 'files', ['file_id'], ['id'], ondelete='CASCADE')
|
||||
op.create_foreign_key('user_requirements_material_id_fkey', 'user_requirements', 'materials', ['material_id'], ['id'], ondelete='CASCADE')
|
||||
op.drop_index(op.f('ix_user_requirements_id'), table_name='user_requirements')
|
||||
op.alter_column('user_requirements', 'due_date',
|
||||
existing_type=sa.DateTime(),
|
||||
type_=sa.DATE(),
|
||||
existing_nullable=True)
|
||||
op.drop_index(op.f('ix_tubing_specifications_id'), table_name='tubing_specifications')
|
||||
op.drop_index(op.f('ix_tubing_products_id'), table_name='tubing_products')
|
||||
op.create_unique_constraint('tubing_products_specification_id_manufacturer_id_manufactur_key', 'tubing_products', ['specification_id', 'manufacturer_id', 'manufacturer_part_number'])
|
||||
op.alter_column('tubing_products', 'last_price_update',
|
||||
existing_type=sa.DateTime(),
|
||||
type_=sa.DATE(),
|
||||
existing_nullable=True)
|
||||
op.drop_index(op.f('ix_tubing_manufacturers_id'), table_name='tubing_manufacturers')
|
||||
op.alter_column('tubing_manufacturers', 'quality_certs',
|
||||
existing_type=sa.JSON(),
|
||||
type_=postgresql.JSONB(astext_type=sa.Text()),
|
||||
existing_nullable=True)
|
||||
op.alter_column('tubing_manufacturers', 'contact_info',
|
||||
existing_type=sa.JSON(),
|
||||
type_=postgresql.JSONB(astext_type=sa.Text()),
|
||||
existing_nullable=True)
|
||||
op.drop_index(op.f('ix_tubing_categories_id'), table_name='tubing_categories')
|
||||
op.drop_index(op.f('ix_special_materials_id'), table_name='special_materials')
|
||||
op.drop_index(op.f('ix_special_material_patterns_id'), table_name='special_material_patterns')
|
||||
op.drop_index(op.f('ix_special_material_grades_id'), table_name='special_material_grades')
|
||||
op.drop_index(op.f('ix_requirement_types_id'), table_name='requirement_types')
|
||||
op.drop_index(op.f('ix_projects_official_project_code'), table_name='projects')
|
||||
op.drop_index(op.f('ix_projects_id'), table_name='projects')
|
||||
op.create_unique_constraint('projects_official_project_code_key', 'projects', ['official_project_code'])
|
||||
op.create_index('idx_projects_official_code', 'projects', ['official_project_code'], unique=False)
|
||||
op.create_index('idx_projects_design_code', 'projects', ['design_project_code'], unique=False)
|
||||
op.add_column('pipe_details', sa.Column('material_spec', sa.VARCHAR(length=100), autoincrement=False, nullable=True))
|
||||
op.add_column('pipe_details', sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True))
|
||||
op.add_column('pipe_details', sa.Column('classification_confidence', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True))
|
||||
op.add_column('pipe_details', sa.Column('additional_info', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True))
|
||||
op.add_column('pipe_details', sa.Column('outer_diameter', sa.VARCHAR(length=50), autoincrement=False, nullable=True))
|
||||
op.drop_constraint(None, 'pipe_details', type_='foreignkey')
|
||||
op.create_foreign_key('pipe_details_file_id_fkey', 'pipe_details', 'files', ['file_id'], ['id'], ondelete='CASCADE')
|
||||
op.create_foreign_key('pipe_details_material_id_fkey', 'pipe_details', 'materials', ['material_id'], ['id'], ondelete='CASCADE')
|
||||
op.drop_index(op.f('ix_pipe_details_id'), table_name='pipe_details')
|
||||
op.alter_column('pipe_details', 'file_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True)
|
||||
op.drop_column('pipe_details', 'schedule_confidence')
|
||||
op.drop_column('pipe_details', 'end_prep_confidence')
|
||||
op.drop_column('pipe_details', 'manufacturing_confidence')
|
||||
op.drop_column('pipe_details', 'material_confidence')
|
||||
op.drop_column('pipe_details', 'nominal_size')
|
||||
op.drop_column('pipe_details', 'wall_thickness')
|
||||
op.drop_column('pipe_details', 'material_type')
|
||||
op.drop_column('pipe_details', 'material_grade')
|
||||
op.drop_column('pipe_details', 'material_standard')
|
||||
op.add_column('materials', sa.Column('classification_details', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True))
|
||||
op.drop_constraint(None, 'materials', type_='foreignkey')
|
||||
op.create_foreign_key('materials_file_id_fkey', 'materials', 'files', ['file_id'], ['id'], ondelete='CASCADE')
|
||||
op.drop_index(op.f('ix_materials_id'), table_name='materials')
|
||||
op.create_index('idx_materials_material_size', 'materials', ['material_grade', 'size_spec'], unique=False)
|
||||
op.create_index('idx_materials_file', 'materials', ['file_id'], unique=False)
|
||||
op.create_index('idx_materials_classification_details', 'materials', ['classification_details'], unique=False, postgresql_using='gin')
|
||||
op.create_index('idx_materials_category', 'materials', ['classified_category', 'classified_subcategory'], unique=False)
|
||||
op.alter_column('materials', 'full_material_grade',
|
||||
existing_type=sa.String(length=100),
|
||||
type_=sa.TEXT(),
|
||||
existing_nullable=True)
|
||||
op.alter_column('materials', 'material_hash',
|
||||
existing_type=sa.String(length=100),
|
||||
type_=sa.VARCHAR(length=64),
|
||||
existing_nullable=True)
|
||||
op.alter_column('materials', 'verified_by',
|
||||
existing_type=sa.String(length=50),
|
||||
type_=sa.VARCHAR(length=100),
|
||||
existing_nullable=True)
|
||||
op.drop_index(op.f('ix_material_tubing_mapping_id'), table_name='material_tubing_mapping')
|
||||
op.drop_index(op.f('ix_material_standards_standard_code'), table_name='material_standards')
|
||||
op.drop_index(op.f('ix_material_standards_id'), table_name='material_standards')
|
||||
op.create_unique_constraint('material_standards_standard_code_key', 'material_standards', ['standard_code'])
|
||||
op.drop_index(op.f('ix_material_specifications_id'), table_name='material_specifications')
|
||||
op.drop_index(op.f('ix_material_patterns_id'), table_name='material_patterns')
|
||||
op.drop_index(op.f('ix_material_grades_id'), table_name='material_grades')
|
||||
op.drop_index(op.f('ix_material_categories_id'), table_name='material_categories')
|
||||
op.add_column('files', sa.Column('parsed_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True))
|
||||
op.add_column('files', sa.Column('job_no', sa.VARCHAR(length=50), autoincrement=False, nullable=True))
|
||||
op.add_column('files', sa.Column('confirmed_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True, comment='구매 수량 확정자'))
|
||||
op.add_column('files', sa.Column('confirmed_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True, comment='구매 수량 확정 시간'))
|
||||
op.add_column('files', sa.Column('bom_name', sa.VARCHAR(length=255), autoincrement=False, nullable=True))
|
||||
op.add_column('files', sa.Column('purchase_confirmed', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True, comment='구매 수량 확정 여부'))
|
||||
op.add_column('files', sa.Column('classification_completed', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True))
|
||||
op.add_column('files', sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True))
|
||||
op.drop_constraint(None, 'files', type_='foreignkey')
|
||||
op.create_foreign_key('files_project_id_fkey', 'files', 'projects', ['project_id'], ['id'], ondelete='CASCADE')
|
||||
op.drop_index(op.f('ix_files_id'), table_name='files')
|
||||
op.create_index('idx_files_uploaded_by', 'files', ['uploaded_by'], unique=False)
|
||||
op.create_index('idx_files_purchase_confirmed', 'files', ['purchase_confirmed'], unique=False)
|
||||
op.create_index('idx_files_project', 'files', ['project_id'], unique=False)
|
||||
op.create_index('idx_files_active', 'files', ['is_active'], unique=False)
|
||||
op.create_table('jobs',
|
||||
sa.Column('job_no', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('job_name', sa.VARCHAR(length=200), autoincrement=False, nullable=False),
|
||||
sa.Column('client_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('end_user', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('epc_company', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('project_site', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
|
||||
sa.Column('contract_date', sa.DATE(), autoincrement=False, nullable=True),
|
||||
sa.Column('delivery_date', sa.DATE(), autoincrement=False, nullable=True),
|
||||
sa.Column('delivery_terms', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'진행중'::character varying"), autoincrement=False, nullable=True),
|
||||
sa.Column('delivery_completed_date', sa.DATE(), autoincrement=False, nullable=True),
|
||||
sa.Column('project_closed_date', sa.DATE(), autoincrement=False, nullable=True),
|
||||
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_by', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('assigned_to', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('project_type', sa.VARCHAR(length=50), server_default=sa.text("'냉동기'::character varying"), autoincrement=False, nullable=False),
|
||||
sa.PrimaryKeyConstraint('job_no', name='jobs_pkey')
|
||||
)
|
||||
op.create_table('users',
|
||||
sa.Column('user_id', sa.INTEGER(), server_default=sa.text("nextval('users_user_id_seq'::regclass)"), autoincrement=True, nullable=False),
|
||||
sa.Column('username', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('password', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('email', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('role', sa.VARCHAR(length=20), server_default=sa.text("'user'::character varying"), autoincrement=False, nullable=True),
|
||||
sa.Column('access_level', sa.VARCHAR(length=20), server_default=sa.text("'worker'::character varying"), autoincrement=False, nullable=True),
|
||||
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
|
||||
sa.Column('failed_login_attempts', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('locked_until', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('department', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('position', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('phone', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('last_login_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'active'::character varying"), autoincrement=False, nullable=True),
|
||||
sa.CheckConstraint("access_level::text = ANY (ARRAY['admin'::character varying, 'system'::character varying, 'group_leader'::character varying, 'support_team'::character varying, 'worker'::character varying]::text[])", name='users_access_level_check'),
|
||||
sa.CheckConstraint("role::text = ANY (ARRAY['admin'::character varying, 'system'::character varying, 'leader'::character varying, 'support'::character varying, 'user'::character varying]::text[])", name='users_role_check'),
|
||||
sa.PrimaryKeyConstraint('user_id', name='users_pkey'),
|
||||
sa.UniqueConstraint('username', name='users_username_key'),
|
||||
postgresql_ignore_search_path=False
|
||||
)
|
||||
op.create_table('inventory_transfers',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('revision_change_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('material_description', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('category', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('quantity', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=False),
|
||||
sa.Column('unit', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
|
||||
sa.Column('inventory_location', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('storage_notes', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('transferred_by', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('transferred_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'transferred'::character varying"), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['revision_change_id'], ['revision_material_changes.id'], name='inventory_transfers_revision_change_id_fkey'),
|
||||
sa.PrimaryKeyConstraint('id', name='inventory_transfers_pkey')
|
||||
)
|
||||
op.create_index('idx_inventory_transfers_material', 'inventory_transfers', ['material_description'], unique=False)
|
||||
op.create_index('idx_inventory_transfers_date', 'inventory_transfers', ['transferred_at'], unique=False)
|
||||
op.create_table('revision_action_logs',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('session_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('revision_change_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('action_type', sa.VARCHAR(length=30), autoincrement=False, nullable=False),
|
||||
sa.Column('action_description', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('executed_by', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('executed_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('result', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||
sa.Column('result_message', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('result_data', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['revision_change_id'], ['revision_material_changes.id'], name='revision_action_logs_revision_change_id_fkey'),
|
||||
sa.ForeignKeyConstraint(['session_id'], ['revision_sessions.id'], name='revision_action_logs_session_id_fkey'),
|
||||
sa.PrimaryKeyConstraint('id', name='revision_action_logs_pkey')
|
||||
)
|
||||
op.create_index('idx_revision_logs_type', 'revision_action_logs', ['action_type'], unique=False)
|
||||
op.create_index('idx_revision_logs_session', 'revision_action_logs', ['session_id'], unique=False)
|
||||
op.create_index('idx_revision_logs_date', 'revision_action_logs', ['executed_at'], unique=False)
|
||||
op.create_table('revision_material_changes',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('session_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('previous_material_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('material_description', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('category', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('change_type', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||
sa.Column('previous_quantity', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
|
||||
sa.Column('current_quantity', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
|
||||
sa.Column('quantity_difference', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
|
||||
sa.Column('purchase_status', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||
sa.Column('purchase_confirmed_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('revision_action', sa.VARCHAR(length=30), autoincrement=False, nullable=True),
|
||||
sa.Column('action_status', sa.VARCHAR(length=20), server_default=sa.text("'pending'::character varying"), autoincrement=False, nullable=True),
|
||||
sa.Column('processed_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('processed_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('processing_notes', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='revision_material_changes_material_id_fkey'),
|
||||
sa.ForeignKeyConstraint(['session_id'], ['revision_sessions.id'], name='revision_material_changes_session_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='revision_material_changes_pkey')
|
||||
)
|
||||
op.create_index('idx_revision_changes_status', 'revision_material_changes', ['action_status'], unique=False)
|
||||
op.create_index('idx_revision_changes_session', 'revision_material_changes', ['session_id'], unique=False)
|
||||
op.create_index('idx_revision_changes_action', 'revision_material_changes', ['revision_action'], unique=False)
|
||||
op.create_table('gasket_details',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('gasket_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('gasket_subtype', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('material_type', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('filler_material', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('size_inches', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('pressure_rating', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('thickness', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('temperature_range', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('fire_safe', sa.BOOLEAN(), autoincrement=False, nullable=True),
|
||||
sa.Column('classification_confidence', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
|
||||
sa.Column('additional_info', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='gasket_details_file_id_fkey', ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='gasket_details_material_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='gasket_details_pkey')
|
||||
)
|
||||
op.create_table('purchase_requests',
|
||||
sa.Column('request_id', sa.INTEGER(), server_default=sa.text("nextval('purchase_requests_request_id_seq'::regclass)"), autoincrement=True, nullable=False),
|
||||
sa.Column('request_no', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('job_no', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('category', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('material_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('excel_file_path', sa.VARCHAR(length=500), autoincrement=False, nullable=True),
|
||||
sa.Column('project_name', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
|
||||
sa.Column('requested_by', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('requested_by_username', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('request_date', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'pending'::character varying"), autoincrement=False, nullable=True),
|
||||
sa.Column('total_items', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('notes', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('approved_by', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('approved_by_username', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('approved_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['approved_by'], ['users.user_id'], name='purchase_requests_approved_by_fkey'),
|
||||
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='purchase_requests_file_id_fkey'),
|
||||
sa.ForeignKeyConstraint(['requested_by'], ['users.user_id'], name='purchase_requests_requested_by_fkey'),
|
||||
sa.PrimaryKeyConstraint('request_id', name='purchase_requests_pkey'),
|
||||
sa.UniqueConstraint('request_no', name='purchase_requests_request_no_key'),
|
||||
postgresql_ignore_search_path=False
|
||||
)
|
||||
op.create_index('idx_purchase_requests_status', 'purchase_requests', ['status'], unique=False)
|
||||
op.create_index('idx_purchase_requests_requested_by', 'purchase_requests', ['requested_by'], unique=False)
|
||||
op.create_index('idx_purchase_requests_job_no', 'purchase_requests', ['job_no'], unique=False)
|
||||
op.create_table('permissions',
|
||||
sa.Column('permission_id', sa.INTEGER(), server_default=sa.text("nextval('permissions_permission_id_seq'::regclass)"), autoincrement=True, nullable=False),
|
||||
sa.Column('permission_name', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('module', sa.VARCHAR(length=30), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.PrimaryKeyConstraint('permission_id', name='permissions_pkey'),
|
||||
sa.UniqueConstraint('permission_name', name='permissions_permission_name_key'),
|
||||
postgresql_ignore_search_path=False
|
||||
)
|
||||
op.create_table('bolt_details',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('bolt_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('thread_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('diameter', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('length', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('material_standard', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('material_grade', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('coating_type', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('pressure_rating', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('includes_nut', sa.BOOLEAN(), autoincrement=False, nullable=True),
|
||||
sa.Column('includes_washer', sa.BOOLEAN(), autoincrement=False, nullable=True),
|
||||
sa.Column('nut_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('washer_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('classification_confidence', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
|
||||
sa.Column('additional_info', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='bolt_details_file_id_fkey', ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='bolt_details_material_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='bolt_details_pkey')
|
||||
)
|
||||
op.create_table('material_purchase_mapping',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('purchase_item_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('quantity_ratio', sa.NUMERIC(precision=5, scale=2), server_default=sa.text('1.0'), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='material_purchase_mapping_material_id_fkey', ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['purchase_item_id'], ['purchase_items.id'], name='material_purchase_mapping_purchase_item_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='material_purchase_mapping_pkey'),
|
||||
sa.UniqueConstraint('material_id', 'purchase_item_id', name='material_purchase_mapping_material_id_purchase_item_id_key')
|
||||
)
|
||||
op.create_table('confirmed_purchase_items',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('confirmation_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('item_code', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('category', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('specification', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('size', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('material', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('bom_quantity', sa.NUMERIC(precision=15, scale=3), server_default=sa.text('0'), autoincrement=False, nullable=False),
|
||||
sa.Column('calculated_qty', sa.NUMERIC(precision=15, scale=3), server_default=sa.text('0'), autoincrement=False, nullable=False),
|
||||
sa.Column('unit', sa.VARCHAR(length=20), server_default=sa.text("'EA'::character varying"), autoincrement=False, nullable=False),
|
||||
sa.Column('safety_factor', sa.NUMERIC(precision=5, scale=3), server_default=sa.text('1.0'), autoincrement=False, nullable=False),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['confirmation_id'], ['purchase_confirmations.id'], name='confirmed_purchase_items_confirmation_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='confirmed_purchase_items_pkey'),
|
||||
comment='확정된 구매 품목 상세 테이블'
|
||||
)
|
||||
op.create_index('idx_confirmed_purchase_items_confirmation', 'confirmed_purchase_items', ['confirmation_id'], unique=False)
|
||||
op.create_index('idx_confirmed_purchase_items_category', 'confirmed_purchase_items', ['category'], unique=False)
|
||||
op.create_table('material_comparison_details',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('comparison_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('material_hash', sa.VARCHAR(length=32), autoincrement=False, nullable=False),
|
||||
sa.Column('change_type', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('size_spec', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('material_grade', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('previous_quantity', sa.NUMERIC(precision=10, scale=3), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('current_quantity', sa.NUMERIC(precision=10, scale=3), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('quantity_diff', sa.NUMERIC(precision=10, scale=3), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('additional_purchase_needed', sa.NUMERIC(precision=10, scale=3), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('classified_category', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('classification_confidence', sa.NUMERIC(precision=3, scale=2), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['comparison_id'], ['material_revisions_comparison.id'], name='material_comparison_details_comparison_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='material_comparison_details_pkey')
|
||||
)
|
||||
op.create_table('purchase_request_items',
|
||||
sa.Column('item_id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('request_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('category', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('subcategory', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('material_grade', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('size_spec', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('quantity', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=False),
|
||||
sa.Column('unit', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
|
||||
sa.Column('drawing_name', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('notes', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('user_requirement', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('is_ordered', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True),
|
||||
sa.Column('is_received', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='purchase_request_items_material_id_fkey', ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['request_id'], ['purchase_requests.request_id'], name='purchase_request_items_request_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('item_id', name='purchase_request_items_pkey')
|
||||
)
|
||||
op.create_index('idx_purchase_request_items_request_id', 'purchase_request_items', ['request_id'], unique=False)
|
||||
op.create_index('idx_purchase_request_items_material_id', 'purchase_request_items', ['material_id'], unique=False)
|
||||
op.create_index('idx_purchase_request_items_category', 'purchase_request_items', ['category'], unique=False)
|
||||
op.create_table('material_revisions_comparison',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('job_no', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('current_revision', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||
sa.Column('previous_revision', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||
sa.Column('current_file_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('previous_file_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('total_current_items', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('total_previous_items', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('new_items_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('modified_items_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('removed_items_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('unchanged_items_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('comparison_details', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('created_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['current_file_id'], ['files.id'], name='material_revisions_comparison_current_file_id_fkey'),
|
||||
sa.ForeignKeyConstraint(['previous_file_id'], ['files.id'], name='material_revisions_comparison_previous_file_id_fkey'),
|
||||
sa.PrimaryKeyConstraint('id', name='material_revisions_comparison_pkey'),
|
||||
sa.UniqueConstraint('job_no', 'current_revision', 'previous_revision', name='material_revisions_comparison_job_no_current_revision_previ_key')
|
||||
)
|
||||
op.create_table('login_logs',
|
||||
sa.Column('log_id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('login_time', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('ip_address', sa.VARCHAR(length=45), autoincrement=False, nullable=True),
|
||||
sa.Column('user_agent', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('login_status', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
|
||||
sa.Column('failure_reason', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('session_duration', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.CheckConstraint("login_status::text = ANY (ARRAY['success'::character varying, 'failed'::character varying]::text[])", name='login_logs_login_status_check'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.user_id'], name='login_logs_user_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('log_id', name='login_logs_pkey')
|
||||
)
|
||||
op.create_table('user_sessions',
|
||||
sa.Column('session_id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('refresh_token', sa.VARCHAR(length=500), autoincrement=False, nullable=False),
|
||||
sa.Column('expires_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
|
||||
sa.Column('ip_address', sa.VARCHAR(length=45), autoincrement=False, nullable=True),
|
||||
sa.Column('user_agent', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.user_id'], name='user_sessions_user_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('session_id', name='user_sessions_pkey')
|
||||
)
|
||||
op.create_table('pipe_end_preparations',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('end_preparation_type', sa.VARCHAR(length=50), server_default=sa.text("'PBE'::character varying"), autoincrement=False, nullable=True),
|
||||
sa.Column('end_preparation_code', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
|
||||
sa.Column('machining_required', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True),
|
||||
sa.Column('cutting_note', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('original_description', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('clean_description', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('confidence', sa.DOUBLE_PRECISION(precision=53), server_default=sa.text('0.0'), autoincrement=False, nullable=True),
|
||||
sa.Column('matched_pattern', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='pipe_end_preparations_file_id_fkey', ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='pipe_end_preparations_material_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='pipe_end_preparations_pkey')
|
||||
)
|
||||
op.create_table('special_material_details',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('special_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('special_subtype', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('material_standard', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('material_grade', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('specifications', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('dimensions', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('weight_kg', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
|
||||
sa.Column('classification_confidence', sa.NUMERIC(precision=3, scale=2), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='special_material_details_file_id_fkey', ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='special_material_details_material_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='special_material_details_pkey')
|
||||
)
|
||||
op.create_index('idx_special_material_details_material_id', 'special_material_details', ['material_id'], unique=False)
|
||||
op.create_index('idx_special_material_details_file_id', 'special_material_details', ['file_id'], unique=False)
|
||||
op.create_table('flange_details',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('flange_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('facing_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('pressure_rating', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('material_standard', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('material_grade', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('size_inches', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('bolt_hole_count', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('bolt_hole_size', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('classification_confidence', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
|
||||
sa.Column('additional_info', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='flange_details_file_id_fkey', ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='flange_details_material_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='flange_details_pkey')
|
||||
)
|
||||
op.create_table('fitting_details',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('fitting_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('fitting_subtype', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('connection_method', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('connection_code', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('pressure_rating', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('max_pressure', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('manufacturing_method', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('material_standard', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('material_grade', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('material_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('main_size', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('reduced_size', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('length_mm', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
|
||||
sa.Column('schedule', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('classification_confidence', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
|
||||
sa.Column('additional_info', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='fitting_details_file_id_fkey', ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='fitting_details_material_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='fitting_details_pkey')
|
||||
)
|
||||
op.create_table('instrument_details',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('instrument_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('instrument_subtype', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('measurement_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('measurement_range', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('accuracy', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('connection_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('connection_size', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('body_material', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('wetted_parts_material', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('electrical_rating', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('output_signal', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('classification_confidence', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
|
||||
sa.Column('additional_info', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='instrument_details_file_id_fkey', ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='instrument_details_material_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='instrument_details_pkey')
|
||||
)
|
||||
op.create_table('revision_sessions',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('job_no', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('current_file_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('previous_file_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('current_revision', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||
sa.Column('previous_revision', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||
sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'processing'::character varying"), autoincrement=False, nullable=True),
|
||||
sa.Column('total_materials', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('processed_materials', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('added_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('removed_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('changed_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('unchanged_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('purchase_cancel_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('inventory_transfer_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('additional_purchase_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('created_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('completed_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['current_file_id'], ['files.id'], name='revision_sessions_current_file_id_fkey'),
|
||||
sa.ForeignKeyConstraint(['previous_file_id'], ['files.id'], name='revision_sessions_previous_file_id_fkey'),
|
||||
sa.PrimaryKeyConstraint('id', name='revision_sessions_pkey')
|
||||
)
|
||||
op.create_index('idx_revision_sessions_status', 'revision_sessions', ['status'], unique=False)
|
||||
op.create_index('idx_revision_sessions_job_no', 'revision_sessions', ['job_no'], unique=False)
|
||||
op.create_table('purchase_items',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('item_code', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('category', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('specification', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('material_spec', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
|
||||
sa.Column('size_spec', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('unit', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
|
||||
sa.Column('bom_quantity', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=False),
|
||||
sa.Column('safety_factor', sa.NUMERIC(precision=3, scale=2), server_default=sa.text('1.10'), autoincrement=False, nullable=True),
|
||||
sa.Column('minimum_order_qty', sa.NUMERIC(precision=10, scale=3), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('order_unit_qty', sa.NUMERIC(precision=10, scale=3), server_default=sa.text('1'), autoincrement=False, nullable=True),
|
||||
sa.Column('calculated_qty', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
|
||||
sa.Column('cutting_loss', sa.NUMERIC(precision=10, scale=3), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('standard_length', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
|
||||
sa.Column('pipes_count', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('waste_length', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
|
||||
sa.Column('detailed_spec', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||
sa.Column('preferred_supplier', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
|
||||
sa.Column('last_unit_price', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=True),
|
||||
sa.Column('currency', sa.VARCHAR(length=10), server_default=sa.text("'KRW'::character varying"), autoincrement=False, nullable=True),
|
||||
sa.Column('lead_time_days', sa.INTEGER(), server_default=sa.text('30'), autoincrement=False, nullable=True),
|
||||
sa.Column('job_no', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('revision', sa.VARCHAR(length=20), server_default=sa.text("'Rev.0'::character varying"), autoincrement=False, nullable=True),
|
||||
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('created_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('approved_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('approved_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='purchase_items_file_id_fkey', ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id', name='purchase_items_pkey'),
|
||||
sa.UniqueConstraint('item_code', name='purchase_items_item_code_key')
|
||||
)
|
||||
op.create_table('valve_details',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('valve_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('valve_subtype', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('actuator_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('connection_method', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('pressure_rating', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('pressure_class', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('body_material', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('trim_material', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('size_inches', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('fire_safe', sa.BOOLEAN(), autoincrement=False, nullable=True),
|
||||
sa.Column('low_temp_service', sa.BOOLEAN(), autoincrement=False, nullable=True),
|
||||
sa.Column('special_features', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||
sa.Column('classification_confidence', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
|
||||
sa.Column('additional_info', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='valve_details_file_id_fkey', ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='valve_details_material_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='valve_details_pkey')
|
||||
)
|
||||
op.create_table('purchase_confirmations',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('job_no', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('bom_name', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('revision', sa.VARCHAR(length=50), server_default=sa.text("'Rev.0'::character varying"), autoincrement=False, nullable=False),
|
||||
sa.Column('confirmed_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
|
||||
sa.Column('confirmed_by', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=False),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='purchase_confirmations_file_id_fkey'),
|
||||
sa.PrimaryKeyConstraint('id', name='purchase_confirmations_pkey'),
|
||||
comment='구매 수량 확정 마스터 테이블'
|
||||
)
|
||||
op.create_index('idx_purchase_confirmations_job_revision', 'purchase_confirmations', ['job_no', 'revision', 'is_active'], unique=False)
|
||||
op.create_table('material_purchase_tracking',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('material_hash', sa.VARCHAR(length=64), autoincrement=False, nullable=False),
|
||||
sa.Column('original_description', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('size_spec', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('material_grade', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('bom_quantity', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=False),
|
||||
sa.Column('confirmed_quantity', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
|
||||
sa.Column('purchase_quantity', sa.NUMERIC(precision=10, scale=3), autoincrement=False, nullable=True),
|
||||
sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'pending'::character varying"), autoincrement=False, nullable=True),
|
||||
sa.Column('confirmed_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('confirmed_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('ordered_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('ordered_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('approved_by', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('approved_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('job_no', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('revision', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
|
||||
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('purchase_status', sa.VARCHAR(length=20), server_default=sa.text("'pending'::character varying"), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='material_purchase_tracking_file_id_fkey', ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id', name='material_purchase_tracking_pkey')
|
||||
)
|
||||
op.create_table('support_details',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('material_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('file_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('support_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('support_subtype', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('load_rating', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('load_capacity', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('material_standard', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('material_grade', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('pipe_size', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
|
||||
sa.Column('length_mm', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=True),
|
||||
sa.Column('width_mm', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=True),
|
||||
sa.Column('height_mm', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=True),
|
||||
sa.Column('classification_confidence', sa.NUMERIC(precision=3, scale=2), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['file_id'], ['files.id'], name='support_details_file_id_fkey', ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['material_id'], ['materials.id'], name='support_details_material_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='support_details_pkey')
|
||||
)
|
||||
op.create_index('idx_support_details_material_id', 'support_details', ['material_id'], unique=False)
|
||||
op.create_index('idx_support_details_file_id', 'support_details', ['file_id'], unique=False)
|
||||
op.create_table('user_activity_logs',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('activity_type', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('activity_description', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('target_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('target_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('ip_address', sa.VARCHAR(length=45), autoincrement=False, nullable=True),
|
||||
sa.Column('user_agent', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.PrimaryKeyConstraint('id', name='user_activity_logs_pkey')
|
||||
)
|
||||
op.create_table('role_permissions',
|
||||
sa.Column('role_permission_id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('role', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||
sa.Column('permission_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['permission_id'], ['permissions.permission_id'], name='role_permissions_permission_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('role_permission_id', name='role_permissions_pkey'),
|
||||
sa.UniqueConstraint('role', 'permission_id', name='role_permissions_role_permission_id_key')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
166
backend/app.py
166
backend/app.py
@@ -1,166 +0,0 @@
|
||||
from flask import Flask, request, jsonify
|
||||
import psycopg2
|
||||
from contextlib import contextmanager
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@contextmanager
|
||||
def get_db_connection():
|
||||
conn = psycopg2.connect(
|
||||
host="localhost",
|
||||
database="tkmp_db",
|
||||
user="tkmp_user",
|
||||
password="tkmp2024!",
|
||||
port="5432"
|
||||
)
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route('/')
|
||||
def home():
|
||||
return {"message": "API 작동 중"}
|
||||
|
||||
@app.route('/api/materials')
|
||||
def get_materials():
|
||||
job_number = request.args.get('job_number')
|
||||
|
||||
if not job_number:
|
||||
return {"error": "job_number 필요"}, 400
|
||||
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, job_number, item_number, description,
|
||||
category, quantity, unit, created_at
|
||||
FROM materials
|
||||
WHERE job_number = %s
|
||||
ORDER BY item_number
|
||||
""", (job_number,))
|
||||
|
||||
rows = cur.fetchall()
|
||||
|
||||
materials = []
|
||||
for r in rows:
|
||||
item = {
|
||||
'id': r[0],
|
||||
'job_number': r[1],
|
||||
'item_number': r[2],
|
||||
'description': r[3],
|
||||
'category': r[4],
|
||||
'quantity': r[5],
|
||||
'unit': r[6],
|
||||
'created_at': str(r[7]) if r[7] else None
|
||||
}
|
||||
materials.append(item)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'data': materials,
|
||||
'count': len(materials)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"DB 오류: {str(e)}"}, 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("🚀 서버 시작: http://localhost:5000")
|
||||
app.run(debug=True, port=5000)
|
||||
# 수정된 get_materials API (올바른 컬럼명 사용)
|
||||
@app.route('/api/materials-fixed', methods=['GET'])
|
||||
def get_materials_fixed():
|
||||
"""올바른 컬럼명을 사용한 자재 조회 API"""
|
||||
try:
|
||||
file_id = request.args.get('file_id')
|
||||
|
||||
if not file_id:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'file_id parameter is required'
|
||||
}), 400
|
||||
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT
|
||||
id, file_id, line_number, original_description,
|
||||
classified_category, classified_subcategory,
|
||||
quantity, unit, created_at
|
||||
FROM materials
|
||||
WHERE file_id = %s
|
||||
ORDER BY line_number
|
||||
""", (file_id,))
|
||||
|
||||
materials = []
|
||||
for item in cur.fetchall():
|
||||
material = {
|
||||
'id': item[0],
|
||||
'file_id': item[1],
|
||||
'line_number': item[2],
|
||||
'original_description': item[3],
|
||||
'classified_category': item[4],
|
||||
'classified_subcategory': item[5],
|
||||
'quantity': float(item[6]) if item[6] else 0,
|
||||
'unit': item[7],
|
||||
'created_at': item[8].isoformat() if item[8] else None
|
||||
}
|
||||
materials.append(material)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': materials,
|
||||
'count': len(materials),
|
||||
'file_id': file_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in get_materials_fixed: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@app.get("/api/materials-test")
|
||||
def get_materials_test(file_id: int):
|
||||
"""테스트용 자재 조회 API"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT
|
||||
id, file_id, line_number, original_description,
|
||||
classified_category, quantity, unit
|
||||
FROM materials
|
||||
WHERE file_id = %s
|
||||
ORDER BY line_number
|
||||
LIMIT 5
|
||||
""", (file_id,))
|
||||
|
||||
rows = cur.fetchall()
|
||||
|
||||
materials = []
|
||||
for r in rows:
|
||||
materials.append({
|
||||
'id': r[0],
|
||||
'file_id': r[1],
|
||||
'line_number': r[2],
|
||||
'description': r[3],
|
||||
'category': r[4],
|
||||
'quantity': float(r[5]) if r[5] else 0,
|
||||
'unit': r[6]
|
||||
})
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'data': materials,
|
||||
'count': len(materials)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
# API 라우터 패키지
|
||||
"""
|
||||
API 모듈
|
||||
분리된 API 엔드포인트들
|
||||
"""
|
||||
File diff suppressed because it is too large
Load Diff
81
backend/app/auth/__init__.py
Normal file
81
backend/app/auth/__init__.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
인증 모듈 초기화
|
||||
TK-MP-Project 인증 시스템의 모든 컴포넌트를 노출
|
||||
"""
|
||||
|
||||
from .jwt_service import jwt_service, JWTService
|
||||
from .auth_service import AuthService, get_auth_service
|
||||
from .auth_controller import router as auth_router
|
||||
from .setup_controller import router as setup_router
|
||||
from .middleware import (
|
||||
auth_middleware,
|
||||
get_current_user,
|
||||
get_current_active_user,
|
||||
require_admin,
|
||||
require_leader_or_admin,
|
||||
require_roles,
|
||||
require_permissions,
|
||||
get_user_from_token,
|
||||
check_user_permission,
|
||||
get_user_permissions_by_role,
|
||||
get_current_user_optional
|
||||
)
|
||||
from .models import (
|
||||
User,
|
||||
LoginLog,
|
||||
UserSession,
|
||||
Permission,
|
||||
RolePermission,
|
||||
UserRepository
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# JWT 서비스
|
||||
'jwt_service',
|
||||
'JWTService',
|
||||
|
||||
# 인증 서비스
|
||||
'AuthService',
|
||||
'get_auth_service',
|
||||
|
||||
# 라우터
|
||||
'auth_router',
|
||||
'setup_router',
|
||||
|
||||
# 미들웨어 및 의존성
|
||||
'auth_middleware',
|
||||
'get_current_user',
|
||||
'get_current_active_user',
|
||||
'require_admin',
|
||||
'require_leader_or_admin',
|
||||
'require_roles',
|
||||
'require_permissions',
|
||||
'get_user_from_token',
|
||||
'check_user_permission',
|
||||
'get_user_permissions_by_role',
|
||||
'get_current_user_optional',
|
||||
|
||||
# 모델
|
||||
'User',
|
||||
'LoginLog',
|
||||
'UserSession',
|
||||
'Permission',
|
||||
'RolePermission',
|
||||
'UserRepository'
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1162
backend/app/auth/auth_controller.py
Normal file
1162
backend/app/auth/auth_controller.py
Normal file
File diff suppressed because it is too large
Load Diff
396
backend/app/auth/auth_service.py
Normal file
396
backend/app/auth/auth_service.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
인증 서비스
|
||||
TK-FB-Project의 auth.service.js를 참고하여 FastAPI용으로 구현
|
||||
"""
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import User, UserRepository
|
||||
from .jwt_service import jwt_service
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.error_handlers import TKMPException
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""인증 서비스 클래스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.user_repo = UserRepository(db)
|
||||
|
||||
async def login(self, username: str, password: str, request: Request) -> Dict[str, Any]:
|
||||
"""
|
||||
사용자 로그인
|
||||
|
||||
Args:
|
||||
username: 사용자명
|
||||
password: 비밀번호
|
||||
request: FastAPI Request 객체
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 로그인 결과 (토큰, 사용자 정보 등)
|
||||
|
||||
Raises:
|
||||
TKMPException: 로그인 실패 시
|
||||
"""
|
||||
try:
|
||||
# 클라이언트 정보 추출
|
||||
ip_address = self._get_client_ip(request)
|
||||
user_agent = request.headers.get('user-agent', 'unknown')
|
||||
|
||||
logger.info(f"Login attempt for username: {username} from IP: {ip_address}")
|
||||
|
||||
# 입력 검증
|
||||
if not username or not password:
|
||||
await self._record_login_failure(None, ip_address, user_agent, 'missing_credentials')
|
||||
raise TKMPException(
|
||||
message="사용자명과 비밀번호를 입력해주세요",
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# 사용자 조회
|
||||
user = self.user_repo.find_by_username(username)
|
||||
if not user:
|
||||
await self._record_login_failure(None, ip_address, user_agent, 'user_not_found')
|
||||
logger.warning(f"Login failed - user not found: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
message="아이디 또는 비밀번호가 올바르지 않습니다"
|
||||
)
|
||||
|
||||
# 계정 상태 확인 (새로운 status 체계)
|
||||
if hasattr(user, 'status'):
|
||||
if user.status == 'pending':
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'pending_account')
|
||||
logger.warning(f"Login failed - pending account: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
message="계정 승인 대기 중입니다. 관리자 승인 후 이용 가능합니다"
|
||||
)
|
||||
elif user.status == 'suspended':
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'suspended_account')
|
||||
logger.warning(f"Login failed - suspended account: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
message="계정이 정지되었습니다. 관리자에게 문의하세요"
|
||||
)
|
||||
elif user.status == 'deleted':
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'deleted_account')
|
||||
logger.warning(f"Login failed - deleted account: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
message="삭제된 계정입니다"
|
||||
)
|
||||
else:
|
||||
# 하위 호환성: status 필드가 없으면 is_active 사용
|
||||
if not user.is_active:
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_disabled')
|
||||
logger.warning(f"Login failed - account disabled: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
message="비활성화된 계정입니다. 관리자에게 문의하세요"
|
||||
)
|
||||
|
||||
# 계정 잠금 상태 확인
|
||||
if user.is_locked():
|
||||
remaining_time = int((user.locked_until - datetime.utcnow()).total_seconds() / 60)
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_locked')
|
||||
logger.warning(f"Login failed - account locked: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
message=f"계정이 잠겨있습니다. {remaining_time}분 후에 다시 시도하세요"
|
||||
)
|
||||
|
||||
# 비밀번호 확인
|
||||
if not user.check_password(password):
|
||||
# 로그인 실패 처리
|
||||
user.increment_failed_attempts()
|
||||
self.user_repo.update_user(user)
|
||||
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'invalid_password')
|
||||
logger.warning(f"Login failed - invalid password: {username}")
|
||||
|
||||
# 계정 잠금 확인
|
||||
if user.failed_login_attempts >= 5:
|
||||
logger.warning(f"Account locked due to failed attempts: {username}")
|
||||
|
||||
raise TKMPException(
|
||||
message="아이디 또는 비밀번호가 올바르지 않습니다",
|
||||
status_code=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
# 로그인 성공 처리
|
||||
user.reset_failed_attempts()
|
||||
user.update_last_login()
|
||||
self.user_repo.update_user(user)
|
||||
|
||||
# 토큰 생성
|
||||
user_data = user.to_dict()
|
||||
access_token = jwt_service.create_access_token(user_data)
|
||||
refresh_token = jwt_service.create_refresh_token(user.user_id)
|
||||
|
||||
# 세션 생성
|
||||
expires_at = datetime.utcnow() + timedelta(days=7)
|
||||
session = self.user_repo.create_session(
|
||||
user_id=user.user_id,
|
||||
refresh_token=refresh_token,
|
||||
expires_at=expires_at,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
# 로그인 성공 기록
|
||||
self.user_repo.record_login_log(
|
||||
user_id=user.user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status='success'
|
||||
)
|
||||
|
||||
# 리디렉션 URL 결정
|
||||
redirect_url = self._get_redirect_url(user.role)
|
||||
|
||||
logger.info(f"Login successful for user: {username} (role: {user.role})")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'token_type': 'bearer',
|
||||
'expires_in': 24 * 3600, # 24시간 (초)
|
||||
'user': user_data,
|
||||
'redirect_url': redirect_url,
|
||||
'permissions': self.user_repo.get_user_permissions(user.role)
|
||||
}
|
||||
|
||||
except TKMPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Login service error for {username}: {str(e)}")
|
||||
raise TKMPException(
|
||||
message="로그인 처리 중 서버 오류가 발생했습니다",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
async def refresh_token(self, refresh_token: str, request: Request) -> Dict[str, Any]:
|
||||
"""
|
||||
토큰 갱신
|
||||
|
||||
Args:
|
||||
refresh_token: 리프레시 토큰
|
||||
request: FastAPI Request 객체
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 새로운 토큰 정보
|
||||
"""
|
||||
try:
|
||||
# 리프레시 토큰 검증
|
||||
payload = jwt_service.verify_refresh_token(refresh_token)
|
||||
user_id = payload['user_id']
|
||||
|
||||
# 세션 확인
|
||||
session = self.user_repo.find_session_by_token(refresh_token)
|
||||
if not session or session.is_expired() or not session.is_active:
|
||||
logger.warning(f"Invalid or expired refresh token for user_id: {user_id}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
message="유효하지 않거나 만료된 리프레시 토큰입니다"
|
||||
)
|
||||
|
||||
# 사용자 조회
|
||||
user = self.user_repo.find_by_id(user_id)
|
||||
if not user or not user.is_active:
|
||||
logger.warning(f"User not found or inactive for token refresh: {user_id}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
message="사용자를 찾을 수 없거나 비활성화된 계정입니다"
|
||||
)
|
||||
|
||||
# 새 액세스 토큰 생성
|
||||
user_data = user.to_dict()
|
||||
new_access_token = jwt_service.create_access_token(user_data)
|
||||
|
||||
logger.info(f"Token refreshed for user: {user.username}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'access_token': new_access_token,
|
||||
'token_type': 'bearer',
|
||||
'expires_in': 24 * 3600,
|
||||
'user': user_data
|
||||
}
|
||||
|
||||
except TKMPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Token refresh error: {str(e)}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
message="토큰 갱신 중 서버 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
async def logout(self, refresh_token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
로그아웃
|
||||
|
||||
Args:
|
||||
refresh_token: 리프레시 토큰
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 로그아웃 결과
|
||||
"""
|
||||
try:
|
||||
# 세션 찾기 및 비활성화
|
||||
session = self.user_repo.find_session_by_token(refresh_token)
|
||||
if session:
|
||||
session.deactivate()
|
||||
self.user_repo.update_user(session.user)
|
||||
logger.info(f"User logged out: user_id {session.user_id}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': '로그아웃되었습니다'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Logout error: {str(e)}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
message="로그아웃 처리 중 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
async def register(self, user_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
사용자 등록
|
||||
|
||||
Args:
|
||||
user_data: 사용자 정보
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 등록 결과
|
||||
"""
|
||||
try:
|
||||
# 필수 필드 검증
|
||||
required_fields = ['username', 'password', 'name']
|
||||
for field in required_fields:
|
||||
if not user_data.get(field):
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
message=f"{field}는 필수 입력 항목입니다"
|
||||
)
|
||||
|
||||
# 중복 사용자명 확인
|
||||
existing_user = self.user_repo.find_by_username(user_data['username'])
|
||||
if existing_user:
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
message="이미 존재하는 사용자명입니다"
|
||||
)
|
||||
|
||||
# 이메일 중복 확인 (이메일이 제공된 경우)
|
||||
if user_data.get('email'):
|
||||
existing_email = self.user_repo.find_by_email(user_data['email'])
|
||||
if existing_email:
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
message="이미 사용 중인 이메일입니다"
|
||||
)
|
||||
|
||||
# 역할 매핑
|
||||
role_map = {
|
||||
'admin': 'admin',
|
||||
'system': 'system',
|
||||
'group_leader': 'leader',
|
||||
'support_team': 'support',
|
||||
'worker': 'user'
|
||||
}
|
||||
access_level = user_data.get('access_level', 'worker')
|
||||
role = role_map.get(access_level, 'user')
|
||||
|
||||
# 사용자 생성
|
||||
new_user_data = {
|
||||
'username': user_data['username'],
|
||||
'name': user_data['name'],
|
||||
'email': user_data.get('email'),
|
||||
'role': role,
|
||||
'access_level': access_level,
|
||||
'department': user_data.get('department'),
|
||||
'position': user_data.get('position'),
|
||||
'phone': user_data.get('phone')
|
||||
}
|
||||
|
||||
user = User(**new_user_data)
|
||||
user.set_password(user_data['password'])
|
||||
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
|
||||
logger.info(f"User registered successfully: {user.username}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': '사용자 등록이 완료되었습니다',
|
||||
'user_id': user.user_id,
|
||||
'username': user.username
|
||||
}
|
||||
|
||||
except TKMPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"User registration error: {str(e)}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
message="사용자 등록 중 서버 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
def _get_client_ip(self, request: Request) -> str:
|
||||
"""클라이언트 IP 주소 추출"""
|
||||
# X-Forwarded-For 헤더 확인 (프록시 환경)
|
||||
forwarded_for = request.headers.get('x-forwarded-for')
|
||||
if forwarded_for:
|
||||
return forwarded_for.split(',')[0].strip()
|
||||
|
||||
# X-Real-IP 헤더 확인
|
||||
real_ip = request.headers.get('x-real-ip')
|
||||
if real_ip:
|
||||
return real_ip
|
||||
|
||||
# 직접 연결된 클라이언트 IP
|
||||
return request.client.host if request.client else 'unknown'
|
||||
|
||||
def _get_redirect_url(self, role: str) -> str:
|
||||
"""역할에 따른 리디렉션 URL 결정"""
|
||||
redirect_urls = {
|
||||
'system': '/admin/system',
|
||||
'admin': '/admin/dashboard',
|
||||
'leader': '/dashboard/leader',
|
||||
'support': '/dashboard/support',
|
||||
'user': '/dashboard'
|
||||
}
|
||||
return redirect_urls.get(role, '/dashboard')
|
||||
|
||||
async def _record_login_failure(self, user_id: Optional[int], ip_address: str,
|
||||
user_agent: str, failure_reason: str):
|
||||
"""로그인 실패 기록"""
|
||||
try:
|
||||
if user_id:
|
||||
self.user_repo.record_login_log(
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status='failed',
|
||||
failure_reason=failure_reason
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to record login failure: {str(e)}")
|
||||
|
||||
|
||||
def get_auth_service(db: Session) -> AuthService:
|
||||
"""인증 서비스 팩토리 함수"""
|
||||
return AuthService(db)
|
||||
273
backend/app/auth/jwt_service.py
Normal file
273
backend/app/auth/jwt_service.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
JWT 토큰 관리 서비스
|
||||
TK-FB-Project의 JWT 구현을 참고하여 FastAPI용으로 구현
|
||||
"""
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from fastapi import HTTPException, status
|
||||
import os
|
||||
from ..config import get_settings
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# JWT 설정
|
||||
JWT_SECRET = os.getenv('JWT_SECRET', 'tkmp-secret-key-2025')
|
||||
JWT_REFRESH_SECRET = os.getenv('JWT_REFRESH_SECRET', 'tkmp-refresh-secret-2025')
|
||||
JWT_ALGORITHM = 'HS256'
|
||||
ACCESS_TOKEN_EXPIRE_HOURS = int(os.getenv('JWT_EXPIRES_IN_HOURS', '24'))
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv('JWT_REFRESH_EXPIRES_IN_DAYS', '7'))
|
||||
|
||||
|
||||
class JWTService:
|
||||
"""JWT 토큰 관리 서비스"""
|
||||
|
||||
@staticmethod
|
||||
def create_access_token(user_data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Access Token 생성
|
||||
|
||||
Args:
|
||||
user_data: 사용자 정보 딕셔너리
|
||||
|
||||
Returns:
|
||||
str: JWT Access Token
|
||||
"""
|
||||
try:
|
||||
# 토큰 만료 시간 설정
|
||||
expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
|
||||
|
||||
# 토큰 페이로드 구성
|
||||
payload = {
|
||||
'user_id': user_data['user_id'],
|
||||
'username': user_data['username'],
|
||||
'name': user_data['name'],
|
||||
'role': user_data['role'],
|
||||
'access_level': user_data['access_level'],
|
||||
'exp': expire,
|
||||
'iat': datetime.utcnow(),
|
||||
'type': 'access'
|
||||
}
|
||||
|
||||
# JWT 토큰 생성
|
||||
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
|
||||
logger.debug(f"Access token created for user: {user_data['username']}")
|
||||
return token
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Access token creation failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="토큰 생성에 실패했습니다"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_refresh_token(user_id: int) -> str:
|
||||
"""
|
||||
Refresh Token 생성
|
||||
|
||||
Args:
|
||||
user_id: 사용자 ID
|
||||
|
||||
Returns:
|
||||
str: JWT Refresh Token
|
||||
"""
|
||||
try:
|
||||
# 토큰 만료 시간 설정
|
||||
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
|
||||
# 토큰 페이로드 구성
|
||||
payload = {
|
||||
'user_id': user_id,
|
||||
'exp': expire,
|
||||
'iat': datetime.utcnow(),
|
||||
'type': 'refresh'
|
||||
}
|
||||
|
||||
# JWT 토큰 생성
|
||||
token = jwt.encode(payload, JWT_REFRESH_SECRET, algorithm=JWT_ALGORITHM)
|
||||
|
||||
logger.debug(f"Refresh token created for user_id: {user_id}")
|
||||
return token
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Refresh token creation failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="리프레시 토큰 생성에 실패했습니다"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def verify_access_token(token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Access Token 검증
|
||||
|
||||
Args:
|
||||
token: JWT Access Token
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 토큰 페이로드
|
||||
|
||||
Raises:
|
||||
HTTPException: 토큰 검증 실패 시
|
||||
"""
|
||||
try:
|
||||
# JWT 토큰 디코딩
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
|
||||
# 토큰 타입 확인
|
||||
if payload.get('type') != 'access':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="잘못된 토큰 타입입니다"
|
||||
)
|
||||
|
||||
# 필수 필드 확인
|
||||
required_fields = ['user_id', 'username', 'role']
|
||||
for field in required_fields:
|
||||
if field not in payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"토큰에 {field} 정보가 없습니다"
|
||||
)
|
||||
|
||||
logger.debug(f"Access token verified for user: {payload['username']}")
|
||||
return payload
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.warning("Access token expired")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="토큰이 만료되었습니다"
|
||||
)
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"Invalid access token: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="유효하지 않은 토큰입니다"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Access token verification failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="토큰 검증에 실패했습니다"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def verify_refresh_token(token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Refresh Token 검증
|
||||
|
||||
Args:
|
||||
token: JWT Refresh Token
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 토큰 페이로드
|
||||
|
||||
Raises:
|
||||
HTTPException: 토큰 검증 실패 시
|
||||
"""
|
||||
try:
|
||||
# JWT 토큰 디코딩
|
||||
payload = jwt.decode(token, JWT_REFRESH_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
|
||||
# 토큰 타입 확인
|
||||
if payload.get('type') != 'refresh':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="잘못된 리프레시 토큰 타입입니다"
|
||||
)
|
||||
|
||||
# 필수 필드 확인
|
||||
if 'user_id' not in payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="토큰에 사용자 정보가 없습니다"
|
||||
)
|
||||
|
||||
logger.debug(f"Refresh token verified for user_id: {payload['user_id']}")
|
||||
return payload
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.warning("Refresh token expired")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="리프레시 토큰이 만료되었습니다"
|
||||
)
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"Invalid refresh token: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="유효하지 않은 리프레시 토큰입니다"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Refresh token verification failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="리프레시 토큰 검증에 실패했습니다"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_token_expiry_info(token: str, token_type: str = 'access') -> Dict[str, Any]:
|
||||
"""
|
||||
토큰 만료 정보 조회
|
||||
|
||||
Args:
|
||||
token: JWT Token
|
||||
token_type: 토큰 타입 ('access' 또는 'refresh')
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 토큰 만료 정보
|
||||
"""
|
||||
try:
|
||||
secret = JWT_SECRET if token_type == 'access' else JWT_REFRESH_SECRET
|
||||
payload = jwt.decode(token, secret, algorithms=[JWT_ALGORITHM])
|
||||
|
||||
exp_timestamp = payload.get('exp')
|
||||
iat_timestamp = payload.get('iat')
|
||||
|
||||
if exp_timestamp:
|
||||
exp_datetime = datetime.fromtimestamp(exp_timestamp)
|
||||
remaining_time = exp_datetime - datetime.utcnow()
|
||||
|
||||
return {
|
||||
'expires_at': exp_datetime,
|
||||
'issued_at': datetime.fromtimestamp(iat_timestamp) if iat_timestamp else None,
|
||||
'remaining_seconds': int(remaining_time.total_seconds()),
|
||||
'is_expired': remaining_time.total_seconds() <= 0
|
||||
}
|
||||
|
||||
return {'error': '토큰에 만료 시간 정보가 없습니다'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Token expiry info retrieval failed: {str(e)}")
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
# JWT 서비스 인스턴스
|
||||
jwt_service = JWTService()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
327
backend/app/auth/middleware.py
Normal file
327
backend/app/auth/middleware.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
인증 및 권한 미들웨어
|
||||
JWT 토큰 기반 인증과 역할 기반 접근 제어(RBAC) 구현
|
||||
"""
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Callable, Any
|
||||
from functools import wraps
|
||||
import inspect
|
||||
|
||||
from ..database import get_db
|
||||
from .jwt_service import jwt_service
|
||||
from .models import UserRepository
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
class AuthMiddleware:
|
||||
"""인증 미들웨어 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
self.security = HTTPBearer()
|
||||
|
||||
async def get_current_user(
|
||||
self,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""
|
||||
현재 사용자 정보 조회
|
||||
|
||||
Args:
|
||||
credentials: JWT 토큰
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
dict: 사용자 정보
|
||||
|
||||
Raises:
|
||||
HTTPException: 인증 실패 시
|
||||
"""
|
||||
try:
|
||||
# 토큰 검증
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
user_id = payload['user_id']
|
||||
|
||||
# 사용자 정보 조회
|
||||
user_repo = UserRepository(db)
|
||||
user = user_repo.find_by_id(user_id)
|
||||
|
||||
if not user:
|
||||
logger.warning(f"User not found for token: user_id {user_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="사용자를 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
logger.warning(f"Inactive user attempted access: {user.username}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="비활성화된 계정입니다"
|
||||
)
|
||||
|
||||
if user.is_locked():
|
||||
logger.warning(f"Locked user attempted access: {user.username}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="잠긴 계정입니다"
|
||||
)
|
||||
|
||||
# 사용자 정보와 토큰 페이로드 결합
|
||||
user_info = user.to_dict()
|
||||
user_info.update({
|
||||
'token_user_id': payload['user_id'],
|
||||
'token_username': payload['username'],
|
||||
'token_role': payload['role']
|
||||
})
|
||||
|
||||
return user_info
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Get current user error: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="인증 처리 중 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
async def get_current_active_user(
|
||||
self,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
) -> dict:
|
||||
"""
|
||||
현재 활성 사용자 정보 조회 (별칭)
|
||||
|
||||
Args:
|
||||
current_user: 현재 사용자 정보
|
||||
|
||||
Returns:
|
||||
dict: 사용자 정보
|
||||
"""
|
||||
return current_user
|
||||
|
||||
def require_roles(self, allowed_roles: List[str]):
|
||||
"""
|
||||
특정 역할을 요구하는 의존성 함수 생성
|
||||
|
||||
Args:
|
||||
allowed_roles: 허용된 역할 목록
|
||||
|
||||
Returns:
|
||||
Callable: 의존성 함수
|
||||
"""
|
||||
async def role_checker(
|
||||
current_user: dict = Depends(self.get_current_user)
|
||||
) -> dict:
|
||||
user_role = current_user.get('role')
|
||||
|
||||
if user_role not in allowed_roles:
|
||||
logger.warning(
|
||||
f"Access denied for user {current_user.get('username')} "
|
||||
f"with role {user_role}. Required roles: {allowed_roles}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"이 기능을 사용하려면 다음 권한이 필요합니다: {', '.join(allowed_roles)}"
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
return role_checker
|
||||
|
||||
def require_permissions(self, required_permissions: List[str]):
|
||||
"""
|
||||
특정 권한을 요구하는 의존성 함수 생성
|
||||
|
||||
Args:
|
||||
required_permissions: 필요한 권한 목록
|
||||
|
||||
Returns:
|
||||
Callable: 의존성 함수
|
||||
"""
|
||||
async def permission_checker(
|
||||
current_user: dict = Depends(self.get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
user_role = current_user.get('role')
|
||||
|
||||
# 사용자 권한 조회
|
||||
user_repo = UserRepository(db)
|
||||
user_permissions = user_repo.get_user_permissions(user_role)
|
||||
|
||||
# 필요한 권한 확인
|
||||
missing_permissions = []
|
||||
for permission in required_permissions:
|
||||
if permission not in user_permissions:
|
||||
missing_permissions.append(permission)
|
||||
|
||||
if missing_permissions:
|
||||
logger.warning(
|
||||
f"Permission denied for user {current_user.get('username')} "
|
||||
f"with role {user_role}. Missing permissions: {missing_permissions}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"이 기능을 사용하려면 다음 권한이 필요합니다: {', '.join(missing_permissions)}"
|
||||
)
|
||||
|
||||
# 사용자 정보에 권한 정보 추가
|
||||
current_user['permissions'] = user_permissions
|
||||
|
||||
return current_user
|
||||
|
||||
return permission_checker
|
||||
|
||||
def require_admin(self):
|
||||
"""관리자 권한을 요구하는 의존성 함수"""
|
||||
return self.require_roles(['admin', 'system'])
|
||||
|
||||
def require_leader_or_admin(self):
|
||||
"""팀장 이상 권한을 요구하는 의존성 함수"""
|
||||
return self.require_roles(['admin', 'system', 'leader'])
|
||||
|
||||
|
||||
# 전역 인증 미들웨어 인스턴스
|
||||
auth_middleware = AuthMiddleware()
|
||||
|
||||
# 편의를 위한 의존성 함수들
|
||||
get_current_user = auth_middleware.get_current_user
|
||||
get_current_active_user = auth_middleware.get_current_active_user
|
||||
require_admin = auth_middleware.require_admin
|
||||
require_leader_or_admin = auth_middleware.require_leader_or_admin
|
||||
|
||||
|
||||
def require_roles(allowed_roles: List[str]):
|
||||
"""역할 기반 접근 제어 데코레이터"""
|
||||
return auth_middleware.require_roles(allowed_roles)
|
||||
|
||||
|
||||
def require_permissions(required_permissions: List[str]):
|
||||
"""권한 기반 접근 제어 데코레이터"""
|
||||
return auth_middleware.require_permissions(required_permissions)
|
||||
|
||||
|
||||
# 추가 유틸리티 함수들
|
||||
async def get_user_from_token(token: str, db: Session) -> Optional[dict]:
|
||||
"""
|
||||
토큰에서 사용자 정보 추출 (미들웨어 없이 직접 사용)
|
||||
|
||||
Args:
|
||||
token: JWT 토큰
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
Optional[dict]: 사용자 정보 또는 None
|
||||
"""
|
||||
try:
|
||||
payload = jwt_service.verify_access_token(token)
|
||||
user_id = payload['user_id']
|
||||
|
||||
user_repo = UserRepository(db)
|
||||
user = user_repo.find_by_id(user_id)
|
||||
|
||||
if user and user.is_active and not user.is_locked():
|
||||
return user.to_dict()
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get user from token error: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def check_user_permission(user_role: str, required_permission: str, db: Session) -> bool:
|
||||
"""
|
||||
사용자 권한 확인
|
||||
|
||||
Args:
|
||||
user_role: 사용자 역할
|
||||
required_permission: 필요한 권한
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
bool: 권한 보유 여부
|
||||
"""
|
||||
try:
|
||||
user_repo = UserRepository(db)
|
||||
user_permissions = user_repo.get_user_permissions(user_role)
|
||||
return required_permission in user_permissions
|
||||
except Exception as e:
|
||||
logger.error(f"Check user permission error: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def get_user_permissions_by_role(role: str, db: Session) -> List[str]:
|
||||
"""
|
||||
역할별 권한 목록 조회
|
||||
|
||||
Args:
|
||||
role: 사용자 역할
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
List[str]: 권한 목록
|
||||
"""
|
||||
try:
|
||||
user_repo = UserRepository(db)
|
||||
return user_repo.get_user_permissions(role)
|
||||
except Exception as e:
|
||||
logger.error(f"Get user permissions by role error: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
# 선택적 인증 (토큰이 있으면 검증, 없으면 None 반환)
|
||||
async def get_current_user_optional(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
선택적 사용자 인증 (토큰이 있으면 검증, 없으면 None)
|
||||
|
||||
Args:
|
||||
request: FastAPI Request 객체
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
Optional[dict]: 사용자 정보 또는 None
|
||||
"""
|
||||
try:
|
||||
# Authorization 헤더 확인
|
||||
authorization = request.headers.get('authorization')
|
||||
if not authorization or not authorization.startswith('Bearer '):
|
||||
return None
|
||||
|
||||
token = authorization.split(' ')[1]
|
||||
return await get_user_from_token(token, db)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Optional auth failed: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
411
backend/app/auth/models.py
Normal file
411
backend/app/auth/models.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
인증 시스템 모델
|
||||
TK-FB-Project의 사용자 모델을 참고하여 SQLAlchemy 기반으로 구현
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
import bcrypt
|
||||
|
||||
from ..database import Base
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""사용자 모델"""
|
||||
__tablename__ = "users"
|
||||
|
||||
user_id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
password = Column(String(255), nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
email = Column(String(100), index=True)
|
||||
|
||||
# 권한 관리 - 3단계 시스템: system(제작자) > admin(관리자) > user(사용자)
|
||||
role = Column(String(20), default='user', nullable=False) # system, admin, user
|
||||
access_level = Column(String(20), default='worker', nullable=False) # 호환성 유지
|
||||
|
||||
# 계정 상태 관리
|
||||
is_active = Column(Boolean, default=True, nullable=False) # DEPRECATED: Use status instead
|
||||
status = Column(String(20), default='active', nullable=False) # pending, active, suspended, deleted
|
||||
failed_login_attempts = Column(Integer, default=0)
|
||||
locked_until = Column(DateTime, nullable=True)
|
||||
|
||||
# 추가 정보
|
||||
department = Column(String(50))
|
||||
position = Column(String(50))
|
||||
phone = Column(String(20))
|
||||
|
||||
# 타임스탬프
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
last_login_at = Column(DateTime, nullable=True)
|
||||
|
||||
# 관계 설정
|
||||
login_logs = relationship("LoginLog", back_populates="user", cascade="all, delete-orphan")
|
||||
sessions = relationship("UserSession", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(username='{self.username}', name='{self.name}', role='{self.role}')>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""사용자 정보를 딕셔너리로 변환 (비밀번호 제외)"""
|
||||
return {
|
||||
'user_id': self.user_id,
|
||||
'username': self.username,
|
||||
'name': self.name,
|
||||
'email': self.email,
|
||||
'role': self.role,
|
||||
'access_level': self.access_level,
|
||||
'is_active': self.is_active,
|
||||
'department': self.department,
|
||||
'position': self.position,
|
||||
'phone': self.phone,
|
||||
'created_at': self.created_at,
|
||||
'last_login_at': self.last_login_at
|
||||
}
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
"""비밀번호 확인"""
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode('utf-8'), self.password.encode('utf-8'))
|
||||
except Exception as e:
|
||||
logger.error(f"Password check failed for user {self.username}: {str(e)}")
|
||||
return False
|
||||
|
||||
def set_password(self, password: str):
|
||||
"""비밀번호 설정 (해싱)"""
|
||||
try:
|
||||
salt = bcrypt.gensalt()
|
||||
self.password = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.error(f"Password hashing failed for user {self.username}: {str(e)}")
|
||||
raise
|
||||
|
||||
def is_locked(self) -> bool:
|
||||
"""계정 잠금 상태 확인"""
|
||||
if self.locked_until is None:
|
||||
return False
|
||||
return datetime.utcnow() < self.locked_until
|
||||
|
||||
def lock_account(self, minutes: int = 15):
|
||||
"""계정 잠금"""
|
||||
self.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
|
||||
logger.warning(f"User account locked: {self.username} for {minutes} minutes")
|
||||
|
||||
def unlock_account(self):
|
||||
"""계정 잠금 해제"""
|
||||
self.locked_until = None
|
||||
self.failed_login_attempts = 0
|
||||
logger.info(f"User account unlocked: {self.username}")
|
||||
|
||||
def increment_failed_attempts(self):
|
||||
"""로그인 실패 횟수 증가"""
|
||||
self.failed_login_attempts += 1
|
||||
if self.failed_login_attempts >= 5:
|
||||
self.lock_account()
|
||||
|
||||
def reset_failed_attempts(self):
|
||||
"""로그인 실패 횟수 초기화"""
|
||||
self.failed_login_attempts = 0
|
||||
self.locked_until = None
|
||||
|
||||
def update_last_login(self):
|
||||
"""마지막 로그인 시간 업데이트"""
|
||||
self.last_login_at = datetime.utcnow()
|
||||
|
||||
# 권한 체크 메서드들
|
||||
def is_system(self) -> bool:
|
||||
"""시스템 관리자 권한 확인"""
|
||||
return self.role == 'system'
|
||||
|
||||
def is_admin(self) -> bool:
|
||||
"""관리자 권한 확인 (시스템 관리자 포함)"""
|
||||
return self.role in ['system', 'admin']
|
||||
|
||||
def is_user(self) -> bool:
|
||||
"""일반 사용자 권한 확인"""
|
||||
return self.role == 'user'
|
||||
|
||||
def can_create_users(self) -> bool:
|
||||
"""사용자 생성 권한 확인 (시스템 관리자만)"""
|
||||
return self.is_system()
|
||||
|
||||
def can_view_logs(self) -> bool:
|
||||
"""로그 조회 권한 확인 (관리자 이상)"""
|
||||
return self.is_admin()
|
||||
|
||||
def can_manage_system(self) -> bool:
|
||||
"""시스템 관리 권한 확인 (시스템 관리자만)"""
|
||||
return self.is_system()
|
||||
|
||||
def get_role_display_name(self) -> str:
|
||||
"""역할 표시명 반환"""
|
||||
role_names = {
|
||||
'system': '시스템 관리자',
|
||||
'admin': '관리자',
|
||||
'user': '사용자'
|
||||
}
|
||||
return role_names.get(self.role, '알 수 없음')
|
||||
|
||||
|
||||
class LoginLog(Base):
|
||||
"""로그인 이력 모델"""
|
||||
__tablename__ = "login_logs"
|
||||
|
||||
log_id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False)
|
||||
login_time = Column(DateTime, default=func.now())
|
||||
ip_address = Column(String(45))
|
||||
user_agent = Column(Text)
|
||||
login_status = Column(String(20), nullable=False) # 'success' or 'failed'
|
||||
failure_reason = Column(String(100))
|
||||
session_duration = Column(Integer) # 세션 지속 시간 (초)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
# 관계 설정
|
||||
user = relationship("User", back_populates="login_logs")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<LoginLog(user_id={self.user_id}, status='{self.login_status}', time='{self.login_time}')>"
|
||||
|
||||
|
||||
class UserSession(Base):
|
||||
"""사용자 세션 모델 (Refresh Token 관리)"""
|
||||
__tablename__ = "user_sessions"
|
||||
|
||||
session_id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False)
|
||||
refresh_token = Column(String(500), nullable=False, index=True)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
ip_address = Column(String(45))
|
||||
user_agent = Column(Text)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
# 관계 설정
|
||||
user = relationship("User", back_populates="sessions")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserSession(user_id={self.user_id}, expires_at='{self.expires_at}')>"
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""세션 만료 여부 확인"""
|
||||
return datetime.utcnow() > self.expires_at
|
||||
|
||||
def deactivate(self):
|
||||
"""세션 비활성화"""
|
||||
self.is_active = False
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
"""권한 모델"""
|
||||
__tablename__ = "permissions"
|
||||
|
||||
permission_id = Column(Integer, primary_key=True, index=True)
|
||||
permission_name = Column(String(50), unique=True, nullable=False)
|
||||
description = Column(Text)
|
||||
module = Column(String(30), index=True) # 모듈별 권한 관리
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Permission(name='{self.permission_name}', module='{self.module}')>"
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
"""역할-권한 매핑 모델"""
|
||||
__tablename__ = "role_permissions"
|
||||
|
||||
role_permission_id = Column(Integer, primary_key=True, index=True)
|
||||
role = Column(String(20), nullable=False, index=True)
|
||||
permission_id = Column(Integer, ForeignKey("permissions.permission_id", ondelete="CASCADE"))
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
# 관계 설정
|
||||
permission = relationship("Permission")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RolePermission(role='{self.role}', permission_id={self.permission_id})>"
|
||||
|
||||
|
||||
class UserRepository:
|
||||
"""사용자 데이터 접근 계층"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def find_by_username(self, username: str) -> Optional[User]:
|
||||
"""사용자명으로 사용자 조회"""
|
||||
try:
|
||||
return self.db.query(User).filter(User.username == username).first()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find user by username {username}: {str(e)}")
|
||||
return None
|
||||
|
||||
def find_by_id(self, user_id: int) -> Optional[User]:
|
||||
"""사용자 ID로 사용자 조회"""
|
||||
try:
|
||||
return self.db.query(User).filter(User.user_id == user_id).first()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find user by id {user_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
def find_by_email(self, email: str) -> Optional[User]:
|
||||
"""이메일로 사용자 조회"""
|
||||
try:
|
||||
return self.db.query(User).filter(User.email == email).first()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find user by email {email}: {str(e)}")
|
||||
return None
|
||||
|
||||
def create_user(self, user_data: Dict[str, Any]) -> User:
|
||||
"""새 사용자 생성"""
|
||||
try:
|
||||
user = User(**user_data)
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
logger.info(f"User created: {user.username}")
|
||||
return user
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to create user: {str(e)}")
|
||||
raise
|
||||
|
||||
def update_user(self, user: User) -> User:
|
||||
"""사용자 정보 업데이트"""
|
||||
try:
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
logger.info(f"User updated: {user.username}")
|
||||
return user
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to update user {user.username}: {str(e)}")
|
||||
raise
|
||||
|
||||
def delete_user(self, user: User):
|
||||
"""사용자 삭제"""
|
||||
try:
|
||||
self.db.delete(user)
|
||||
self.db.commit()
|
||||
logger.info(f"User deleted: {user.username}")
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to delete user {user.username}: {str(e)}")
|
||||
raise
|
||||
|
||||
def get_all_users(self, skip: int = 0, limit: int = 100) -> List[User]:
|
||||
"""활성 사용자만 조회 (status='active')"""
|
||||
try:
|
||||
# status 필드가 있으면 status='active', 없으면 is_active=True (하위 호환성)
|
||||
users = self.db.query(User)
|
||||
if hasattr(User, 'status'):
|
||||
users = users.filter(User.status == 'active')
|
||||
else:
|
||||
users = users.filter(User.is_active == True)
|
||||
return users.offset(skip).limit(limit).all()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get all users: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_user_permissions(self, role: str) -> List[str]:
|
||||
"""사용자 역할에 따른 권한 목록 조회"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT p.permission_name
|
||||
FROM permissions p
|
||||
JOIN role_permissions rp ON p.permission_id = rp.permission_id
|
||||
WHERE rp.role = :role
|
||||
""")
|
||||
result = self.db.execute(query, {"role": role})
|
||||
return [row[0] for row in result.fetchall()]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get permissions for role {role}: {str(e)}")
|
||||
return []
|
||||
|
||||
def record_login_log(self, user_id: int, ip_address: str, user_agent: str,
|
||||
status: str, failure_reason: str = None):
|
||||
"""로그인 이력 기록"""
|
||||
try:
|
||||
log = LoginLog(
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
login_status=status,
|
||||
failure_reason=failure_reason
|
||||
)
|
||||
self.db.add(log)
|
||||
self.db.commit()
|
||||
logger.debug(f"Login log recorded for user_id {user_id}: {status}")
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to record login log: {str(e)}")
|
||||
|
||||
def create_session(self, user_id: int, refresh_token: str, expires_at: datetime,
|
||||
ip_address: str, user_agent: str) -> UserSession:
|
||||
"""사용자 세션 생성"""
|
||||
try:
|
||||
session = UserSession(
|
||||
user_id=user_id,
|
||||
refresh_token=refresh_token,
|
||||
expires_at=expires_at,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
self.db.add(session)
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
logger.debug(f"Session created for user_id {user_id}")
|
||||
return session
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to create session: {str(e)}")
|
||||
raise
|
||||
|
||||
def find_session_by_token(self, refresh_token: str) -> Optional[UserSession]:
|
||||
"""리프레시 토큰으로 세션 조회"""
|
||||
try:
|
||||
return self.db.query(UserSession).filter(
|
||||
UserSession.refresh_token == refresh_token,
|
||||
UserSession.is_active == True
|
||||
).first()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find session by token: {str(e)}")
|
||||
return None
|
||||
|
||||
def deactivate_user_sessions(self, user_id: int):
|
||||
"""사용자의 모든 세션 비활성화"""
|
||||
try:
|
||||
self.db.query(UserSession).filter(
|
||||
UserSession.user_id == user_id
|
||||
).update({"is_active": False})
|
||||
self.db.commit()
|
||||
logger.info(f"All sessions deactivated for user_id {user_id}")
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to deactivate sessions for user_id {user_id}: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
198
backend/app/auth/setup_controller.py
Normal file
198
backend/app/auth/setup_controller.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
초기 시스템 설정 컨트롤러
|
||||
배포 후 첫 실행 시 시스템 관리자 계정 생성
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
|
||||
from ..database import get_db
|
||||
from .models import User, UserRepository
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SystemSetupRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
name: str
|
||||
email: Optional[EmailStr] = None
|
||||
department: Optional[str] = None
|
||||
position: Optional[str] = None
|
||||
|
||||
|
||||
class SystemSetupResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
user_id: Optional[int] = None
|
||||
setup_completed: bool
|
||||
|
||||
|
||||
@router.get("/setup/status")
|
||||
async def get_setup_status(db: Session = Depends(get_db)):
|
||||
"""
|
||||
시스템 초기 설정 상태 확인
|
||||
|
||||
Returns:
|
||||
Dict: 설정 완료 여부
|
||||
"""
|
||||
try:
|
||||
user_repo = UserRepository(db)
|
||||
|
||||
# 시스템 관리자가 존재하는지 확인
|
||||
system_admin = db.query(User).filter(User.role == 'system').first()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'setup_completed': system_admin is not None,
|
||||
'has_system_admin': system_admin is not None,
|
||||
'total_users': db.query(User).count()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Setup status check error: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="설정 상태 확인 중 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/setup/initialize", response_model=SystemSetupResponse)
|
||||
async def initialize_system(
|
||||
setup_data: SystemSetupRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
시스템 초기화 및 첫 번째 시스템 관리자 생성
|
||||
|
||||
Args:
|
||||
setup_data: 시스템 관리자 계정 정보
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
SystemSetupResponse: 설정 결과
|
||||
"""
|
||||
try:
|
||||
user_repo = UserRepository(db)
|
||||
|
||||
# 이미 시스템 관리자가 존재하는지 확인
|
||||
existing_admin = db.query(User).filter(User.role == 'system').first()
|
||||
if existing_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="시스템이 이미 초기화되었습니다"
|
||||
)
|
||||
|
||||
# 사용자명 중복 확인
|
||||
existing_user = user_repo.find_by_username(setup_data.username)
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미 존재하는 사용자명입니다"
|
||||
)
|
||||
|
||||
# 이메일 중복 확인 (이메일이 제공된 경우)
|
||||
if setup_data.email:
|
||||
existing_email = user_repo.find_by_email(setup_data.email)
|
||||
if existing_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미 존재하는 이메일입니다"
|
||||
)
|
||||
|
||||
# 시스템 관리자 계정 생성
|
||||
user = User(
|
||||
username=setup_data.username,
|
||||
name=setup_data.name,
|
||||
email=setup_data.email,
|
||||
role='system',
|
||||
access_level='system',
|
||||
department=setup_data.department,
|
||||
position=setup_data.position,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# 비밀번호 설정
|
||||
user.set_password(setup_data.password)
|
||||
|
||||
# 데이터베이스에 저장
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
logger.info(f"System initialized with admin user: {user.username}")
|
||||
|
||||
return SystemSetupResponse(
|
||||
success=True,
|
||||
message="시스템이 성공적으로 초기화되었습니다",
|
||||
user_id=user.user_id,
|
||||
setup_completed=True
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"System initialization error: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="시스템 초기화 중 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/setup/reset")
|
||||
async def reset_system_setup(
|
||||
confirm_reset: bool = False,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
시스템 설정 리셋 (개발/테스트 용도)
|
||||
|
||||
Args:
|
||||
confirm_reset: 리셋 확인
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
Dict: 리셋 결과
|
||||
"""
|
||||
try:
|
||||
if not confirm_reset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="리셋을 확인해주세요 (confirm_reset=true)"
|
||||
)
|
||||
|
||||
# 개발 환경에서만 허용
|
||||
from ..config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
if settings.environment != 'development':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="개발 환경에서만 시스템 리셋이 가능합니다"
|
||||
)
|
||||
|
||||
# 모든 사용자 삭제
|
||||
db.query(User).delete()
|
||||
db.commit()
|
||||
|
||||
logger.warning("System setup has been reset (development only)")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': '시스템 설정이 리셋되었습니다',
|
||||
'setup_completed': False
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"System reset error: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="시스템 리셋 중 오류가 발생했습니다"
|
||||
)
|
||||
337
backend/app/auth/signup_routes.py
Normal file
337
backend/app/auth/signup_routes.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
회원가입 요청 및 관리자 승인 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from ..database import get_db
|
||||
from .auth_service import AuthService
|
||||
from .models import UserRepository
|
||||
from .middleware import get_current_user
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix="/auth", tags=["signup"])
|
||||
|
||||
|
||||
class SignupRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
name: str
|
||||
email: Optional[str] = None
|
||||
department: Optional[str] = None
|
||||
position: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
reason: Optional[str] = None # 가입 사유
|
||||
|
||||
|
||||
@router.post("/signup-request")
|
||||
async def signup_request(
|
||||
signup_data: SignupRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
회원가입 요청 (관리자 승인 대기)
|
||||
|
||||
Args:
|
||||
signup_data: 가입 신청 정보
|
||||
|
||||
Returns:
|
||||
dict: 요청 결과
|
||||
"""
|
||||
try:
|
||||
# 중복 사용자명 확인
|
||||
user_repo = UserRepository(db)
|
||||
existing_user = user_repo.find_by_username(signup_data.username)
|
||||
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미 존재하는 사용자명입니다"
|
||||
)
|
||||
|
||||
# 중복 이메일 확인
|
||||
if signup_data.email:
|
||||
check_email = text("SELECT id FROM users WHERE email = :email")
|
||||
existing_email = db.execute(check_email, {"email": signup_data.email}).fetchone()
|
||||
|
||||
if existing_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미 등록된 이메일입니다"
|
||||
)
|
||||
|
||||
# 비밀번호 해싱
|
||||
import bcrypt
|
||||
hashed_password = bcrypt.hashpw(
|
||||
signup_data.password.encode('utf-8'),
|
||||
bcrypt.gensalt()
|
||||
).decode('utf-8')
|
||||
|
||||
# 승인 대기 상태로 사용자 생성
|
||||
new_user = user_repo.create_user({
|
||||
'username': signup_data.username,
|
||||
'password': hashed_password, # 필드명은 'password'
|
||||
'name': signup_data.name,
|
||||
'email': signup_data.email,
|
||||
'access_level': 'worker', # 기본 레벨 (승인 시 변경 가능)
|
||||
'department': signup_data.department,
|
||||
'position': signup_data.position,
|
||||
'phone': signup_data.phone,
|
||||
'role': 'user',
|
||||
'is_active': False, # 하위 호환성
|
||||
'status': 'pending' # 새로운 status 체계: 승인 대기
|
||||
})
|
||||
|
||||
# 가입 사유 저장 (notes 컬럼 활용)
|
||||
if signup_data.reason:
|
||||
update_notes = text("UPDATE users SET notes = :reason WHERE user_id = :user_id")
|
||||
db.execute(update_notes, {"reason": signup_data.reason, "user_id": new_user.user_id})
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "회원가입 요청이 전송되었습니다. 관리자 승인 후 이용 가능합니다.",
|
||||
"user_id": new_user.user_id,
|
||||
"username": new_user.username,
|
||||
"status": "pending_approval"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"회원가입 요청 실패: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"회원가입 요청 처리 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/signup-requests")
|
||||
async def get_signup_requests(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
회원가입 요청 목록 조회 (관리자 전용)
|
||||
|
||||
Returns:
|
||||
dict: 승인 대기 중인 사용자 목록
|
||||
"""
|
||||
try:
|
||||
# 관리자 권한 확인
|
||||
if current_user.get('role') not in ['admin', 'system']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자만 접근 가능합니다"
|
||||
)
|
||||
|
||||
# 승인 대기 중인 사용자 조회 (status='pending'인 사용자)
|
||||
query = text("""
|
||||
SELECT
|
||||
user_id, username, name, email, department, position,
|
||||
phone, created_at, role, is_active, status
|
||||
FROM users
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
|
||||
results = db.execute(query).fetchall()
|
||||
|
||||
pending_users = []
|
||||
for row in results:
|
||||
pending_users.append({
|
||||
"user_id": row.user_id,
|
||||
"id": row.user_id, # 호환성을 위해 둘 다 제공
|
||||
"username": row.username,
|
||||
"name": row.name,
|
||||
"email": row.email,
|
||||
"department": row.department,
|
||||
"position": row.position,
|
||||
"phone": row.phone,
|
||||
"role": row.role,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"requested_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"is_active": row.is_active
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"requests": pending_users,
|
||||
"count": len(pending_users)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"가입 요청 목록 조회 실패: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"가입 요청 목록 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pending-signups/count")
|
||||
async def get_pending_signups_count(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
승인 대기 중인 회원가입 수 조회 (관리자 전용)
|
||||
|
||||
Returns:
|
||||
dict: 승인 대기 중인 사용자 수
|
||||
"""
|
||||
try:
|
||||
# 관리자 권한 확인
|
||||
if current_user.get('role') not in ['admin', 'system']:
|
||||
return {"count": 0} # 관리자가 아니면 0 반환
|
||||
|
||||
# 승인 대기 중인 사용자 수 조회
|
||||
query = text("""
|
||||
SELECT COUNT(*) as count
|
||||
FROM users
|
||||
WHERE status = 'pending'
|
||||
""")
|
||||
|
||||
result = db.execute(query).fetchone()
|
||||
count = result.count if result else 0
|
||||
|
||||
return {"count": count}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"승인 대기 회원가입 수 조회 실패: {str(e)}")
|
||||
return {"count": 0} # 오류 시 0 반환
|
||||
|
||||
|
||||
@router.post("/approve-signup/{user_id}")
|
||||
async def approve_signup(
|
||||
user_id: int,
|
||||
access_level: str = 'worker',
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
회원가입 승인 (관리자 전용)
|
||||
|
||||
Args:
|
||||
user_id: 승인할 사용자 ID
|
||||
access_level: 부여할 접근 레벨 (worker, manager, admin)
|
||||
|
||||
Returns:
|
||||
dict: 승인 결과
|
||||
"""
|
||||
try:
|
||||
# 관리자 권한 확인
|
||||
if current_user.get('role') not in ['admin', 'system']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자만 접근 가능합니다"
|
||||
)
|
||||
|
||||
# 사용자 활성화 및 접근 레벨 설정
|
||||
update_query = text("""
|
||||
UPDATE users
|
||||
SET is_active = TRUE,
|
||||
status = 'active',
|
||||
access_level = :access_level,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = :user_id AND status = 'pending'
|
||||
RETURNING user_id as id, username, name
|
||||
""")
|
||||
|
||||
result = db.execute(update_query, {
|
||||
"user_id": user_id,
|
||||
"access_level": access_level
|
||||
}).fetchone()
|
||||
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="승인 대기 중인 사용자를 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"{result.name}님의 가입이 승인되었습니다",
|
||||
"user": {
|
||||
"id": result.id,
|
||||
"username": result.username,
|
||||
"name": result.name,
|
||||
"access_level": access_level
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"가입 승인 실패: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"가입 승인 처리 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/reject-signup/{user_id}")
|
||||
async def reject_signup(
|
||||
user_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
회원가입 거부 (관리자 전용)
|
||||
|
||||
Args:
|
||||
user_id: 거부할 사용자 ID
|
||||
|
||||
Returns:
|
||||
dict: 거부 결과
|
||||
"""
|
||||
try:
|
||||
# 관리자 권한 확인
|
||||
if current_user.get('role') not in ['admin', 'system']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자만 접근 가능합니다"
|
||||
)
|
||||
|
||||
# 승인 대기 사용자 삭제
|
||||
delete_query = text("""
|
||||
DELETE FROM users
|
||||
WHERE user_id = :user_id AND is_active = FALSE
|
||||
RETURNING username, name
|
||||
""")
|
||||
|
||||
result = db.execute(delete_query, {"user_id": user_id}).fetchone()
|
||||
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="승인 대기 중인 사용자를 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"{result.name}님의 가입 요청이 거부되었습니다"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"가입 거부 실패: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"가입 거부 처리 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
287
backend/app/config.py
Normal file
287
backend/app/config.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
TK-MP-Project 설정 관리
|
||||
환경별 설정을 중앙화하여 관리
|
||||
"""
|
||||
import os
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import Field, validator
|
||||
import json
|
||||
|
||||
|
||||
class DatabaseSettings(BaseSettings):
|
||||
"""데이터베이스 설정"""
|
||||
url: str = Field(
|
||||
default="postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom",
|
||||
description="데이터베이스 연결 URL"
|
||||
)
|
||||
pool_size: int = Field(default=10, description="연결 풀 크기")
|
||||
max_overflow: int = Field(default=20, description="최대 오버플로우")
|
||||
pool_timeout: int = Field(default=30, description="연결 타임아웃 (초)")
|
||||
pool_recycle: int = Field(default=3600, description="연결 재활용 시간 (초)")
|
||||
echo: bool = Field(default=False, description="SQL 로그 출력 여부")
|
||||
|
||||
class Config:
|
||||
env_prefix = "DB_"
|
||||
|
||||
|
||||
class RedisSettings(BaseSettings):
|
||||
"""Redis 설정"""
|
||||
url: str = Field(default="redis://redis:6379", description="Redis 연결 URL")
|
||||
max_connections: int = Field(default=20, description="최대 연결 수")
|
||||
socket_timeout: int = Field(default=5, description="소켓 타임아웃 (초)")
|
||||
socket_connect_timeout: int = Field(default=5, description="연결 타임아웃 (초)")
|
||||
retry_on_timeout: bool = Field(default=True, description="타임아웃 시 재시도")
|
||||
decode_responses: bool = Field(default=False, description="응답 디코딩 여부")
|
||||
|
||||
class Config:
|
||||
env_prefix = "REDIS_"
|
||||
|
||||
|
||||
class SecuritySettings(BaseSettings):
|
||||
"""보안 설정"""
|
||||
cors_origins: List[str] = Field(default=[], description="CORS 허용 도메인")
|
||||
cors_methods: List[str] = Field(
|
||||
default=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
description="CORS 허용 메서드"
|
||||
)
|
||||
cors_headers: List[str] = Field(
|
||||
default=["*"],
|
||||
description="CORS 허용 헤더"
|
||||
)
|
||||
cors_credentials: bool = Field(default=True, description="CORS 자격증명 허용")
|
||||
|
||||
# 파일 업로드 보안
|
||||
max_file_size: int = Field(default=50 * 1024 * 1024, description="최대 파일 크기 (bytes)")
|
||||
allowed_file_extensions: List[str] = Field(
|
||||
default=['.xlsx', '.xls', '.csv'],
|
||||
description="허용된 파일 확장자"
|
||||
)
|
||||
upload_path: str = Field(default="uploads", description="업로드 경로")
|
||||
|
||||
# API 보안
|
||||
api_key_header: str = Field(default="X-API-Key", description="API 키 헤더명")
|
||||
rate_limit_per_minute: int = Field(default=100, description="분당 요청 제한")
|
||||
|
||||
class Config:
|
||||
env_prefix = "SECURITY_"
|
||||
|
||||
|
||||
class LoggingSettings(BaseSettings):
|
||||
"""로깅 설정"""
|
||||
level: str = Field(default="INFO", description="로그 레벨")
|
||||
file_path: str = Field(default="logs/app.log", description="로그 파일 경로")
|
||||
max_file_size: int = Field(default=10 * 1024 * 1024, description="로그 파일 최대 크기")
|
||||
backup_count: int = Field(default=5, description="백업 파일 수")
|
||||
format: str = Field(
|
||||
default="%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s",
|
||||
description="로그 포맷"
|
||||
)
|
||||
date_format: str = Field(default="%Y-%m-%d %H:%M:%S", description="날짜 포맷")
|
||||
|
||||
# 환경별 로그 레벨
|
||||
development_level: str = Field(default="DEBUG", description="개발 환경 로그 레벨")
|
||||
production_level: str = Field(default="INFO", description="운영 환경 로그 레벨")
|
||||
test_level: str = Field(default="WARNING", description="테스트 환경 로그 레벨")
|
||||
|
||||
class Config:
|
||||
env_prefix = "LOG_"
|
||||
|
||||
|
||||
class PerformanceSettings(BaseSettings):
|
||||
"""성능 설정"""
|
||||
# 캐시 설정
|
||||
cache_ttl_default: int = Field(default=3600, description="기본 캐시 TTL (초)")
|
||||
cache_ttl_files: int = Field(default=300, description="파일 목록 캐시 TTL")
|
||||
cache_ttl_materials: int = Field(default=600, description="자재 목록 캐시 TTL")
|
||||
cache_ttl_jobs: int = Field(default=1800, description="작업 목록 캐시 TTL")
|
||||
cache_ttl_classification: int = Field(default=3600, description="분류 결과 캐시 TTL")
|
||||
cache_ttl_statistics: int = Field(default=900, description="통계 데이터 캐시 TTL")
|
||||
|
||||
# 파일 처리 설정
|
||||
chunk_size: int = Field(default=1000, description="파일 처리 청크 크기")
|
||||
max_workers: int = Field(default=4, description="최대 워커 수")
|
||||
memory_limit_mb: int = Field(default=512, description="메모리 제한 (MB)")
|
||||
|
||||
class Config:
|
||||
env_prefix = "PERF_"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""메인 애플리케이션 설정"""
|
||||
|
||||
# 기본 설정
|
||||
app_name: str = Field(default="TK-MP BOM Management API", description="애플리케이션 이름")
|
||||
app_version: str = Field(default="1.0.0", description="애플리케이션 버전")
|
||||
app_description: str = Field(
|
||||
default="자재 분류 및 프로젝트 관리 시스템",
|
||||
description="애플리케이션 설명"
|
||||
)
|
||||
debug: bool = Field(default=False, description="디버그 모드")
|
||||
|
||||
# 환경 설정
|
||||
environment: str = Field(
|
||||
default="development",
|
||||
description="실행 환경 (development, production, test, synology)"
|
||||
)
|
||||
|
||||
# 서버 설정
|
||||
host: str = Field(default="0.0.0.0", description="서버 호스트")
|
||||
port: int = Field(default=8000, description="서버 포트")
|
||||
reload: bool = Field(default=False, description="자동 재로드")
|
||||
workers: int = Field(default=1, description="워커 프로세스 수")
|
||||
|
||||
# 하위 설정들
|
||||
database: DatabaseSettings = Field(default_factory=DatabaseSettings)
|
||||
redis: RedisSettings = Field(default_factory=RedisSettings)
|
||||
security: SecuritySettings = Field(default_factory=SecuritySettings)
|
||||
logging: LoggingSettings = Field(default_factory=LoggingSettings)
|
||||
performance: PerformanceSettings = Field(default_factory=PerformanceSettings)
|
||||
|
||||
# 추가 설정
|
||||
timezone: str = Field(default="Asia/Seoul", description="시간대")
|
||||
language: str = Field(default="ko", description="기본 언어")
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
extra = "ignore"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._setup_environment_specific_settings()
|
||||
self._setup_cors_origins()
|
||||
self._validate_settings()
|
||||
|
||||
@validator('environment')
|
||||
def validate_environment(cls, v):
|
||||
"""환경 값 검증"""
|
||||
allowed_environments = ['development', 'production', 'test', 'synology']
|
||||
if v not in allowed_environments:
|
||||
raise ValueError(f'Environment must be one of: {allowed_environments}')
|
||||
return v
|
||||
|
||||
@validator('port')
|
||||
def validate_port(cls, v):
|
||||
"""포트 번호 검증"""
|
||||
if not 1 <= v <= 65535:
|
||||
raise ValueError('Port must be between 1 and 65535')
|
||||
return v
|
||||
|
||||
def _setup_environment_specific_settings(self):
|
||||
"""환경별 특정 설정 적용"""
|
||||
if self.environment == "development":
|
||||
self.debug = True
|
||||
self.reload = True
|
||||
self.database.echo = True
|
||||
self.logging.level = self.logging.development_level
|
||||
|
||||
elif self.environment == "production":
|
||||
self.debug = False
|
||||
self.reload = False
|
||||
self.database.echo = False
|
||||
self.logging.level = self.logging.production_level
|
||||
self.workers = max(2, os.cpu_count() or 1)
|
||||
|
||||
elif self.environment == "test":
|
||||
self.debug = False
|
||||
self.reload = False
|
||||
self.database.echo = False
|
||||
self.logging.level = self.logging.test_level
|
||||
# 테스트용 인메모리 데이터베이스
|
||||
self.database.url = "sqlite:///:memory:"
|
||||
|
||||
elif self.environment == "synology":
|
||||
self.debug = False
|
||||
self.reload = False
|
||||
self.host = "0.0.0.0"
|
||||
self.port = 10080
|
||||
|
||||
def _setup_cors_origins(self):
|
||||
"""환경별 CORS origins 설정"""
|
||||
if not self.security.cors_origins:
|
||||
cors_config = {
|
||||
"development": [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:13000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:13000"
|
||||
],
|
||||
"production": [
|
||||
"https://your-domain.com",
|
||||
"https://api.your-domain.com"
|
||||
],
|
||||
"synology": [
|
||||
"http://192.168.0.3:10173",
|
||||
"http://localhost:10173"
|
||||
],
|
||||
"test": [
|
||||
"http://testserver"
|
||||
]
|
||||
}
|
||||
|
||||
self.security.cors_origins = cors_config.get(
|
||||
self.environment,
|
||||
cors_config["development"]
|
||||
)
|
||||
|
||||
def _validate_settings(self):
|
||||
"""설정 검증"""
|
||||
# 로그 디렉토리 생성
|
||||
log_dir = Path(self.logging.file_path).parent
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 업로드 디렉토리 생성
|
||||
upload_dir = Path(self.security.upload_path)
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def get_database_url(self) -> str:
|
||||
"""데이터베이스 URL 반환"""
|
||||
return self.database.url
|
||||
|
||||
def get_redis_url(self) -> str:
|
||||
"""Redis URL 반환"""
|
||||
return self.redis.url
|
||||
|
||||
def is_development(self) -> bool:
|
||||
"""개발 환경 여부"""
|
||||
return self.environment == "development"
|
||||
|
||||
def is_production(self) -> bool:
|
||||
"""운영 환경 여부"""
|
||||
return self.environment == "production"
|
||||
|
||||
def is_test(self) -> bool:
|
||||
"""테스트 환경 여부"""
|
||||
return self.environment == "test"
|
||||
|
||||
def get_cors_config(self) -> Dict[str, Any]:
|
||||
"""CORS 설정 반환"""
|
||||
return {
|
||||
"allow_origins": self.security.cors_origins,
|
||||
"allow_methods": self.security.cors_methods,
|
||||
"allow_headers": self.security.cors_headers,
|
||||
"allow_credentials": self.security.cors_credentials
|
||||
}
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""설정을 딕셔너리로 변환"""
|
||||
return self.dict()
|
||||
|
||||
def save_to_file(self, file_path: str):
|
||||
"""설정을 파일로 저장"""
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.to_dict(), f, indent=2, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
# 전역 설정 인스턴스
|
||||
settings = Settings()
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""설정 인스턴스 반환"""
|
||||
return settings
|
||||
@@ -3,11 +3,22 @@ from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import os
|
||||
|
||||
# 데이터베이스 URL
|
||||
DATABASE_URL = "postgresql://tkmp_user:tkmp_password_2025@localhost:5432/tk_mp_bom"
|
||||
# 데이터베이스 URL (환경변수에서 읽거나 기본값 사용)
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom"
|
||||
)
|
||||
|
||||
# SQLAlchemy 엔진 생성
|
||||
engine = create_engine(DATABASE_URL)
|
||||
# SQLAlchemy 엔진 생성 (UTF-8 인코딩 설정)
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={
|
||||
"client_encoding": "utf8",
|
||||
"options": "-c client_encoding=utf8 -c timezone=UTC"
|
||||
},
|
||||
pool_pre_ping=True,
|
||||
echo=False
|
||||
)
|
||||
|
||||
# 세션 팩토리 생성
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
@@ -7,162 +7,154 @@ from fastapi import Depends
|
||||
from typing import Optional, List, Dict
|
||||
import os
|
||||
import shutil
|
||||
# 설정 및 로깅 import
|
||||
from .config import get_settings
|
||||
from .utils.logger import get_logger
|
||||
from .utils.error_handlers import setup_error_handlers
|
||||
|
||||
# FastAPI 앱 생성
|
||||
# 설정 로드
|
||||
settings = get_settings()
|
||||
|
||||
# 로거 설정
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# FastAPI 앱 생성 (요청 크기 제한 증가)
|
||||
app = FastAPI(
|
||||
title="TK-MP BOM Management API",
|
||||
title=settings.app_name,
|
||||
description="자재 분류 및 프로젝트 관리 시스템",
|
||||
version="1.0.0"
|
||||
version=settings.app_version,
|
||||
debug=settings.debug
|
||||
)
|
||||
|
||||
# CORS 설정
|
||||
# 요청 크기 제한 설정 (100MB로 증가)
|
||||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app, max_request_size: int = 100 * 1024 * 1024): # 100MB
|
||||
super().__init__(app)
|
||||
self.max_request_size = max_request_size
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if "content-length" in request.headers:
|
||||
content_length = int(request.headers["content-length"])
|
||||
if content_length > self.max_request_size:
|
||||
return Response("Request Entity Too Large", status_code=413)
|
||||
return await call_next(request)
|
||||
|
||||
# 요청 크기 제한 미들웨어 추가
|
||||
app.add_middleware(RequestSizeLimitMiddleware, max_request_size=100 * 1024 * 1024)
|
||||
|
||||
# 에러 핸들러 설정
|
||||
setup_error_handlers(app)
|
||||
|
||||
# CORS 설정 (환경별 분리)
|
||||
cors_config = settings.get_cors_config()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
**cors_config
|
||||
)
|
||||
|
||||
# 라우터들 import 및 등록
|
||||
logger.info(f"CORS origins configured for {settings.environment}: {settings.security.cors_origins}")
|
||||
|
||||
# 라우터들 import 및 등록 - files 라우터를 최우선으로 등록
|
||||
try:
|
||||
from .routers import files
|
||||
app.include_router(files.router, prefix="/files", tags=["files"])
|
||||
logger.info("FILES 라우터 등록 완료 - 최우선")
|
||||
except ImportError:
|
||||
print("files 라우터를 찾을 수 없습니다")
|
||||
logger.warning("files 라우터를 찾을 수 없습니다")
|
||||
|
||||
try:
|
||||
from .routers import jobs
|
||||
app.include_router(jobs.router, prefix="/jobs", tags=["jobs"])
|
||||
except ImportError:
|
||||
print("jobs 라우터를 찾을 수 없습니다")
|
||||
logger.warning("jobs 라우터를 찾을 수 없습니다")
|
||||
|
||||
try:
|
||||
from .routers import purchase
|
||||
app.include_router(purchase.router, tags=["purchase"])
|
||||
except ImportError:
|
||||
print("purchase 라우터를 찾을 수 없습니다")
|
||||
logger.warning("purchase 라우터를 찾을 수 없습니다")
|
||||
|
||||
try:
|
||||
from .routers import material_comparison
|
||||
app.include_router(material_comparison.router, tags=["material-comparison"])
|
||||
except ImportError:
|
||||
print("material_comparison 라우터를 찾을 수 없습니다")
|
||||
logger.warning("material_comparison 라우터를 찾을 수 없습니다")
|
||||
|
||||
# 파일 목록 조회 API
|
||||
@app.get("/files")
|
||||
async def get_files(
|
||||
job_no: Optional[str] = None, # project_id 대신 job_no 사용
|
||||
show_history: bool = False, # 이력 표시 여부
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""파일 목록 조회 (BOM별 그룹화)"""
|
||||
try:
|
||||
if show_history:
|
||||
# 전체 이력 표시
|
||||
query = "SELECT * FROM files"
|
||||
params = {}
|
||||
|
||||
if job_no:
|
||||
query += " WHERE job_no = :job_no"
|
||||
params["job_no"] = job_no
|
||||
|
||||
query += " ORDER BY original_filename, revision DESC"
|
||||
else:
|
||||
# 최신 리비전만 표시
|
||||
if job_no:
|
||||
query = """
|
||||
SELECT f1.* FROM files f1
|
||||
INNER JOIN (
|
||||
SELECT original_filename, MAX(revision) as max_revision
|
||||
FROM files
|
||||
WHERE job_no = :job_no
|
||||
GROUP BY original_filename
|
||||
) f2 ON f1.original_filename = f2.original_filename
|
||||
AND f1.revision = f2.max_revision
|
||||
WHERE f1.job_no = :job_no
|
||||
ORDER BY f1.upload_date DESC
|
||||
"""
|
||||
params = {"job_no": job_no}
|
||||
else:
|
||||
# job_no가 없으면 전체 파일 조회
|
||||
query = "SELECT * FROM files ORDER BY upload_date DESC"
|
||||
params = {}
|
||||
|
||||
result = db.execute(text(query), params)
|
||||
files = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": f.id,
|
||||
"filename": f.original_filename,
|
||||
"original_filename": f.original_filename,
|
||||
"name": f.original_filename,
|
||||
"job_no": f.job_no, # job_no 사용
|
||||
"bom_name": f.bom_name or f.original_filename, # 실제 bom_name 값 사용, 없으면 파일명
|
||||
"revision": f.revision or "Rev.0", # 실제 리비전 또는 기본값
|
||||
"parsed_count": f.parsed_count or 0, # 파싱된 자재 수
|
||||
"bom_type": f.file_type or "unknown", # file_type을 BOM 종류로 사용
|
||||
"status": "active" if f.is_active else "inactive", # is_active 상태
|
||||
"file_size": f.file_size,
|
||||
"created_at": f.upload_date,
|
||||
"upload_date": f.upload_date,
|
||||
"description": f"파일: {f.original_filename}"
|
||||
}
|
||||
for f in files
|
||||
]
|
||||
except Exception as e:
|
||||
print(f"파일 목록 조회 에러: {str(e)}")
|
||||
return {"error": f"파일 목록 조회 실패: {str(e)}"}
|
||||
try:
|
||||
from .routers import dashboard
|
||||
app.include_router(dashboard.router, tags=["dashboard"])
|
||||
except ImportError:
|
||||
logger.warning("dashboard 라우터를 찾을 수 없습니다")
|
||||
|
||||
# 파일 삭제 API
|
||||
@app.delete("/files/{file_id}")
|
||||
async def delete_file(
|
||||
file_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""파일 삭제"""
|
||||
try:
|
||||
# 먼저 파일 정보 조회
|
||||
file_query = text("SELECT * FROM files WHERE id = :file_id")
|
||||
file_result = db.execute(file_query, {"file_id": file_id})
|
||||
file = file_result.fetchone()
|
||||
|
||||
if not file:
|
||||
return {"error": "파일을 찾을 수 없습니다"}
|
||||
|
||||
# 먼저 상세 테이블의 데이터 삭제 (외래 키 제약 조건 때문)
|
||||
# 각 자재 타입별 상세 테이블 데이터 삭제
|
||||
detail_tables = [
|
||||
'pipe_details', 'fitting_details', 'valve_details',
|
||||
'flange_details', 'bolt_details', 'gasket_details',
|
||||
'instrument_details'
|
||||
]
|
||||
|
||||
# 해당 파일의 materials ID 조회
|
||||
material_ids_query = text("SELECT id FROM materials WHERE file_id = :file_id")
|
||||
material_ids_result = db.execute(material_ids_query, {"file_id": file_id})
|
||||
material_ids = [row[0] for row in material_ids_result]
|
||||
|
||||
if material_ids:
|
||||
# 각 상세 테이블에서 관련 데이터 삭제
|
||||
for table in detail_tables:
|
||||
delete_detail_query = text(f"DELETE FROM {table} WHERE material_id = ANY(:material_ids)")
|
||||
db.execute(delete_detail_query, {"material_ids": material_ids})
|
||||
|
||||
# materials 테이블 데이터 삭제
|
||||
materials_query = text("DELETE FROM materials WHERE file_id = :file_id")
|
||||
db.execute(materials_query, {"file_id": file_id})
|
||||
|
||||
# 파일 삭제
|
||||
delete_query = text("DELETE FROM files WHERE id = :file_id")
|
||||
db.execute(delete_query, {"file_id": file_id})
|
||||
|
||||
db.commit()
|
||||
return {"success": True, "message": "파일과 관련 데이터가 삭제되었습니다"}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return {"error": f"파일 삭제 실패: {str(e)}"}
|
||||
# 리비전 관리 라우터 (임시 비활성화)
|
||||
# try:
|
||||
# from .routers import revision_management
|
||||
# app.include_router(revision_management.router, tags=["revision-management"])
|
||||
# except ImportError:
|
||||
# logger.warning("revision_management 라우터를 찾을 수 없습니다")
|
||||
|
||||
try:
|
||||
from .routers import tubing
|
||||
app.include_router(tubing.router, prefix="/tubing", tags=["tubing"])
|
||||
except ImportError:
|
||||
logger.warning("tubing 라우터를 찾을 수 없습니다")
|
||||
|
||||
# 구매 추적 라우터
|
||||
try:
|
||||
from .routers import purchase_tracking
|
||||
app.include_router(purchase_tracking.router)
|
||||
except ImportError:
|
||||
logger.warning("purchase_tracking 라우터를 찾을 수 없습니다")
|
||||
|
||||
# 엑셀 내보내기 관리 라우터
|
||||
try:
|
||||
from .routers import export_manager
|
||||
app.include_router(export_manager.router)
|
||||
except ImportError:
|
||||
logger.warning("export_manager 라우터를 찾을 수 없습니다")
|
||||
|
||||
# 구매신청 관리 라우터
|
||||
try:
|
||||
from .routers import purchase_request
|
||||
app.include_router(purchase_request.router)
|
||||
logger.info("purchase_request 라우터 등록 완료")
|
||||
except ImportError as e:
|
||||
logger.warning(f"purchase_request 라우터를 찾을 수 없습니다: {e}")
|
||||
|
||||
# 자재 관리 라우터
|
||||
try:
|
||||
from .routers import materials
|
||||
app.include_router(materials.router)
|
||||
logger.info("materials 라우터 등록 완료")
|
||||
except ImportError as e:
|
||||
logger.warning(f"materials 라우터를 찾을 수 없습니다: {e}")
|
||||
|
||||
# 파일 관리 API 라우터 등록 (비활성화 - files 라우터와 충돌 방지)
|
||||
# try:
|
||||
# from .api import file_management
|
||||
# app.include_router(file_management.router, tags=["file-management"])
|
||||
# logger.info("파일 관리 API 라우터 등록 완료")
|
||||
# except ImportError as e:
|
||||
# logger.warning(f"파일 관리 라우터를 찾을 수 없습니다: {e}")
|
||||
logger.info("파일 관리 API 라우터 비활성화됨 (files 라우터 사용)")
|
||||
|
||||
# 인증 API 라우터 등록
|
||||
try:
|
||||
from .auth import auth_router, setup_router
|
||||
from .auth.signup_routes import router as signup_router
|
||||
app.include_router(auth_router, prefix="/auth", tags=["authentication"])
|
||||
app.include_router(setup_router, prefix="/setup", tags=["system-setup"])
|
||||
app.include_router(signup_router, tags=["signup"])
|
||||
logger.info("인증 API 라우터 등록 완료")
|
||||
logger.info("시스템 설정 API 라우터 등록 완료")
|
||||
logger.info("회원가입 API 라우터 등록 완료")
|
||||
except ImportError as e:
|
||||
logger.warning(f"인증 라우터를 찾을 수 없습니다: {e}")
|
||||
|
||||
# 프로젝트 관리 API (비활성화 - jobs 테이블 사용)
|
||||
# projects 테이블은 더 이상 사용하지 않음
|
||||
@@ -249,6 +241,14 @@ async def root():
|
||||
# print(f"Jobs 조회 에러: {str(e)}")
|
||||
# return {"error": f"Jobs 조회 실패: {str(e)}"}
|
||||
|
||||
# 리비전 관리 라우터
|
||||
try:
|
||||
from .routers import revision_management
|
||||
app.include_router(revision_management.router)
|
||||
logger.info("revision_management 라우터 등록 완료")
|
||||
except ImportError as e:
|
||||
logger.warning(f"revision_management 라우터를 찾을 수 없습니다: {e}")
|
||||
|
||||
# 파일 업로드는 /files/upload 엔드포인트를 사용하세요 (routers/files.py)
|
||||
|
||||
# parse_file과 classify_material_item 함수는 routers/files.py로 이동되었습니다
|
||||
|
||||
@@ -70,6 +70,25 @@ class Material(Base):
|
||||
drawing_reference = Column(String(100))
|
||||
notes = Column(Text)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# 추가 필드들
|
||||
main_nom = Column(String(50))
|
||||
red_nom = Column(String(50))
|
||||
purchase_confirmed = Column(Boolean, default=False)
|
||||
purchase_confirmed_at = Column(DateTime)
|
||||
purchase_status = Column(String(20), default='not_purchased')
|
||||
purchase_confirmed_by = Column(String(100))
|
||||
confirmed_quantity = Column(Numeric(10, 3))
|
||||
revision_status = Column(String(20), default='active')
|
||||
material_hash = Column(String(100))
|
||||
normalized_description = Column(Text)
|
||||
full_material_grade = Column(String(100))
|
||||
row_number = Column(Integer)
|
||||
length = Column(Numeric(10, 3))
|
||||
brand = Column(String(100))
|
||||
user_requirement = Column(Text)
|
||||
total_length = Column(Numeric(10, 3))
|
||||
|
||||
# 관계 설정
|
||||
file = relationship("File", back_populates="materials")
|
||||
@@ -276,8 +295,7 @@ class RequirementType(Base):
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
requirements = relationship("UserRequirement", back_populates="requirement_type")
|
||||
# 관계 설정은 문자열 기반이므로 제거
|
||||
|
||||
class UserRequirement(Base):
|
||||
"""사용자 추가 요구사항"""
|
||||
@@ -285,6 +303,7 @@ class UserRequirement(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
file_id = Column(Integer, ForeignKey("files.id"), nullable=False)
|
||||
material_id = Column(Integer, ForeignKey("materials.id"), nullable=True) # 자재 ID (개별 자재별 요구사항 연결)
|
||||
|
||||
# 요구사항 타입
|
||||
requirement_type = Column(String(50), nullable=False) # 'IMPACT_TEST', 'HEAT_TREATMENT', 'CUSTOM_SPEC', 'CERTIFICATION' 등
|
||||
@@ -308,4 +327,145 @@ class UserRequirement(Base):
|
||||
|
||||
# 관계 설정
|
||||
file = relationship("File", backref="user_requirements")
|
||||
requirement_type_rel = relationship("RequirementType", back_populates="requirements")
|
||||
|
||||
# ========== Tubing 시스템 모델들 ==========
|
||||
|
||||
class TubingCategory(Base):
|
||||
"""Tubing 카테고리 (일반, VCR, 위생용 등)"""
|
||||
__tablename__ = "tubing_categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
category_code = Column(String(20), unique=True, nullable=False)
|
||||
category_name = Column(String(100), nullable=False)
|
||||
description = Column(Text)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
specifications = relationship("TubingSpecification", back_populates="category")
|
||||
|
||||
class TubingSpecification(Base):
|
||||
"""Tubing 규격 마스터"""
|
||||
__tablename__ = "tubing_specifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
category_id = Column(Integer, ForeignKey("tubing_categories.id"))
|
||||
spec_code = Column(String(50), unique=True, nullable=False)
|
||||
spec_name = Column(String(200), nullable=False)
|
||||
|
||||
# 물리적 규격
|
||||
outer_diameter_mm = Column(Numeric(8, 3))
|
||||
wall_thickness_mm = Column(Numeric(6, 3))
|
||||
inner_diameter_mm = Column(Numeric(8, 3))
|
||||
|
||||
# 재질 정보
|
||||
material_grade = Column(String(100))
|
||||
material_standard = Column(String(100))
|
||||
|
||||
# 압력/온도 등급
|
||||
max_pressure_bar = Column(Numeric(8, 2))
|
||||
max_temperature_c = Column(Numeric(6, 2))
|
||||
min_temperature_c = Column(Numeric(6, 2))
|
||||
|
||||
# 표준 규격
|
||||
standard_length_m = Column(Numeric(8, 3))
|
||||
bend_radius_min_mm = Column(Numeric(8, 2))
|
||||
|
||||
# 기타 정보
|
||||
surface_finish = Column(String(100))
|
||||
hardness = Column(String(50))
|
||||
notes = Column(Text)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
category = relationship("TubingCategory", back_populates="specifications")
|
||||
products = relationship("TubingProduct", back_populates="specification")
|
||||
|
||||
class TubingManufacturer(Base):
|
||||
"""Tubing 제조사"""
|
||||
__tablename__ = "tubing_manufacturers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
manufacturer_code = Column(String(20), unique=True, nullable=False)
|
||||
manufacturer_name = Column(String(200), nullable=False)
|
||||
country = Column(String(100))
|
||||
website = Column(String(500))
|
||||
contact_info = Column(JSON) # JSONB 타입
|
||||
quality_certs = Column(JSON) # JSONB 타입
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
products = relationship("TubingProduct", back_populates="manufacturer")
|
||||
|
||||
class TubingProduct(Base):
|
||||
"""제조사별 Tubing 제품 (품목번호 매핑)"""
|
||||
__tablename__ = "tubing_products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
specification_id = Column(Integer, ForeignKey("tubing_specifications.id"))
|
||||
manufacturer_id = Column(Integer, ForeignKey("tubing_manufacturers.id"))
|
||||
|
||||
# 제조사 품목번호 정보
|
||||
manufacturer_part_number = Column(String(200), nullable=False)
|
||||
manufacturer_product_name = Column(String(300))
|
||||
|
||||
# 가격/공급 정보
|
||||
list_price = Column(Numeric(12, 2))
|
||||
currency = Column(String(10), default='KRW')
|
||||
lead_time_days = Column(Integer)
|
||||
minimum_order_qty = Column(Numeric(10, 3))
|
||||
standard_packaging_qty = Column(Numeric(10, 3))
|
||||
|
||||
# 가용성 정보
|
||||
availability_status = Column(String(50))
|
||||
last_price_update = Column(DateTime)
|
||||
|
||||
# 추가 정보
|
||||
datasheet_url = Column(String(500))
|
||||
catalog_page = Column(String(100))
|
||||
notes = Column(Text)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
specification = relationship("TubingSpecification", back_populates="products")
|
||||
manufacturer = relationship("TubingManufacturer", back_populates="products")
|
||||
material_mappings = relationship("MaterialTubingMapping", back_populates="tubing_product")
|
||||
|
||||
class MaterialTubingMapping(Base):
|
||||
"""BOM 자재와 Tubing 제품 매핑"""
|
||||
__tablename__ = "material_tubing_mapping"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
material_id = Column(Integer, ForeignKey("materials.id", ondelete="CASCADE"))
|
||||
tubing_product_id = Column(Integer, ForeignKey("tubing_products.id"))
|
||||
|
||||
# 매핑 정보
|
||||
confidence_score = Column(Numeric(3, 2))
|
||||
mapping_method = Column(String(50))
|
||||
mapped_by = Column(String(100))
|
||||
mapped_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 수량 정보
|
||||
required_length_m = Column(Numeric(10, 3))
|
||||
calculated_quantity = Column(Numeric(10, 3))
|
||||
|
||||
# 검증 정보
|
||||
is_verified = Column(Boolean, default=False)
|
||||
verified_by = Column(String(100))
|
||||
verified_at = Column(DateTime)
|
||||
|
||||
notes = Column(Text)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
material = relationship("Material", backref="tubing_mappings")
|
||||
tubing_product = relationship("TubingProduct", back_populates="material_mappings")
|
||||
|
||||
610
backend/app/routers/dashboard.py
Normal file
610
backend/app/routers/dashboard.py
Normal file
@@ -0,0 +1,610 @@
|
||||
"""
|
||||
대시보드 API
|
||||
사용자별 맞춤형 대시보드 데이터 제공
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, func
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..database import get_db
|
||||
from ..auth.middleware import get_current_user
|
||||
from ..services.activity_logger import ActivityLogger
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_dashboard_stats(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
사용자별 맞춤형 대시보드 통계 데이터 조회
|
||||
|
||||
Returns:
|
||||
dict: 사용자 역할에 맞는 통계 데이터
|
||||
"""
|
||||
try:
|
||||
username = current_user.get('username')
|
||||
user_role = current_user.get('role', 'user')
|
||||
|
||||
# 역할별 맞춤 통계 생성
|
||||
if user_role == 'admin':
|
||||
stats = await get_admin_stats(db)
|
||||
elif user_role == 'manager':
|
||||
stats = await get_manager_stats(db, username)
|
||||
elif user_role == 'designer':
|
||||
stats = await get_designer_stats(db, username)
|
||||
elif user_role == 'purchaser':
|
||||
stats = await get_purchaser_stats(db, username)
|
||||
else:
|
||||
stats = await get_user_stats(db, username)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"user_role": user_role,
|
||||
"stats": stats
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Dashboard stats error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"대시보드 통계 조회 실패: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/activities")
|
||||
async def get_user_activities(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
사용자 활동 이력 조회
|
||||
|
||||
Args:
|
||||
limit: 조회할 활동 수 (1-50)
|
||||
|
||||
Returns:
|
||||
dict: 사용자 활동 이력
|
||||
"""
|
||||
try:
|
||||
username = current_user.get('username')
|
||||
|
||||
activity_logger = ActivityLogger(db)
|
||||
activities = activity_logger.get_user_activities(
|
||||
username=username,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"activities": activities,
|
||||
"total": len(activities)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"User activities error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"활동 이력 조회 실패: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/recent-activities")
|
||||
async def get_recent_activities(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
days: int = Query(7, ge=1, le=30),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
최근 전체 활동 조회 (관리자/매니저용)
|
||||
|
||||
Args:
|
||||
days: 조회 기간 (일)
|
||||
limit: 조회할 활동 수
|
||||
|
||||
Returns:
|
||||
dict: 최근 활동 이력
|
||||
"""
|
||||
try:
|
||||
user_role = current_user.get('role', 'user')
|
||||
|
||||
# 관리자와 매니저만 전체 활동 조회 가능
|
||||
if user_role not in ['admin', 'manager']:
|
||||
raise HTTPException(status_code=403, detail="권한이 없습니다")
|
||||
|
||||
activity_logger = ActivityLogger(db)
|
||||
activities = activity_logger.get_recent_activities(
|
||||
days=days,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"activities": activities,
|
||||
"period_days": days,
|
||||
"total": len(activities)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Recent activities error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"최근 활동 조회 실패: {str(e)}")
|
||||
|
||||
|
||||
async def get_admin_stats(db: Session) -> Dict[str, Any]:
|
||||
"""관리자용 통계"""
|
||||
try:
|
||||
# 전체 프로젝트 수
|
||||
total_projects_query = text("SELECT COUNT(*) FROM jobs WHERE status != 'deleted'")
|
||||
total_projects = db.execute(total_projects_query).scalar()
|
||||
|
||||
# 활성 사용자 수 (최근 30일 로그인)
|
||||
active_users_query = text("""
|
||||
SELECT COUNT(DISTINCT username)
|
||||
FROM user_activity_logs
|
||||
WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '30 days'
|
||||
""")
|
||||
active_users = db.execute(active_users_query).scalar() or 0
|
||||
|
||||
# 오늘 업로드된 파일 수
|
||||
today_uploads_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM files
|
||||
WHERE DATE(upload_date) = CURRENT_DATE
|
||||
""")
|
||||
today_uploads = db.execute(today_uploads_query).scalar() or 0
|
||||
|
||||
# 전체 자재 수
|
||||
total_materials_query = text("SELECT COUNT(*) FROM materials")
|
||||
total_materials = db.execute(total_materials_query).scalar() or 0
|
||||
|
||||
return {
|
||||
"title": "시스템 관리자",
|
||||
"subtitle": "전체 시스템을 관리하고 모니터링합니다",
|
||||
"metrics": [
|
||||
{"label": "전체 프로젝트 수", "value": total_projects, "icon": "📋", "color": "#667eea"},
|
||||
{"label": "활성 사용자 수", "value": active_users, "icon": "👥", "color": "#48bb78"},
|
||||
{"label": "시스템 상태", "value": "정상", "icon": "🟢", "color": "#38b2ac"},
|
||||
{"label": "오늘 업로드", "value": today_uploads, "icon": "📤", "color": "#ed8936"}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Admin stats error: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
async def get_manager_stats(db: Session, username: str) -> Dict[str, Any]:
|
||||
"""매니저용 통계"""
|
||||
try:
|
||||
# 담당 프로젝트 수 (향후 assigned_to 필드 활용)
|
||||
assigned_projects_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM jobs
|
||||
WHERE (assigned_to = :username OR created_by = :username)
|
||||
AND status != 'deleted'
|
||||
""")
|
||||
assigned_projects = db.execute(assigned_projects_query, {"username": username}).scalar() or 0
|
||||
|
||||
# 이번 주 완료된 작업 (활동 로그 기반)
|
||||
week_completed_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM user_activity_logs
|
||||
WHERE activity_type IN ('PROJECT_CREATE', 'PURCHASE_CONFIRM')
|
||||
AND created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'
|
||||
""")
|
||||
week_completed = db.execute(week_completed_query).scalar() or 0
|
||||
|
||||
# 승인 대기 (구매 확정 대기 등)
|
||||
pending_approvals_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM material_purchase_tracking
|
||||
WHERE purchase_status = 'PENDING'
|
||||
OR purchase_status = 'REQUESTED'
|
||||
""")
|
||||
pending_approvals = db.execute(pending_approvals_query).scalar() or 0
|
||||
|
||||
return {
|
||||
"title": "프로젝트 매니저",
|
||||
"subtitle": "팀 프로젝트를 관리하고 진행상황을 모니터링합니다",
|
||||
"metrics": [
|
||||
{"label": "담당 프로젝트", "value": assigned_projects, "icon": "📋", "color": "#667eea"},
|
||||
{"label": "팀 진행률", "value": "87%", "icon": "📈", "color": "#48bb78"},
|
||||
{"label": "승인 대기", "value": pending_approvals, "icon": "⏳", "color": "#ed8936"},
|
||||
{"label": "이번 주 완료", "value": week_completed, "icon": "✅", "color": "#38b2ac"}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Manager stats error: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
async def get_designer_stats(db: Session, username: str) -> Dict[str, Any]:
|
||||
"""설계자용 통계"""
|
||||
try:
|
||||
# 내가 업로드한 BOM 파일 수
|
||||
my_files_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM files
|
||||
WHERE uploaded_by = :username
|
||||
AND is_active = true
|
||||
""")
|
||||
my_files = db.execute(my_files_query, {"username": username}).scalar() or 0
|
||||
|
||||
# 분류된 자재 수
|
||||
classified_materials_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM materials m
|
||||
JOIN files f ON m.file_id = f.id
|
||||
WHERE f.uploaded_by = :username
|
||||
AND m.classified_category IS NOT NULL
|
||||
""")
|
||||
classified_materials = db.execute(classified_materials_query, {"username": username}).scalar() or 0
|
||||
|
||||
# 검증 대기 자재 수
|
||||
pending_verification_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM materials m
|
||||
JOIN files f ON m.file_id = f.id
|
||||
WHERE f.uploaded_by = :username
|
||||
AND m.is_verified = false
|
||||
""")
|
||||
pending_verification = db.execute(pending_verification_query, {"username": username}).scalar() or 0
|
||||
|
||||
# 이번 주 업로드 수
|
||||
week_uploads_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM files
|
||||
WHERE uploaded_by = :username
|
||||
AND upload_date >= CURRENT_TIMESTAMP - INTERVAL '7 days'
|
||||
""")
|
||||
week_uploads = db.execute(week_uploads_query, {"username": username}).scalar() or 0
|
||||
|
||||
# 분류 완료율 계산
|
||||
total_materials_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM materials m
|
||||
JOIN files f ON m.file_id = f.id
|
||||
WHERE f.uploaded_by = :username
|
||||
""")
|
||||
total_materials = db.execute(total_materials_query, {"username": username}).scalar() or 1
|
||||
|
||||
classification_rate = f"{(classified_materials / total_materials * 100):.0f}%" if total_materials > 0 else "0%"
|
||||
|
||||
return {
|
||||
"title": "설계 담당자",
|
||||
"subtitle": "BOM 파일을 관리하고 자재를 분류합니다",
|
||||
"metrics": [
|
||||
{"label": "내 BOM 파일", "value": my_files, "icon": "📄", "color": "#667eea"},
|
||||
{"label": "분류 완료율", "value": classification_rate, "icon": "🎯", "color": "#48bb78"},
|
||||
{"label": "검증 대기", "value": pending_verification, "icon": "⏳", "color": "#ed8936"},
|
||||
{"label": "이번 주 업로드", "value": week_uploads, "icon": "📤", "color": "#9f7aea"}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Designer stats error: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
async def get_purchaser_stats(db: Session, username: str) -> Dict[str, Any]:
|
||||
"""구매자용 통계"""
|
||||
try:
|
||||
# 구매 요청 수
|
||||
purchase_requests_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM material_purchase_tracking
|
||||
WHERE purchase_status IN ('PENDING', 'REQUESTED')
|
||||
""")
|
||||
purchase_requests = db.execute(purchase_requests_query).scalar() or 0
|
||||
|
||||
# 발주 완료 수
|
||||
orders_completed_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM material_purchase_tracking
|
||||
WHERE purchase_status = 'CONFIRMED'
|
||||
AND confirmed_by = :username
|
||||
""")
|
||||
orders_completed = db.execute(orders_completed_query, {"username": username}).scalar() or 0
|
||||
|
||||
# 입고 대기 수
|
||||
receiving_pending_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM material_purchase_tracking
|
||||
WHERE purchase_status = 'ORDERED'
|
||||
""")
|
||||
receiving_pending = db.execute(receiving_pending_query).scalar() or 0
|
||||
|
||||
# 이번 달 구매 금액 (임시 데이터)
|
||||
monthly_amount = "₩2.3M" # 실제로는 계산 필요
|
||||
|
||||
return {
|
||||
"title": "구매 담당자",
|
||||
"subtitle": "구매 요청을 처리하고 발주를 관리합니다",
|
||||
"metrics": [
|
||||
{"label": "구매 요청", "value": purchase_requests, "icon": "🛒", "color": "#667eea"},
|
||||
{"label": "발주 완료", "value": orders_completed, "icon": "✅", "color": "#48bb78"},
|
||||
{"label": "입고 대기", "value": receiving_pending, "icon": "📦", "color": "#ed8936"},
|
||||
{"label": "이번 달 금액", "value": monthly_amount, "icon": "💰", "color": "#9f7aea"}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Purchaser stats error: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
async def get_user_stats(db: Session, username: str) -> Dict[str, Any]:
|
||||
"""일반 사용자용 통계"""
|
||||
try:
|
||||
# 내 활동 수 (최근 7일)
|
||||
my_activities_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM user_activity_logs
|
||||
WHERE username = :username
|
||||
AND created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'
|
||||
""")
|
||||
my_activities = db.execute(my_activities_query, {"username": username}).scalar() or 0
|
||||
|
||||
# 접근 가능한 프로젝트 수 (임시)
|
||||
accessible_projects = 5
|
||||
|
||||
return {
|
||||
"title": "일반 사용자",
|
||||
"subtitle": "할당된 업무를 수행하고 프로젝트에 참여합니다",
|
||||
"metrics": [
|
||||
{"label": "내 업무", "value": 6, "icon": "📋", "color": "#667eea"},
|
||||
{"label": "완료율", "value": "75%", "icon": "📈", "color": "#48bb78"},
|
||||
{"label": "대기 중", "value": 2, "icon": "⏳", "color": "#ed8936"},
|
||||
{"label": "이번 주 활동", "value": my_activities, "icon": "🎯", "color": "#9f7aea"}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"User stats error: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
@router.get("/quick-actions")
|
||||
async def get_quick_actions(
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
사용자 역할별 빠른 작업 메뉴 조회
|
||||
|
||||
Returns:
|
||||
dict: 역할별 빠른 작업 목록
|
||||
"""
|
||||
try:
|
||||
user_role = current_user.get('role', 'user')
|
||||
|
||||
quick_actions = {
|
||||
"admin": [
|
||||
{"title": "사용자 관리", "icon": "👤", "path": "/admin/users", "color": "#667eea"},
|
||||
{"title": "시스템 설정", "icon": "⚙️", "path": "/admin/settings", "color": "#48bb78"},
|
||||
{"title": "백업 관리", "icon": "💾", "path": "/admin/backup", "color": "#ed8936"},
|
||||
{"title": "활동 로그", "icon": "📊", "path": "/admin/logs", "color": "#9f7aea"}
|
||||
],
|
||||
"manager": [
|
||||
{"title": "프로젝트 생성", "icon": "➕", "path": "/projects/new", "color": "#667eea"},
|
||||
{"title": "팀 관리", "icon": "👥", "path": "/team", "color": "#48bb78"},
|
||||
{"title": "진행 상황", "icon": "📊", "path": "/progress", "color": "#38b2ac"},
|
||||
{"title": "승인 처리", "icon": "✅", "path": "/approvals", "color": "#ed8936"}
|
||||
],
|
||||
"designer": [
|
||||
{"title": "BOM 업로드", "icon": "📤", "path": "/upload", "color": "#667eea"},
|
||||
{"title": "자재 분류", "icon": "🔧", "path": "/materials", "color": "#48bb78"},
|
||||
{"title": "리비전 관리", "icon": "🔄", "path": "/revisions", "color": "#38b2ac"},
|
||||
{"title": "분류 검증", "icon": "✅", "path": "/verify", "color": "#ed8936"}
|
||||
],
|
||||
"purchaser": [
|
||||
{"title": "구매 확정", "icon": "🛒", "path": "/purchase", "color": "#667eea"},
|
||||
{"title": "발주 관리", "icon": "📋", "path": "/orders", "color": "#48bb78"},
|
||||
{"title": "공급업체", "icon": "🏢", "path": "/suppliers", "color": "#38b2ac"},
|
||||
{"title": "입고 처리", "icon": "📦", "path": "/receiving", "color": "#ed8936"}
|
||||
],
|
||||
"user": [
|
||||
{"title": "내 업무", "icon": "📋", "path": "/my-tasks", "color": "#667eea"},
|
||||
{"title": "프로젝트 보기", "icon": "👁️", "path": "/projects", "color": "#48bb78"},
|
||||
{"title": "리포트 다운로드", "icon": "📊", "path": "/reports", "color": "#38b2ac"},
|
||||
{"title": "도움말", "icon": "❓", "path": "/help", "color": "#9f7aea"}
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"user_role": user_role,
|
||||
"quick_actions": quick_actions.get(user_role, quick_actions["user"])
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Quick actions error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"빠른 작업 조회 실패: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/projects")
|
||||
async def create_project(
|
||||
official_project_code: str = Query(..., description="프로젝트 코드"),
|
||||
project_name: str = Query(..., description="프로젝트 이름"),
|
||||
client_name: str = Query(None, description="고객사명"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
새 프로젝트 생성
|
||||
|
||||
Args:
|
||||
official_project_code: 프로젝트 코드 (예: J24-001)
|
||||
project_name: 프로젝트 이름
|
||||
client_name: 고객사명 (선택)
|
||||
|
||||
Returns:
|
||||
dict: 생성된 프로젝트 정보
|
||||
"""
|
||||
try:
|
||||
# 중복 확인
|
||||
check_query = text("SELECT id FROM projects WHERE official_project_code = :code")
|
||||
existing = db.execute(check_query, {"code": official_project_code}).fetchone()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="이미 존재하는 프로젝트 코드입니다")
|
||||
|
||||
# 프로젝트 생성
|
||||
insert_query = text("""
|
||||
INSERT INTO projects (official_project_code, project_name, client_name, status)
|
||||
VALUES (:code, :name, :client, 'active')
|
||||
RETURNING *
|
||||
""")
|
||||
|
||||
new_project = db.execute(insert_query, {
|
||||
"code": official_project_code,
|
||||
"name": project_name,
|
||||
"client": client_name
|
||||
}).fetchone()
|
||||
|
||||
db.commit()
|
||||
|
||||
# TODO: 활동 로그 기록 (추후 구현)
|
||||
# ActivityLogger 사용법 확인 필요
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "프로젝트가 생성되었습니다",
|
||||
"project": {
|
||||
"id": new_project.id,
|
||||
"official_project_code": new_project.official_project_code,
|
||||
"project_name": new_project.project_name,
|
||||
"client_name": new_project.client_name,
|
||||
"status": new_project.status
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"프로젝트 생성 실패: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"프로젝트 생성 실패: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/projects")
|
||||
async def get_projects(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
프로젝트 목록 조회
|
||||
|
||||
Returns:
|
||||
dict: 프로젝트 목록
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
id,
|
||||
official_project_code,
|
||||
project_name,
|
||||
client_name,
|
||||
design_project_code,
|
||||
design_project_name,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM projects
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
|
||||
results = db.execute(query).fetchall()
|
||||
|
||||
projects = []
|
||||
for row in results:
|
||||
projects.append({
|
||||
"id": row.id,
|
||||
"official_project_code": row.official_project_code,
|
||||
"job_no": row.official_project_code, # job_no 필드 추가 (프론트엔드 호환성)
|
||||
"project_name": row.project_name,
|
||||
"job_name": row.project_name, # 호환성을 위해 추가
|
||||
"client_name": row.client_name,
|
||||
"design_project_code": row.design_project_code,
|
||||
"design_project_name": row.design_project_name,
|
||||
"status": row.status,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"projects": projects,
|
||||
"count": len(projects)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"프로젝트 목록 조회 실패: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"프로젝트 목록 조회 실패: {str(e)}")
|
||||
|
||||
|
||||
@router.patch("/projects/{project_id}")
|
||||
async def update_project_name(
|
||||
project_id: int,
|
||||
job_name: str = Query(..., description="새 프로젝트 이름"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
프로젝트 이름 수정
|
||||
|
||||
Args:
|
||||
project_id: 프로젝트 ID
|
||||
job_name: 새 프로젝트 이름
|
||||
|
||||
Returns:
|
||||
dict: 수정 결과
|
||||
"""
|
||||
try:
|
||||
# 프로젝트 존재 확인
|
||||
query = text("SELECT * FROM projects WHERE id = :project_id")
|
||||
result = db.execute(query, {"project_id": project_id}).fetchone()
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||
|
||||
# 프로젝트 이름 업데이트
|
||||
update_query = text("""
|
||||
UPDATE projects
|
||||
SET project_name = :project_name,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :project_id
|
||||
RETURNING *
|
||||
""")
|
||||
|
||||
updated = db.execute(update_query, {
|
||||
"project_name": job_name,
|
||||
"project_id": project_id
|
||||
}).fetchone()
|
||||
|
||||
db.commit()
|
||||
|
||||
# TODO: 활동 로그 기록 (추후 구현)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "프로젝트 이름이 수정되었습니다",
|
||||
"project": {
|
||||
"id": updated.id,
|
||||
"official_project_code": updated.official_project_code,
|
||||
"project_name": updated.project_name,
|
||||
"job_name": updated.project_name # 호환성
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"프로젝트 수정 실패: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"프로젝트 수정 실패: {str(e)}")
|
||||
591
backend/app/routers/export_manager.py
Normal file
591
backend/app/routers/export_manager.py
Normal file
@@ -0,0 +1,591 @@
|
||||
"""
|
||||
엑셀 내보내기 및 구매 배치 관리 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
import uuid
|
||||
|
||||
from ..database import get_db
|
||||
from ..auth.jwt_service import get_current_user
|
||||
from ..utils.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/export", tags=["Export Management"])
|
||||
|
||||
# 엑셀 파일 저장 경로
|
||||
EXPORT_DIR = "exports"
|
||||
os.makedirs(EXPORT_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def create_excel_from_materials(materials: List[Dict], batch_info: Dict) -> str:
|
||||
"""
|
||||
자재 목록으로 엑셀 파일 생성
|
||||
"""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = batch_info.get("category", "자재목록")
|
||||
|
||||
# 헤더 스타일
|
||||
header_fill = PatternFill(start_color="B8E6FF", end_color="B8E6FF", fill_type="solid")
|
||||
header_font = Font(bold=True, size=11)
|
||||
header_alignment = Alignment(horizontal="center", vertical="center")
|
||||
thin_border = Border(
|
||||
left=Side(style='thin'),
|
||||
right=Side(style='thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
|
||||
# 배치 정보 추가 (상단 3줄)
|
||||
ws.merge_cells('A1:J1')
|
||||
ws['A1'] = f"구매 배치: {batch_info.get('batch_no', 'N/A')}"
|
||||
ws['A1'].font = Font(bold=True, size=14)
|
||||
|
||||
ws.merge_cells('A2:J2')
|
||||
ws['A2'] = f"프로젝트: {batch_info.get('job_no', '')} - {batch_info.get('job_name', '')}"
|
||||
|
||||
ws.merge_cells('A3:J3')
|
||||
ws['A3'] = f"내보낸 날짜: {batch_info.get('export_date', datetime.now().strftime('%Y-%m-%d %H:%M'))}"
|
||||
|
||||
# 빈 줄
|
||||
ws.append([])
|
||||
|
||||
# 헤더 행
|
||||
headers = [
|
||||
"No.", "카테고리", "자재 설명", "크기", "스케줄/등급",
|
||||
"재질", "수량", "단위", "추가요구", "사용자요구",
|
||||
"구매상태", "PR번호", "PO번호", "공급업체", "예정일"
|
||||
]
|
||||
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=5, column=col, value=header)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = header_alignment
|
||||
cell.border = thin_border
|
||||
|
||||
# 데이터 행
|
||||
row_num = 6
|
||||
for idx, material in enumerate(materials, 1):
|
||||
row_data = [
|
||||
idx,
|
||||
material.get("category", ""),
|
||||
material.get("description", ""),
|
||||
material.get("size", ""),
|
||||
material.get("schedule", ""),
|
||||
material.get("material_grade", ""),
|
||||
material.get("quantity", ""),
|
||||
material.get("unit", ""),
|
||||
material.get("additional_req", ""),
|
||||
material.get("user_requirement", ""),
|
||||
material.get("purchase_status", "pending"),
|
||||
material.get("purchase_request_no", ""),
|
||||
material.get("purchase_order_no", ""),
|
||||
material.get("vendor_name", ""),
|
||||
material.get("expected_date", "")
|
||||
]
|
||||
|
||||
for col, value in enumerate(row_data, 1):
|
||||
cell = ws.cell(row=row_num, column=col, value=value)
|
||||
cell.border = thin_border
|
||||
if col == 11: # 구매상태 컬럼
|
||||
if value == "pending":
|
||||
cell.fill = PatternFill(start_color="FFFFE0", end_color="FFFFE0", fill_type="solid")
|
||||
elif value == "requested":
|
||||
cell.fill = PatternFill(start_color="FFE4B5", end_color="FFE4B5", fill_type="solid")
|
||||
elif value == "ordered":
|
||||
cell.fill = PatternFill(start_color="ADD8E6", end_color="ADD8E6", fill_type="solid")
|
||||
elif value == "received":
|
||||
cell.fill = PatternFill(start_color="90EE90", end_color="90EE90", fill_type="solid")
|
||||
|
||||
row_num += 1
|
||||
|
||||
# 열 너비 자동 조정
|
||||
for column in ws.columns:
|
||||
max_length = 0
|
||||
column_letter = get_column_letter(column[0].column)
|
||||
for cell in column:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except:
|
||||
pass
|
||||
adjusted_width = min(max_length + 2, 50)
|
||||
ws.column_dimensions[column_letter].width = adjusted_width
|
||||
|
||||
# 파일 저장
|
||||
file_name = f"batch_{batch_info.get('batch_no', uuid.uuid4().hex[:8])}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
file_path = os.path.join(EXPORT_DIR, file_name)
|
||||
wb.save(file_path)
|
||||
|
||||
return file_name
|
||||
|
||||
|
||||
@router.post("/create-batch")
|
||||
async def create_export_batch(
|
||||
file_id: int,
|
||||
job_no: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
materials: List[Dict] = [],
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
엑셀 내보내기 배치 생성 (자재 그룹화)
|
||||
"""
|
||||
try:
|
||||
# 배치 번호 생성 (YYYYMMDD-XXX 형식)
|
||||
batch_date = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
# 오늘 생성된 배치 수 확인
|
||||
count_query = text("""
|
||||
SELECT COUNT(*) as count
|
||||
FROM excel_export_history
|
||||
WHERE DATE(export_date) = CURRENT_DATE
|
||||
""")
|
||||
count_result = db.execute(count_query).fetchone()
|
||||
batch_seq = (count_result.count + 1) if count_result else 1
|
||||
batch_no = f"{batch_date}-{str(batch_seq).zfill(3)}"
|
||||
|
||||
# Job 정보 조회
|
||||
job_name = ""
|
||||
if job_no:
|
||||
job_query = text("SELECT job_name FROM jobs WHERE job_no = :job_no")
|
||||
job_result = db.execute(job_query, {"job_no": job_no}).fetchone()
|
||||
if job_result:
|
||||
job_name = job_result.job_name
|
||||
|
||||
# 배치 정보
|
||||
batch_info = {
|
||||
"batch_no": batch_no,
|
||||
"job_no": job_no,
|
||||
"job_name": job_name,
|
||||
"category": category,
|
||||
"export_date": datetime.now().strftime('%Y-%m-%d %H:%M')
|
||||
}
|
||||
|
||||
# 엑셀 파일 생성
|
||||
excel_file_name = create_excel_from_materials(materials, batch_info)
|
||||
|
||||
# 내보내기 이력 저장
|
||||
insert_history = text("""
|
||||
INSERT INTO excel_export_history (
|
||||
file_id, job_no, exported_by, export_type,
|
||||
category, material_count, file_name, notes
|
||||
) VALUES (
|
||||
:file_id, :job_no, :exported_by, :export_type,
|
||||
:category, :material_count, :file_name, :notes
|
||||
) RETURNING export_id
|
||||
""")
|
||||
|
||||
result = db.execute(insert_history, {
|
||||
"file_id": file_id,
|
||||
"job_no": job_no,
|
||||
"exported_by": current_user.get("user_id"),
|
||||
"export_type": "batch",
|
||||
"category": category,
|
||||
"material_count": len(materials),
|
||||
"file_name": excel_file_name,
|
||||
"notes": f"배치번호: {batch_no}"
|
||||
})
|
||||
|
||||
export_id = result.fetchone().export_id
|
||||
|
||||
# 자재별 내보내기 기록
|
||||
material_ids = []
|
||||
for material in materials:
|
||||
material_id = material.get("id")
|
||||
if material_id:
|
||||
material_ids.append(material_id)
|
||||
|
||||
insert_material = text("""
|
||||
INSERT INTO exported_materials (
|
||||
export_id, material_id, purchase_status,
|
||||
quantity_exported
|
||||
) VALUES (
|
||||
:export_id, :material_id, 'pending',
|
||||
:quantity
|
||||
)
|
||||
""")
|
||||
|
||||
db.execute(insert_material, {
|
||||
"export_id": export_id,
|
||||
"material_id": material_id,
|
||||
"quantity": material.get("quantity", 0)
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Export batch created: {batch_no} with {len(materials)} materials")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"batch_no": batch_no,
|
||||
"export_id": export_id,
|
||||
"file_name": excel_file_name,
|
||||
"material_count": len(materials),
|
||||
"message": f"배치 {batch_no}가 생성되었습니다"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to create export batch: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"배치 생성 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/batches")
|
||||
async def get_export_batches(
|
||||
file_id: Optional[int] = None,
|
||||
job_no: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
내보내기 배치 목록 조회
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
eeh.export_id,
|
||||
eeh.file_id,
|
||||
eeh.job_no,
|
||||
eeh.export_date,
|
||||
eeh.category,
|
||||
eeh.material_count,
|
||||
eeh.file_name,
|
||||
eeh.notes,
|
||||
u.name as exported_by,
|
||||
j.job_name,
|
||||
f.original_filename,
|
||||
-- 상태별 집계
|
||||
COUNT(DISTINCT em.material_id) as total_materials,
|
||||
COUNT(DISTINCT CASE WHEN em.purchase_status = 'pending' THEN em.material_id END) as pending_count,
|
||||
COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) as requested_count,
|
||||
COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) as ordered_count,
|
||||
COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) as received_count,
|
||||
-- 전체 상태 계산
|
||||
CASE
|
||||
WHEN COUNT(DISTINCT em.material_id) = COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END)
|
||||
THEN 'completed'
|
||||
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) > 0
|
||||
THEN 'in_progress'
|
||||
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) > 0
|
||||
THEN 'requested'
|
||||
ELSE 'pending'
|
||||
END as batch_status
|
||||
FROM excel_export_history eeh
|
||||
LEFT JOIN users u ON eeh.exported_by = u.user_id
|
||||
LEFT JOIN jobs j ON eeh.job_no = j.job_no
|
||||
LEFT JOIN files f ON eeh.file_id = f.id
|
||||
LEFT JOIN exported_materials em ON eeh.export_id = em.export_id
|
||||
WHERE eeh.export_type = 'batch'
|
||||
AND (:file_id IS NULL OR eeh.file_id = :file_id)
|
||||
AND (:job_no IS NULL OR eeh.job_no = :job_no)
|
||||
GROUP BY
|
||||
eeh.export_id, eeh.file_id, eeh.job_no, eeh.export_date,
|
||||
eeh.category, eeh.material_count, eeh.file_name, eeh.notes,
|
||||
u.name, j.job_name, f.original_filename
|
||||
HAVING (:status IS NULL OR
|
||||
CASE
|
||||
WHEN COUNT(DISTINCT em.material_id) = COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END)
|
||||
THEN 'completed'
|
||||
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) > 0
|
||||
THEN 'in_progress'
|
||||
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) > 0
|
||||
THEN 'requested'
|
||||
ELSE 'pending'
|
||||
END = :status)
|
||||
ORDER BY eeh.export_date DESC
|
||||
LIMIT :limit
|
||||
""")
|
||||
|
||||
results = db.execute(query, {
|
||||
"file_id": file_id,
|
||||
"job_no": job_no,
|
||||
"status": status,
|
||||
"limit": limit
|
||||
}).fetchall()
|
||||
|
||||
batches = []
|
||||
for row in results:
|
||||
# 배치 번호 추출 (notes에서)
|
||||
batch_no = ""
|
||||
if row.notes and "배치번호:" in row.notes:
|
||||
batch_no = row.notes.split("배치번호:")[1].strip()
|
||||
|
||||
batches.append({
|
||||
"export_id": row.export_id,
|
||||
"batch_no": batch_no,
|
||||
"file_id": row.file_id,
|
||||
"job_no": row.job_no,
|
||||
"job_name": row.job_name,
|
||||
"export_date": row.export_date.isoformat() if row.export_date else None,
|
||||
"category": row.category,
|
||||
"material_count": row.total_materials,
|
||||
"file_name": row.file_name,
|
||||
"exported_by": row.exported_by,
|
||||
"source_file": row.original_filename,
|
||||
"batch_status": row.batch_status,
|
||||
"status_detail": {
|
||||
"pending": row.pending_count,
|
||||
"requested": row.requested_count,
|
||||
"ordered": row.ordered_count,
|
||||
"received": row.received_count,
|
||||
"total": row.total_materials
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"batches": batches,
|
||||
"count": len(batches)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get export batches: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"배치 목록 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/batch/{export_id}/materials")
|
||||
async def get_batch_materials(
|
||||
export_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
배치에 포함된 자재 목록 조회
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
em.id as exported_material_id,
|
||||
em.material_id,
|
||||
m.original_description,
|
||||
m.classified_category,
|
||||
m.size_inch,
|
||||
m.schedule,
|
||||
m.material_grade,
|
||||
m.quantity,
|
||||
m.unit,
|
||||
em.purchase_status,
|
||||
em.purchase_request_no,
|
||||
em.purchase_order_no,
|
||||
em.vendor_name,
|
||||
em.expected_date,
|
||||
em.quantity_ordered,
|
||||
em.quantity_received,
|
||||
em.unit_price,
|
||||
em.total_price,
|
||||
em.notes,
|
||||
ur.requirement as user_requirement
|
||||
FROM exported_materials em
|
||||
JOIN materials m ON em.material_id = m.id
|
||||
LEFT JOIN user_requirements ur ON m.id = ur.material_id
|
||||
WHERE em.export_id = :export_id
|
||||
ORDER BY m.classified_category, m.original_description
|
||||
""")
|
||||
|
||||
results = db.execute(query, {"export_id": export_id}).fetchall()
|
||||
|
||||
materials = []
|
||||
for row in results:
|
||||
materials.append({
|
||||
"exported_material_id": row.exported_material_id,
|
||||
"material_id": row.material_id,
|
||||
"description": row.original_description,
|
||||
"category": row.classified_category,
|
||||
"size": row.size_inch,
|
||||
"schedule": row.schedule,
|
||||
"material_grade": row.material_grade,
|
||||
"quantity": row.quantity,
|
||||
"unit": row.unit,
|
||||
"user_requirement": row.user_requirement,
|
||||
"purchase_status": row.purchase_status,
|
||||
"purchase_request_no": row.purchase_request_no,
|
||||
"purchase_order_no": row.purchase_order_no,
|
||||
"vendor_name": row.vendor_name,
|
||||
"expected_date": row.expected_date.isoformat() if row.expected_date else None,
|
||||
"quantity_ordered": row.quantity_ordered,
|
||||
"quantity_received": row.quantity_received,
|
||||
"unit_price": float(row.unit_price) if row.unit_price else None,
|
||||
"total_price": float(row.total_price) if row.total_price else None,
|
||||
"notes": row.notes
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"materials": materials,
|
||||
"count": len(materials)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get batch materials: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"배치 자재 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/batch/{export_id}/download")
|
||||
async def download_batch_excel(
|
||||
export_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
저장된 배치 엑셀 파일 다운로드
|
||||
"""
|
||||
try:
|
||||
# 배치 정보 조회
|
||||
query = text("""
|
||||
SELECT file_name, notes
|
||||
FROM excel_export_history
|
||||
WHERE export_id = :export_id
|
||||
""")
|
||||
result = db.execute(query, {"export_id": export_id}).fetchone()
|
||||
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="배치를 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
file_path = os.path.join(EXPORT_DIR, result.file_name)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
# 파일이 없으면 재생성
|
||||
materials = await get_batch_materials(export_id, current_user, db)
|
||||
|
||||
batch_no = ""
|
||||
if result.notes and "배치번호:" in result.notes:
|
||||
batch_no = result.notes.split("배치번호:")[1].strip()
|
||||
|
||||
batch_info = {
|
||||
"batch_no": batch_no,
|
||||
"export_date": datetime.now().strftime('%Y-%m-%d %H:%M')
|
||||
}
|
||||
|
||||
file_name = create_excel_from_materials(materials["materials"], batch_info)
|
||||
file_path = os.path.join(EXPORT_DIR, file_name)
|
||||
|
||||
# DB 업데이트
|
||||
update_query = text("""
|
||||
UPDATE excel_export_history
|
||||
SET file_name = :file_name
|
||||
WHERE export_id = :export_id
|
||||
""")
|
||||
db.execute(update_query, {
|
||||
"file_name": file_name,
|
||||
"export_id": export_id
|
||||
})
|
||||
db.commit()
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=result.file_name,
|
||||
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download batch excel: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"엑셀 다운로드 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/batch/{export_id}/status")
|
||||
async def update_batch_status(
|
||||
export_id: int,
|
||||
status: str,
|
||||
purchase_request_no: Optional[str] = None,
|
||||
purchase_order_no: Optional[str] = None,
|
||||
vendor_name: Optional[str] = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
배치 전체 상태 일괄 업데이트
|
||||
"""
|
||||
try:
|
||||
# 배치의 모든 자재 상태 업데이트
|
||||
update_query = text("""
|
||||
UPDATE exported_materials
|
||||
SET
|
||||
purchase_status = :status,
|
||||
purchase_request_no = COALESCE(:pr_no, purchase_request_no),
|
||||
purchase_order_no = COALESCE(:po_no, purchase_order_no),
|
||||
vendor_name = COALESCE(:vendor, vendor_name),
|
||||
updated_by = :updated_by,
|
||||
requested_date = CASE WHEN :status = 'requested' THEN CURRENT_TIMESTAMP ELSE requested_date END,
|
||||
ordered_date = CASE WHEN :status = 'ordered' THEN CURRENT_TIMESTAMP ELSE ordered_date END,
|
||||
received_date = CASE WHEN :status = 'received' THEN CURRENT_TIMESTAMP ELSE received_date END
|
||||
WHERE export_id = :export_id
|
||||
""")
|
||||
|
||||
result = db.execute(update_query, {
|
||||
"export_id": export_id,
|
||||
"status": status,
|
||||
"pr_no": purchase_request_no,
|
||||
"po_no": purchase_order_no,
|
||||
"vendor": vendor_name,
|
||||
"updated_by": current_user.get("user_id")
|
||||
})
|
||||
|
||||
# 이력 기록
|
||||
history_query = text("""
|
||||
INSERT INTO purchase_status_history (
|
||||
exported_material_id, material_id,
|
||||
previous_status, new_status,
|
||||
changed_by, reason
|
||||
)
|
||||
SELECT
|
||||
em.id, em.material_id,
|
||||
em.purchase_status, :new_status,
|
||||
:changed_by, :reason
|
||||
FROM exported_materials em
|
||||
WHERE em.export_id = :export_id
|
||||
""")
|
||||
|
||||
db.execute(history_query, {
|
||||
"export_id": export_id,
|
||||
"new_status": status,
|
||||
"changed_by": current_user.get("user_id"),
|
||||
"reason": f"배치 일괄 업데이트"
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Batch {export_id} status updated to {status}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"배치의 모든 자재가 {status} 상태로 변경되었습니다",
|
||||
"updated_count": result.rowcount
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to update batch status: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"배치 상태 업데이트 실패: {str(e)}"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,399 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List, Optional
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import pandas as pd
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from ..database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
UPLOAD_DIR = Path("uploads")
|
||||
UPLOAD_DIR.mkdir(exist_ok=True)
|
||||
ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"}
|
||||
|
||||
@router.get("/")
|
||||
async def get_files_info():
|
||||
return {
|
||||
"message": "파일 관리 API",
|
||||
"allowed_extensions": list(ALLOWED_EXTENSIONS),
|
||||
"upload_directory": str(UPLOAD_DIR)
|
||||
}
|
||||
|
||||
@router.get("/test")
|
||||
async def test_endpoint():
|
||||
return {"status": "파일 API가 정상 작동합니다!"}
|
||||
|
||||
@router.post("/add-missing-columns")
|
||||
async def add_missing_columns(db: Session = Depends(get_db)):
|
||||
"""누락된 컬럼들 추가"""
|
||||
try:
|
||||
db.execute(text("ALTER TABLE files ADD COLUMN IF NOT EXISTS parsed_count INTEGER DEFAULT 0"))
|
||||
db.execute(text("ALTER TABLE materials ADD COLUMN IF NOT EXISTS row_number INTEGER"))
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "누락된 컬럼들이 추가되었습니다",
|
||||
"added_columns": ["files.parsed_count", "materials.row_number"]
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return {"success": False, "error": f"컬럼 추가 실패: {str(e)}"}
|
||||
|
||||
def validate_file_extension(filename: str) -> bool:
|
||||
return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
def generate_unique_filename(original_filename: str) -> str:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
stem = Path(original_filename).stem
|
||||
suffix = Path(original_filename).suffix
|
||||
return f"{stem}_{timestamp}_{unique_id}{suffix}"
|
||||
|
||||
def parse_dataframe(df):
|
||||
df = df.dropna(how='all')
|
||||
df.columns = df.columns.str.strip().str.lower()
|
||||
|
||||
column_mapping = {
|
||||
'description': ['description', 'item', 'material', '품명', '자재명'],
|
||||
'quantity': ['qty', 'quantity', 'ea', '수량'],
|
||||
'main_size': ['main_nom', 'nominal_diameter', 'nd', '주배관'],
|
||||
'red_size': ['red_nom', 'reduced_diameter', '축소배관'],
|
||||
'length': ['length', 'len', '길이'],
|
||||
'weight': ['weight', 'wt', '중량'],
|
||||
'dwg_name': ['dwg_name', 'drawing', '도면명'],
|
||||
'line_num': ['line_num', 'line_number', '라인번호']
|
||||
}
|
||||
|
||||
mapped_columns = {}
|
||||
for standard_col, possible_names in column_mapping.items():
|
||||
for possible_name in possible_names:
|
||||
if possible_name in df.columns:
|
||||
mapped_columns[standard_col] = possible_name
|
||||
break
|
||||
|
||||
materials = []
|
||||
for index, row in df.iterrows():
|
||||
description = str(row.get(mapped_columns.get('description', ''), ''))
|
||||
quantity_raw = row.get(mapped_columns.get('quantity', ''), 0)
|
||||
|
||||
try:
|
||||
quantity = float(quantity_raw) if pd.notna(quantity_raw) else 0
|
||||
except:
|
||||
quantity = 0
|
||||
|
||||
material_grade = ""
|
||||
if "ASTM" in description.upper():
|
||||
astm_match = re.search(r'ASTM\s+([A-Z0-9\s]+)', description.upper())
|
||||
if astm_match:
|
||||
material_grade = astm_match.group(0).strip()
|
||||
|
||||
main_size = str(row.get(mapped_columns.get('main_size', ''), ''))
|
||||
red_size = str(row.get(mapped_columns.get('red_size', ''), ''))
|
||||
|
||||
if main_size != 'nan' and red_size != 'nan' and red_size != '':
|
||||
size_spec = f"{main_size} x {red_size}"
|
||||
elif main_size != 'nan' and main_size != '':
|
||||
size_spec = main_size
|
||||
else:
|
||||
size_spec = ""
|
||||
|
||||
if description and description not in ['nan', 'None', '']:
|
||||
materials.append({
|
||||
'original_description': description,
|
||||
'quantity': quantity,
|
||||
'unit': "EA",
|
||||
'size_spec': size_spec,
|
||||
'material_grade': material_grade,
|
||||
'line_number': index + 1,
|
||||
'row_number': index + 1
|
||||
})
|
||||
|
||||
return materials
|
||||
|
||||
def parse_file_data(file_path):
|
||||
file_extension = Path(file_path).suffix.lower()
|
||||
|
||||
try:
|
||||
if file_extension == ".csv":
|
||||
df = pd.read_csv(file_path, encoding='utf-8')
|
||||
elif file_extension in [".xlsx", ".xls"]:
|
||||
df = pd.read_excel(file_path, sheet_name=0)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="지원하지 않는 파일 형식")
|
||||
|
||||
return parse_dataframe(df)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"파일 파싱 실패: {str(e)}")
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
job_no: str = Form(...),
|
||||
revision: str = Form("Rev.0"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if not validate_file_extension(file.filename):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"지원하지 않는 파일 형식입니다. 허용된 확장자: {', '.join(ALLOWED_EXTENSIONS)}"
|
||||
)
|
||||
|
||||
if file.size and file.size > 10 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="파일 크기는 10MB를 초과할 수 없습니다")
|
||||
|
||||
unique_filename = generate_unique_filename(file.filename)
|
||||
file_path = UPLOAD_DIR / unique_filename
|
||||
|
||||
try:
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
|
||||
|
||||
try:
|
||||
materials_data = parse_file_data(str(file_path))
|
||||
parsed_count = len(materials_data)
|
||||
|
||||
# 파일 정보 저장
|
||||
file_insert_query = text("""
|
||||
INSERT INTO files (filename, original_filename, file_path, job_no, revision, description, file_size, parsed_count, is_active)
|
||||
VALUES (:filename, :original_filename, :file_path, :job_no, :revision, :description, :file_size, :parsed_count, :is_active)
|
||||
RETURNING id
|
||||
""")
|
||||
|
||||
file_result = db.execute(file_insert_query, {
|
||||
"filename": unique_filename,
|
||||
"original_filename": file.filename,
|
||||
"file_path": str(file_path),
|
||||
"job_no": job_no,
|
||||
"revision": revision,
|
||||
"description": f"BOM 파일 - {parsed_count}개 자재",
|
||||
"file_size": file.size,
|
||||
"parsed_count": parsed_count,
|
||||
"is_active": True
|
||||
})
|
||||
|
||||
file_id = file_result.fetchone()[0]
|
||||
|
||||
# 자재 데이터 저장
|
||||
materials_inserted = 0
|
||||
for material_data in materials_data:
|
||||
material_insert_query = text("""
|
||||
INSERT INTO materials (
|
||||
file_id, original_description, quantity, unit, size_spec,
|
||||
material_grade, line_number, row_number, classified_category,
|
||||
classification_confidence, is_verified, created_at
|
||||
)
|
||||
VALUES (
|
||||
:file_id, :original_description, :quantity, :unit, :size_spec,
|
||||
:material_grade, :line_number, :row_number, :classified_category,
|
||||
:classification_confidence, :is_verified, :created_at
|
||||
)
|
||||
""")
|
||||
|
||||
db.execute(material_insert_query, {
|
||||
"file_id": file_id,
|
||||
"original_description": material_data["original_description"],
|
||||
"quantity": material_data["quantity"],
|
||||
"unit": material_data["unit"],
|
||||
"size_spec": material_data["size_spec"],
|
||||
"material_grade": material_data["material_grade"],
|
||||
"line_number": material_data["line_number"],
|
||||
"row_number": material_data["row_number"],
|
||||
"classified_category": None,
|
||||
"classification_confidence": None,
|
||||
"is_verified": False,
|
||||
"created_at": datetime.now()
|
||||
})
|
||||
materials_inserted += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"완전한 DB 저장 성공! {materials_inserted}개 자재 저장됨",
|
||||
"original_filename": file.filename,
|
||||
"file_id": file_id,
|
||||
"parsed_materials_count": parsed_count,
|
||||
"saved_materials_count": materials_inserted,
|
||||
"sample_materials": materials_data[:3] if materials_data else []
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
raise HTTPException(status_code=500, detail=f"파일 처리 실패: {str(e)}")
|
||||
@router.get("/materials")
|
||||
async def get_materials(
|
||||
job_no: Optional[str] = None,
|
||||
file_id: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""저장된 자재 목록 조회"""
|
||||
try:
|
||||
query = """
|
||||
SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit,
|
||||
m.size_spec, m.material_grade, m.line_number, m.row_number,
|
||||
m.created_at,
|
||||
f.original_filename, f.job_no,
|
||||
j.job_no, j.job_name
|
||||
FROM materials m
|
||||
LEFT JOIN files f ON m.file_id = f.id
|
||||
LEFT JOIN jobs j ON f.job_no = j.job_no
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
params = {}
|
||||
|
||||
if job_no:
|
||||
query += " AND f.job_no = :job_no"
|
||||
params["job_no"] = job_no
|
||||
|
||||
if file_id:
|
||||
query += " AND m.file_id = :file_id"
|
||||
params["file_id"] = file_id
|
||||
|
||||
query += " ORDER BY m.line_number ASC LIMIT :limit OFFSET :skip"
|
||||
params["limit"] = limit
|
||||
params["skip"] = skip
|
||||
|
||||
result = db.execute(text(query), params)
|
||||
materials = result.fetchall()
|
||||
|
||||
# 전체 개수 조회
|
||||
count_query = """
|
||||
SELECT COUNT(*) as total
|
||||
FROM materials m
|
||||
LEFT JOIN files f ON m.file_id = f.id
|
||||
WHERE 1=1
|
||||
"""
|
||||
count_params = {}
|
||||
|
||||
if job_no:
|
||||
count_query += " AND f.job_no = :job_no"
|
||||
count_params["job_no"] = job_no
|
||||
|
||||
if file_id:
|
||||
count_query += " AND m.file_id = :file_id"
|
||||
count_params["file_id"] = file_id
|
||||
|
||||
count_result = db.execute(text(count_query), count_params)
|
||||
total_count = count_result.fetchone()[0]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total_count": total_count,
|
||||
"returned_count": len(materials),
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"materials": [
|
||||
{
|
||||
"id": m.id,
|
||||
"file_id": m.file_id,
|
||||
"filename": m.original_filename,
|
||||
"job_no": m.job_no,
|
||||
"project_code": m.official_project_code,
|
||||
"project_name": m.project_name,
|
||||
"original_description": m.original_description,
|
||||
"quantity": float(m.quantity) if m.quantity else 0,
|
||||
"unit": m.unit,
|
||||
"size_spec": m.size_spec,
|
||||
"material_grade": m.material_grade,
|
||||
"line_number": m.line_number,
|
||||
"row_number": m.row_number,
|
||||
"created_at": m.created_at
|
||||
}
|
||||
for m in materials
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"자재 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/materials/summary")
|
||||
async def get_materials_summary(
|
||||
job_no: Optional[str] = None,
|
||||
file_id: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""자재 요약 통계"""
|
||||
try:
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(*) as total_items,
|
||||
COUNT(DISTINCT m.original_description) as unique_descriptions,
|
||||
COUNT(DISTINCT m.size_spec) as unique_sizes,
|
||||
COUNT(DISTINCT m.material_grade) as unique_materials,
|
||||
SUM(m.quantity) as total_quantity,
|
||||
AVG(m.quantity) as avg_quantity,
|
||||
MIN(m.created_at) as earliest_upload,
|
||||
MAX(m.created_at) as latest_upload
|
||||
FROM materials m
|
||||
LEFT JOIN files f ON m.file_id = f.id
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
params = {}
|
||||
|
||||
if job_no:
|
||||
query += " AND f.job_no = :job_no"
|
||||
params["job_no"] = job_no
|
||||
|
||||
if file_id:
|
||||
query += " AND m.file_id = :file_id"
|
||||
params["file_id"] = file_id
|
||||
|
||||
result = db.execute(text(query), params)
|
||||
summary = result.fetchone()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"summary": {
|
||||
"total_items": summary.total_items,
|
||||
"unique_descriptions": summary.unique_descriptions,
|
||||
"unique_sizes": summary.unique_sizes,
|
||||
"unique_materials": summary.unique_materials,
|
||||
"total_quantity": float(summary.total_quantity) if summary.total_quantity else 0,
|
||||
"avg_quantity": round(float(summary.avg_quantity), 2) if summary.avg_quantity else 0,
|
||||
"earliest_upload": summary.earliest_upload,
|
||||
"latest_upload": summary.latest_upload
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"요약 조회 실패: {str(e)}")
|
||||
# Job 검증 함수 (파일 끝에 추가할 예정)
|
||||
async def validate_job_exists(job_no: str, db: Session):
|
||||
"""Job 존재 여부 및 활성 상태 확인"""
|
||||
try:
|
||||
query = text("SELECT job_no, job_name, status FROM jobs WHERE job_no = :job_no AND is_active = true")
|
||||
job = db.execute(query, {"job_no": job_no}).fetchone()
|
||||
|
||||
if not job:
|
||||
return {"valid": False, "error": f"Job No. '{job_no}'를 찾을 수 없습니다"}
|
||||
|
||||
if job.status == '완료':
|
||||
return {"valid": False, "error": f"완료된 Job '{job.job_name}'에는 파일을 업로드할 수 없습니다"}
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"job": {
|
||||
"job_no": job.job_no,
|
||||
"job_name": job.job_name,
|
||||
"status": job.status
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"valid": False, "error": f"Job 검증 실패: {str(e)}"}
|
||||
@@ -20,6 +20,7 @@ class JobCreate(BaseModel):
|
||||
contract_date: Optional[date] = None
|
||||
delivery_date: Optional[date] = None
|
||||
delivery_terms: Optional[str] = None
|
||||
project_type: Optional[str] = "냉동기"
|
||||
description: Optional[str] = None
|
||||
|
||||
@router.get("/")
|
||||
@@ -34,7 +35,7 @@ async def get_jobs(
|
||||
query = """
|
||||
SELECT job_no, job_name, client_name, end_user, epc_company,
|
||||
project_site, contract_date, delivery_date, delivery_terms,
|
||||
status, description, created_by, created_at, updated_at, is_active
|
||||
project_type, status, description, created_by, created_at, updated_at, is_active
|
||||
FROM jobs
|
||||
WHERE is_active = true
|
||||
"""
|
||||
@@ -66,6 +67,7 @@ async def get_jobs(
|
||||
"contract_date": job.contract_date,
|
||||
"delivery_date": job.delivery_date,
|
||||
"delivery_terms": job.delivery_terms,
|
||||
"project_type": job.project_type,
|
||||
"status": job.status,
|
||||
"description": job.description,
|
||||
"created_at": job.created_at,
|
||||
@@ -85,7 +87,7 @@ async def get_job(job_no: str, db: Session = Depends(get_db)):
|
||||
query = text("""
|
||||
SELECT job_no, job_name, client_name, end_user, epc_company,
|
||||
project_site, contract_date, delivery_date, delivery_terms,
|
||||
status, description, created_by, created_at, updated_at, is_active
|
||||
project_type, status, description, created_by, created_at, updated_at, is_active
|
||||
FROM jobs
|
||||
WHERE job_no = :job_no AND is_active = true
|
||||
""")
|
||||
@@ -108,6 +110,7 @@ async def get_job(job_no: str, db: Session = Depends(get_db)):
|
||||
"contract_date": job.contract_date,
|
||||
"delivery_date": job.delivery_date,
|
||||
"delivery_terms": job.delivery_terms,
|
||||
"project_type": job.project_type,
|
||||
"status": job.status,
|
||||
"description": job.description,
|
||||
"created_by": job.created_by,
|
||||
@@ -139,14 +142,14 @@ async def create_job(job: JobCreate, db: Session = Depends(get_db)):
|
||||
INSERT INTO jobs (
|
||||
job_no, job_name, client_name, end_user, epc_company,
|
||||
project_site, contract_date, delivery_date, delivery_terms,
|
||||
description, created_by, status, is_active
|
||||
project_type, description, created_by, status, is_active
|
||||
)
|
||||
VALUES (
|
||||
:job_no, :job_name, :client_name, :end_user, :epc_company,
|
||||
:project_site, :contract_date, :delivery_date, :delivery_terms,
|
||||
:description, :created_by, :status, :is_active
|
||||
:project_type, :description, :created_by, :status, :is_active
|
||||
)
|
||||
RETURNING job_no, job_name, client_name
|
||||
RETURNING job_no, job_name, client_name, project_type
|
||||
""")
|
||||
|
||||
result = db.execute(insert_query, {
|
||||
@@ -165,7 +168,8 @@ async def create_job(job: JobCreate, db: Session = Depends(get_db)):
|
||||
"job": {
|
||||
"job_no": new_job.job_no,
|
||||
"job_name": new_job.job_name,
|
||||
"client_name": new_job.client_name
|
||||
"client_name": new_job.client_name,
|
||||
"project_type": new_job.project_type
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,26 @@ async def confirm_material_purchase(
|
||||
]
|
||||
"""
|
||||
try:
|
||||
# 입력 데이터 검증
|
||||
if not job_no or not revision:
|
||||
raise HTTPException(status_code=400, detail="Job 번호와 리비전은 필수입니다")
|
||||
|
||||
if not confirmations:
|
||||
raise HTTPException(status_code=400, detail="확정할 자재가 없습니다")
|
||||
|
||||
# 각 확정 항목 검증
|
||||
for i, confirmation in enumerate(confirmations):
|
||||
if not confirmation.get("material_hash"):
|
||||
raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 material_hash가 없습니다")
|
||||
|
||||
confirmed_qty = confirmation.get("confirmed_quantity")
|
||||
if confirmed_qty is None or confirmed_qty < 0:
|
||||
raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 확정 수량이 유효하지 않습니다")
|
||||
|
||||
unit_price = confirmation.get("unit_price", 0)
|
||||
if unit_price < 0:
|
||||
raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 단가가 유효하지 않습니다")
|
||||
|
||||
confirmed_items = []
|
||||
|
||||
for confirmation in confirmations:
|
||||
@@ -470,7 +490,7 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
|
||||
"""파일의 자재를 해시별로 그룹화하여 조회"""
|
||||
import hashlib
|
||||
|
||||
print(f"🚨🚨🚨 get_materials_by_hash 호출됨! file_id={file_id} 🚨🚨🚨")
|
||||
# 로그 제거
|
||||
|
||||
query = text("""
|
||||
SELECT
|
||||
@@ -492,11 +512,7 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
|
||||
result = db.execute(query, {"file_id": file_id})
|
||||
materials = result.fetchall()
|
||||
|
||||
print(f"🔍 쿼리 결과 개수: {len(materials)}")
|
||||
if len(materials) > 0:
|
||||
print(f"🔍 첫 번째 자료 샘플: {materials[0]}")
|
||||
else:
|
||||
print(f"❌ 자료가 없음! file_id={file_id}")
|
||||
# 로그 제거
|
||||
|
||||
# 🔄 같은 파이프들을 Python에서 올바르게 그룹핑
|
||||
materials_dict = {}
|
||||
@@ -505,38 +521,41 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
|
||||
hash_source = f"{mat[1] or ''}|{mat[2] or ''}|{mat[3] or ''}"
|
||||
material_hash = hashlib.md5(hash_source.encode()).hexdigest()
|
||||
|
||||
print(f"📝 개별 자재: {mat[1][:50]}... ({mat[2]}) - 수량: {mat[4]}, 길이: {mat[7]}mm")
|
||||
# 개별 자재 로그 제거 (너무 많음)
|
||||
|
||||
if material_hash in materials_dict:
|
||||
# 🔄 기존 항목에 수량 합계
|
||||
existing = materials_dict[material_hash]
|
||||
existing["quantity"] += float(mat[4]) if mat[4] else 0.0
|
||||
# 파이프가 아닌 경우만 quantity 합산 (파이프는 개별 길이가 다르므로 합산하지 않음)
|
||||
if mat[5] != 'PIPE':
|
||||
existing["quantity"] += float(mat[4]) if mat[4] else 0.0
|
||||
existing["line_number"] += f", {mat[8]}" if mat[8] else ""
|
||||
|
||||
# 파이프인 경우 길이 정보 합산
|
||||
if mat[5] == 'PIPE' and mat[7] is not None:
|
||||
if "pipe_details" in existing:
|
||||
# 총길이 합산: 기존 총길이 + (현재 수량 × 현재 길이)
|
||||
# 총길이 합산: 기존 총길이 + 현재 파이프의 실제 길이 (DB에 저장된 개별 길이)
|
||||
current_total = existing["pipe_details"]["total_length_mm"]
|
||||
current_count = existing["pipe_details"]["pipe_count"]
|
||||
|
||||
new_length = float(mat[4]) * float(mat[7]) # 수량 × 단위길이
|
||||
existing["pipe_details"]["total_length_mm"] = current_total + new_length
|
||||
existing["pipe_details"]["pipe_count"] = current_count + float(mat[4])
|
||||
# ✅ DB에서 가져온 length_mm는 이미 개별 파이프의 실제 길이이므로 수량을 곱하지 않음
|
||||
individual_length = float(mat[7]) # 개별 파이프의 실제 길이
|
||||
existing["pipe_details"]["total_length_mm"] = current_total + individual_length
|
||||
existing["pipe_details"]["pipe_count"] = current_count + 1 # 파이프 개수는 1개씩 증가
|
||||
|
||||
# 평균 단위 길이 재계산
|
||||
total_length = existing["pipe_details"]["total_length_mm"]
|
||||
total_count = existing["pipe_details"]["pipe_count"]
|
||||
existing["pipe_details"]["length_mm"] = total_length / total_count
|
||||
|
||||
print(f"🔄 파이프 합산: {mat[1]} ({mat[2]}) - 총길이: {total_length}mm, 총개수: {total_count}개, 평균: {total_length/total_count:.1f}mm")
|
||||
# 파이프 합산 로그 제거 (너무 많음)
|
||||
else:
|
||||
# 첫 파이프 정보 설정
|
||||
pipe_length = float(mat[4]) * float(mat[7])
|
||||
individual_length = float(mat[7]) # 개별 파이프의 실제 길이
|
||||
existing["pipe_details"] = {
|
||||
"length_mm": float(mat[7]),
|
||||
"total_length_mm": pipe_length,
|
||||
"pipe_count": float(mat[4])
|
||||
"length_mm": individual_length,
|
||||
"total_length_mm": individual_length, # 첫 번째 파이프이므로 개별 길이와 동일
|
||||
"pipe_count": 1 # 첫 번째 파이프이므로 1개
|
||||
}
|
||||
else:
|
||||
# 🆕 새 항목 생성
|
||||
@@ -553,27 +572,22 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
|
||||
|
||||
# 파이프인 경우 pipe_details 정보 추가
|
||||
if mat[5] == 'PIPE' and mat[7] is not None:
|
||||
pipe_length = float(mat[4]) * float(mat[7]) # 수량 × 단위길이
|
||||
individual_length = float(mat[7]) # 개별 파이프의 실제 길이
|
||||
material_data["pipe_details"] = {
|
||||
"length_mm": float(mat[7]), # 단위 길이
|
||||
"total_length_mm": pipe_length, # 총 길이
|
||||
"pipe_count": float(mat[4]) # 파이프 개수
|
||||
"length_mm": individual_length, # 개별 파이프 길이
|
||||
"total_length_mm": individual_length, # 첫 번째 파이프이므로 개별 길이와 동일
|
||||
"pipe_count": 1 # 첫 번째 파이프이므로 1개
|
||||
}
|
||||
print(f"🆕 파이프 신규: {mat[1]} ({mat[2]}) - 단위: {mat[7]}mm, 총길이: {pipe_length}mm")
|
||||
# 파이프는 quantity를 1로 설정 (pipe_count와 동일)
|
||||
material_data["quantity"] = 1
|
||||
|
||||
materials_dict[material_hash] = material_data
|
||||
|
||||
# 파이프 데이터가 포함되었는지 확인
|
||||
# 파이프 데이터 요약만 출력
|
||||
pipe_count = sum(1 for data in materials_dict.values() if data.get('category') == 'PIPE')
|
||||
pipe_with_details = sum(1 for data in materials_dict.values()
|
||||
if data.get('category') == 'PIPE' and 'pipe_details' in data)
|
||||
print(f"🔍 반환 결과: 총 {len(materials_dict)}개 자재, 파이프 {pipe_count}개, pipe_details 있는 파이프 {pipe_with_details}개")
|
||||
|
||||
# 첫 번째 파이프 데이터 샘플 출력
|
||||
for hash_key, data in materials_dict.items():
|
||||
if data.get('category') == 'PIPE':
|
||||
print(f"🔍 파이프 샘플: {data}")
|
||||
break
|
||||
print(f"✅ 자재 처리 완료: 총 {len(materials_dict)}개, 파이프 {pipe_count}개 (길이정보: {pipe_with_details}개)")
|
||||
|
||||
return materials_dict
|
||||
|
||||
|
||||
161
backend/app/routers/materials.py
Normal file
161
backend/app/routers/materials.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from pydantic import BaseModel
|
||||
from ..database import get_db
|
||||
from ..auth.middleware import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/materials", tags=["materials"])
|
||||
|
||||
class BrandUpdate(BaseModel):
|
||||
brand: str
|
||||
|
||||
class UserRequirementUpdate(BaseModel):
|
||||
user_requirement: str
|
||||
|
||||
@router.patch("/{material_id}/brand")
|
||||
async def update_material_brand(
|
||||
material_id: int,
|
||||
brand_data: BrandUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""자재의 브랜드 정보를 업데이트합니다."""
|
||||
try:
|
||||
# 자재 존재 여부 확인
|
||||
result = db.execute(
|
||||
text("SELECT id FROM materials WHERE id = :material_id"),
|
||||
{"material_id": material_id}
|
||||
)
|
||||
material = result.fetchone()
|
||||
|
||||
if not material:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="자재를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 브랜드 업데이트
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE materials
|
||||
SET brand = :brand,
|
||||
updated_by = :updated_by
|
||||
WHERE id = :material_id
|
||||
"""),
|
||||
{
|
||||
"brand": brand_data.brand.strip(),
|
||||
"updated_by": current_user.get("username", "unknown"),
|
||||
"material_id": material_id
|
||||
}
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "브랜드가 성공적으로 업데이트되었습니다.",
|
||||
"material_id": material_id,
|
||||
"brand": brand_data.brand.strip()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"브랜드 업데이트 실패: {str(e)}"
|
||||
)
|
||||
|
||||
@router.patch("/{material_id}/user-requirement")
|
||||
async def update_material_user_requirement(
|
||||
material_id: int,
|
||||
requirement_data: UserRequirementUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""자재의 사용자 요구사항을 업데이트합니다."""
|
||||
try:
|
||||
# 자재 존재 여부 확인
|
||||
result = db.execute(
|
||||
text("SELECT id FROM materials WHERE id = :material_id"),
|
||||
{"material_id": material_id}
|
||||
)
|
||||
material = result.fetchone()
|
||||
|
||||
if not material:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="자재를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 사용자 요구사항 업데이트
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE materials
|
||||
SET user_requirement = :user_requirement,
|
||||
updated_by = :updated_by
|
||||
WHERE id = :material_id
|
||||
"""),
|
||||
{
|
||||
"user_requirement": requirement_data.user_requirement.strip(),
|
||||
"updated_by": current_user.get("username", "unknown"),
|
||||
"material_id": material_id
|
||||
}
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "사용자 요구사항이 성공적으로 업데이트되었습니다.",
|
||||
"material_id": material_id,
|
||||
"user_requirement": requirement_data.user_requirement.strip()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"사용자 요구사항 업데이트 실패: {str(e)}"
|
||||
)
|
||||
|
||||
@router.get("/{material_id}")
|
||||
async def get_material(
|
||||
material_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""자재 정보를 조회합니다."""
|
||||
try:
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT id, original_description, classified_category,
|
||||
brand, user_requirement, created_at, updated_by
|
||||
FROM materials
|
||||
WHERE id = :material_id
|
||||
"""),
|
||||
{"material_id": material_id}
|
||||
)
|
||||
material = result.fetchone()
|
||||
|
||||
if not material:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="자재를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
return {
|
||||
"id": material.id,
|
||||
"original_description": material.original_description,
|
||||
"classified_category": material.classified_category,
|
||||
"brand": material.brand,
|
||||
"user_requirement": material.user_requirement,
|
||||
"created_at": material.created_at,
|
||||
"updated_by": material.updated_by
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"자재 조회 실패: {str(e)}"
|
||||
)
|
||||
@@ -5,11 +5,13 @@
|
||||
- 리비전 비교
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from ..database import get_db
|
||||
from ..services.purchase_calculator import (
|
||||
@@ -21,6 +23,28 @@ from ..services.purchase_calculator import (
|
||||
|
||||
router = APIRouter(prefix="/purchase", tags=["purchase"])
|
||||
|
||||
# Pydantic 모델 (최적화된 구조)
|
||||
class PurchaseItemMinimal(BaseModel):
|
||||
"""구매 확정용 최소 필수 데이터"""
|
||||
item_code: str
|
||||
category: str
|
||||
specification: str
|
||||
size: str = ""
|
||||
material: str = ""
|
||||
bom_quantity: float
|
||||
calculated_qty: float
|
||||
unit: str = "EA"
|
||||
safety_factor: float = 1.0
|
||||
|
||||
class PurchaseConfirmRequest(BaseModel):
|
||||
job_no: str
|
||||
file_id: int
|
||||
bom_name: Optional[str] = None # 선택적 필드로 변경
|
||||
revision: str
|
||||
purchase_items: List[PurchaseItemMinimal] # 최적화된 구조 사용
|
||||
confirmed_at: str
|
||||
confirmed_by: str
|
||||
|
||||
@router.get("/items/calculate")
|
||||
async def calculate_purchase_items(
|
||||
job_no: str = Query(..., description="Job 번호"),
|
||||
@@ -39,7 +63,7 @@ async def calculate_purchase_items(
|
||||
file_query = text("""
|
||||
SELECT id FROM files
|
||||
WHERE job_no = :job_no AND revision = :revision AND is_active = TRUE
|
||||
ORDER BY created_at DESC
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
file_result = db.execute(file_query, {"job_no": job_no, "revision": revision}).fetchone()
|
||||
@@ -62,6 +86,139 @@ async def calculate_purchase_items(
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"구매 품목 계산 실패: {str(e)}")
|
||||
|
||||
@router.post("/confirm")
|
||||
async def confirm_purchase_quantities(
|
||||
request: PurchaseConfirmRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
구매 수량 확정
|
||||
- 계산된 구매 수량을 확정 상태로 저장
|
||||
- 자재별 확정 수량 및 상태 업데이트
|
||||
- 리비전 비교를 위한 기준 데이터 생성
|
||||
"""
|
||||
try:
|
||||
# 1. 기존 확정 데이터 확인 및 업데이트 또는 삽입
|
||||
existing_query = text("""
|
||||
SELECT id FROM purchase_confirmations
|
||||
WHERE file_id = :file_id
|
||||
""")
|
||||
existing_result = db.execute(existing_query, {"file_id": request.file_id}).fetchone()
|
||||
|
||||
if existing_result:
|
||||
# 기존 데이터 업데이트
|
||||
confirmation_id = existing_result[0]
|
||||
update_query = text("""
|
||||
UPDATE purchase_confirmations
|
||||
SET job_no = :job_no,
|
||||
bom_name = :bom_name,
|
||||
revision = :revision,
|
||||
confirmed_at = :confirmed_at,
|
||||
confirmed_by = :confirmed_by,
|
||||
is_active = TRUE,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :confirmation_id
|
||||
""")
|
||||
db.execute(update_query, {
|
||||
"confirmation_id": confirmation_id,
|
||||
"job_no": request.job_no,
|
||||
"bom_name": request.bom_name or f"{request.job_no}_{request.revision}", # 기본값 제공
|
||||
"revision": request.revision,
|
||||
"confirmed_at": request.confirmed_at,
|
||||
"confirmed_by": request.confirmed_by
|
||||
})
|
||||
|
||||
# 기존 확정 품목들 삭제
|
||||
delete_items_query = text("""
|
||||
DELETE FROM confirmed_purchase_items
|
||||
WHERE confirmation_id = :confirmation_id
|
||||
""")
|
||||
db.execute(delete_items_query, {"confirmation_id": confirmation_id})
|
||||
else:
|
||||
# 새로운 확정 데이터 삽입
|
||||
confirm_query = text("""
|
||||
INSERT INTO purchase_confirmations (
|
||||
job_no, file_id, bom_name, revision,
|
||||
confirmed_at, confirmed_by, is_active, created_at
|
||||
) VALUES (
|
||||
:job_no, :file_id, :bom_name, :revision,
|
||||
:confirmed_at, :confirmed_by, TRUE, CURRENT_TIMESTAMP
|
||||
) RETURNING id
|
||||
""")
|
||||
|
||||
confirm_result = db.execute(confirm_query, {
|
||||
"job_no": request.job_no,
|
||||
"file_id": request.file_id,
|
||||
"bom_name": request.bom_name or f"{request.job_no}_{request.revision}", # 기본값 제공
|
||||
"revision": request.revision,
|
||||
"confirmed_at": request.confirmed_at,
|
||||
"confirmed_by": request.confirmed_by
|
||||
})
|
||||
|
||||
confirmation_id = confirm_result.fetchone()[0]
|
||||
|
||||
# 3. 확정된 구매 품목들 저장
|
||||
saved_items = 0
|
||||
for item in request.purchase_items:
|
||||
item_query = text("""
|
||||
INSERT INTO confirmed_purchase_items (
|
||||
confirmation_id, item_code, category, specification,
|
||||
size, material, bom_quantity, calculated_qty,
|
||||
unit, safety_factor, created_at
|
||||
) VALUES (
|
||||
:confirmation_id, :item_code, :category, :specification,
|
||||
:size, :material, :bom_quantity, :calculated_qty,
|
||||
:unit, :safety_factor, CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
db.execute(item_query, {
|
||||
"confirmation_id": confirmation_id,
|
||||
"item_code": item.item_code or f"{item.category}-{saved_items+1}",
|
||||
"category": item.category,
|
||||
"specification": item.specification,
|
||||
"size": item.size or "",
|
||||
"material": item.material or "",
|
||||
"bom_quantity": item.bom_quantity,
|
||||
"calculated_qty": item.calculated_qty,
|
||||
"unit": item.unit,
|
||||
"safety_factor": item.safety_factor
|
||||
})
|
||||
saved_items += 1
|
||||
|
||||
# 4. 파일 상태를 확정으로 업데이트
|
||||
file_update_query = text("""
|
||||
UPDATE files
|
||||
SET purchase_confirmed = TRUE,
|
||||
confirmed_at = :confirmed_at,
|
||||
confirmed_by = :confirmed_by,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :file_id
|
||||
""")
|
||||
|
||||
db.execute(file_update_query, {
|
||||
"file_id": request.file_id,
|
||||
"confirmed_at": request.confirmed_at,
|
||||
"confirmed_by": request.confirmed_by
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "구매 수량이 성공적으로 확정되었습니다",
|
||||
"confirmation_id": confirmation_id,
|
||||
"confirmed_items": saved_items,
|
||||
"job_no": request.job_no,
|
||||
"revision": request.revision,
|
||||
"confirmed_at": request.confirmed_at,
|
||||
"confirmed_by": request.confirmed_by
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"구매 수량 확정 실패: {str(e)}")
|
||||
|
||||
@router.post("/items/save")
|
||||
async def save_purchase_items(
|
||||
job_no: str,
|
||||
|
||||
834
backend/app/routers/purchase_request.py
Normal file
834
backend/app/routers/purchase_request.py
Normal file
@@ -0,0 +1,834 @@
|
||||
"""
|
||||
구매신청 관리 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Body, File, UploadFile, Form
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List, Dict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import os
|
||||
import json
|
||||
|
||||
from ..database import get_db
|
||||
from ..auth.middleware import get_current_user
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/purchase-request", tags=["Purchase Request"])
|
||||
|
||||
# 엑셀 파일 저장 경로
|
||||
EXCEL_DIR = "uploads/excel_exports"
|
||||
os.makedirs(EXCEL_DIR, exist_ok=True)
|
||||
|
||||
class PurchaseRequestCreate(BaseModel):
|
||||
file_id: int
|
||||
job_no: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
material_ids: List[int] = []
|
||||
materials_data: List[Dict] = []
|
||||
grouped_materials: Optional[List[Dict]] = [] # 그룹화된 자재 정보
|
||||
|
||||
@router.post("/create")
|
||||
async def create_purchase_request(
|
||||
request_data: PurchaseRequestCreate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
구매신청 생성 (엑셀 내보내기 = 구매신청)
|
||||
"""
|
||||
try:
|
||||
# 🔍 디버깅: 요청 데이터 로깅
|
||||
logger.info(f"🔍 구매신청 생성 요청 - file_id: {request_data.file_id}, job_no: {request_data.job_no}")
|
||||
logger.info(f"🔍 material_ids 개수: {len(request_data.material_ids)}")
|
||||
logger.info(f"🔍 materials_data 개수: {len(request_data.materials_data)}")
|
||||
if request_data.material_ids:
|
||||
logger.info(f"🔍 material_ids 샘플: {request_data.material_ids[:5]}")
|
||||
|
||||
print(f"🔍 [DEBUG] 구매신청 API 호출됨 - material_ids: {len(request_data.material_ids)}개")
|
||||
# 구매신청 번호 생성
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
count_query = text("""
|
||||
SELECT COUNT(*) as count
|
||||
FROM purchase_requests
|
||||
WHERE request_no LIKE :pattern
|
||||
""")
|
||||
count = db.execute(count_query, {"pattern": f"PR-{today}%"}).fetchone().count
|
||||
request_no = f"PR-{today}-{str(count + 1).zfill(3)}"
|
||||
|
||||
# 자재 데이터를 JSON과 엑셀 파일로 저장
|
||||
json_filename = f"{request_no}.json"
|
||||
excel_filename = f"{request_no}.xlsx"
|
||||
json_path = os.path.join(EXCEL_DIR, json_filename)
|
||||
excel_path = os.path.join(EXCEL_DIR, excel_filename)
|
||||
|
||||
# JSON 저장
|
||||
save_materials_data(
|
||||
request_data.materials_data,
|
||||
json_path,
|
||||
request_no,
|
||||
request_data.job_no,
|
||||
request_data.grouped_materials # 그룹화 정보 추가
|
||||
)
|
||||
|
||||
# 엑셀 파일 생성 및 저장
|
||||
create_excel_file(
|
||||
request_data.grouped_materials or request_data.materials_data,
|
||||
excel_path,
|
||||
request_no,
|
||||
request_data.job_no
|
||||
)
|
||||
|
||||
# 구매신청 레코드 생성
|
||||
insert_request = text("""
|
||||
INSERT INTO purchase_requests (
|
||||
request_no, file_id, job_no, category,
|
||||
material_count, excel_file_path, requested_by
|
||||
) VALUES (
|
||||
:request_no, :file_id, :job_no, :category,
|
||||
:material_count, :excel_file_path, :requested_by
|
||||
) RETURNING request_id
|
||||
""")
|
||||
|
||||
result = db.execute(insert_request, {
|
||||
"request_no": request_no,
|
||||
"file_id": request_data.file_id,
|
||||
"job_no": request_data.job_no,
|
||||
"category": request_data.category,
|
||||
"material_count": len(request_data.material_ids),
|
||||
"excel_file_path": excel_filename, # 엑셀 파일명 저장 (JSON 대신)
|
||||
"requested_by": current_user.get("user_id")
|
||||
})
|
||||
request_id = result.fetchone().request_id
|
||||
|
||||
# 구매신청 자재 상세 저장
|
||||
logger.info(f"Processing {len(request_data.material_ids)} material IDs for purchase request {request_no}")
|
||||
logger.info(f"First 10 Material IDs: {request_data.material_ids[:10]}") # 처음 10개만 로그
|
||||
logger.info(f"Category: {request_data.category}, Job: {request_data.job_no}")
|
||||
|
||||
inserted_count = 0
|
||||
for i, material_id in enumerate(request_data.material_ids):
|
||||
material_data = request_data.materials_data[i] if i < len(request_data.materials_data) else {}
|
||||
|
||||
# 이미 구매신청된 자재인지 확인
|
||||
check_existing = text("""
|
||||
SELECT 1 FROM purchase_request_items
|
||||
WHERE material_id = :material_id
|
||||
""")
|
||||
existing = db.execute(check_existing, {"material_id": material_id}).fetchone()
|
||||
|
||||
if not existing:
|
||||
insert_item = text("""
|
||||
INSERT INTO purchase_request_items (
|
||||
request_id, material_id, description, category, subcategory,
|
||||
material_grade, size_spec, quantity, unit, drawing_name,
|
||||
notes, user_requirement
|
||||
) VALUES (
|
||||
:request_id, :material_id, :description, :category, :subcategory,
|
||||
:material_grade, :size_spec, :quantity, :unit, :drawing_name,
|
||||
:notes, :user_requirement
|
||||
)
|
||||
""")
|
||||
# quantity를 정수로 변환 (소수점 제거)
|
||||
quantity_str = str(material_data.get("quantity", 0))
|
||||
try:
|
||||
quantity = int(float(quantity_str))
|
||||
except (ValueError, TypeError):
|
||||
quantity = 0
|
||||
|
||||
db.execute(insert_item, {
|
||||
"request_id": request_id,
|
||||
"material_id": material_id,
|
||||
"description": material_data.get("description", material_data.get("original_description", "")),
|
||||
"category": material_data.get("category", material_data.get("classified_category", "")),
|
||||
"subcategory": material_data.get("subcategory", material_data.get("classified_subcategory", "")),
|
||||
"material_grade": material_data.get("material_grade", ""),
|
||||
"size_spec": material_data.get("size_spec", ""),
|
||||
"quantity": quantity,
|
||||
"unit": material_data.get("unit", "EA"),
|
||||
"drawing_name": material_data.get("drawing_name", ""),
|
||||
"notes": material_data.get("notes", ""),
|
||||
"user_requirement": material_data.get("user_requirement", "")
|
||||
})
|
||||
inserted_count += 1
|
||||
else:
|
||||
logger.warning(f"Material {material_id} already in another purchase request, skipping")
|
||||
|
||||
# 🔥 중요: materials 테이블의 purchase_confirmed 업데이트
|
||||
if request_data.material_ids:
|
||||
print(f"🔥 [PURCHASE] purchase_confirmed 업데이트 시작: {len(request_data.material_ids)}개 자재")
|
||||
print(f"🔥 [PURCHASE] material_ids: {request_data.material_ids[:5]}...") # 처음 5개만 로그
|
||||
|
||||
update_materials_query = text("""
|
||||
UPDATE materials
|
||||
SET purchase_confirmed = true,
|
||||
purchase_confirmed_at = NOW(),
|
||||
purchase_confirmed_by = :confirmed_by
|
||||
WHERE id = ANY(:material_ids)
|
||||
""")
|
||||
|
||||
result = db.execute(update_materials_query, {
|
||||
"material_ids": request_data.material_ids,
|
||||
"confirmed_by": current_user.get("username", "system")
|
||||
})
|
||||
|
||||
print(f"🔥 [PURCHASE] UPDATE 결과: {result.rowcount}개 행 업데이트됨")
|
||||
logger.info(f"✅ {len(request_data.material_ids)}개 자재의 purchase_confirmed를 true로 업데이트")
|
||||
else:
|
||||
print(f"⚠️ [PURCHASE] material_ids가 비어있음!")
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Purchase request created: {request_no} with {inserted_count} materials (out of {len(request_data.material_ids)} requested)")
|
||||
|
||||
# 실제 저장된 자재 확인
|
||||
verify_query = text("""
|
||||
SELECT COUNT(*) as count FROM purchase_request_items WHERE request_id = :request_id
|
||||
""")
|
||||
verified_count = db.execute(verify_query, {"request_id": request_id}).fetchone().count
|
||||
logger.info(f"✅ DB 검증: purchase_request_items에 {verified_count}개 저장됨")
|
||||
|
||||
# purchase_requests 테이블의 total_items 필드 업데이트
|
||||
update_total_items = text("""
|
||||
UPDATE purchase_requests
|
||||
SET total_items = :total_items
|
||||
WHERE request_id = :request_id
|
||||
""")
|
||||
db.execute(update_total_items, {
|
||||
"request_id": request_id,
|
||||
"total_items": verified_count
|
||||
})
|
||||
db.commit()
|
||||
|
||||
logger.info(f"✅ total_items 업데이트 완료: {verified_count}개")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"request_no": request_no,
|
||||
"request_id": request_id,
|
||||
"material_count": len(request_data.material_ids),
|
||||
"inserted_count": inserted_count,
|
||||
"verified_count": verified_count,
|
||||
"message": f"구매신청 {request_no}이 생성되었습니다"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to create purchase request: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"구매신청 생성 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def get_purchase_requests(
|
||||
file_id: Optional[int] = None,
|
||||
job_no: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
# current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
구매신청 목록 조회
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
pr.request_id,
|
||||
pr.request_no,
|
||||
pr.file_id,
|
||||
pr.job_no,
|
||||
pr.total_items,
|
||||
pr.request_date,
|
||||
pr.status,
|
||||
pr.requested_by_username as requested_by,
|
||||
f.original_filename,
|
||||
j.job_name,
|
||||
COUNT(pri.item_id) as item_count
|
||||
FROM purchase_requests pr
|
||||
LEFT JOIN files f ON pr.file_id = f.id
|
||||
LEFT JOIN jobs j ON pr.job_no = j.job_no
|
||||
LEFT JOIN purchase_request_items pri ON pr.request_id = pri.request_id
|
||||
WHERE 1=1
|
||||
AND (:file_id IS NULL OR pr.file_id = :file_id)
|
||||
AND (:job_no IS NULL OR pr.job_no = :job_no)
|
||||
AND (:status IS NULL OR pr.status = :status)
|
||||
GROUP BY
|
||||
pr.request_id, pr.request_no, pr.file_id, pr.job_no,
|
||||
pr.total_items, pr.request_date, pr.status,
|
||||
pr.requested_by_username, f.original_filename, j.job_name
|
||||
ORDER BY pr.request_date DESC
|
||||
""")
|
||||
|
||||
results = db.execute(query, {
|
||||
"file_id": file_id,
|
||||
"job_no": job_no,
|
||||
"status": status
|
||||
}).fetchall()
|
||||
|
||||
requests = []
|
||||
for row in results:
|
||||
requests.append({
|
||||
"request_id": row.request_id,
|
||||
"request_no": row.request_no,
|
||||
"file_id": row.file_id,
|
||||
"job_no": row.job_no,
|
||||
"job_name": row.job_name,
|
||||
"category": "ALL", # 기본값
|
||||
"material_count": row.item_count or 0, # 실제 자재 개수 사용
|
||||
"item_count": row.item_count,
|
||||
"excel_file_path": None, # 현재 테이블에 없음
|
||||
"requested_at": row.request_date.isoformat() if row.request_date else None,
|
||||
"status": row.status,
|
||||
"requested_by": row.requested_by,
|
||||
"source_file": row.original_filename
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"requests": requests,
|
||||
"count": len(requests)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get purchase requests: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"구매신청 목록 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{request_id}/materials")
|
||||
async def get_request_materials(
|
||||
request_id: int,
|
||||
# current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
구매신청에 포함된 자재 목록 조회 (그룹화 정보 포함)
|
||||
"""
|
||||
try:
|
||||
# 구매신청 정보 조회하여 JSON 파일 경로 가져오기
|
||||
info_query = text("""
|
||||
SELECT excel_file_path
|
||||
FROM purchase_requests
|
||||
WHERE request_id = :request_id
|
||||
""")
|
||||
info_result = db.execute(info_query, {"request_id": request_id}).fetchone()
|
||||
|
||||
grouped_materials = []
|
||||
if info_result and info_result.excel_file_path:
|
||||
json_path = os.path.join(EXCEL_DIR, info_result.excel_file_path)
|
||||
if os.path.exists(json_path):
|
||||
try:
|
||||
with open(json_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
data = json.load(f)
|
||||
grouped_materials = data.get("grouped_materials", [])
|
||||
except Exception as e:
|
||||
print(f"⚠️ JSON 파일 읽기 오류 (무시): {e}")
|
||||
grouped_materials = []
|
||||
|
||||
# 개별 자재 정보 조회 (기존 코드)
|
||||
query = text("""
|
||||
SELECT
|
||||
pri.item_id,
|
||||
pri.material_id,
|
||||
pri.quantity as requested_quantity,
|
||||
pri.unit as requested_unit,
|
||||
pri.user_requirement,
|
||||
pri.is_ordered,
|
||||
pri.is_received,
|
||||
m.original_description,
|
||||
m.classified_category,
|
||||
m.size_spec,
|
||||
m.main_nom,
|
||||
m.red_nom,
|
||||
m.schedule,
|
||||
m.material_grade,
|
||||
m.full_material_grade,
|
||||
m.quantity as original_quantity,
|
||||
m.unit as original_unit,
|
||||
m.classification_details,
|
||||
pd.outer_diameter, pd.schedule as pipe_schedule, pd.material_spec, pd.manufacturing_method,
|
||||
pd.end_preparation, pd.length_mm,
|
||||
fd.fitting_type, fd.fitting_subtype, fd.connection_method as fitting_connection,
|
||||
fd.pressure_rating as fitting_pressure, fd.schedule as fitting_schedule,
|
||||
fld.flange_type, fld.facing_type,
|
||||
fld.pressure_rating as flange_pressure,
|
||||
gd.gasket_type, gd.gasket_subtype, gd.material_type as gasket_material,
|
||||
gd.filler_material, gd.pressure_rating as gasket_pressure, gd.thickness as gasket_thickness,
|
||||
bd.bolt_type, bd.material_standard as bolt_material, bd.length as bolt_length
|
||||
FROM purchase_request_items pri
|
||||
JOIN materials m ON pri.material_id = m.id
|
||||
LEFT JOIN pipe_details pd ON m.id = pd.material_id
|
||||
LEFT JOIN fitting_details fd ON m.id = fd.material_id
|
||||
LEFT JOIN flange_details fld ON m.id = fld.material_id
|
||||
LEFT JOIN gasket_details gd ON m.id = gd.material_id
|
||||
LEFT JOIN bolt_details bd ON m.id = bd.material_id
|
||||
WHERE pri.request_id = :request_id
|
||||
ORDER BY m.classified_category, m.original_description
|
||||
""")
|
||||
|
||||
# 🎯 데이터베이스 쿼리 실행
|
||||
results = db.execute(query, {"request_id": request_id}).fetchall()
|
||||
|
||||
materials = []
|
||||
|
||||
# 🎯 안전한 문자열 변환 함수
|
||||
def safe_str(value):
|
||||
if value is None:
|
||||
return ''
|
||||
try:
|
||||
if isinstance(value, bytes):
|
||||
return value.decode('utf-8', errors='ignore')
|
||||
return str(value)
|
||||
except Exception:
|
||||
return str(value) if value else ''
|
||||
|
||||
for row in results:
|
||||
try:
|
||||
# quantity를 정수로 변환 (소수점 제거)
|
||||
qty = row.requested_quantity or row.original_quantity
|
||||
try:
|
||||
qty_int = int(float(qty)) if qty else 0
|
||||
except (ValueError, TypeError):
|
||||
qty_int = 0
|
||||
|
||||
# 안전한 문자열 변환
|
||||
original_description = safe_str(row.original_description)
|
||||
size_spec = safe_str(row.size_spec)
|
||||
material_grade = safe_str(row.material_grade)
|
||||
full_material_grade = safe_str(row.full_material_grade)
|
||||
user_requirement = safe_str(row.user_requirement)
|
||||
|
||||
except Exception as e:
|
||||
# 오류 발생 시 기본값 사용
|
||||
qty_int = 0
|
||||
original_description = ''
|
||||
size_spec = ''
|
||||
material_grade = ''
|
||||
full_material_grade = ''
|
||||
user_requirement = ''
|
||||
|
||||
# BOM 페이지와 동일한 형식으로 데이터 구성
|
||||
material_dict = {
|
||||
"item_id": row.item_id,
|
||||
"material_id": row.material_id,
|
||||
"id": row.material_id,
|
||||
"original_description": original_description,
|
||||
"classified_category": safe_str(row.classified_category),
|
||||
"size_spec": size_spec,
|
||||
"size_inch": safe_str(row.main_nom),
|
||||
"main_nom": safe_str(row.main_nom),
|
||||
"red_nom": safe_str(row.red_nom),
|
||||
"schedule": safe_str(row.schedule),
|
||||
"material_grade": material_grade,
|
||||
"full_material_grade": full_material_grade,
|
||||
"quantity": qty_int,
|
||||
"unit": safe_str(row.requested_unit or row.original_unit),
|
||||
"user_requirement": user_requirement,
|
||||
"is_ordered": row.is_ordered,
|
||||
"is_received": row.is_received,
|
||||
"classification_details": safe_str(row.classification_details)
|
||||
}
|
||||
|
||||
# 카테고리별 상세 정보 추가 (안전한 문자열 처리)
|
||||
if row.classified_category == 'PIPE' and row.manufacturing_method:
|
||||
material_dict["pipe_details"] = {
|
||||
"manufacturing_method": safe_str(row.manufacturing_method),
|
||||
"schedule": safe_str(row.pipe_schedule),
|
||||
"material_spec": safe_str(row.material_spec),
|
||||
"end_preparation": safe_str(row.end_preparation),
|
||||
"length_mm": row.length_mm
|
||||
}
|
||||
elif row.classified_category == 'FITTING' and row.fitting_type:
|
||||
material_dict["fitting_details"] = {
|
||||
"fitting_type": safe_str(row.fitting_type),
|
||||
"fitting_subtype": safe_str(row.fitting_subtype),
|
||||
"connection_method": safe_str(row.fitting_connection),
|
||||
"pressure_rating": safe_str(row.fitting_pressure),
|
||||
"schedule": safe_str(row.fitting_schedule)
|
||||
}
|
||||
elif row.classified_category == 'FLANGE' and row.flange_type:
|
||||
material_dict["flange_details"] = {
|
||||
"flange_type": safe_str(row.flange_type),
|
||||
"facing_type": safe_str(row.facing_type),
|
||||
"pressure_rating": safe_str(row.flange_pressure)
|
||||
}
|
||||
elif row.classified_category == 'GASKET' and row.gasket_type:
|
||||
material_dict["gasket_details"] = {
|
||||
"gasket_type": safe_str(row.gasket_type),
|
||||
"gasket_subtype": safe_str(row.gasket_subtype),
|
||||
"material_type": safe_str(row.gasket_material),
|
||||
"filler_material": safe_str(row.filler_material),
|
||||
"pressure_rating": safe_str(row.gasket_pressure),
|
||||
"thickness": safe_str(row.gasket_thickness)
|
||||
}
|
||||
elif row.classified_category == 'BOLT' and row.bolt_type:
|
||||
material_dict["bolt_details"] = {
|
||||
"bolt_type": safe_str(row.bolt_type),
|
||||
"material_standard": safe_str(row.bolt_material),
|
||||
"length": safe_str(row.bolt_length)
|
||||
}
|
||||
|
||||
materials.append(material_dict)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"materials": materials,
|
||||
"grouped_materials": grouped_materials, # 그룹화 정보 추가
|
||||
"count": len(grouped_materials) if grouped_materials else len(materials)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get request materials: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"구매신청 자재 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/requested-materials")
|
||||
async def get_requested_material_ids(
|
||||
file_id: Optional[int] = None,
|
||||
job_no: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
구매신청된 자재 ID 목록 조회 (BOM 페이지에서 비활성화용)
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT DISTINCT pri.material_id
|
||||
FROM purchase_request_items pri
|
||||
JOIN purchase_requests pr ON pri.request_id = pr.request_id
|
||||
WHERE 1=1
|
||||
AND (:file_id IS NULL OR pr.file_id = :file_id)
|
||||
AND (:job_no IS NULL OR pr.job_no = :job_no)
|
||||
""")
|
||||
|
||||
results = db.execute(query, {
|
||||
"file_id": file_id,
|
||||
"job_no": job_no
|
||||
}).fetchall()
|
||||
|
||||
material_ids = [row.material_id for row in results]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"requested_material_ids": material_ids,
|
||||
"count": len(material_ids)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get requested material IDs: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"구매신청 자재 ID 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{request_id}/title")
|
||||
async def update_request_title(
|
||||
request_id: int,
|
||||
title: str = Body(..., embed=True),
|
||||
# current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
구매신청 제목(request_no) 업데이트
|
||||
"""
|
||||
try:
|
||||
# 구매신청 존재 확인
|
||||
check_query = text("""
|
||||
SELECT request_no FROM purchase_requests
|
||||
WHERE request_id = :request_id
|
||||
""")
|
||||
existing = db.execute(check_query, {"request_id": request_id}).fetchone()
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="구매신청을 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
# 제목 업데이트
|
||||
update_query = text("""
|
||||
UPDATE purchase_requests
|
||||
SET request_no = :title
|
||||
WHERE request_id = :request_id
|
||||
""")
|
||||
|
||||
db.execute(update_query, {
|
||||
"request_id": request_id,
|
||||
"title": title
|
||||
})
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "구매신청 제목이 업데이트되었습니다",
|
||||
"old_title": existing.request_no,
|
||||
"new_title": title
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to update request title: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"구매신청 제목 업데이트 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{request_id}/download-excel")
|
||||
async def download_request_excel(
|
||||
request_id: int,
|
||||
# current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
구매신청 엑셀 파일 직접 다운로드 (BOM 페이지에서 생성한 파일 그대로)
|
||||
"""
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
try:
|
||||
# 구매신청 정보 조회
|
||||
query = text("""
|
||||
SELECT request_no, excel_file_path, job_no
|
||||
FROM purchase_requests
|
||||
WHERE request_id = :request_id
|
||||
""")
|
||||
result = db.execute(query, {"request_id": request_id}).fetchone()
|
||||
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="구매신청을 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
excel_file_path = os.path.join(EXCEL_DIR, result.excel_file_path)
|
||||
|
||||
if not os.path.exists(excel_file_path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="엑셀 파일을 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
# 엑셀 파일 직접 다운로드
|
||||
return FileResponse(
|
||||
path=excel_file_path,
|
||||
filename=f"{result.job_no}_{result.request_no}.xlsx",
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download request excel: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"엑셀 다운로드 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def save_materials_data(materials_data: List[Dict], file_path: str, request_no: str, job_no: str, grouped_materials: List[Dict] = None):
|
||||
"""
|
||||
자재 데이터를 JSON으로 저장 (프론트엔드에서 동일한 엑셀 포맷으로 생성하기 위해)
|
||||
"""
|
||||
# 수량을 정수로 변환하여 저장
|
||||
cleaned_materials = []
|
||||
for material in materials_data:
|
||||
cleaned_material = material.copy()
|
||||
if 'quantity' in cleaned_material:
|
||||
try:
|
||||
cleaned_material['quantity'] = int(float(cleaned_material['quantity']))
|
||||
except (ValueError, TypeError):
|
||||
cleaned_material['quantity'] = 0
|
||||
cleaned_materials.append(cleaned_material)
|
||||
|
||||
# 그룹화된 자재도 수량 정수 변환
|
||||
cleaned_grouped = []
|
||||
if grouped_materials:
|
||||
for group in grouped_materials:
|
||||
cleaned_group = group.copy()
|
||||
if 'quantity' in cleaned_group:
|
||||
try:
|
||||
cleaned_group['quantity'] = int(float(cleaned_group['quantity']))
|
||||
except (ValueError, TypeError):
|
||||
cleaned_group['quantity'] = 0
|
||||
cleaned_grouped.append(cleaned_group)
|
||||
|
||||
data_to_save = {
|
||||
"request_no": request_no,
|
||||
"job_no": job_no,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"materials": cleaned_materials,
|
||||
"grouped_materials": cleaned_grouped or []
|
||||
}
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data_to_save, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def create_excel_file(materials_data: List[Dict], file_path: str, request_no: str, job_no: str):
|
||||
"""
|
||||
자재 데이터로 엑셀 파일 생성 (BOM 페이지와 동일한 형식)
|
||||
"""
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
|
||||
# 새 워크북 생성
|
||||
wb = openpyxl.Workbook()
|
||||
wb.remove(wb.active) # 기본 시트 제거
|
||||
|
||||
# 카테고리별 그룹화
|
||||
category_groups = {}
|
||||
for material in materials_data:
|
||||
category = material.get('category', 'UNKNOWN')
|
||||
if category not in category_groups:
|
||||
category_groups[category] = []
|
||||
category_groups[category].append(material)
|
||||
|
||||
# 각 카테고리별 시트 생성
|
||||
for category, items in category_groups.items():
|
||||
if not items:
|
||||
continue
|
||||
|
||||
ws = wb.create_sheet(title=category)
|
||||
|
||||
# 헤더 정의 (P열에 납기일, 관리항목 통일)
|
||||
headers = ['TAGNO', '품목명', '수량', '통화구분', '단가', '크기', '압력등급', '스케줄',
|
||||
'재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3',
|
||||
'관리항목4', '납기일(YYYY-MM-DD)', '관리항목5', '관리항목6', '관리항목7',
|
||||
'관리항목8', '관리항목9', '관리항목10']
|
||||
|
||||
# 헤더 작성
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col, value=header)
|
||||
cell.font = Font(bold=True, color="000000", size=12, name="맑은 고딕")
|
||||
cell.fill = PatternFill(start_color="B3D9FF", end_color="B3D9FF", fill_type="solid")
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
cell.border = Border(
|
||||
top=Side(style="thin", color="666666"),
|
||||
bottom=Side(style="thin", color="666666"),
|
||||
left=Side(style="thin", color="666666"),
|
||||
right=Side(style="thin", color="666666")
|
||||
)
|
||||
|
||||
# 데이터 작성
|
||||
for row_idx, material in enumerate(items, 2):
|
||||
data = [
|
||||
'', # TAGNO
|
||||
category, # 품목명
|
||||
material.get('quantity', 0), # 수량
|
||||
'KRW', # 통화구분
|
||||
1, # 단가
|
||||
material.get('size', '-'), # 크기
|
||||
'-', # 압력등급 (추후 개선)
|
||||
material.get('schedule', '-'), # 스케줄
|
||||
material.get('material_grade', '-'), # 재질
|
||||
'-', # 상세내역 (추후 개선)
|
||||
material.get('user_requirement', ''), # 사용자요구
|
||||
'', '', '', '', '', # 관리항목들
|
||||
datetime.now().strftime('%Y-%m-%d') # 납기일
|
||||
]
|
||||
|
||||
for col, value in enumerate(data, 1):
|
||||
ws.cell(row=row_idx, column=col, value=value)
|
||||
|
||||
# 컬럼 너비 자동 조정
|
||||
for column in ws.columns:
|
||||
max_length = 0
|
||||
column_letter = column[0].column_letter
|
||||
for cell in column:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except:
|
||||
pass
|
||||
adjusted_width = min(max(max_length + 2, 10), 50)
|
||||
ws.column_dimensions[column_letter].width = adjusted_width
|
||||
|
||||
# 파일 저장
|
||||
wb.save(file_path)
|
||||
|
||||
|
||||
@router.post("/upload-excel")
|
||||
async def upload_request_excel(
|
||||
excel_file: UploadFile = File(...),
|
||||
request_id: int = Form(...),
|
||||
category: str = Form(...),
|
||||
# current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
구매신청에 대한 엑셀 파일 업로드 (BOM에서 생성한 원본 파일)
|
||||
"""
|
||||
try:
|
||||
# 구매신청 정보 조회
|
||||
query = text("""
|
||||
SELECT request_no, job_no
|
||||
FROM purchase_requests
|
||||
WHERE request_id = :request_id
|
||||
""")
|
||||
result = db.execute(query, {"request_id": request_id}).fetchone()
|
||||
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="구매신청을 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
# 엑셀 저장 디렉토리 생성
|
||||
excel_dir = Path("uploads/excel_exports")
|
||||
excel_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 파일명 생성
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe_filename = f"{result.job_no}_{result.request_no}_{timestamp}.xlsx"
|
||||
file_path = excel_dir / safe_filename
|
||||
|
||||
# 파일 저장
|
||||
content = await excel_file.read()
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# 구매신청 테이블에 엑셀 파일 경로 업데이트
|
||||
update_query = text("""
|
||||
UPDATE purchase_requests
|
||||
SET excel_file_path = :excel_file_path
|
||||
WHERE request_id = :request_id
|
||||
""")
|
||||
|
||||
db.execute(update_query, {
|
||||
"excel_file_path": safe_filename,
|
||||
"request_id": request_id
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"엑셀 파일 업로드 완료: {safe_filename}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "엑셀 파일이 성공적으로 업로드되었습니다",
|
||||
"file_path": safe_filename
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to upload excel file: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"엑셀 파일 업로드 실패: {str(e)}"
|
||||
)
|
||||
452
backend/app/routers/purchase_tracking.py
Normal file
452
backend/app/routers/purchase_tracking.py
Normal file
@@ -0,0 +1,452 @@
|
||||
"""
|
||||
구매 추적 및 관리 API
|
||||
엑셀 내보내기 이력 및 구매 상태 관리
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, date
|
||||
import json
|
||||
|
||||
from ..database import get_db
|
||||
from ..auth.jwt_service import get_current_user
|
||||
from ..utils.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/purchase", tags=["Purchase Tracking"])
|
||||
|
||||
|
||||
@router.post("/export-history")
|
||||
async def create_export_history(
|
||||
file_id: int,
|
||||
job_no: Optional[str] = None,
|
||||
export_type: str = "full",
|
||||
category: Optional[str] = None,
|
||||
material_ids: List[int] = [],
|
||||
filters_applied: Optional[Dict] = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
엑셀 내보내기 이력 생성 및 자재 추적
|
||||
"""
|
||||
try:
|
||||
# 내보내기 이력 생성
|
||||
insert_history = text("""
|
||||
INSERT INTO excel_export_history (
|
||||
file_id, job_no, exported_by, export_type,
|
||||
category, material_count, filters_applied
|
||||
) VALUES (
|
||||
:file_id, :job_no, :exported_by, :export_type,
|
||||
:category, :material_count, :filters_applied
|
||||
) RETURNING export_id
|
||||
""")
|
||||
|
||||
result = db.execute(insert_history, {
|
||||
"file_id": file_id,
|
||||
"job_no": job_no,
|
||||
"exported_by": current_user.get("user_id"),
|
||||
"export_type": export_type,
|
||||
"category": category,
|
||||
"material_count": len(material_ids),
|
||||
"filters_applied": json.dumps(filters_applied) if filters_applied else None
|
||||
})
|
||||
|
||||
export_id = result.fetchone().export_id
|
||||
|
||||
# 내보낸 자재들 기록
|
||||
if material_ids:
|
||||
for material_id in material_ids:
|
||||
insert_material = text("""
|
||||
INSERT INTO exported_materials (
|
||||
export_id, material_id, purchase_status
|
||||
) VALUES (
|
||||
:export_id, :material_id, 'pending'
|
||||
)
|
||||
""")
|
||||
db.execute(insert_material, {
|
||||
"export_id": export_id,
|
||||
"material_id": material_id
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Export history created: {export_id} with {len(material_ids)} materials")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"export_id": export_id,
|
||||
"message": f"{len(material_ids)}개 자재의 내보내기 이력이 저장되었습니다"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to create export history: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"내보내기 이력 생성 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export-history")
|
||||
async def get_export_history(
|
||||
file_id: Optional[int] = None,
|
||||
job_no: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
엑셀 내보내기 이력 조회
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
eeh.export_id,
|
||||
eeh.file_id,
|
||||
eeh.job_no,
|
||||
eeh.export_date,
|
||||
eeh.export_type,
|
||||
eeh.category,
|
||||
eeh.material_count,
|
||||
u.name as exported_by_name,
|
||||
f.original_filename,
|
||||
COUNT(DISTINCT em.material_id) as actual_material_count,
|
||||
COUNT(DISTINCT CASE WHEN em.purchase_status = 'pending' THEN em.material_id END) as pending_count,
|
||||
COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) as requested_count,
|
||||
COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) as ordered_count,
|
||||
COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) as received_count
|
||||
FROM excel_export_history eeh
|
||||
LEFT JOIN users u ON eeh.exported_by = u.user_id
|
||||
LEFT JOIN files f ON eeh.file_id = f.id
|
||||
LEFT JOIN exported_materials em ON eeh.export_id = em.export_id
|
||||
WHERE 1=1
|
||||
AND (:file_id IS NULL OR eeh.file_id = :file_id)
|
||||
AND (:job_no IS NULL OR eeh.job_no = :job_no)
|
||||
GROUP BY
|
||||
eeh.export_id, eeh.file_id, eeh.job_no, eeh.export_date,
|
||||
eeh.export_type, eeh.category, eeh.material_count,
|
||||
u.name, f.original_filename
|
||||
ORDER BY eeh.export_date DESC
|
||||
LIMIT :limit
|
||||
""")
|
||||
|
||||
results = db.execute(query, {
|
||||
"file_id": file_id,
|
||||
"job_no": job_no,
|
||||
"limit": limit
|
||||
}).fetchall()
|
||||
|
||||
history = []
|
||||
for row in results:
|
||||
history.append({
|
||||
"export_id": row.export_id,
|
||||
"file_id": row.file_id,
|
||||
"job_no": row.job_no,
|
||||
"export_date": row.export_date.isoformat() if row.export_date else None,
|
||||
"export_type": row.export_type,
|
||||
"category": row.category,
|
||||
"material_count": row.material_count,
|
||||
"exported_by": row.exported_by_name,
|
||||
"file_name": row.original_filename,
|
||||
"status_summary": {
|
||||
"total": row.actual_material_count,
|
||||
"pending": row.pending_count,
|
||||
"requested": row.requested_count,
|
||||
"ordered": row.ordered_count,
|
||||
"received": row.received_count
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"history": history,
|
||||
"count": len(history)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get export history: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"내보내기 이력 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/materials/status")
|
||||
async def get_materials_by_status(
|
||||
status: Optional[str] = None,
|
||||
export_id: Optional[int] = None,
|
||||
file_id: Optional[int] = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
구매 상태별 자재 목록 조회
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
em.id as exported_material_id,
|
||||
em.material_id,
|
||||
m.original_description,
|
||||
m.classified_category,
|
||||
m.quantity,
|
||||
m.unit,
|
||||
em.purchase_status,
|
||||
em.purchase_request_no,
|
||||
em.purchase_order_no,
|
||||
em.vendor_name,
|
||||
em.expected_date,
|
||||
em.quantity_ordered,
|
||||
em.quantity_received,
|
||||
em.unit_price,
|
||||
em.total_price,
|
||||
em.notes,
|
||||
em.updated_at,
|
||||
eeh.export_date,
|
||||
f.original_filename as file_name,
|
||||
j.job_no,
|
||||
j.job_name
|
||||
FROM exported_materials em
|
||||
JOIN materials m ON em.material_id = m.id
|
||||
JOIN excel_export_history eeh ON em.export_id = eeh.export_id
|
||||
LEFT JOIN files f ON eeh.file_id = f.id
|
||||
LEFT JOIN jobs j ON eeh.job_no = j.job_no
|
||||
WHERE 1=1
|
||||
AND (:status IS NULL OR em.purchase_status = :status)
|
||||
AND (:export_id IS NULL OR em.export_id = :export_id)
|
||||
AND (:file_id IS NULL OR eeh.file_id = :file_id)
|
||||
ORDER BY em.updated_at DESC
|
||||
""")
|
||||
|
||||
results = db.execute(query, {
|
||||
"status": status,
|
||||
"export_id": export_id,
|
||||
"file_id": file_id
|
||||
}).fetchall()
|
||||
|
||||
materials = []
|
||||
for row in results:
|
||||
materials.append({
|
||||
"exported_material_id": row.exported_material_id,
|
||||
"material_id": row.material_id,
|
||||
"description": row.original_description,
|
||||
"category": row.classified_category,
|
||||
"quantity": row.quantity,
|
||||
"unit": row.unit,
|
||||
"purchase_status": row.purchase_status,
|
||||
"purchase_request_no": row.purchase_request_no,
|
||||
"purchase_order_no": row.purchase_order_no,
|
||||
"vendor_name": row.vendor_name,
|
||||
"expected_date": row.expected_date.isoformat() if row.expected_date else None,
|
||||
"quantity_ordered": row.quantity_ordered,
|
||||
"quantity_received": row.quantity_received,
|
||||
"unit_price": float(row.unit_price) if row.unit_price else None,
|
||||
"total_price": float(row.total_price) if row.total_price else None,
|
||||
"notes": row.notes,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
"export_date": row.export_date.isoformat() if row.export_date else None,
|
||||
"file_name": row.file_name,
|
||||
"job_no": row.job_no,
|
||||
"job_name": row.job_name
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"materials": materials,
|
||||
"count": len(materials)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get materials by status: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"구매 상태별 자재 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/materials/{exported_material_id}/status")
|
||||
async def update_purchase_status(
|
||||
exported_material_id: int,
|
||||
new_status: str,
|
||||
purchase_request_no: Optional[str] = None,
|
||||
purchase_order_no: Optional[str] = None,
|
||||
vendor_name: Optional[str] = None,
|
||||
expected_date: Optional[date] = None,
|
||||
quantity_ordered: Optional[int] = None,
|
||||
quantity_received: Optional[int] = None,
|
||||
unit_price: Optional[float] = None,
|
||||
notes: Optional[str] = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
자재 구매 상태 업데이트
|
||||
"""
|
||||
try:
|
||||
# 현재 상태 조회
|
||||
get_current = text("""
|
||||
SELECT purchase_status, material_id
|
||||
FROM exported_materials
|
||||
WHERE id = :id
|
||||
""")
|
||||
current = db.execute(get_current, {"id": exported_material_id}).fetchone()
|
||||
|
||||
if not current:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="해당 자재를 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
# 상태 업데이트
|
||||
update_query = text("""
|
||||
UPDATE exported_materials
|
||||
SET
|
||||
purchase_status = :new_status,
|
||||
purchase_request_no = COALESCE(:pr_no, purchase_request_no),
|
||||
purchase_order_no = COALESCE(:po_no, purchase_order_no),
|
||||
vendor_name = COALESCE(:vendor, vendor_name),
|
||||
expected_date = COALESCE(:expected_date, expected_date),
|
||||
quantity_ordered = COALESCE(:qty_ordered, quantity_ordered),
|
||||
quantity_received = COALESCE(:qty_received, quantity_received),
|
||||
unit_price = COALESCE(:unit_price, unit_price),
|
||||
total_price = CASE
|
||||
WHEN :unit_price IS NOT NULL AND :qty_ordered IS NOT NULL
|
||||
THEN :unit_price * :qty_ordered
|
||||
WHEN :unit_price IS NOT NULL AND quantity_ordered IS NOT NULL
|
||||
THEN :unit_price * quantity_ordered
|
||||
ELSE total_price
|
||||
END,
|
||||
notes = COALESCE(:notes, notes),
|
||||
updated_by = :updated_by,
|
||||
requested_date = CASE WHEN :new_status = 'requested' THEN CURRENT_TIMESTAMP ELSE requested_date END,
|
||||
ordered_date = CASE WHEN :new_status = 'ordered' THEN CURRENT_TIMESTAMP ELSE ordered_date END,
|
||||
received_date = CASE WHEN :new_status = 'received' THEN CURRENT_TIMESTAMP ELSE received_date END
|
||||
WHERE id = :id
|
||||
""")
|
||||
|
||||
db.execute(update_query, {
|
||||
"id": exported_material_id,
|
||||
"new_status": new_status,
|
||||
"pr_no": purchase_request_no,
|
||||
"po_no": purchase_order_no,
|
||||
"vendor": vendor_name,
|
||||
"expected_date": expected_date,
|
||||
"qty_ordered": quantity_ordered,
|
||||
"qty_received": quantity_received,
|
||||
"unit_price": unit_price,
|
||||
"notes": notes,
|
||||
"updated_by": current_user.get("user_id")
|
||||
})
|
||||
|
||||
# 이력 기록
|
||||
insert_history = text("""
|
||||
INSERT INTO purchase_status_history (
|
||||
exported_material_id, material_id,
|
||||
previous_status, new_status,
|
||||
changed_by, reason
|
||||
) VALUES (
|
||||
:em_id, :material_id,
|
||||
:prev_status, :new_status,
|
||||
:changed_by, :reason
|
||||
)
|
||||
""")
|
||||
|
||||
db.execute(insert_history, {
|
||||
"em_id": exported_material_id,
|
||||
"material_id": current.material_id,
|
||||
"prev_status": current.purchase_status,
|
||||
"new_status": new_status,
|
||||
"changed_by": current_user.get("user_id"),
|
||||
"reason": notes
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Purchase status updated: {exported_material_id} from {current.purchase_status} to {new_status}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"구매 상태가 {new_status}로 변경되었습니다"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to update purchase status: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"구매 상태 업데이트 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status-summary")
|
||||
async def get_status_summary(
|
||||
file_id: Optional[int] = None,
|
||||
job_no: Optional[str] = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
구매 상태 요약 통계
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
em.purchase_status,
|
||||
COUNT(DISTINCT em.material_id) as material_count,
|
||||
SUM(em.quantity_exported) as total_quantity,
|
||||
SUM(em.total_price) as total_amount,
|
||||
COUNT(DISTINCT em.export_id) as export_count
|
||||
FROM exported_materials em
|
||||
JOIN excel_export_history eeh ON em.export_id = eeh.export_id
|
||||
WHERE 1=1
|
||||
AND (:file_id IS NULL OR eeh.file_id = :file_id)
|
||||
AND (:job_no IS NULL OR eeh.job_no = :job_no)
|
||||
GROUP BY em.purchase_status
|
||||
""")
|
||||
|
||||
results = db.execute(query, {
|
||||
"file_id": file_id,
|
||||
"job_no": job_no
|
||||
}).fetchall()
|
||||
|
||||
summary = {}
|
||||
total_materials = 0
|
||||
total_amount = 0
|
||||
|
||||
for row in results:
|
||||
summary[row.purchase_status] = {
|
||||
"material_count": row.material_count,
|
||||
"total_quantity": row.total_quantity,
|
||||
"total_amount": float(row.total_amount) if row.total_amount else 0,
|
||||
"export_count": row.export_count
|
||||
}
|
||||
total_materials += row.material_count
|
||||
if row.total_amount:
|
||||
total_amount += float(row.total_amount)
|
||||
|
||||
# 기본 상태들 추가 (없는 경우 0으로)
|
||||
for status in ['pending', 'requested', 'ordered', 'received', 'cancelled']:
|
||||
if status not in summary:
|
||||
summary[status] = {
|
||||
"material_count": 0,
|
||||
"total_quantity": 0,
|
||||
"total_amount": 0,
|
||||
"export_count": 0
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"summary": summary,
|
||||
"total_materials": total_materials,
|
||||
"total_amount": total_amount
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get status summary: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"구매 상태 요약 조회 실패: {str(e)}"
|
||||
)
|
||||
327
backend/app/routers/revision_management.py
Normal file
327
backend/app/routers/revision_management.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
간단한 리비전 관리 API
|
||||
- 리비전 세션 생성 및 관리
|
||||
- 자재 비교 및 변경사항 처리
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..database import get_db
|
||||
from ..auth.middleware import get_current_user
|
||||
from ..services.revision_session_service import RevisionSessionService
|
||||
from ..services.revision_comparison_service import RevisionComparisonService
|
||||
|
||||
router = APIRouter(prefix="/revision-management", tags=["revision-management"])
|
||||
|
||||
class RevisionSessionCreate(BaseModel):
|
||||
job_no: str
|
||||
current_file_id: int
|
||||
previous_file_id: int
|
||||
|
||||
@router.post("/sessions")
|
||||
async def create_revision_session(
|
||||
session_data: RevisionSessionCreate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""리비전 세션 생성"""
|
||||
try:
|
||||
session_service = RevisionSessionService(db)
|
||||
|
||||
# 실제 DB에 세션 생성
|
||||
result = session_service.create_revision_session(
|
||||
job_no=session_data.job_no,
|
||||
current_file_id=session_data.current_file_id,
|
||||
previous_file_id=session_data.previous_file_id,
|
||||
username=current_user.get("username")
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result,
|
||||
"message": "리비전 세션이 생성되었습니다."
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"리비전 세션 생성 실패: {str(e)}")
|
||||
|
||||
@router.get("/sessions/{session_id}")
|
||||
async def get_session_status(
|
||||
session_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""세션 상태 조회"""
|
||||
try:
|
||||
session_service = RevisionSessionService(db)
|
||||
|
||||
# 실제 DB에서 세션 상태 조회
|
||||
result = session_service.get_session_status(session_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"세션 상태 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/sessions/{session_id}/summary")
|
||||
async def get_revision_summary(
|
||||
session_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""리비전 요약 조회"""
|
||||
try:
|
||||
comparison_service = RevisionComparisonService(db)
|
||||
|
||||
# 세션의 모든 변경사항 조회
|
||||
changes = comparison_service.get_session_changes(session_id)
|
||||
|
||||
# 요약 통계 계산
|
||||
summary = {
|
||||
"session_id": session_id,
|
||||
"total_changes": len(changes),
|
||||
"new_materials": len([c for c in changes if c['change_type'] == 'added']),
|
||||
"changed_materials": len([c for c in changes if c['change_type'] == 'quantity_changed']),
|
||||
"removed_materials": len([c for c in changes if c['change_type'] == 'removed']),
|
||||
"categories": {}
|
||||
}
|
||||
|
||||
# 카테고리별 통계
|
||||
for change in changes:
|
||||
category = change['category']
|
||||
if category not in summary["categories"]:
|
||||
summary["categories"][category] = {
|
||||
"total_changes": 0,
|
||||
"added": 0,
|
||||
"changed": 0,
|
||||
"removed": 0
|
||||
}
|
||||
|
||||
summary["categories"][category]["total_changes"] += 1
|
||||
if change['change_type'] == 'added':
|
||||
summary["categories"][category]["added"] += 1
|
||||
elif change['change_type'] == 'quantity_changed':
|
||||
summary["categories"][category]["changed"] += 1
|
||||
elif change['change_type'] == 'removed':
|
||||
summary["categories"][category]["removed"] += 1
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": summary
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"리비전 요약 조회 실패: {str(e)}")
|
||||
|
||||
@router.post("/sessions/{session_id}/compare/{category}")
|
||||
async def compare_category(
|
||||
session_id: int,
|
||||
category: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""카테고리별 자재 비교"""
|
||||
try:
|
||||
# 세션 정보 조회
|
||||
session_service = RevisionSessionService(db)
|
||||
session_status = session_service.get_session_status(session_id)
|
||||
session_info = session_status["session_info"]
|
||||
|
||||
# 자재 비교 수행
|
||||
comparison_service = RevisionComparisonService(db)
|
||||
result = comparison_service.compare_materials_by_category(
|
||||
current_file_id=session_info["current_file_id"],
|
||||
previous_file_id=session_info["previous_file_id"],
|
||||
category=category,
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"카테고리 비교 실패: {str(e)}")
|
||||
|
||||
@router.get("/history/{job_no}")
|
||||
async def get_revision_history(
|
||||
job_no: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""리비전 히스토리 조회"""
|
||||
try:
|
||||
session_service = RevisionSessionService(db)
|
||||
|
||||
# 실제 DB에서 리비전 히스토리 조회
|
||||
history = session_service.get_job_revision_history(job_no)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"job_no": job_no,
|
||||
"history": history
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"리비전 히스토리 조회 실패: {str(e)}")
|
||||
|
||||
# 세션 변경사항 조회
|
||||
@router.get("/sessions/{session_id}/changes")
|
||||
async def get_session_changes(
|
||||
session_id: int,
|
||||
category: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""세션의 변경사항 조회"""
|
||||
try:
|
||||
comparison_service = RevisionComparisonService(db)
|
||||
|
||||
# 세션의 변경사항 조회
|
||||
changes = comparison_service.get_session_changes(session_id, category)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"changes": changes,
|
||||
"total_count": len(changes)
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"세션 변경사항 조회 실패: {str(e)}")
|
||||
|
||||
# 리비전 액션 처리
|
||||
@router.post("/changes/{change_id}/process")
|
||||
async def process_revision_action(
|
||||
change_id: int,
|
||||
action_data: Dict[str, Any],
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""리비전 액션 처리"""
|
||||
try:
|
||||
comparison_service = RevisionComparisonService(db)
|
||||
|
||||
# 액션 처리
|
||||
result = comparison_service.process_revision_action(
|
||||
change_id=change_id,
|
||||
action=action_data.get("action"),
|
||||
username=current_user.get("username"),
|
||||
notes=action_data.get("notes")
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"리비전 액션 처리 실패: {str(e)}")
|
||||
|
||||
# 세션 완료
|
||||
@router.post("/sessions/{session_id}/complete")
|
||||
async def complete_revision_session(
|
||||
session_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""리비전 세션 완료"""
|
||||
try:
|
||||
session_service = RevisionSessionService(db)
|
||||
|
||||
# 세션 완료 처리
|
||||
result = session_service.complete_session(
|
||||
session_id=session_id,
|
||||
username=current_user.get("username")
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"리비전 세션 완료 실패: {str(e)}")
|
||||
|
||||
# 세션 취소
|
||||
@router.post("/sessions/{session_id}/cancel")
|
||||
async def cancel_revision_session(
|
||||
session_id: int,
|
||||
reason: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""리비전 세션 취소"""
|
||||
try:
|
||||
session_service = RevisionSessionService(db)
|
||||
|
||||
# 세션 취소 처리
|
||||
result = session_service.cancel_session(
|
||||
session_id=session_id,
|
||||
username=current_user.get("username"),
|
||||
reason=reason
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"cancelled": result}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"리비전 세션 취소 실패: {str(e)}")
|
||||
|
||||
@router.get("/categories")
|
||||
async def get_supported_categories():
|
||||
"""지원 카테고리 목록 조회"""
|
||||
try:
|
||||
categories = [
|
||||
{"key": "PIPE", "name": "배관", "description": "파이프 및 배관 자재"},
|
||||
{"key": "FITTING", "name": "피팅", "description": "배관 연결 부품"},
|
||||
{"key": "FLANGE", "name": "플랜지", "description": "플랜지 및 연결 부품"},
|
||||
{"key": "VALVE", "name": "밸브", "description": "각종 밸브류"},
|
||||
{"key": "GASKET", "name": "가스켓", "description": "씰링 부품"},
|
||||
{"key": "BOLT", "name": "볼트", "description": "체결 부품"},
|
||||
{"key": "SUPPORT", "name": "서포트", "description": "지지대 및 구조물"},
|
||||
{"key": "SPECIAL", "name": "특수자재", "description": "특수 목적 자재"},
|
||||
{"key": "UNCLASSIFIED", "name": "미분류", "description": "분류되지 않은 자재"}
|
||||
]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"categories": categories
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"지원 카테고리 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/actions")
|
||||
async def get_supported_actions():
|
||||
"""지원 액션 목록 조회"""
|
||||
try:
|
||||
actions = [
|
||||
{"key": "new_material", "name": "신규 자재", "description": "새로 추가된 자재"},
|
||||
{"key": "additional_purchase", "name": "추가 구매", "description": "구매된 자재의 수량 증가"},
|
||||
{"key": "inventory_transfer", "name": "재고 이관", "description": "구매된 자재의 수량 감소 또는 제거"},
|
||||
{"key": "purchase_cancel", "name": "구매 취소", "description": "미구매 자재의 제거"},
|
||||
{"key": "quantity_update", "name": "수량 업데이트", "description": "미구매 자재의 수량 변경"},
|
||||
{"key": "maintain", "name": "유지", "description": "변경사항 없음"}
|
||||
]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"actions": actions
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"지원 액션 조회 실패: {str(e)}")
|
||||
538
backend/app/routers/tubing.py
Normal file
538
backend/app/routers/tubing.py
Normal file
@@ -0,0 +1,538 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import text
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date
|
||||
from pydantic import BaseModel
|
||||
from decimal import Decimal
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import (
|
||||
TubingCategory, TubingSpecification, TubingManufacturer,
|
||||
TubingProduct, MaterialTubingMapping, Material
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ================================
|
||||
# Pydantic 모델들
|
||||
# ================================
|
||||
|
||||
class TubingCategoryResponse(BaseModel):
|
||||
id: int
|
||||
category_code: str
|
||||
category_name: str
|
||||
description: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TubingManufacturerResponse(BaseModel):
|
||||
id: int
|
||||
manufacturer_code: str
|
||||
manufacturer_name: str
|
||||
country: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TubingSpecificationResponse(BaseModel):
|
||||
id: int
|
||||
spec_code: str
|
||||
spec_name: str
|
||||
category_name: Optional[str] = None
|
||||
outer_diameter_mm: Optional[float] = None
|
||||
wall_thickness_mm: Optional[float] = None
|
||||
inner_diameter_mm: Optional[float] = None
|
||||
material_grade: Optional[str] = None
|
||||
material_standard: Optional[str] = None
|
||||
max_pressure_bar: Optional[float] = None
|
||||
max_temperature_c: Optional[float] = None
|
||||
min_temperature_c: Optional[float] = None
|
||||
standard_length_m: Optional[float] = None
|
||||
surface_finish: Optional[str] = None
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TubingProductResponse(BaseModel):
|
||||
id: int
|
||||
specification_id: int
|
||||
manufacturer_id: int
|
||||
manufacturer_part_number: str
|
||||
manufacturer_product_name: Optional[str] = None
|
||||
spec_name: Optional[str] = None
|
||||
manufacturer_name: Optional[str] = None
|
||||
list_price: Optional[float] = None
|
||||
currency: Optional[str] = 'KRW'
|
||||
lead_time_days: Optional[int] = None
|
||||
availability_status: Optional[str] = None
|
||||
datasheet_url: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TubingProductCreate(BaseModel):
|
||||
specification_id: int
|
||||
manufacturer_id: int
|
||||
manufacturer_part_number: str
|
||||
manufacturer_product_name: Optional[str] = None
|
||||
list_price: Optional[float] = None
|
||||
currency: str = 'KRW'
|
||||
lead_time_days: Optional[int] = None
|
||||
minimum_order_qty: Optional[float] = None
|
||||
standard_packaging_qty: Optional[float] = None
|
||||
availability_status: Optional[str] = None
|
||||
datasheet_url: Optional[str] = None
|
||||
catalog_page: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
class MaterialTubingMappingCreate(BaseModel):
|
||||
material_id: int
|
||||
tubing_product_id: int
|
||||
confidence_score: Optional[float] = None
|
||||
mapping_method: str = 'manual'
|
||||
required_length_m: Optional[float] = None
|
||||
calculated_quantity: Optional[float] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
# ================================
|
||||
# API 엔드포인트들
|
||||
# ================================
|
||||
|
||||
@router.get("/categories", response_model=List[TubingCategoryResponse])
|
||||
async def get_tubing_categories(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Tubing 카테고리 목록 조회"""
|
||||
try:
|
||||
categories = db.query(TubingCategory)\
|
||||
.filter(TubingCategory.is_active == True)\
|
||||
.offset(skip)\
|
||||
.limit(limit)\
|
||||
.all()
|
||||
|
||||
return categories
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"카테고리 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/manufacturers", response_model=List[TubingManufacturerResponse])
|
||||
async def get_tubing_manufacturers(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
search: Optional[str] = Query(None),
|
||||
country: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Tubing 제조사 목록 조회"""
|
||||
try:
|
||||
query = db.query(TubingManufacturer)\
|
||||
.filter(TubingManufacturer.is_active == True)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
TubingManufacturer.manufacturer_name.ilike(f"%{search}%") |
|
||||
TubingManufacturer.manufacturer_code.ilike(f"%{search}%")
|
||||
)
|
||||
|
||||
if country:
|
||||
query = query.filter(TubingManufacturer.country.ilike(f"%{country}%"))
|
||||
|
||||
manufacturers = query.offset(skip).limit(limit).all()
|
||||
return manufacturers
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"제조사 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/specifications", response_model=List[TubingSpecificationResponse])
|
||||
async def get_tubing_specifications(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
category_id: Optional[int] = Query(None),
|
||||
material_grade: Optional[str] = Query(None),
|
||||
outer_diameter_min: Optional[float] = Query(None),
|
||||
outer_diameter_max: Optional[float] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Tubing 규격 목록 조회"""
|
||||
try:
|
||||
query = db.query(TubingSpecification)\
|
||||
.options(joinedload(TubingSpecification.category))\
|
||||
.filter(TubingSpecification.is_active == True)
|
||||
|
||||
if category_id:
|
||||
query = query.filter(TubingSpecification.category_id == category_id)
|
||||
|
||||
if material_grade:
|
||||
query = query.filter(TubingSpecification.material_grade.ilike(f"%{material_grade}%"))
|
||||
|
||||
if outer_diameter_min:
|
||||
query = query.filter(TubingSpecification.outer_diameter_mm >= outer_diameter_min)
|
||||
|
||||
if outer_diameter_max:
|
||||
query = query.filter(TubingSpecification.outer_diameter_mm <= outer_diameter_max)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
TubingSpecification.spec_name.ilike(f"%{search}%") |
|
||||
TubingSpecification.spec_code.ilike(f"%{search}%") |
|
||||
TubingSpecification.material_grade.ilike(f"%{search}%")
|
||||
)
|
||||
|
||||
specifications = query.offset(skip).limit(limit).all()
|
||||
|
||||
# 응답 데이터 변환
|
||||
result = []
|
||||
for spec in specifications:
|
||||
spec_dict = {
|
||||
"id": spec.id,
|
||||
"spec_code": spec.spec_code,
|
||||
"spec_name": spec.spec_name,
|
||||
"category_name": spec.category.category_name if spec.category else None,
|
||||
"outer_diameter_mm": float(spec.outer_diameter_mm) if spec.outer_diameter_mm else None,
|
||||
"wall_thickness_mm": float(spec.wall_thickness_mm) if spec.wall_thickness_mm else None,
|
||||
"inner_diameter_mm": float(spec.inner_diameter_mm) if spec.inner_diameter_mm else None,
|
||||
"material_grade": spec.material_grade,
|
||||
"material_standard": spec.material_standard,
|
||||
"max_pressure_bar": float(spec.max_pressure_bar) if spec.max_pressure_bar else None,
|
||||
"max_temperature_c": float(spec.max_temperature_c) if spec.max_temperature_c else None,
|
||||
"min_temperature_c": float(spec.min_temperature_c) if spec.min_temperature_c else None,
|
||||
"standard_length_m": float(spec.standard_length_m) if spec.standard_length_m else None,
|
||||
"surface_finish": spec.surface_finish,
|
||||
"is_active": spec.is_active
|
||||
}
|
||||
result.append(spec_dict)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"규격 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/products", response_model=List[TubingProductResponse])
|
||||
async def get_tubing_products(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
specification_id: Optional[int] = Query(None),
|
||||
manufacturer_id: Optional[int] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Tubing 제품 목록 조회"""
|
||||
try:
|
||||
query = db.query(TubingProduct)\
|
||||
.options(
|
||||
joinedload(TubingProduct.specification),
|
||||
joinedload(TubingProduct.manufacturer)
|
||||
)\
|
||||
.filter(TubingProduct.is_active == True)
|
||||
|
||||
if specification_id:
|
||||
query = query.filter(TubingProduct.specification_id == specification_id)
|
||||
|
||||
if manufacturer_id:
|
||||
query = query.filter(TubingProduct.manufacturer_id == manufacturer_id)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
TubingProduct.manufacturer_part_number.ilike(f"%{search}%") |
|
||||
TubingProduct.manufacturer_product_name.ilike(f"%{search}%")
|
||||
)
|
||||
|
||||
products = query.offset(skip).limit(limit).all()
|
||||
|
||||
# 응답 데이터 변환
|
||||
result = []
|
||||
for product in products:
|
||||
product_dict = {
|
||||
"id": product.id,
|
||||
"specification_id": product.specification_id,
|
||||
"manufacturer_id": product.manufacturer_id,
|
||||
"manufacturer_part_number": product.manufacturer_part_number,
|
||||
"manufacturer_product_name": product.manufacturer_product_name,
|
||||
"spec_name": product.specification.spec_name if product.specification else None,
|
||||
"manufacturer_name": product.manufacturer.manufacturer_name if product.manufacturer else None,
|
||||
"list_price": float(product.list_price) if product.list_price else None,
|
||||
"currency": product.currency,
|
||||
"lead_time_days": product.lead_time_days,
|
||||
"availability_status": product.availability_status,
|
||||
"datasheet_url": product.datasheet_url,
|
||||
"notes": product.notes,
|
||||
"is_active": product.is_active
|
||||
}
|
||||
result.append(product_dict)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"제품 조회 실패: {str(e)}")
|
||||
|
||||
@router.post("/products", response_model=TubingProductResponse)
|
||||
async def create_tubing_product(
|
||||
product_data: TubingProductCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""새 Tubing 제품 등록"""
|
||||
try:
|
||||
# 중복 확인
|
||||
existing = db.query(TubingProduct)\
|
||||
.filter(
|
||||
TubingProduct.specification_id == product_data.specification_id,
|
||||
TubingProduct.manufacturer_id == product_data.manufacturer_id,
|
||||
TubingProduct.manufacturer_part_number == product_data.manufacturer_part_number
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"동일한 제품이 이미 등록되어 있습니다: {product_data.manufacturer_part_number}"
|
||||
)
|
||||
|
||||
# 새 제품 생성
|
||||
new_product = TubingProduct(**product_data.dict())
|
||||
db.add(new_product)
|
||||
db.commit()
|
||||
db.refresh(new_product)
|
||||
|
||||
# 관련 정보와 함께 조회
|
||||
product_with_relations = db.query(TubingProduct)\
|
||||
.options(
|
||||
joinedload(TubingProduct.specification),
|
||||
joinedload(TubingProduct.manufacturer)
|
||||
)\
|
||||
.filter(TubingProduct.id == new_product.id)\
|
||||
.first()
|
||||
|
||||
return {
|
||||
"id": product_with_relations.id,
|
||||
"specification_id": product_with_relations.specification_id,
|
||||
"manufacturer_id": product_with_relations.manufacturer_id,
|
||||
"manufacturer_part_number": product_with_relations.manufacturer_part_number,
|
||||
"manufacturer_product_name": product_with_relations.manufacturer_product_name,
|
||||
"spec_name": product_with_relations.specification.spec_name if product_with_relations.specification else None,
|
||||
"manufacturer_name": product_with_relations.manufacturer.manufacturer_name if product_with_relations.manufacturer else None,
|
||||
"list_price": float(product_with_relations.list_price) if product_with_relations.list_price else None,
|
||||
"currency": product_with_relations.currency,
|
||||
"lead_time_days": product_with_relations.lead_time_days,
|
||||
"availability_status": product_with_relations.availability_status,
|
||||
"datasheet_url": product_with_relations.datasheet_url,
|
||||
"notes": product_with_relations.notes,
|
||||
"is_active": product_with_relations.is_active
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"제품 등록 실패: {str(e)}")
|
||||
|
||||
@router.post("/material-mapping")
|
||||
async def create_material_tubing_mapping(
|
||||
mapping_data: MaterialTubingMappingCreate,
|
||||
mapped_by: str = "admin",
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""BOM 자재와 Tubing 제품 매핑 생성"""
|
||||
try:
|
||||
# 기존 매핑 확인
|
||||
existing = db.query(MaterialTubingMapping)\
|
||||
.filter(
|
||||
MaterialTubingMapping.material_id == mapping_data.material_id,
|
||||
MaterialTubingMapping.tubing_product_id == mapping_data.tubing_product_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="이미 매핑된 자재와 제품입니다"
|
||||
)
|
||||
|
||||
# 새 매핑 생성
|
||||
new_mapping = MaterialTubingMapping(
|
||||
**mapping_data.dict(),
|
||||
mapped_by=mapped_by
|
||||
)
|
||||
db.add(new_mapping)
|
||||
db.commit()
|
||||
db.refresh(new_mapping)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "매핑이 성공적으로 생성되었습니다",
|
||||
"mapping_id": new_mapping.id
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"매핑 생성 실패: {str(e)}")
|
||||
|
||||
@router.get("/material-mappings/{material_id}")
|
||||
async def get_material_tubing_mappings(
|
||||
material_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""특정 자재의 Tubing 매핑 조회"""
|
||||
try:
|
||||
mappings = db.query(MaterialTubingMapping)\
|
||||
.options(
|
||||
joinedload(MaterialTubingMapping.tubing_product)
|
||||
.joinedload(TubingProduct.specification),
|
||||
joinedload(MaterialTubingMapping.tubing_product)
|
||||
.joinedload(TubingProduct.manufacturer)
|
||||
)\
|
||||
.filter(MaterialTubingMapping.material_id == material_id)\
|
||||
.all()
|
||||
|
||||
result = []
|
||||
for mapping in mappings:
|
||||
product = mapping.tubing_product
|
||||
mapping_dict = {
|
||||
"mapping_id": mapping.id,
|
||||
"confidence_score": float(mapping.confidence_score) if mapping.confidence_score else None,
|
||||
"mapping_method": mapping.mapping_method,
|
||||
"mapped_by": mapping.mapped_by,
|
||||
"mapped_at": mapping.mapped_at,
|
||||
"required_length_m": float(mapping.required_length_m) if mapping.required_length_m else None,
|
||||
"calculated_quantity": float(mapping.calculated_quantity) if mapping.calculated_quantity else None,
|
||||
"is_verified": mapping.is_verified,
|
||||
"tubing_product": {
|
||||
"id": product.id,
|
||||
"manufacturer_part_number": product.manufacturer_part_number,
|
||||
"manufacturer_product_name": product.manufacturer_product_name,
|
||||
"spec_name": product.specification.spec_name if product.specification else None,
|
||||
"manufacturer_name": product.manufacturer.manufacturer_name if product.manufacturer else None,
|
||||
"list_price": float(product.list_price) if product.list_price else None,
|
||||
"currency": product.currency,
|
||||
"availability_status": product.availability_status
|
||||
}
|
||||
}
|
||||
result.append(mapping_dict)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"material_id": material_id,
|
||||
"mappings": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"매핑 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/search")
|
||||
async def search_tubing_products(
|
||||
query: str = Query(..., min_length=2),
|
||||
category: Optional[str] = Query(None),
|
||||
manufacturer: Optional[str] = Query(None),
|
||||
min_diameter: Optional[float] = Query(None),
|
||||
max_diameter: Optional[float] = Query(None),
|
||||
material_grade: Optional[str] = Query(None),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""통합 Tubing 검색 (규격, 제품, 제조사)"""
|
||||
try:
|
||||
# SQL 쿼리로 복합 검색
|
||||
sql_query = """
|
||||
SELECT DISTINCT
|
||||
tp.id as product_id,
|
||||
tp.manufacturer_part_number,
|
||||
tp.manufacturer_product_name,
|
||||
tp.list_price,
|
||||
tp.currency,
|
||||
tp.availability_status,
|
||||
ts.spec_code,
|
||||
ts.spec_name,
|
||||
ts.outer_diameter_mm,
|
||||
ts.wall_thickness_mm,
|
||||
ts.material_grade,
|
||||
tc.category_name,
|
||||
tm.manufacturer_name,
|
||||
tm.country
|
||||
FROM tubing_products tp
|
||||
JOIN tubing_specifications ts ON tp.specification_id = ts.id
|
||||
JOIN tubing_categories tc ON ts.category_id = tc.id
|
||||
JOIN tubing_manufacturers tm ON tp.manufacturer_id = tm.id
|
||||
WHERE tp.is_active = true
|
||||
AND ts.is_active = true
|
||||
AND tc.is_active = true
|
||||
AND tm.is_active = true
|
||||
AND (
|
||||
tp.manufacturer_part_number ILIKE :query OR
|
||||
tp.manufacturer_product_name ILIKE :query OR
|
||||
ts.spec_name ILIKE :query OR
|
||||
ts.spec_code ILIKE :query OR
|
||||
ts.material_grade ILIKE :query OR
|
||||
tm.manufacturer_name ILIKE :query
|
||||
)
|
||||
"""
|
||||
|
||||
params = {"query": f"%{query}%"}
|
||||
|
||||
# 필터 조건 추가
|
||||
if category:
|
||||
sql_query += " AND tc.category_code = :category"
|
||||
params["category"] = category
|
||||
|
||||
if manufacturer:
|
||||
sql_query += " AND tm.manufacturer_code = :manufacturer"
|
||||
params["manufacturer"] = manufacturer
|
||||
|
||||
if min_diameter:
|
||||
sql_query += " AND ts.outer_diameter_mm >= :min_diameter"
|
||||
params["min_diameter"] = min_diameter
|
||||
|
||||
if max_diameter:
|
||||
sql_query += " AND ts.outer_diameter_mm <= :max_diameter"
|
||||
params["max_diameter"] = max_diameter
|
||||
|
||||
if material_grade:
|
||||
sql_query += " AND ts.material_grade ILIKE :material_grade"
|
||||
params["material_grade"] = f"%{material_grade}%"
|
||||
|
||||
sql_query += " ORDER BY tp.manufacturer_part_number LIMIT :limit"
|
||||
params["limit"] = limit
|
||||
|
||||
result = db.execute(text(sql_query), params)
|
||||
products = result.fetchall()
|
||||
|
||||
search_results = []
|
||||
for product in products:
|
||||
product_dict = {
|
||||
"product_id": product.product_id,
|
||||
"manufacturer_part_number": product.manufacturer_part_number,
|
||||
"manufacturer_product_name": product.manufacturer_product_name,
|
||||
"list_price": float(product.list_price) if product.list_price else None,
|
||||
"currency": product.currency,
|
||||
"availability_status": product.availability_status,
|
||||
"spec_code": product.spec_code,
|
||||
"spec_name": product.spec_name,
|
||||
"outer_diameter_mm": float(product.outer_diameter_mm) if product.outer_diameter_mm else None,
|
||||
"wall_thickness_mm": float(product.wall_thickness_mm) if product.wall_thickness_mm else None,
|
||||
"material_grade": product.material_grade,
|
||||
"category_name": product.category_name,
|
||||
"manufacturer_name": product.manufacturer_name,
|
||||
"country": product.country
|
||||
}
|
||||
search_results.append(product_dict)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"total_results": len(search_results),
|
||||
"results": search_results
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"검색 실패: {str(e)}")
|
||||
69
backend/app/schemas/__init__.py
Normal file
69
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
스키마 모듈
|
||||
API 요청/응답 모델 정의
|
||||
"""
|
||||
from .response_models import (
|
||||
BaseResponse,
|
||||
ErrorResponse,
|
||||
SuccessResponse,
|
||||
FileInfo,
|
||||
FileListResponse,
|
||||
FileDeleteResponse,
|
||||
MaterialInfo,
|
||||
MaterialListResponse,
|
||||
JobInfo,
|
||||
JobListResponse,
|
||||
ClassificationResult,
|
||||
ClassificationResponse,
|
||||
MaterialStatistics,
|
||||
ProjectStatistics,
|
||||
StatisticsResponse,
|
||||
CacheInfo,
|
||||
SystemHealthResponse,
|
||||
APIResponse,
|
||||
# 열거형
|
||||
FileStatus,
|
||||
MaterialCategory,
|
||||
JobStatus
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 기본 응답 모델
|
||||
"BaseResponse",
|
||||
"ErrorResponse",
|
||||
"SuccessResponse",
|
||||
|
||||
# 파일 관련
|
||||
"FileInfo",
|
||||
"FileListResponse",
|
||||
"FileDeleteResponse",
|
||||
|
||||
# 자재 관련
|
||||
"MaterialInfo",
|
||||
"MaterialListResponse",
|
||||
|
||||
# 작업 관련
|
||||
"JobInfo",
|
||||
"JobListResponse",
|
||||
|
||||
# 분류 관련
|
||||
"ClassificationResult",
|
||||
"ClassificationResponse",
|
||||
|
||||
# 통계 관련
|
||||
"MaterialStatistics",
|
||||
"ProjectStatistics",
|
||||
"StatisticsResponse",
|
||||
|
||||
# 시스템 관련
|
||||
"CacheInfo",
|
||||
"SystemHealthResponse",
|
||||
|
||||
# 유니온 타입
|
||||
"APIResponse",
|
||||
|
||||
# 열거형
|
||||
"FileStatus",
|
||||
"MaterialCategory",
|
||||
"JobStatus"
|
||||
]
|
||||
354
backend/app/schemas/response_models.py
Normal file
354
backend/app/schemas/response_models.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
API 응답 모델 정의
|
||||
타입 안정성 및 API 문서화를 위한 Pydantic 모델들
|
||||
"""
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# ================================
|
||||
# 기본 응답 모델
|
||||
# ================================
|
||||
|
||||
class BaseResponse(BaseModel):
|
||||
"""기본 응답 모델"""
|
||||
success: bool = Field(description="요청 성공 여부")
|
||||
message: Optional[str] = Field(None, description="응답 메시지")
|
||||
timestamp: datetime = Field(default_factory=datetime.now, description="응답 시간")
|
||||
|
||||
|
||||
class ErrorResponse(BaseResponse):
|
||||
"""에러 응답 모델"""
|
||||
success: bool = Field(False, description="요청 성공 여부")
|
||||
error: Dict[str, Any] = Field(description="에러 정보")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"success": False,
|
||||
"message": "요청 처리 중 오류가 발생했습니다",
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"details": "입력 데이터가 올바르지 않습니다"
|
||||
},
|
||||
"timestamp": "2025-01-01T12:00:00"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SuccessResponse(BaseResponse):
|
||||
"""성공 응답 모델"""
|
||||
success: bool = Field(True, description="요청 성공 여부")
|
||||
data: Optional[Any] = Field(None, description="응답 데이터")
|
||||
|
||||
|
||||
# ================================
|
||||
# 열거형 정의
|
||||
# ================================
|
||||
|
||||
class FileStatus(str, Enum):
|
||||
"""파일 상태"""
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
PROCESSING = "processing"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class MaterialCategory(str, Enum):
|
||||
"""자재 카테고리"""
|
||||
PIPE = "PIPE"
|
||||
FITTING = "FITTING"
|
||||
VALVE = "VALVE"
|
||||
FLANGE = "FLANGE"
|
||||
BOLT = "BOLT"
|
||||
GASKET = "GASKET"
|
||||
INSTRUMENT = "INSTRUMENT"
|
||||
EXCLUDE = "EXCLUDE"
|
||||
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
"""작업 상태"""
|
||||
ACTIVE = "active"
|
||||
COMPLETED = "completed"
|
||||
ON_HOLD = "on_hold"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
# ================================
|
||||
# 파일 관련 모델
|
||||
# ================================
|
||||
|
||||
class FileInfo(BaseModel):
|
||||
"""파일 정보 모델"""
|
||||
id: int = Field(description="파일 ID")
|
||||
filename: str = Field(description="파일명")
|
||||
original_filename: str = Field(description="원본 파일명")
|
||||
job_no: Optional[str] = Field(None, description="작업 번호")
|
||||
bom_name: Optional[str] = Field(None, description="BOM 이름")
|
||||
revision: str = Field(default="Rev.0", description="리비전")
|
||||
parsed_count: int = Field(default=0, description="파싱된 자재 수")
|
||||
bom_type: str = Field(default="unknown", description="BOM 타입")
|
||||
status: FileStatus = Field(description="파일 상태")
|
||||
file_size: Optional[int] = Field(None, description="파일 크기 (bytes)")
|
||||
upload_date: datetime = Field(description="업로드 일시")
|
||||
description: Optional[str] = Field(None, description="파일 설명")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"filename": "BOM_Rev1.xlsx",
|
||||
"original_filename": "BOM_Rev1.xlsx",
|
||||
"job_no": "TK-2025-001",
|
||||
"bom_name": "메인 BOM",
|
||||
"revision": "Rev.1",
|
||||
"parsed_count": 150,
|
||||
"bom_type": "excel",
|
||||
"status": "active",
|
||||
"file_size": 2048576,
|
||||
"upload_date": "2025-01-01T12:00:00",
|
||||
"description": "파일: BOM_Rev1.xlsx"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FileListResponse(BaseResponse):
|
||||
"""파일 목록 응답 모델"""
|
||||
success: bool = Field(True, description="요청 성공 여부")
|
||||
data: List[FileInfo] = Field(description="파일 목록")
|
||||
total_count: int = Field(description="전체 파일 수")
|
||||
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
|
||||
|
||||
|
||||
class FileDeleteResponse(BaseResponse):
|
||||
"""파일 삭제 응답 모델"""
|
||||
success: bool = Field(True, description="삭제 성공 여부")
|
||||
message: str = Field(description="삭제 결과 메시지")
|
||||
deleted_file_id: int = Field(description="삭제된 파일 ID")
|
||||
|
||||
|
||||
# ================================
|
||||
# 자재 관련 모델
|
||||
# ================================
|
||||
|
||||
class MaterialInfo(BaseModel):
|
||||
"""자재 정보 모델"""
|
||||
id: int = Field(description="자재 ID")
|
||||
file_id: int = Field(description="파일 ID")
|
||||
line_number: Optional[int] = Field(None, description="엑셀 행 번호")
|
||||
original_description: str = Field(description="원본 품명")
|
||||
classified_category: Optional[MaterialCategory] = Field(None, description="분류된 카테고리")
|
||||
classified_subcategory: Optional[str] = Field(None, description="세부 분류")
|
||||
material_grade: Optional[str] = Field(None, description="재질 등급")
|
||||
schedule: Optional[str] = Field(None, description="스케줄")
|
||||
size_spec: Optional[str] = Field(None, description="사이즈 규격")
|
||||
quantity: float = Field(description="수량")
|
||||
unit: str = Field(description="단위")
|
||||
classification_confidence: Optional[float] = Field(None, description="분류 신뢰도")
|
||||
is_verified: bool = Field(default=False, description="검증 여부")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"file_id": 1,
|
||||
"line_number": 5,
|
||||
"original_description": "PIPE, SEAMLESS, A333-6, 6\", SCH40",
|
||||
"classified_category": "PIPE",
|
||||
"classified_subcategory": "SEAMLESS",
|
||||
"material_grade": "A333-6",
|
||||
"schedule": "SCH40",
|
||||
"size_spec": "6\"",
|
||||
"quantity": 12.5,
|
||||
"unit": "EA",
|
||||
"classification_confidence": 0.95,
|
||||
"is_verified": False
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class MaterialListResponse(BaseResponse):
|
||||
"""자재 목록 응답 모델"""
|
||||
success: bool = Field(True, description="요청 성공 여부")
|
||||
data: List[MaterialInfo] = Field(description="자재 목록")
|
||||
total_count: int = Field(description="전체 자재 수")
|
||||
file_info: Optional[FileInfo] = Field(None, description="파일 정보")
|
||||
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
|
||||
|
||||
|
||||
# ================================
|
||||
# 작업 관련 모델
|
||||
# ================================
|
||||
|
||||
class JobInfo(BaseModel):
|
||||
"""작업 정보 모델"""
|
||||
job_no: str = Field(description="작업 번호")
|
||||
job_name: str = Field(description="작업명")
|
||||
client_name: Optional[str] = Field(None, description="고객사명")
|
||||
end_user: Optional[str] = Field(None, description="최종 사용자")
|
||||
epc_company: Optional[str] = Field(None, description="EPC 회사")
|
||||
status: JobStatus = Field(description="작업 상태")
|
||||
created_at: datetime = Field(description="생성 일시")
|
||||
file_count: int = Field(default=0, description="파일 수")
|
||||
material_count: int = Field(default=0, description="자재 수")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"job_no": "TK-2025-001",
|
||||
"job_name": "석유화학 플랜트 배관 프로젝트",
|
||||
"client_name": "한국석유화학",
|
||||
"end_user": "울산공장",
|
||||
"epc_company": "현대엔지니어링",
|
||||
"status": "active",
|
||||
"created_at": "2025-01-01T09:00:00",
|
||||
"file_count": 3,
|
||||
"material_count": 450
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class JobListResponse(BaseResponse):
|
||||
"""작업 목록 응답 모델"""
|
||||
success: bool = Field(True, description="요청 성공 여부")
|
||||
data: List[JobInfo] = Field(description="작업 목록")
|
||||
total_count: int = Field(description="전체 작업 수")
|
||||
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
|
||||
|
||||
|
||||
# ================================
|
||||
# 분류 관련 모델
|
||||
# ================================
|
||||
|
||||
class ClassificationResult(BaseModel):
|
||||
"""분류 결과 모델"""
|
||||
category: MaterialCategory = Field(description="분류된 카테고리")
|
||||
subcategory: Optional[str] = Field(None, description="세부 분류")
|
||||
confidence: float = Field(description="분류 신뢰도 (0.0-1.0)")
|
||||
material_grade: Optional[str] = Field(None, description="재질 등급")
|
||||
size_spec: Optional[str] = Field(None, description="사이즈 규격")
|
||||
schedule: Optional[str] = Field(None, description="스케줄")
|
||||
details: Optional[Dict[str, Any]] = Field(None, description="분류 상세 정보")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"category": "PIPE",
|
||||
"subcategory": "SEAMLESS",
|
||||
"confidence": 0.95,
|
||||
"material_grade": "A333-6",
|
||||
"size_spec": "6\"",
|
||||
"schedule": "SCH40",
|
||||
"details": {
|
||||
"matched_keywords": ["PIPE", "SEAMLESS", "A333-6"],
|
||||
"size_detected": True,
|
||||
"material_detected": True
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ClassificationResponse(BaseResponse):
|
||||
"""분류 응답 모델"""
|
||||
success: bool = Field(True, description="분류 성공 여부")
|
||||
data: ClassificationResult = Field(description="분류 결과")
|
||||
processing_time: float = Field(description="처리 시간 (초)")
|
||||
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
|
||||
|
||||
|
||||
# ================================
|
||||
# 통계 관련 모델
|
||||
# ================================
|
||||
|
||||
class MaterialStatistics(BaseModel):
|
||||
"""자재 통계 모델"""
|
||||
category: MaterialCategory = Field(description="자재 카테고리")
|
||||
count: int = Field(description="개수")
|
||||
percentage: float = Field(description="비율 (%)")
|
||||
total_quantity: float = Field(description="총 수량")
|
||||
unique_items: int = Field(description="고유 항목 수")
|
||||
|
||||
|
||||
class ProjectStatistics(BaseModel):
|
||||
"""프로젝트 통계 모델"""
|
||||
job_no: str = Field(description="작업 번호")
|
||||
total_materials: int = Field(description="총 자재 수")
|
||||
total_files: int = Field(description="총 파일 수")
|
||||
category_breakdown: List[MaterialStatistics] = Field(description="카테고리별 분석")
|
||||
classification_accuracy: float = Field(description="분류 정확도")
|
||||
verified_percentage: float = Field(description="검증 완료율")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"job_no": "TK-2025-001",
|
||||
"total_materials": 450,
|
||||
"total_files": 3,
|
||||
"category_breakdown": [
|
||||
{
|
||||
"category": "PIPE",
|
||||
"count": 180,
|
||||
"percentage": 40.0,
|
||||
"total_quantity": 1250.5,
|
||||
"unique_items": 45
|
||||
}
|
||||
],
|
||||
"classification_accuracy": 0.92,
|
||||
"verified_percentage": 0.75
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class StatisticsResponse(BaseResponse):
|
||||
"""통계 응답 모델"""
|
||||
success: bool = Field(True, description="요청 성공 여부")
|
||||
data: ProjectStatistics = Field(description="통계 데이터")
|
||||
cache_hit: bool = Field(default=False, description="캐시 히트 여부")
|
||||
|
||||
|
||||
# ================================
|
||||
# 시스템 관련 모델
|
||||
# ================================
|
||||
|
||||
class CacheInfo(BaseModel):
|
||||
"""캐시 정보 모델"""
|
||||
status: str = Field(description="캐시 상태")
|
||||
used_memory: str = Field(description="사용 메모리")
|
||||
connected_clients: int = Field(description="연결된 클라이언트 수")
|
||||
hit_rate: float = Field(description="캐시 히트율 (%)")
|
||||
total_commands: int = Field(description="총 명령 수")
|
||||
|
||||
|
||||
class SystemHealthResponse(BaseResponse):
|
||||
"""시스템 상태 응답 모델"""
|
||||
success: bool = Field(True, description="요청 성공 여부")
|
||||
data: Dict[str, Any] = Field(description="시스템 상태 정보")
|
||||
cache_info: Optional[CacheInfo] = Field(None, description="캐시 정보")
|
||||
database_status: str = Field(description="데이터베이스 상태")
|
||||
api_version: str = Field(description="API 버전")
|
||||
|
||||
|
||||
# ================================
|
||||
# 유니온 타입 (여러 응답 타입)
|
||||
# ================================
|
||||
|
||||
# API 응답으로 사용할 수 있는 모든 타입
|
||||
APIResponse = Union[
|
||||
SuccessResponse,
|
||||
ErrorResponse,
|
||||
FileListResponse,
|
||||
FileDeleteResponse,
|
||||
MaterialListResponse,
|
||||
JobListResponse,
|
||||
ClassificationResponse,
|
||||
StatisticsResponse,
|
||||
SystemHealthResponse
|
||||
]
|
||||
362
backend/app/services/activity_logger.py
Normal file
362
backend/app/services/activity_logger.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
사용자 활동 로그 서비스
|
||||
모든 업무 활동을 추적하고 기록하는 서비스
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import Optional, Dict, Any
|
||||
from fastapi import Request
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ActivityLogger:
|
||||
"""사용자 활동 로그 관리 클래스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def log_activity(
|
||||
self,
|
||||
username: str,
|
||||
activity_type: str,
|
||||
activity_description: str,
|
||||
target_id: Optional[int] = None,
|
||||
target_type: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""
|
||||
사용자 활동 로그 기록
|
||||
|
||||
Args:
|
||||
username: 사용자명 (필수)
|
||||
activity_type: 활동 유형 (FILE_UPLOAD, PROJECT_CREATE 등)
|
||||
activity_description: 활동 설명
|
||||
target_id: 대상 ID (파일, 프로젝트 등)
|
||||
target_type: 대상 유형 (FILE, PROJECT 등)
|
||||
user_id: 사용자 ID
|
||||
ip_address: IP 주소
|
||||
user_agent: 브라우저 정보
|
||||
metadata: 추가 메타데이터
|
||||
|
||||
Returns:
|
||||
int: 생성된 로그 ID
|
||||
"""
|
||||
try:
|
||||
insert_query = text("""
|
||||
INSERT INTO user_activity_logs (
|
||||
user_id, username, activity_type, activity_description,
|
||||
target_id, target_type, ip_address, user_agent, metadata
|
||||
) VALUES (
|
||||
:user_id, :username, :activity_type, :activity_description,
|
||||
:target_id, :target_type, :ip_address, :user_agent, :metadata
|
||||
) RETURNING id
|
||||
""")
|
||||
|
||||
result = self.db.execute(insert_query, {
|
||||
'user_id': user_id,
|
||||
'username': username,
|
||||
'activity_type': activity_type,
|
||||
'activity_description': activity_description,
|
||||
'target_id': target_id,
|
||||
'target_type': target_type,
|
||||
'ip_address': ip_address,
|
||||
'user_agent': user_agent,
|
||||
'metadata': json.dumps(metadata) if metadata else None
|
||||
})
|
||||
|
||||
log_id = result.fetchone()[0]
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Activity logged: {username} - {activity_type} - {activity_description}")
|
||||
return log_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log activity: {str(e)}")
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
def log_file_upload(
|
||||
self,
|
||||
username: str,
|
||||
file_id: int,
|
||||
filename: str,
|
||||
file_size: int,
|
||||
job_no: str,
|
||||
revision: str,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> int:
|
||||
"""파일 업로드 활동 로그"""
|
||||
metadata = {
|
||||
'filename': filename,
|
||||
'file_size': file_size,
|
||||
'job_no': job_no,
|
||||
'revision': revision,
|
||||
'upload_time': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return self.log_activity(
|
||||
username=username,
|
||||
activity_type='FILE_UPLOAD',
|
||||
activity_description=f'BOM 파일 업로드: {filename} (Job: {job_no}, Rev: {revision})',
|
||||
target_id=file_id,
|
||||
target_type='FILE',
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def log_project_create(
|
||||
self,
|
||||
username: str,
|
||||
project_id: int,
|
||||
project_name: str,
|
||||
job_no: str,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> int:
|
||||
"""프로젝트 생성 활동 로그"""
|
||||
metadata = {
|
||||
'project_name': project_name,
|
||||
'job_no': job_no,
|
||||
'create_time': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return self.log_activity(
|
||||
username=username,
|
||||
activity_type='PROJECT_CREATE',
|
||||
activity_description=f'프로젝트 생성: {project_name} ({job_no})',
|
||||
target_id=project_id,
|
||||
target_type='PROJECT',
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def log_material_classify(
|
||||
self,
|
||||
username: str,
|
||||
file_id: int,
|
||||
classified_count: int,
|
||||
job_no: str,
|
||||
revision: str,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> int:
|
||||
"""자재 분류 활동 로그"""
|
||||
metadata = {
|
||||
'classified_count': classified_count,
|
||||
'job_no': job_no,
|
||||
'revision': revision,
|
||||
'classify_time': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return self.log_activity(
|
||||
username=username,
|
||||
activity_type='MATERIAL_CLASSIFY',
|
||||
activity_description=f'자재 분류 완료: {classified_count}개 자재 (Job: {job_no}, Rev: {revision})',
|
||||
target_id=file_id,
|
||||
target_type='FILE',
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def log_purchase_confirm(
|
||||
self,
|
||||
username: str,
|
||||
job_no: str,
|
||||
revision: str,
|
||||
confirmed_count: int,
|
||||
total_amount: Optional[float] = None,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> int:
|
||||
"""구매 확정 활동 로그"""
|
||||
metadata = {
|
||||
'job_no': job_no,
|
||||
'revision': revision,
|
||||
'confirmed_count': confirmed_count,
|
||||
'total_amount': total_amount,
|
||||
'confirm_time': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return self.log_activity(
|
||||
username=username,
|
||||
activity_type='PURCHASE_CONFIRM',
|
||||
activity_description=f'구매 확정: {confirmed_count}개 품목 (Job: {job_no}, Rev: {revision})',
|
||||
target_id=None, # 구매는 특정 ID가 없음
|
||||
target_type='PURCHASE',
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def get_user_activities(
|
||||
self,
|
||||
username: str,
|
||||
activity_type: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> list:
|
||||
"""사용자 활동 이력 조회"""
|
||||
try:
|
||||
where_clause = "WHERE username = :username"
|
||||
params = {'username': username}
|
||||
|
||||
if activity_type:
|
||||
where_clause += " AND activity_type = :activity_type"
|
||||
params['activity_type'] = activity_type
|
||||
|
||||
query = text(f"""
|
||||
SELECT
|
||||
id, activity_type, activity_description,
|
||||
target_id, target_type, metadata, created_at
|
||||
FROM user_activity_logs
|
||||
{where_clause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
""")
|
||||
|
||||
params.update({'limit': limit, 'offset': offset})
|
||||
result = self.db.execute(query, params)
|
||||
|
||||
activities = []
|
||||
for row in result.fetchall():
|
||||
activity = {
|
||||
'id': row[0],
|
||||
'activity_type': row[1],
|
||||
'activity_description': row[2],
|
||||
'target_id': row[3],
|
||||
'target_type': row[4],
|
||||
'metadata': json.loads(row[5]) if row[5] else {},
|
||||
'created_at': row[6].isoformat() if row[6] else None
|
||||
}
|
||||
activities.append(activity)
|
||||
|
||||
return activities
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get user activities: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_recent_activities(
|
||||
self,
|
||||
days: int = 7,
|
||||
limit: int = 100
|
||||
) -> list:
|
||||
"""최근 활동 조회 (전체 사용자)"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
username, activity_type, activity_description,
|
||||
target_id, target_type, created_at
|
||||
FROM user_activity_logs
|
||||
WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '%s days'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit
|
||||
""" % days)
|
||||
|
||||
result = self.db.execute(query, {'limit': limit})
|
||||
|
||||
activities = []
|
||||
for row in result.fetchall():
|
||||
activity = {
|
||||
'username': row[0],
|
||||
'activity_type': row[1],
|
||||
'activity_description': row[2],
|
||||
'target_id': row[3],
|
||||
'target_type': row[4],
|
||||
'created_at': row[5].isoformat() if row[5] else None
|
||||
}
|
||||
activities.append(activity)
|
||||
|
||||
return activities
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get recent activities: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
def get_client_info(request: Request) -> tuple:
|
||||
"""
|
||||
요청에서 클라이언트 정보 추출
|
||||
|
||||
Args:
|
||||
request: FastAPI Request 객체
|
||||
|
||||
Returns:
|
||||
tuple: (ip_address, user_agent)
|
||||
"""
|
||||
# IP 주소 추출 (프록시 고려)
|
||||
ip_address = (
|
||||
request.headers.get('x-forwarded-for', '').split(',')[0].strip() or
|
||||
request.headers.get('x-real-ip', '') or
|
||||
request.client.host if request.client else 'unknown'
|
||||
)
|
||||
|
||||
# User-Agent 추출
|
||||
user_agent = request.headers.get('user-agent', 'unknown')
|
||||
|
||||
return ip_address, user_agent
|
||||
|
||||
|
||||
def log_activity_from_request(
|
||||
db: Session,
|
||||
request: Request,
|
||||
username: str,
|
||||
activity_type: str,
|
||||
activity_description: str,
|
||||
target_id: Optional[int] = None,
|
||||
target_type: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""
|
||||
요청 정보를 포함한 활동 로그 기록 (편의 함수)
|
||||
|
||||
Args:
|
||||
db: 데이터베이스 세션
|
||||
request: FastAPI Request 객체
|
||||
username: 사용자명
|
||||
activity_type: 활동 유형
|
||||
activity_description: 활동 설명
|
||||
target_id: 대상 ID
|
||||
target_type: 대상 유형
|
||||
user_id: 사용자 ID
|
||||
metadata: 추가 메타데이터
|
||||
|
||||
Returns:
|
||||
int: 생성된 로그 ID
|
||||
"""
|
||||
ip_address, user_agent = get_client_info(request)
|
||||
|
||||
activity_logger = ActivityLogger(db)
|
||||
return activity_logger.log_activity(
|
||||
username=username,
|
||||
activity_type=activity_type,
|
||||
activity_description=activity_description,
|
||||
target_id=target_id,
|
||||
target_type=target_type,
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
@@ -12,6 +12,55 @@ def classify_bolt_material(description: str) -> Dict:
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# A320/A194M 동시 처리 (예: "ASTM A320/A194M GR B8/8") - 저온용 볼트 조합
|
||||
if "A320" in desc_upper and "A194" in desc_upper:
|
||||
# B8/8 등급 추출
|
||||
bolt_grade = "UNKNOWN"
|
||||
nut_grade = "UNKNOWN"
|
||||
|
||||
if "B8" in desc_upper:
|
||||
bolt_grade = "B8"
|
||||
nut_grade = "8" # A320/A194M의 경우 보통 B8/8 조합
|
||||
elif "L7" in desc_upper:
|
||||
bolt_grade = "L7"
|
||||
elif "B8M" in desc_upper:
|
||||
bolt_grade = "B8M"
|
||||
|
||||
combined_grade = f"{bolt_grade}/{nut_grade}" if bolt_grade != "UNKNOWN" and nut_grade != "UNKNOWN" else f"{bolt_grade}" if bolt_grade != "UNKNOWN" else "A320/A194M"
|
||||
|
||||
return {
|
||||
"standard": "ASTM A320/A194M",
|
||||
"grade": combined_grade,
|
||||
"material_type": "LOW_TEMP_STAINLESS", # 저온용 스테인리스
|
||||
"manufacturing": "FORGED",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["ASTM_A320_A194M_COMBINED"]
|
||||
}
|
||||
|
||||
# A193/A194 동시 처리 (예: "ASTM A193/A194 GR B7/2H")
|
||||
if "A193" in desc_upper and "A194" in desc_upper:
|
||||
# B7/2H 등급 추출
|
||||
bolt_grade = "UNKNOWN"
|
||||
nut_grade = "UNKNOWN"
|
||||
|
||||
if "B7" in desc_upper:
|
||||
bolt_grade = "B7"
|
||||
if "2H" in desc_upper:
|
||||
nut_grade = "2H"
|
||||
elif " 8" in desc_upper or "GR 8" in desc_upper:
|
||||
nut_grade = "8"
|
||||
|
||||
combined_grade = f"{bolt_grade}/{nut_grade}" if bolt_grade != "UNKNOWN" and nut_grade != "UNKNOWN" else "A193/A194"
|
||||
|
||||
return {
|
||||
"standard": "ASTM A193/A194",
|
||||
"grade": combined_grade,
|
||||
"material_type": "ALLOY_STEEL", # B7/2H 조합은 보통 합금강
|
||||
"manufacturing": "FORGED",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["ASTM_A193_A194_COMBINED"]
|
||||
}
|
||||
|
||||
# ASTM A193 (볼트용 강재)
|
||||
if any(pattern in desc_upper for pattern in ["A193", "ASTM A193"]):
|
||||
# B7, B8 등 등급 추출 (GR B7/2H 형태도 지원)
|
||||
@@ -112,6 +161,39 @@ def classify_bolt_material(description: str) -> Dict:
|
||||
"evidence": ["ISO_4762_SOCKET_SCREW"]
|
||||
}
|
||||
|
||||
# 일반적인 볼트 재질 패턴 추가 확인
|
||||
if "B7" in desc_upper and "2H" in desc_upper:
|
||||
return {
|
||||
"standard": "ASTM A193/A194",
|
||||
"grade": "B7/2H",
|
||||
"material_type": "ALLOY_STEEL",
|
||||
"manufacturing": "FORGED",
|
||||
"confidence": 0.85,
|
||||
"evidence": ["B7_2H_PATTERN"]
|
||||
}
|
||||
|
||||
# 단독 B7 패턴
|
||||
if "B7" in desc_upper:
|
||||
return {
|
||||
"standard": "ASTM A193",
|
||||
"grade": "B7",
|
||||
"material_type": "ALLOY_STEEL",
|
||||
"manufacturing": "FORGED",
|
||||
"confidence": 0.80,
|
||||
"evidence": ["B7_PATTERN"]
|
||||
}
|
||||
|
||||
# 단독 2H 패턴
|
||||
if "2H" in desc_upper:
|
||||
return {
|
||||
"standard": "ASTM A194",
|
||||
"grade": "2H",
|
||||
"material_type": "ALLOY_STEEL",
|
||||
"manufacturing": "FORGED",
|
||||
"confidence": 0.80,
|
||||
"evidence": ["2H_PATTERN"]
|
||||
}
|
||||
|
||||
# 기본 재질 분류기 호출 (materials_schema 문제가 있어도 우회)
|
||||
try:
|
||||
return classify_material(description)
|
||||
@@ -153,13 +235,49 @@ BOLT_TYPES = {
|
||||
},
|
||||
|
||||
"FLANGE_BOLT": {
|
||||
"dat_file_patterns": ["FLG_BOLT", "FLANGE_BOLT", "BLT_150", "BLT_300", "BLT_600"],
|
||||
"description_keywords": ["FLANGE BOLT", "플랜지볼트", "150LB", "300LB", "600LB"],
|
||||
"dat_file_patterns": ["FLG_BOLT", "FLANGE_BOLT"],
|
||||
"description_keywords": ["FLANGE BOLT", "플랜지볼트"],
|
||||
"characteristics": "플랜지 전용 볼트",
|
||||
"applications": "플랜지 체결 전용",
|
||||
"head_type": "HEXAGON"
|
||||
},
|
||||
|
||||
"PSV_BOLT": {
|
||||
"dat_file_patterns": ["PSV_BOLT", "PSV_BLT"],
|
||||
"description_keywords": ["PSV", "PRESSURE SAFETY VALVE BOLT"],
|
||||
"characteristics": "압력안전밸브용 특수 볼트",
|
||||
"applications": "PSV 체결 전용",
|
||||
"head_type": "HEXAGON",
|
||||
"special_application": "PSV"
|
||||
},
|
||||
|
||||
"LT_BOLT": {
|
||||
"dat_file_patterns": ["LT_BOLT", "LT_BLT"],
|
||||
"description_keywords": ["LT BOLT", "LT BLT", "LOW TEMP", "저온용"],
|
||||
"characteristics": "저온용 특수 볼트",
|
||||
"applications": "저온 환경 체결용",
|
||||
"head_type": "HEXAGON",
|
||||
"special_application": "LT"
|
||||
},
|
||||
|
||||
"CK_BOLT": {
|
||||
"dat_file_patterns": ["CK_BOLT", "CK_BLT", "CHECK_BOLT"],
|
||||
"description_keywords": ["CK", "CHECK VALVE BOLT"],
|
||||
"characteristics": "체크밸브용 특수 볼트",
|
||||
"applications": "체크밸브 체결 전용",
|
||||
"head_type": "HEXAGON",
|
||||
"special_application": "CK"
|
||||
},
|
||||
|
||||
"ORI_BOLT": {
|
||||
"dat_file_patterns": ["ORI_BOLT", "ORI_BLT", "ORIFICE_BOLT"],
|
||||
"description_keywords": ["ORI", "ORIFICE", "오리피스"],
|
||||
"characteristics": "오리피스용 특수 볼트",
|
||||
"applications": "오리피스 체결 전용",
|
||||
"head_type": "HEXAGON",
|
||||
"special_application": "ORI"
|
||||
},
|
||||
|
||||
"MACHINE_SCREW": {
|
||||
"dat_file_patterns": ["MACH_SCR", "M_SCR"],
|
||||
"description_keywords": ["MACHINE SCREW", "머신스크류", "기계나사"],
|
||||
@@ -272,11 +390,17 @@ THREAD_STANDARDS = {
|
||||
},
|
||||
|
||||
"INCH": {
|
||||
"patterns": [r"(\d+(?:/\d+)?)\s*[\"\']\s*UNC", r"(\d+(?:/\d+)?)\s*[\"\']\s*UNF",
|
||||
r"(\d+(?:/\d+)?)\s*[\"\']-(\d+)"],
|
||||
"patterns": [
|
||||
r"(\d+(?:/\d+)?)\s*[\"\']\s*UNC", # 1/2" UNC
|
||||
r"(\d+(?:/\d+)?)\s*[\"\']\s*UNF", # 1/2" UNF
|
||||
r"(\d+(?:/\d+)?)\s*[\"\']-(\d+)", # 1/2"-13
|
||||
r"(\d+\.\d+)", # 0.625 (소수점 인치)
|
||||
r"(\d+(?:/\d+)?)\s*INCH", # 1/2 INCH
|
||||
r"(\d+(?:/\d+)?)\s*IN" # 1/2 IN
|
||||
],
|
||||
"description": "인치 나사",
|
||||
"thread_types": ["UNC", "UNF"],
|
||||
"common_sizes": ["1/4\"", "5/16\"", "3/8\"", "1/2\"", "5/8\"", "3/4\"", "7/8\"", "1\""]
|
||||
"common_sizes": ["1/4\"", "5/16\"", "3/8\"", "1/2\"", "5/8\"", "0.625\"", "3/4\"", "7/8\"", "1\""]
|
||||
},
|
||||
|
||||
"BSW": {
|
||||
@@ -302,6 +426,204 @@ BOLT_GRADES = {
|
||||
}
|
||||
}
|
||||
|
||||
def convert_decimal_to_fraction(decimal_str: str) -> str:
|
||||
"""소수점 인치를 분수로 변환 (현장 표준)"""
|
||||
|
||||
try:
|
||||
decimal = float(decimal_str)
|
||||
|
||||
# 일반적인 인치 분수 변환표
|
||||
inch_fractions = {
|
||||
0.125: "1/8",
|
||||
0.1875: "3/16",
|
||||
0.25: "1/4",
|
||||
0.3125: "5/16",
|
||||
0.375: "3/8",
|
||||
0.4375: "7/16",
|
||||
0.5: "1/2",
|
||||
0.5625: "9/16",
|
||||
0.625: "5/8",
|
||||
0.6875: "11/16",
|
||||
0.75: "3/4",
|
||||
0.8125: "13/16",
|
||||
0.875: "7/8",
|
||||
0.9375: "15/16",
|
||||
1.0: "1",
|
||||
1.125: "1-1/8",
|
||||
1.25: "1-1/4",
|
||||
1.375: "1-3/8",
|
||||
1.5: "1-1/2",
|
||||
1.625: "1-5/8",
|
||||
1.75: "1-3/4",
|
||||
1.875: "1-7/8",
|
||||
2.0: "2"
|
||||
}
|
||||
|
||||
# 정확한 매칭 (소수점 오차 고려)
|
||||
for dec_val, fraction in inch_fractions.items():
|
||||
if abs(decimal - dec_val) < 0.001: # 1mm 오차 허용
|
||||
return fraction
|
||||
|
||||
# 정확한 매칭이 없으면 가장 가까운 값 찾기
|
||||
closest_decimal = min(inch_fractions.keys(), key=lambda x: abs(x - decimal))
|
||||
if abs(closest_decimal - decimal) < 0.0625: # 1/16" 이내 오차만 허용
|
||||
return inch_fractions[closest_decimal]
|
||||
|
||||
# 변환할 수 없으면 원래 값 반환
|
||||
return str(decimal)
|
||||
|
||||
except ValueError:
|
||||
return decimal_str
|
||||
|
||||
def classify_surface_treatment(description: str) -> Dict:
|
||||
"""볼트 표면처리 분류 (아연도금, 스테인리스 등)"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
treatments = []
|
||||
|
||||
# 전기아연도금
|
||||
if any(keyword in desc_upper for keyword in ["ELEC.GALV", "ELEC GALV", "ELECTRO GALV", "전기아연도금"]):
|
||||
treatments.append({
|
||||
"type": "ELECTRO_GALVANIZING",
|
||||
"description": "전기아연도금",
|
||||
"code": "ELEC.GALV",
|
||||
"corrosion_resistance": "보통"
|
||||
})
|
||||
|
||||
# 용융아연도금
|
||||
if any(keyword in desc_upper for keyword in ["HOT DIP GALV", "HDG", "용융아연도금"]):
|
||||
treatments.append({
|
||||
"type": "HOT_DIP_GALVANIZING",
|
||||
"description": "용융아연도금",
|
||||
"code": "HDG",
|
||||
"corrosion_resistance": "높음"
|
||||
})
|
||||
|
||||
# 스테인리스 (표면처리 불필요)
|
||||
if any(keyword in desc_upper for keyword in ["STAINLESS", "STS", "스테인리스"]):
|
||||
treatments.append({
|
||||
"type": "STAINLESS_STEEL",
|
||||
"description": "스테인리스강",
|
||||
"code": "STS",
|
||||
"corrosion_resistance": "매우높음"
|
||||
})
|
||||
|
||||
# 니켈도금
|
||||
if any(keyword in desc_upper for keyword in ["NICKEL", "NI PLATING", "니켈도금"]):
|
||||
treatments.append({
|
||||
"type": "NICKEL_PLATING",
|
||||
"description": "니켈도금",
|
||||
"code": "NI",
|
||||
"corrosion_resistance": "높음"
|
||||
})
|
||||
|
||||
# 크롬도금
|
||||
if any(keyword in desc_upper for keyword in ["CHROME", "CR PLATING", "크롬도금"]):
|
||||
treatments.append({
|
||||
"type": "CHROME_PLATING",
|
||||
"description": "크롬도금",
|
||||
"code": "CR",
|
||||
"corrosion_resistance": "매우높음"
|
||||
})
|
||||
|
||||
return {
|
||||
"treatments": treatments,
|
||||
"has_treatment": len(treatments) > 0,
|
||||
"treatment_count": len(treatments),
|
||||
"primary_treatment": treatments[0] if treatments else None
|
||||
}
|
||||
|
||||
def classify_special_application_bolts(description: str) -> Dict:
|
||||
"""
|
||||
특수 용도 볼트 분류 및 카운팅 (PSV, LT, CK)
|
||||
|
||||
주의: 이 함수는 이미 BOLT로 분류된 아이템에서만 호출되어야 함
|
||||
PSV, LT, CK는 해당 장비용 볼트를 의미하며, 장비 자체가 아님
|
||||
"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
special_applications = []
|
||||
special_details = {}
|
||||
|
||||
# PSV 볼트 확인 (압력안전밸브용 볼트)
|
||||
psv_patterns = [
|
||||
r'\bPSV\b', # 단어 경계로 PSV만
|
||||
r'PRESSURE\s+SAFETY\s+VALVE',
|
||||
r'압력안전밸브',
|
||||
r'PSV\s+BOLT',
|
||||
r'PSV\s+BLT'
|
||||
]
|
||||
|
||||
import re
|
||||
if any(re.search(pattern, desc_upper) for pattern in psv_patterns):
|
||||
special_applications.append("PSV")
|
||||
special_details["PSV"] = {
|
||||
"type": "압력안전밸브용 볼트",
|
||||
"application": "PSV 체결 전용",
|
||||
"critical": True # 안전 장비용으로 중요
|
||||
}
|
||||
|
||||
# LT 볼트 확인 (저온용 볼트)
|
||||
lt_patterns = [
|
||||
r'\bLT\s', # LT 다음에 공백이 있는 경우만 (LT BOLT, LT BLT)
|
||||
r'^LT\b', # 문장 시작의 LT만
|
||||
r'LOW\s+TEMP',
|
||||
r'저온용',
|
||||
r'CRYOGENIC',
|
||||
r'LT\s+BOLT',
|
||||
r'LT\s+BLT'
|
||||
]
|
||||
|
||||
if any(re.search(pattern, desc_upper) for pattern in lt_patterns):
|
||||
special_applications.append("LT")
|
||||
special_details["LT"] = {
|
||||
"type": "저온용 볼트",
|
||||
"application": "저온 환경 체결용",
|
||||
"critical": True # 저온 환경용으로 중요
|
||||
}
|
||||
|
||||
# CK 볼트 확인 (체크밸브용 볼트)
|
||||
ck_patterns = [
|
||||
r'\bCK\b', # 단어 경계로 CK만
|
||||
r'CHECK\s+VALVE',
|
||||
r'체크밸브',
|
||||
r'CK\s+BOLT',
|
||||
r'CK\s+BLT'
|
||||
]
|
||||
|
||||
if any(re.search(pattern, desc_upper) for pattern in ck_patterns):
|
||||
special_applications.append("CK")
|
||||
special_details["CK"] = {
|
||||
"type": "체크밸브용 볼트",
|
||||
"application": "체크밸브 체결 전용",
|
||||
"critical": False # 일반적
|
||||
}
|
||||
|
||||
# ORI 볼트 확인 (오리피스용 볼트)
|
||||
ori_patterns = [
|
||||
r'\bORI\b', # 단어 경계로 ORI만
|
||||
r'ORIFICE',
|
||||
r'오리피스',
|
||||
r'ORI\s+BOLT',
|
||||
r'ORI\s+BLT'
|
||||
]
|
||||
|
||||
if any(re.search(pattern, desc_upper) for pattern in ori_patterns):
|
||||
special_applications.append("ORI")
|
||||
special_details["ORI"] = {
|
||||
"type": "오리피스용 볼트",
|
||||
"application": "오리피스 체결 전용",
|
||||
"critical": True # 유량 측정용으로 중요
|
||||
}
|
||||
|
||||
return {
|
||||
"detected_applications": special_applications,
|
||||
"special_details": special_details,
|
||||
"is_special_bolt": len(special_applications) > 0,
|
||||
"special_count": len(special_applications),
|
||||
"classification_note": "특수 장비용 볼트 (장비 자체 아님)"
|
||||
}
|
||||
|
||||
def classify_bolt(dat_file: str, description: str, main_nom: str, length: Optional[float] = None) -> Dict:
|
||||
"""
|
||||
완전한 BOLT 분류
|
||||
@@ -337,7 +659,13 @@ def classify_bolt(dat_file: str, description: str, main_nom: str, length: Option
|
||||
# 6. 등급 및 강도 분류
|
||||
grade_result = classify_bolt_grade(description, thread_result)
|
||||
|
||||
# 7. 최종 결과 조합
|
||||
# 7. 특수 용도 볼트 분류 (PSV, LT, CK)
|
||||
special_result = classify_special_application_bolts(description)
|
||||
|
||||
# 8. 표면처리 분류 (ELEC.GALV 등)
|
||||
surface_result = classify_surface_treatment(description)
|
||||
|
||||
# 9. 최종 결과 조합
|
||||
return {
|
||||
"category": "BOLT",
|
||||
|
||||
@@ -367,6 +695,7 @@ def classify_bolt(dat_file: str, description: str, main_nom: str, length: Option
|
||||
"thread_specification": {
|
||||
"standard": thread_result.get('standard', 'UNKNOWN'),
|
||||
"size": thread_result.get('size', ''),
|
||||
"size_fraction": thread_result.get('size_fraction', ''),
|
||||
"pitch": thread_result.get('pitch', ''),
|
||||
"thread_type": thread_result.get('thread_type', ''),
|
||||
"confidence": thread_result.get('confidence', 0.0)
|
||||
@@ -374,9 +703,11 @@ def classify_bolt(dat_file: str, description: str, main_nom: str, length: Option
|
||||
|
||||
"dimensions": {
|
||||
"nominal_size": dimensions_result.get('nominal_size', main_nom),
|
||||
"nominal_size_fraction": dimensions_result.get('nominal_size_fraction', main_nom),
|
||||
"length": dimensions_result.get('length', ''),
|
||||
"diameter": dimensions_result.get('diameter', ''),
|
||||
"dimension_description": dimensions_result.get('dimension_description', '')
|
||||
"dimension_description": dimensions_result.get('dimension_description', ''),
|
||||
"bolts_per_flange": dimensions_result.get('bolts_per_flange', 1)
|
||||
},
|
||||
|
||||
"grade_strength": {
|
||||
@@ -386,6 +717,23 @@ def classify_bolt(dat_file: str, description: str, main_nom: str, length: Option
|
||||
"confidence": grade_result.get('confidence', 0.0)
|
||||
},
|
||||
|
||||
# 특수 용도 볼트 정보
|
||||
"special_applications": {
|
||||
"is_special_bolt": special_result.get('is_special_bolt', False),
|
||||
"detected_applications": special_result.get('detected_applications', []),
|
||||
"special_details": special_result.get('special_details', {}),
|
||||
"special_count": special_result.get('special_count', 0),
|
||||
"classification_note": special_result.get('classification_note', '')
|
||||
},
|
||||
|
||||
# 표면처리 정보
|
||||
"surface_treatment": {
|
||||
"has_treatment": surface_result.get('has_treatment', False),
|
||||
"treatments": surface_result.get('treatments', []),
|
||||
"treatment_count": surface_result.get('treatment_count', 0),
|
||||
"primary_treatment": surface_result.get('primary_treatment', None)
|
||||
},
|
||||
|
||||
# 전체 신뢰도
|
||||
"overall_confidence": calculate_bolt_confidence({
|
||||
"material": material_result.get('confidence', 0),
|
||||
@@ -536,9 +884,19 @@ def classify_thread_specification(main_nom: str, description: str) -> Dict:
|
||||
thread_type = t_type
|
||||
break
|
||||
|
||||
# 인치 사이즈를 분수로 변환
|
||||
size_fraction = size
|
||||
if standard == "INCH":
|
||||
try:
|
||||
if '.' in size and size.replace('.', '').isdigit():
|
||||
size_fraction = convert_decimal_to_fraction(size).replace('"', '')
|
||||
except:
|
||||
size_fraction = size
|
||||
|
||||
return {
|
||||
"standard": standard,
|
||||
"size": size,
|
||||
"size": size, # 원래 값
|
||||
"size_fraction": size_fraction, # 분수 변환값
|
||||
"pitch": pitch,
|
||||
"thread_type": thread_type,
|
||||
"confidence": 0.9,
|
||||
@@ -559,28 +917,98 @@ def extract_bolt_dimensions(main_nom: str, description: str) -> Dict:
|
||||
"""볼트 치수 정보 추출"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
actual_bolt_size = main_nom
|
||||
|
||||
# 실제 BOM 형태: "ORI, 0.75, 145.0000 LG, 300LB, ASTM A193/A194 GR B7/2H, ELEC.GALV"
|
||||
# 첫 번째 숫자가 실제 볼트 사이즈 (접두사 건너뛰기)
|
||||
import re
|
||||
# 설명에서 첫 번째 숫자 추출 (볼트 사이즈)
|
||||
first_number_match = re.search(r'(\d+(?:\.\d+)?)', description)
|
||||
if first_number_match:
|
||||
actual_bolt_size = first_number_match.group(1)
|
||||
|
||||
# 플랜지 볼트의 경우 실제 볼트 직경을 description에서 추출
|
||||
if "FLANGE BOLT" in desc_upper or "FLG_BOLT" in desc_upper:
|
||||
# 플랜지 볼트에서 실제 볼트 사이즈 패턴 찾기
|
||||
# 예: "FLANGE BOLT 6" 150LB M16" → M16
|
||||
# 예: "FLANGE BOLT 1-1/2" 5/8" x 100mm" → 5/8
|
||||
|
||||
bolt_size_patterns = [
|
||||
r'M(\d+)', # M16, M20 등 메트릭
|
||||
r'(\d+-\d+/\d+)\s*["\']?\s*X', # 1-1/2" X 등 (복합 분수)
|
||||
r'(\d+/\d+)\s*["\']?\s*X', # 5/8" X, 3/4" X 등 (단순 분수)
|
||||
r'(\d+(?:\.\d+)?)\s*["\']?\s*X', # 0.625" X 등 (소수)
|
||||
r'(\d+-\d+/\d+)\s*["\']?\s*DIA', # 1-1/2" DIA 등 (복합 분수)
|
||||
r'(\d+/\d+)\s*["\']?\s*DIA', # 5/8" DIA 등 (단순 분수)
|
||||
r'(\d+(?:\.\d+)?)\s*["\']?\s*DIA', # 0.625" DIA 등 (소수)
|
||||
r'(\d+-\d+/\d+)\s*["\']?\s*(?:LONG|LG|LENGTH)', # 복합 분수 + 길이
|
||||
r'(\d+/\d+)\s*["\']?\s*(?:LONG|LG|LENGTH)', # 단순 분수 + 길이
|
||||
r'(\d+(?:\.\d+)?)\s*["\']?\s*(?:LONG|LG|LENGTH)', # 소수 + 길이
|
||||
r'(\d+(?:\.\d+)?)\s*MM\s*DIA', # 16MM DIA 등
|
||||
]
|
||||
|
||||
for pattern in bolt_size_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
extracted_size = match.group(1)
|
||||
# M16 같은 메트릭은 M 제거
|
||||
if pattern.startswith(r'M'):
|
||||
actual_bolt_size = extracted_size
|
||||
else:
|
||||
actual_bolt_size = extracted_size
|
||||
break
|
||||
|
||||
# 볼트 사이즈를 분수로 변환 (인치인 경우)
|
||||
nominal_size_fraction = actual_bolt_size
|
||||
try:
|
||||
# 소수점 인치를 분수로 변환
|
||||
if '.' in actual_bolt_size and actual_bolt_size.replace('.', '').isdigit():
|
||||
nominal_size_fraction = convert_decimal_to_fraction(actual_bolt_size)
|
||||
except:
|
||||
nominal_size_fraction = actual_bolt_size
|
||||
|
||||
# 플랜지당 볼트 세트 수 추출 (예: (8), (4))
|
||||
bolts_per_flange = 1 # 기본값
|
||||
flange_bolt_pattern = re.search(r'\((\d+)\)', description)
|
||||
if flange_bolt_pattern:
|
||||
bolts_per_flange = int(flange_bolt_pattern.group(1))
|
||||
|
||||
dimensions = {
|
||||
"nominal_size": main_nom,
|
||||
"nominal_size": actual_bolt_size, # 실제 볼트 사이즈
|
||||
"nominal_size_fraction": nominal_size_fraction, # 분수 변환값
|
||||
"length": "",
|
||||
"diameter": "",
|
||||
"dimension_description": main_nom
|
||||
"dimension_description": nominal_size_fraction, # 분수로 표시
|
||||
"bolts_per_flange": bolts_per_flange # 플랜지당 볼트 세트 수
|
||||
}
|
||||
|
||||
# 길이 정보 추출
|
||||
# 길이 정보 추출 (개선된 패턴)
|
||||
length_patterns = [
|
||||
r'(\d+(?:\.\d+)?)\s*LG', # 70.0000 LG 형태 (최우선)
|
||||
r'(\d+(?:\.\d+)?)\s*LG', # 70.0000 LG, 145.0000 LG 형태 (최우선)
|
||||
r'(\d+(?:\.\d+)?)\s*MM\s*LG', # 70MM LG 형태
|
||||
r'L\s*(\d+(?:\.\d+)?)\s*MM',
|
||||
r'LENGTH\s*(\d+(?:\.\d+)?)\s*MM',
|
||||
r'(\d+(?:\.\d+)?)\s*MM\s*LONG',
|
||||
r'X\s*(\d+(?:\.\d+)?)\s*MM' # M8 X 20MM 형태
|
||||
r'X\s*(\d+(?:\.\d+)?)\s*MM', # M8 X 20MM 형태
|
||||
r',\s*(\d+(?:\.\d+)?)\s*LG', # ", 145.0000 LG" 형태 (PSV, LT 볼트용)
|
||||
r',\s*(\d+(?:\.\d+)?)\s+(?:CK|PSV|LT)', # ", 140 CK" 형태 (PSV 볼트용)
|
||||
r'PSV\s+(\d+(?:\.\d+)?)', # PSV 140 형태 (PSV 볼트 전용)
|
||||
r'(\d+(?:\.\d+)?)\s+PSV', # 140 PSV 형태 (PSV 볼트 전용)
|
||||
r'(\d+(?:\.\d+)?)\s*MM(?:\s|,|$)', # 75MM 형태 (단독)
|
||||
r'(\d+(?:\.\d+)?)\s*mm(?:\s|,|$)' # 75mm 형태 (단독)
|
||||
]
|
||||
|
||||
for pattern in length_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
dimensions["length"] = f"{match.group(1)}mm"
|
||||
length_value = match.group(1)
|
||||
# 소수점 제거 (145.0000 → 145)
|
||||
if '.' in length_value and length_value.endswith('.0000'):
|
||||
length_value = length_value.split('.')[0]
|
||||
elif '.' in length_value and all(c == '0' for c in length_value.split('.')[1]):
|
||||
length_value = length_value.split('.')[0]
|
||||
|
||||
dimensions["length"] = f"{length_value}mm"
|
||||
break
|
||||
|
||||
# 지름 정보 (이미 main_nom에 있지만 확인)
|
||||
@@ -595,8 +1023,8 @@ def extract_bolt_dimensions(main_nom: str, description: str) -> Dict:
|
||||
dimensions["diameter"] = f"{match.group(1)}mm"
|
||||
break
|
||||
|
||||
# 치수 설명 조합
|
||||
desc_parts = [main_nom]
|
||||
# 치수 설명 조합 (분수 사용)
|
||||
desc_parts = [nominal_size_fraction]
|
||||
if dimensions["length"]:
|
||||
desc_parts.append(f"L{dimensions['length']}")
|
||||
|
||||
@@ -705,6 +1133,68 @@ def calculate_bolt_confidence(confidence_scores: Dict) -> float:
|
||||
|
||||
# ========== 특수 기능들 ==========
|
||||
|
||||
def extract_bolt_additional_requirements(description: str) -> str:
|
||||
"""볼트 설명에서 추가요구사항 추출 (표면처리, 특수 요구사항 등)"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
additional_reqs = []
|
||||
|
||||
# 표면처리 패턴들
|
||||
surface_treatments = {
|
||||
'ELEC.GALV': '전기아연도금',
|
||||
'ELEC GALV': '전기아연도금',
|
||||
'GALVANIZED': '아연도금',
|
||||
'GALV': '아연도금',
|
||||
'HOT DIP GALV': '용융아연도금',
|
||||
'HDG': '용융아연도금',
|
||||
'ZINC PLATED': '아연도금',
|
||||
'ZINC': '아연도금',
|
||||
'STAINLESS': '스테인리스',
|
||||
'SS': '스테인리스',
|
||||
'PASSIVATED': '부동태화',
|
||||
'ANODIZED': '아노다이징',
|
||||
'BLACK OXIDE': '흑색산화',
|
||||
'PHOSPHATE': '인산처리',
|
||||
'DACROMET': '다크로메트',
|
||||
'GEOMET': '지오메트'
|
||||
}
|
||||
|
||||
# 특수 요구사항 패턴들
|
||||
special_requirements = {
|
||||
'HEAVY HEX': '중육각',
|
||||
'FULL THREAD': '전나사',
|
||||
'PARTIAL THREAD': '부분나사',
|
||||
'FINE THREAD': '세나사',
|
||||
'COARSE THREAD': '조나사',
|
||||
'LEFT HAND': '좌나사',
|
||||
'RIGHT HAND': '우나사',
|
||||
'SOCKET HEAD': '소켓헤드',
|
||||
'BUTTON HEAD': '버튼헤드',
|
||||
'FLAT HEAD': '평머리',
|
||||
'PAN HEAD': '팬헤드',
|
||||
'TRUSS HEAD': '트러스헤드',
|
||||
'WASHER FACE': '와셔면',
|
||||
'SERRATED': '톱니형',
|
||||
'LOCK': '잠금',
|
||||
'SPRING': '스프링',
|
||||
'WAVE': '웨이브'
|
||||
}
|
||||
|
||||
# 표면처리 확인
|
||||
for pattern, korean in surface_treatments.items():
|
||||
if pattern in desc_upper:
|
||||
additional_reqs.append(korean)
|
||||
|
||||
# 특수 요구사항 확인
|
||||
for pattern, korean in special_requirements.items():
|
||||
if pattern in desc_upper:
|
||||
additional_reqs.append(korean)
|
||||
|
||||
# 중복 제거 및 정렬
|
||||
additional_reqs = list(set(additional_reqs))
|
||||
|
||||
return ', '.join(additional_reqs) if additional_reqs else ''
|
||||
|
||||
def get_bolt_purchase_info(bolt_result: Dict) -> Dict:
|
||||
"""볼트 구매 정보 생성"""
|
||||
|
||||
|
||||
157
backend/app/services/classifier_constants.py
Normal file
157
backend/app/services/classifier_constants.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
자재 분류 시스템용 상수 및 키워드 정의
|
||||
중복 로직 제거 및 유지보수성 향상을 위해 중앙 집중화됨
|
||||
"""
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
# ==============================================================================
|
||||
# 1. 압력 등급 (Pressure Ratings)
|
||||
# ==============================================================================
|
||||
|
||||
# 단순 키워드 목록 (Integrated Classifier용)
|
||||
LEVEL3_PRESSURE_KEYWORDS = [
|
||||
"150LB", "300LB", "600LB", "900LB", "1500LB",
|
||||
"2500LB", "3000LB", "6000LB", "9000LB"
|
||||
]
|
||||
|
||||
# 상세 스펙 및 메타데이터 (Fitting Classifier용)
|
||||
PRESSURE_RATINGS_SPECS = {
|
||||
"150LB": {"max_pressure": "285 PSI", "common_use": "저압 일반용"},
|
||||
"300LB": {"max_pressure": "740 PSI", "common_use": "중압용"},
|
||||
"600LB": {"max_pressure": "1480 PSI", "common_use": "고압용"},
|
||||
"900LB": {"max_pressure": "2220 PSI", "common_use": "고압용"},
|
||||
"1500LB": {"max_pressure": "3705 PSI", "common_use": "고압용"},
|
||||
"2500LB": {"max_pressure": "6170 PSI", "common_use": "초고압용"},
|
||||
"3000LB": {"max_pressure": "7400 PSI", "common_use": "소구경 고압용"},
|
||||
"6000LB": {"max_pressure": "14800 PSI", "common_use": "소구경 초고압용"},
|
||||
"9000LB": {"max_pressure": "22200 PSI", "common_use": "소구경 극고압용"}
|
||||
}
|
||||
|
||||
# 정규식 패턴 (Fitting Classifier용)
|
||||
PRESSURE_PATTERNS = [
|
||||
r"(\d+)LB",
|
||||
r"CLASS\s*(\d+)",
|
||||
r"CL\s*(\d+)",
|
||||
r"(\d+)#",
|
||||
r"(\d+)\s*LB"
|
||||
]
|
||||
|
||||
# ==============================================================================
|
||||
# 2. OLET 키워드 (OLET Keywords)
|
||||
# ==============================================================================
|
||||
# Fitting Classifier와 Integrated Classifier에서 공통 사용
|
||||
OLET_KEYWORDS = [
|
||||
# Full Names
|
||||
"SOCK-O-LET", "WELD-O-LET", "ELL-O-LET", "THREAD-O-LET", "ELB-O-LET",
|
||||
"NIP-O-LET", "COUP-O-LET",
|
||||
# Variations
|
||||
"SOCKOLET", "WELDOLET", "ELLOLET", "THREADOLET", "ELBOLET", "NIPOLET", "COUPOLET",
|
||||
"OLET", "올렛", "O-LET", "SOCKLET", "SOCKET-O-LET", "WELD O-LET", "ELL O-LET",
|
||||
"THREADED-O-LET", "ELBOW-O-LET", "NIPPLE-O-LET", "COUPLING-O-LET",
|
||||
# Abbreviations (Caution: specific context needed sometimes)
|
||||
"SOL", "WOL", "EOL", "TOL", "NOL", "COL"
|
||||
]
|
||||
|
||||
# ==============================================================================
|
||||
# 3. 연결 방식 (Connection Methods)
|
||||
# ==============================================================================
|
||||
LEVEL3_CONNECTION_KEYWORDS = {
|
||||
"SW": ["SW", "SOCKET WELD", "소켓웰드", "SOCKET-WELD", "_SW_"],
|
||||
"THD": ["THD", "THREADED", "NPT", "나사", "THRD", "TR", "_TR", "_THD"],
|
||||
"BW": ["BW", "BUTT WELD", "맞대기용접", "BUTT-WELD", "_BW"],
|
||||
"FL": ["FL", "FLANGED", "플랜지", "FLG", "_FL_"]
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 4. 재질 키워드 (Material Keywords)
|
||||
# ==============================================================================
|
||||
LEVEL4_MATERIAL_KEYWORDS = {
|
||||
"PIPE": ["A106", "A333", "A312", "A53"],
|
||||
"FITTING": ["A234", "A403", "A420"],
|
||||
"FLANGE": ["A182", "A350"],
|
||||
"VALVE": ["A216", "A217", "A351", "A352"],
|
||||
"BOLT": ["A193", "A194", "A320", "A325", "A490"]
|
||||
}
|
||||
|
||||
GENERIC_MATERIALS = {
|
||||
"A105": ["VALVE", "FLANGE", "FITTING"],
|
||||
"316": ["VALVE", "FLANGE", "FITTING", "PIPE", "BOLT"],
|
||||
"304": ["VALVE", "FLANGE", "FITTING", "PIPE", "BOLT"]
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 5. 메인 분류 키워드 (Level 1 Type Keywords)
|
||||
# ==============================================================================
|
||||
LEVEL1_TYPE_KEYWORDS = {
|
||||
"BOLT": [
|
||||
"FLANGE BOLT", "U-BOLT", "U BOLT", "BOLT", "STUD", "NUT", "SCREW",
|
||||
"WASHER", "볼트", "너트", "스터드", "나사", "와셔", "유볼트"
|
||||
],
|
||||
"VALVE": [
|
||||
"VALVE", "GATE", "BALL", "GLOBE", "CHECK", "BUTTERFLY", "NEEDLE",
|
||||
"RELIEF", "SIGHT GLASS", "STRAINER", "밸브", "게이트", "볼", "글로브",
|
||||
"체크", "버터플라이", "니들", "릴리프", "사이트글라스", "스트레이너"
|
||||
],
|
||||
"FLANGE": [
|
||||
"FLG", "FLANGE", "플랜지", "프랜지", "ORIFICE", "SPECTACLE", "PADDLE",
|
||||
"SPACER", "BLIND", "REDUCING FLANGE", "RED FLANGE"
|
||||
],
|
||||
"PIPE": [
|
||||
"PIPE", "TUBE", "파이프", "배관", "SMLS", "SEAMLESS"
|
||||
],
|
||||
"FITTING": [
|
||||
# Standard Fittings
|
||||
"ELBOW", "ELL", "TEE", "REDUCER", "CAP", "COUPLING", "NIPPLE", "SWAGE", "PLUG",
|
||||
"엘보", "티", "리듀서", "캡", "니플", "커플링", "플러그", "CONC", "ECC",
|
||||
# Instrument Fittings
|
||||
"SWAGELOK", "DK-LOK", "HY-LOK", "SUPERLOK", "TUBE FITTING", "COMPRESSION",
|
||||
"UNION", "FERRULE", "NUT & FERRULE", "MALE CONNECTOR", "FEMALE CONNECTOR",
|
||||
"TUBE ADAPTER", "PORT CONNECTOR", "CONNECTOR"
|
||||
] + OLET_KEYWORDS, # OLET Keywords 병합
|
||||
"GASKET": [
|
||||
"GASKET", "GASK", "가스켓", "SWG", "SPIRAL"
|
||||
],
|
||||
"INSTRUMENT": [
|
||||
"GAUGE", "TRANSMITTER", "SENSOR", "THERMOMETER", "계기", "게이지", "트랜스미터", "센서"
|
||||
],
|
||||
"SUPPORT": [
|
||||
"URETHANE BLOCK", "URETHANE", "BLOCK SHOE", "CLAMP", "SUPPORT", "HANGER",
|
||||
"SPRING", "우레탄", "블록", "클램프", "서포트", "행거", "스프링", "PIPE CLAMP"
|
||||
],
|
||||
"PLATE": [
|
||||
"PLATE", "PL", "CHECKER PLATE", "판재", "철판"
|
||||
],
|
||||
"STRUCTURAL": [
|
||||
"H-BEAM", "BEAM", "ANGLE", "CHANNEL", "H-SECTION", "I-BEAM", "형강", "앵글", "채널"
|
||||
]
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 6. 서브타입 키워드 (Level 2 Subtype Keywords)
|
||||
# ==============================================================================
|
||||
LEVEL2_SUBTYPE_KEYWORDS = {
|
||||
"VALVE": {
|
||||
"GATE": ["GATE VALVE", "GATE", "게이트 밸브"],
|
||||
"BALL": ["BALL VALVE", "BALL", "볼 밸브"],
|
||||
"GLOBE": ["GLOBE VALVE", "GLOBE", "글로브 밸브"],
|
||||
"CHECK": ["CHECK VALVE", "CHECK", "체크 밸브", "역지 밸브"]
|
||||
},
|
||||
"FLANGE": {
|
||||
"WELD_NECK": ["WELD NECK", "WN", "웰드넥"],
|
||||
"SLIP_ON": ["SLIP ON", "SO", "슬립온"],
|
||||
"BLIND": ["BLIND", "BL", "막음", "차단"],
|
||||
"SOCKET_WELD": ["SOCKET WELD", "소켓웰드"]
|
||||
},
|
||||
"BOLT": {
|
||||
"HEX_BOLT": ["HEX BOLT", "HEXAGON", "육각 볼트"],
|
||||
"STUD_BOLT": ["STUD BOLT", "STUD", "스터드 볼트"],
|
||||
"U_BOLT": ["U-BOLT", "U BOLT", "유볼트"]
|
||||
},
|
||||
"SUPPORT": {
|
||||
"URETHANE_BLOCK": ["URETHANE BLOCK", "BLOCK SHOE", "우레탄 블록"],
|
||||
"CLAMP": ["CLAMP", "클램프"],
|
||||
"HANGER": ["HANGER", "SUPPORT", "행거", "서포트"],
|
||||
"SPRING": ["SPRING", "스프링"]
|
||||
}
|
||||
}
|
||||
300
backend/app/services/excel_parser.py
Normal file
300
backend/app/services/excel_parser.py
Normal file
@@ -0,0 +1,300 @@
|
||||
|
||||
import pandas as pd
|
||||
import re
|
||||
from typing import List, Dict, Optional
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# 허용된 확장자
|
||||
ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"}
|
||||
|
||||
class BOMParser:
|
||||
"""BOM 파일 파싱을 담당하는 클래스"""
|
||||
|
||||
@staticmethod
|
||||
def validate_extension(filename: str) -> bool:
|
||||
return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_filename(original_filename: str) -> str:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
stem = Path(original_filename).stem
|
||||
suffix = Path(original_filename).suffix
|
||||
return f"{stem}_{timestamp}_{unique_id}{suffix}"
|
||||
|
||||
@staticmethod
|
||||
def detect_format(df: pd.DataFrame) -> str:
|
||||
"""
|
||||
엑셀 헤더를 분석하여 양식을 감지합니다.
|
||||
|
||||
Returns:
|
||||
'INVENTOR': 인벤터 추출 양식 (NO., NAME, Q'ty, LENGTH & THICKNESS...)
|
||||
'STANDARD': 기존 표준/퍼지 매핑 엑셀 (DWG_NAME, LINE_NUM, MAIN_NOM...)
|
||||
"""
|
||||
columns = [str(c).strip().upper() for c in df.columns]
|
||||
|
||||
# 인벤터 양식 특징 (오타 포함)
|
||||
INVENTOR_KEYWORDS = ['LENGTH & THICKNESS', 'DESCIPTION', "Q'TY"]
|
||||
|
||||
for keyword in INVENTOR_KEYWORDS:
|
||||
if any(keyword in col for col in columns):
|
||||
return 'INVENTOR'
|
||||
|
||||
# 표준 양식 특징
|
||||
STANDARD_KEYWORDS = ['MAIN_NOM', 'DWG_NAME', 'LINE_NUM']
|
||||
for keyword in STANDARD_KEYWORDS:
|
||||
if any(keyword in col for col in columns):
|
||||
return 'STANDARD'
|
||||
|
||||
return 'STANDARD' # 기본값
|
||||
|
||||
@classmethod
|
||||
def parse_file(cls, file_path: str) -> List[Dict]:
|
||||
"""파일 경로를 받아 적절한 파서를 통해 데이터를 추출합니다."""
|
||||
file_extension = Path(file_path).suffix.lower()
|
||||
|
||||
try:
|
||||
if file_extension == ".csv":
|
||||
df = pd.read_csv(file_path, encoding='utf-8')
|
||||
elif file_extension in [".xlsx", ".xls"]:
|
||||
# xlrd 엔진 명시 (xls 지원)
|
||||
if file_extension == ".xls":
|
||||
df = pd.read_excel(file_path, sheet_name=0, engine='xlrd')
|
||||
else:
|
||||
df = pd.read_excel(file_path, sheet_name=0, engine='openpyxl')
|
||||
else:
|
||||
raise ValueError("지원하지 않는 파일 형식")
|
||||
|
||||
# 데이터프레임 전처리 (빈 행 제거 등)
|
||||
df = df.dropna(how='all')
|
||||
|
||||
# 양식 감지
|
||||
format_type = cls.detect_format(df)
|
||||
print(f"📋 감지된 BOM 양식: {format_type}")
|
||||
|
||||
if format_type == 'INVENTOR':
|
||||
return cls._parse_inventor_bom(df)
|
||||
else:
|
||||
return cls._parse_standard_bom(df)
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"파일 파싱 실패: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def _parse_standard_bom(df: pd.DataFrame) -> List[Dict]:
|
||||
"""기존의 퍼지 매핑 방식 파서 (표준 양식)"""
|
||||
# 컬럼명 전처리
|
||||
df.columns = df.columns.str.strip().str.upper()
|
||||
|
||||
# 대소문자 구분 없는 매핑을 위해 컬럼명 대문자화 사용
|
||||
column_mapping = {
|
||||
'description': ['DESCRIPTION', 'ITEM', 'MATERIAL', '품명', '자재명', 'SPECIFICATION'],
|
||||
'quantity': ['QTY', 'QUANTITY', 'EA', '수량', 'AMOUNT'],
|
||||
'main_size': ['MAIN_NOM', 'NOMINAL_DIAMETER', 'ND', '주배관', 'SIZE'],
|
||||
'red_size': ['RED_NOM', 'REDUCED_DIAMETER', '축소배관'],
|
||||
'length': ['LENGTH', 'LEN', '길이'],
|
||||
'weight': ['WEIGHT', 'WT', '중량'],
|
||||
'dwg_name': ['DWG_NAME', 'DRAWING', '도면명', '도면번호'],
|
||||
'line_num': ['LINE_NUM', 'LINE_NUMBER', '라인번호']
|
||||
}
|
||||
|
||||
mapped_columns = {}
|
||||
for standard_col, possible_names in column_mapping.items():
|
||||
for possible_name in possible_names:
|
||||
# 대문자로 비교
|
||||
possible_upper = possible_name.upper()
|
||||
if possible_upper in df.columns:
|
||||
mapped_columns[standard_col] = possible_upper
|
||||
break
|
||||
|
||||
print(f"📋 [Standard] 컬럼 매핑 결과: {mapped_columns}")
|
||||
|
||||
materials = []
|
||||
for index, row in df.iterrows():
|
||||
description = str(row.get(mapped_columns.get('description', ''), ''))
|
||||
|
||||
# 제외 항목 처리
|
||||
description_upper = description.upper()
|
||||
if ('WELD GAP' in description_upper or 'WELDING GAP' in description_upper or
|
||||
'웰드갭' in description_upper or '용접갭' in description_upper):
|
||||
continue
|
||||
|
||||
# 수량 처리
|
||||
quantity_raw = row.get(mapped_columns.get('quantity', ''), 0)
|
||||
try:
|
||||
quantity = float(quantity_raw) if pd.notna(quantity_raw) else 0
|
||||
except:
|
||||
quantity = 0
|
||||
|
||||
# 재질 등급 추출 (ASTM)
|
||||
material_grade = ""
|
||||
if "ASTM" in description_upper:
|
||||
astm_match = re.search(r'ASTM\s+(A\d{3,4}[A-Z]*(?:\s+(?:GR\s+)?[A-Z0-9]+)?)', description_upper)
|
||||
if astm_match:
|
||||
material_grade = astm_match.group(0).strip()
|
||||
|
||||
# 사이즈 처리
|
||||
main_size = str(row.get(mapped_columns.get('main_size', ''), ''))
|
||||
red_size = str(row.get(mapped_columns.get('red_size', ''), ''))
|
||||
|
||||
main_nom = main_size if main_size != 'nan' and main_size != '' else None
|
||||
red_nom = red_size if red_size != 'nan' and red_size != '' else None
|
||||
|
||||
if main_size != 'nan' and red_size != 'nan' and red_size != '':
|
||||
size_spec = f"{main_size} x {red_size}"
|
||||
elif main_size != 'nan' and main_size != '':
|
||||
size_spec = main_size
|
||||
else:
|
||||
size_spec = ""
|
||||
|
||||
# 길이 처리
|
||||
length_raw = row.get(mapped_columns.get('length', ''), '')
|
||||
length_value = None
|
||||
if pd.notna(length_raw) and str(length_raw).strip() != '':
|
||||
try:
|
||||
length_value = float(str(length_raw).strip())
|
||||
except:
|
||||
length_value = None
|
||||
|
||||
# 도면/라인 번호
|
||||
dwg_name = row.get(mapped_columns.get('dwg_name', ''), '')
|
||||
dwg_name = str(dwg_name).strip() if pd.notna(dwg_name) and str(dwg_name).strip() not in ['', 'nan', 'None'] else None
|
||||
|
||||
line_num = row.get(mapped_columns.get('line_num', ''), '')
|
||||
line_num = str(line_num).strip() if pd.notna(line_num) and str(line_num).strip() not in ['', 'nan', 'None'] else None
|
||||
|
||||
if description and description not in ['nan', 'None', '']:
|
||||
materials.append({
|
||||
'original_description': description,
|
||||
'quantity': quantity,
|
||||
'unit': "EA",
|
||||
'size_spec': size_spec,
|
||||
'main_nom': main_nom,
|
||||
'red_nom': red_nom,
|
||||
'material_grade': material_grade,
|
||||
'length': length_value,
|
||||
'dwg_name': dwg_name,
|
||||
'line_num': line_num,
|
||||
'line_number': index + 1,
|
||||
'row_number': index + 1
|
||||
})
|
||||
|
||||
return materials
|
||||
|
||||
@staticmethod
|
||||
def _parse_inventor_bom(df: pd.DataFrame) -> List[Dict]:
|
||||
"""
|
||||
[신규] 인벤터 추출 양식 파서
|
||||
헤더: NO., NAME, Q'ty, LENGTH & THICKNESS, WEIGHT, DESCIPTION, REMARK
|
||||
특징: Size 컬럼 부재, NAME에 주요 정보 포함
|
||||
"""
|
||||
print("⚠️ [Inventor] 인벤터 양식 파서를 사용합니다.")
|
||||
|
||||
# 컬럼명 전처리 (좌우 공백 제거 및 대문자화)
|
||||
df.columns = df.columns.str.strip().str.upper()
|
||||
|
||||
# 인벤터 전용 매핑
|
||||
col_name = 'NAME'
|
||||
col_qty = "Q'TY"
|
||||
col_desc = 'DESCIPTION' # 오타 그대로 반영
|
||||
col_remark = 'REMARK'
|
||||
col_length = 'LENGTH & THICKNESS' # 길이 정보가 여기 있을 수 있음
|
||||
|
||||
materials = []
|
||||
for index, row in df.iterrows():
|
||||
# 1. 품명 (NAME 컬럼 우선 사용)
|
||||
name_val = str(row.get(col_name, '')).strip()
|
||||
desc_val = str(row.get(col_desc, '')).strip()
|
||||
|
||||
# NAME과 DESCIPTION 병합 (필요시)
|
||||
# 보통 인벤터에서 NAME이 'ADAPTER OD 1/4" x NPT(F) 1/2"' 같이 상세 스펙
|
||||
# DESCIPTION은 'SS-400-7-8' 같은 자재 코드일 수 있음
|
||||
# 일단 NAME을 메인 description으로 사용하고, DESCIPTION을 괄호에 추가
|
||||
if desc_val and desc_val not in ['nan', 'None', '']:
|
||||
full_description = f"{name_val} ({desc_val})"
|
||||
else:
|
||||
full_description = name_val
|
||||
|
||||
if not full_description or full_description in ['nan', 'None', '']:
|
||||
continue
|
||||
|
||||
# 2. 수량
|
||||
qty_raw = row.get(col_qty, 0)
|
||||
try:
|
||||
quantity = float(qty_raw) if pd.notna(qty_raw) else 0
|
||||
except:
|
||||
quantity = 0
|
||||
|
||||
# 3. 사이즈 추출 (NAME 컬럼 분석)
|
||||
# 패턴: 1/2", 1/4", 100A, 50A, 10x20 등
|
||||
size_spec = ""
|
||||
main_nom = None
|
||||
red_nom = None
|
||||
|
||||
# 인치/MM 사이즈 추출 시도
|
||||
# 예: "ADAPTER OD 1/4" x NPT(F) 1/2"" -> 1/4" x 1/2"
|
||||
# 예: "ELBOW 90D 100A" -> 100A
|
||||
|
||||
# 인치 패턴 (1/2", 3/4" 등)
|
||||
inch_sizes = re.findall(r'(\d+(?:/\d+)?)"', name_val)
|
||||
# A단위 패턴 (100A, 50A 등)
|
||||
a_sizes = re.findall(r'(\d+)A', name_val)
|
||||
|
||||
if inch_sizes:
|
||||
if len(inch_sizes) >= 2:
|
||||
main_nom = f'{inch_sizes[0]}"'
|
||||
red_nom = f'{inch_sizes[1]}"'
|
||||
size_spec = f'{main_nom} x {red_nom}'
|
||||
else:
|
||||
main_nom = f'{inch_sizes[0]}"'
|
||||
size_spec = main_nom
|
||||
elif a_sizes:
|
||||
if len(a_sizes) >= 2:
|
||||
main_nom = f'{a_sizes[0]}A'
|
||||
red_nom = f'{a_sizes[1]}A'
|
||||
size_spec = f'{main_nom} x {red_nom}'
|
||||
else:
|
||||
main_nom = f'{a_sizes[0]}A'
|
||||
size_spec = main_nom
|
||||
|
||||
# 4. 재질 정보
|
||||
material_grade = ""
|
||||
# NAME이나 DESCIPTION에서 재질 키워드 찾기 (SS, SUS, A105 등)
|
||||
combined_text = (full_description + " " + desc_val).upper()
|
||||
if "SUS" in combined_text or "SS" in combined_text:
|
||||
if "304" in combined_text: material_grade = "SUS304"
|
||||
elif "316" in combined_text: material_grade = "SUS316"
|
||||
else: material_grade = "SUS"
|
||||
elif "A105" in combined_text:
|
||||
material_grade = "A105"
|
||||
|
||||
# 5. 길이 정보
|
||||
length_value = None
|
||||
length_raw = row.get(col_length, '')
|
||||
# 값이 있고 숫자로 변환 가능하면 사용
|
||||
if pd.notna(length_raw) and str(length_raw).strip():
|
||||
try:
|
||||
# '100 mm' 등의 형식 처리 필요할 수 있음
|
||||
length_str = str(length_raw).lower().replace('mm', '').strip()
|
||||
length_value = float(length_str)
|
||||
except:
|
||||
pass
|
||||
|
||||
materials.append({
|
||||
'original_description': full_description,
|
||||
'quantity': quantity,
|
||||
'unit': "EA",
|
||||
'size_spec': size_spec,
|
||||
'main_nom': main_nom,
|
||||
'red_nom': red_nom,
|
||||
'material_grade': material_grade,
|
||||
'length': length_value,
|
||||
'dwg_name': None, # 인벤터 파일엔 도면명 컬럼이 없음
|
||||
'line_num': None,
|
||||
'line_number': index + 1,
|
||||
'row_number': index + 1
|
||||
})
|
||||
|
||||
return materials
|
||||
@@ -8,11 +8,6 @@ from typing import Dict, List, Optional
|
||||
|
||||
# ========== 제외 대상 타입 ==========
|
||||
EXCLUDE_TYPES = {
|
||||
"WELD_GAP": {
|
||||
"description_keywords": ["WELD GAP", "WELDING GAP", "GAP", "용접갭", "웰드갭"],
|
||||
"characteristics": "용접 시 수축 고려용 계산 항목",
|
||||
"reason": "실제 자재 아님 - 용접 갭 계산용"
|
||||
},
|
||||
"CUTTING_LOSS": {
|
||||
"description_keywords": ["CUTTING LOSS", "CUT LOSS", "절단로스", "컷팅로스"],
|
||||
"characteristics": "절단 시 손실 고려용 계산 항목",
|
||||
|
||||
333
backend/app/services/file_service.py
Normal file
333
backend/app/services/file_service.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
파일 관리 비즈니스 로직
|
||||
API 레이어에서 분리된 핵심 비즈니스 로직
|
||||
"""
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.cache_manager import tkmp_cache
|
||||
from ..utils.transaction_manager import TransactionManager, async_transactional
|
||||
from ..schemas.response_models import FileInfo
|
||||
from ..config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class FileService:
|
||||
"""파일 관리 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.transaction_manager = TransactionManager(db)
|
||||
|
||||
async def get_files(
|
||||
self,
|
||||
job_no: Optional[str] = None,
|
||||
show_history: bool = False,
|
||||
use_cache: bool = True
|
||||
) -> Tuple[List[Dict], bool]:
|
||||
"""
|
||||
파일 목록 조회
|
||||
|
||||
Args:
|
||||
job_no: 작업 번호
|
||||
show_history: 이력 표시 여부
|
||||
use_cache: 캐시 사용 여부
|
||||
|
||||
Returns:
|
||||
Tuple[List[Dict], bool]: (파일 목록, 캐시 히트 여부)
|
||||
"""
|
||||
try:
|
||||
logger.info(f"파일 목록 조회 - job_no: {job_no}, show_history: {show_history}")
|
||||
|
||||
# 캐시 확인
|
||||
if use_cache:
|
||||
cached_files = tkmp_cache.get_file_list(job_no, show_history)
|
||||
if cached_files:
|
||||
logger.info(f"캐시에서 파일 목록 반환 - {len(cached_files)}개 파일")
|
||||
return cached_files, True
|
||||
|
||||
# 데이터베이스에서 조회
|
||||
query, params = self._build_file_query(job_no, show_history)
|
||||
result = self.db.execute(text(query), params)
|
||||
files = result.fetchall()
|
||||
|
||||
# 결과 변환
|
||||
file_list = self._convert_files_to_dict(files)
|
||||
|
||||
# 캐시에 저장
|
||||
if use_cache:
|
||||
tkmp_cache.set_file_list(file_list, job_no, show_history)
|
||||
logger.debug("파일 목록 캐시 저장 완료")
|
||||
|
||||
logger.info(f"파일 목록 조회 완료 - {len(file_list)}개 파일 반환")
|
||||
return file_list, False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 목록 조회 실패: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}")
|
||||
|
||||
def _build_file_query(self, job_no: Optional[str], show_history: bool) -> Tuple[str, Dict]:
|
||||
"""파일 조회 쿼리 생성"""
|
||||
if show_history:
|
||||
# 전체 이력 표시
|
||||
query = "SELECT * FROM files"
|
||||
params = {}
|
||||
|
||||
if job_no:
|
||||
query += " WHERE job_no = :job_no"
|
||||
params["job_no"] = job_no
|
||||
|
||||
query += " ORDER BY original_filename, revision DESC"
|
||||
else:
|
||||
# 최신 리비전만 표시
|
||||
if job_no:
|
||||
query = """
|
||||
SELECT f1.* FROM files f1
|
||||
INNER JOIN (
|
||||
SELECT original_filename, MAX(revision) as max_revision
|
||||
FROM files
|
||||
WHERE job_no = :job_no
|
||||
GROUP BY original_filename
|
||||
) f2 ON f1.original_filename = f2.original_filename
|
||||
AND f1.revision = f2.max_revision
|
||||
WHERE f1.job_no = :job_no
|
||||
ORDER BY f1.upload_date DESC
|
||||
"""
|
||||
params = {"job_no": job_no}
|
||||
else:
|
||||
query = "SELECT * FROM files ORDER BY upload_date DESC"
|
||||
params = {}
|
||||
|
||||
return query, params
|
||||
|
||||
def _convert_files_to_dict(self, files) -> List[Dict]:
|
||||
"""파일 결과를 딕셔너리로 변환"""
|
||||
return [
|
||||
{
|
||||
"id": f.id,
|
||||
"filename": f.original_filename,
|
||||
"original_filename": f.original_filename,
|
||||
"name": f.original_filename,
|
||||
"job_no": f.job_no,
|
||||
"bom_name": f.bom_name or f.original_filename,
|
||||
"revision": f.revision or "Rev.0",
|
||||
"parsed_count": f.parsed_count or 0,
|
||||
"bom_type": f.file_type or "unknown",
|
||||
"status": "active" if f.is_active else "inactive",
|
||||
"file_size": f.file_size,
|
||||
"created_at": f.upload_date,
|
||||
"upload_date": f.upload_date,
|
||||
"description": f"파일: {f.original_filename}"
|
||||
}
|
||||
for f in files
|
||||
]
|
||||
|
||||
async def delete_file(self, file_id: int) -> Dict:
|
||||
"""
|
||||
파일 삭제 (트랜잭션 관리 적용)
|
||||
|
||||
Args:
|
||||
file_id: 파일 ID
|
||||
|
||||
Returns:
|
||||
Dict: 삭제 결과
|
||||
"""
|
||||
try:
|
||||
logger.info(f"파일 삭제 요청 - file_id: {file_id}")
|
||||
|
||||
# 트랜잭션 내에서 삭제 작업 수행
|
||||
with self.transaction_manager.transaction():
|
||||
# 파일 정보 조회
|
||||
file_info = self._get_file_info(file_id)
|
||||
if not file_info:
|
||||
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
||||
|
||||
# 관련 데이터 삭제 (세이브포인트 사용)
|
||||
with self.transaction_manager.savepoint("delete_related_data"):
|
||||
self._delete_related_data(file_id)
|
||||
|
||||
# 파일 삭제
|
||||
with self.transaction_manager.savepoint("delete_file_record"):
|
||||
self._delete_file_record(file_id)
|
||||
|
||||
# 트랜잭션이 성공적으로 완료되면 캐시 무효화
|
||||
self._invalidate_file_cache(file_id, file_info)
|
||||
|
||||
logger.info(f"파일 삭제 완료 - file_id: {file_id}, filename: {file_info.original_filename}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "파일과 관련 데이터가 삭제되었습니다",
|
||||
"deleted_file_id": file_id
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"파일 삭제 실패 - file_id: {file_id}, error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {str(e)}")
|
||||
|
||||
def _get_file_info(self, file_id: int):
|
||||
"""파일 정보 조회"""
|
||||
file_query = text("SELECT * FROM files WHERE id = :file_id")
|
||||
file_result = self.db.execute(file_query, {"file_id": file_id})
|
||||
return file_result.fetchone()
|
||||
|
||||
def _delete_related_data(self, file_id: int):
|
||||
"""관련 데이터 삭제"""
|
||||
# 상세 테이블 목록
|
||||
detail_tables = [
|
||||
'pipe_details', 'fitting_details', 'valve_details',
|
||||
'flange_details', 'bolt_details', 'gasket_details',
|
||||
'instrument_details'
|
||||
]
|
||||
|
||||
# 해당 파일의 materials ID 조회
|
||||
material_ids_query = text("SELECT id FROM materials WHERE file_id = :file_id")
|
||||
material_ids_result = self.db.execute(material_ids_query, {"file_id": file_id})
|
||||
material_ids = [row[0] for row in material_ids_result]
|
||||
|
||||
if material_ids:
|
||||
logger.info(f"관련 자재 데이터 삭제 - {len(material_ids)}개 자재")
|
||||
# 각 상세 테이블에서 관련 데이터 삭제
|
||||
for table in detail_tables:
|
||||
delete_detail_query = text(f"DELETE FROM {table} WHERE material_id = ANY(:material_ids)")
|
||||
self.db.execute(delete_detail_query, {"material_ids": material_ids})
|
||||
|
||||
# materials 테이블 데이터 삭제
|
||||
materials_query = text("DELETE FROM materials WHERE file_id = :file_id")
|
||||
self.db.execute(materials_query, {"file_id": file_id})
|
||||
|
||||
def _delete_file_record(self, file_id: int):
|
||||
"""파일 레코드 삭제"""
|
||||
delete_query = text("DELETE FROM files WHERE id = :file_id")
|
||||
self.db.execute(delete_query, {"file_id": file_id})
|
||||
|
||||
def _invalidate_file_cache(self, file_id: int, file_info):
|
||||
"""파일 관련 캐시 무효화"""
|
||||
tkmp_cache.invalidate_file_cache(file_id)
|
||||
if hasattr(file_info, 'job_no') and file_info.job_no:
|
||||
tkmp_cache.invalidate_job_cache(file_info.job_no)
|
||||
|
||||
async def get_file_statistics(self, job_no: Optional[str] = None) -> Dict:
|
||||
"""
|
||||
파일 통계 조회
|
||||
|
||||
Args:
|
||||
job_no: 작업 번호
|
||||
|
||||
Returns:
|
||||
Dict: 파일 통계
|
||||
"""
|
||||
try:
|
||||
# 캐시 확인
|
||||
if job_no:
|
||||
cached_stats = tkmp_cache.get_statistics(job_no, "file_stats")
|
||||
if cached_stats:
|
||||
return cached_stats
|
||||
|
||||
# 통계 쿼리 실행
|
||||
stats_query = self._build_statistics_query(job_no)
|
||||
result = self.db.execute(text(stats_query["query"]), stats_query["params"])
|
||||
stats_data = result.fetchall()
|
||||
|
||||
# 통계 데이터 변환
|
||||
statistics = self._convert_statistics_data(stats_data)
|
||||
|
||||
# 캐시에 저장
|
||||
if job_no:
|
||||
tkmp_cache.set_statistics(statistics, job_no, "file_stats")
|
||||
|
||||
return statistics
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 통계 조회 실패: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"파일 통계 조회 실패: {str(e)}")
|
||||
|
||||
def _build_statistics_query(self, job_no: Optional[str]) -> Dict:
|
||||
"""통계 쿼리 생성"""
|
||||
base_query = """
|
||||
SELECT
|
||||
COUNT(*) as total_files,
|
||||
COUNT(DISTINCT job_no) as total_jobs,
|
||||
SUM(CASE WHEN is_active = true THEN 1 ELSE 0 END) as active_files,
|
||||
SUM(file_size) as total_size,
|
||||
AVG(file_size) as avg_size,
|
||||
MAX(upload_date) as latest_upload,
|
||||
MIN(upload_date) as earliest_upload
|
||||
FROM files
|
||||
"""
|
||||
|
||||
params = {}
|
||||
if job_no:
|
||||
base_query += " WHERE job_no = :job_no"
|
||||
params["job_no"] = job_no
|
||||
|
||||
return {"query": base_query, "params": params}
|
||||
|
||||
def _convert_statistics_data(self, stats_data) -> Dict:
|
||||
"""통계 데이터 변환"""
|
||||
if not stats_data:
|
||||
return {
|
||||
"total_files": 0,
|
||||
"total_jobs": 0,
|
||||
"active_files": 0,
|
||||
"total_size": 0,
|
||||
"avg_size": 0,
|
||||
"latest_upload": None,
|
||||
"earliest_upload": None
|
||||
}
|
||||
|
||||
stats = stats_data[0]
|
||||
return {
|
||||
"total_files": stats.total_files or 0,
|
||||
"total_jobs": stats.total_jobs or 0,
|
||||
"active_files": stats.active_files or 0,
|
||||
"total_size": stats.total_size or 0,
|
||||
"total_size_mb": round((stats.total_size or 0) / (1024 * 1024), 2),
|
||||
"avg_size": stats.avg_size or 0,
|
||||
"avg_size_mb": round((stats.avg_size or 0) / (1024 * 1024), 2),
|
||||
"latest_upload": stats.latest_upload,
|
||||
"earliest_upload": stats.earliest_upload
|
||||
}
|
||||
|
||||
async def validate_file_access(self, file_id: int, user_id: Optional[str] = None) -> bool:
|
||||
"""
|
||||
파일 접근 권한 검증
|
||||
|
||||
Args:
|
||||
file_id: 파일 ID
|
||||
user_id: 사용자 ID
|
||||
|
||||
Returns:
|
||||
bool: 접근 권한 여부
|
||||
"""
|
||||
try:
|
||||
# 파일 존재 여부 확인
|
||||
file_info = self._get_file_info(file_id)
|
||||
if not file_info:
|
||||
return False
|
||||
|
||||
# 파일이 활성 상태인지 확인
|
||||
if not file_info.is_active:
|
||||
logger.warning(f"비활성 파일 접근 시도 - file_id: {file_id}")
|
||||
return False
|
||||
|
||||
# 추가 권한 검증 로직 (필요시 구현)
|
||||
# 예: 사용자별 프로젝트 접근 권한 등
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 접근 권한 검증 실패: {str(e)}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def get_file_service(db: Session) -> FileService:
|
||||
"""파일 서비스 팩토리 함수"""
|
||||
return FileService(db)
|
||||
@@ -6,13 +6,18 @@ FITTING 분류 시스템 V2
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
from .material_classifier import classify_material, get_manufacturing_method_from_material
|
||||
from .classifier_constants import PRESSURE_PATTERNS, PRESSURE_RATINGS_SPECS, OLET_KEYWORDS
|
||||
|
||||
# ========== FITTING 타입별 분류 (실제 BOM 기반) ==========
|
||||
FITTING_TYPES = {
|
||||
"ELBOW": {
|
||||
"dat_file_patterns": ["90L_", "45L_", "ELL_", "ELBOW_"],
|
||||
"description_keywords": ["ELBOW", "ELL", "엘보"],
|
||||
"description_keywords": ["ELBOW", "ELL", "엘보", "90 ELBOW", "45 ELBOW", "LR ELBOW", "SR ELBOW", "90 ELL", "45 ELL"],
|
||||
"subtypes": {
|
||||
"90DEG_LONG_RADIUS": ["90 LR", "90° LR", "90DEG LR", "90도 장반경", "90 LONG RADIUS", "LR 90"],
|
||||
"90DEG_SHORT_RADIUS": ["90 SR", "90° SR", "90DEG SR", "90도 단반경", "90 SHORT RADIUS", "SR 90"],
|
||||
"45DEG_LONG_RADIUS": ["45 LR", "45° LR", "45DEG LR", "45도 장반경", "45 LONG RADIUS", "LR 45"],
|
||||
"45DEG_SHORT_RADIUS": ["45 SR", "45° SR", "45DEG SR", "45도 단반경", "45 SHORT RADIUS", "SR 45"],
|
||||
"90DEG": ["90", "90°", "90DEG", "90도"],
|
||||
"45DEG": ["45", "45°", "45DEG", "45도"],
|
||||
"LONG_RADIUS": ["LR", "LONG RADIUS", "장반경"],
|
||||
@@ -98,11 +103,12 @@ FITTING_TYPES = {
|
||||
},
|
||||
|
||||
"OLET": {
|
||||
"dat_file_patterns": ["SOL_", "WOL_", "TOL_", "OLET_", "SOCK-O-LET", "WELD-O-LET"],
|
||||
"description_keywords": ["OLET", "올렛", "O-LET", "SOCK-O-LET", "WELD-O-LET", "SOCKOLET", "WELDOLET", "THREAD-O-LET", "THREADOLET", "SOCKLET", "SOCKET"],
|
||||
"dat_file_patterns": ["SOL_", "WOL_", "TOL_", "EOL_", "NOL_", "COL_", "OLET_", "SOCK-O-LET", "WELD-O-LET", "ELL-O-LET", "THREAD-O-LET", "ELB-O-LET", "NIP-O-LET", "COUP-O-LET"],
|
||||
"description_keywords": OLET_KEYWORDS,
|
||||
"subtypes": {
|
||||
"SOCKOLET": ["SOCK-O-LET", "SOCKOLET", "SOL", "SOCK O-LET", "SOCKET-O-LET", "SOCKLET"],
|
||||
"WELDOLET": ["WELD-O-LET", "WELDOLET", "WOL", "WELD O-LET", "WELDING-O-LET"],
|
||||
"ELLOLET": ["ELL-O-LET", "ELLOLET", "EOL", "ELL O-LET", "ELBOW-O-LET"],
|
||||
"THREADOLET": ["THREAD-O-LET", "THREADOLET", "TOL", "THREADED-O-LET"],
|
||||
"ELBOLET": ["ELB-O-LET", "ELBOLET", "EOL", "ELBOW-O-LET"],
|
||||
"NIPOLET": ["NIP-O-LET", "NIPOLET", "NOL", "NIPPLE-O-LET"],
|
||||
@@ -164,24 +170,8 @@ CONNECTION_METHODS = {
|
||||
|
||||
# ========== 압력 등급별 분류 ==========
|
||||
PRESSURE_RATINGS = {
|
||||
"patterns": [
|
||||
r"(\d+)LB",
|
||||
r"CLASS\s*(\d+)",
|
||||
r"CL\s*(\d+)",
|
||||
r"(\d+)#",
|
||||
r"(\d+)\s*LB"
|
||||
],
|
||||
"standard_ratings": {
|
||||
"150LB": {"max_pressure": "285 PSI", "common_use": "저압 일반용"},
|
||||
"300LB": {"max_pressure": "740 PSI", "common_use": "중압용"},
|
||||
"600LB": {"max_pressure": "1480 PSI", "common_use": "고압용"},
|
||||
"900LB": {"max_pressure": "2220 PSI", "common_use": "고압용"},
|
||||
"1500LB": {"max_pressure": "3705 PSI", "common_use": "고압용"},
|
||||
"2500LB": {"max_pressure": "6170 PSI", "common_use": "초고압용"},
|
||||
"3000LB": {"max_pressure": "7400 PSI", "common_use": "소구경 고압용"},
|
||||
"6000LB": {"max_pressure": "14800 PSI", "common_use": "소구경 초고압용"},
|
||||
"9000LB": {"max_pressure": "22200 PSI", "common_use": "소구경 극고압용"}
|
||||
}
|
||||
"patterns": PRESSURE_PATTERNS,
|
||||
"standard_ratings": PRESSURE_RATINGS_SPECS
|
||||
}
|
||||
|
||||
def classify_fitting(dat_file: str, description: str, main_nom: str,
|
||||
@@ -202,15 +192,24 @@ def classify_fitting(dat_file: str, description: str, main_nom: str,
|
||||
desc_upper = description.upper()
|
||||
dat_upper = dat_file.upper()
|
||||
|
||||
# 1. 명칭 우선 확인 (피팅 키워드가 있으면 피팅)
|
||||
fitting_keywords = ['ELBOW', 'ELL', 'TEE', 'REDUCER', 'RED', 'CAP', 'NIPPLE', 'SWAGE', 'OLET', 'COUPLING', 'PLUG', 'SOCKLET', 'SOCKET', '엘보', '티', '리듀서', '캡', '니플', '스웨지', '올렛', '커플링', 'SOCK-O-LET', 'WELD-O-LET', 'SOCKOLET', 'WELDOLET']
|
||||
is_fitting = any(keyword in desc_upper or keyword in dat_upper for keyword in fitting_keywords)
|
||||
# 1. 피팅 키워드 확인 (재질만 있어도 통합 분류기가 이미 피팅으로 분류했으므로 진행)
|
||||
# OLET 키워드를 우선 확인하여 정확한 분류 수행
|
||||
olet_keywords = OLET_KEYWORDS
|
||||
has_olet_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in olet_keywords)
|
||||
|
||||
if not is_fitting:
|
||||
fitting_keywords = ['ELBOW', 'ELL', 'TEE', 'REDUCER', 'RED', 'CAP', 'NIPPLE', 'SWAGE', 'COUPLING', 'PLUG', '엘보', '티', '리듀서', '캡', '니플', '스웨지', '올렛', '커플링', '플러그'] + olet_keywords
|
||||
has_fitting_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in fitting_keywords)
|
||||
|
||||
# 피팅 재질 확인 (A234, A403, A420)
|
||||
fitting_materials = ['A234', 'A403', 'A420']
|
||||
has_fitting_material = any(material in desc_upper for material in fitting_materials)
|
||||
|
||||
# 피팅 키워드도 없고 피팅 재질도 없으면 UNKNOWN
|
||||
if not has_fitting_keyword and not has_fitting_material:
|
||||
return {
|
||||
"category": "UNKNOWN",
|
||||
"overall_confidence": 0.0,
|
||||
"reason": "피팅 키워드 없음"
|
||||
"reason": "피팅 키워드 및 재질 없음"
|
||||
}
|
||||
|
||||
# 2. 재질 분류 (공통 모듈 사용)
|
||||
@@ -225,8 +224,8 @@ def classify_fitting(dat_file: str, description: str, main_nom: str,
|
||||
# 4. 압력 등급 분류
|
||||
pressure_result = classify_pressure_rating(dat_file, description)
|
||||
|
||||
# 4.5. 스케줄 분류 (니플 등에 중요)
|
||||
schedule_result = classify_fitting_schedule(description)
|
||||
# 4.5. 스케줄 분류 (니플 등에 중요) - 분리 스케줄 지원
|
||||
schedule_result = classify_fitting_schedule_with_reducing(description, main_nom, red_nom)
|
||||
|
||||
# 5. 제작 방법 추정
|
||||
manufacturing_result = determine_fitting_manufacturing(
|
||||
@@ -234,71 +233,152 @@ def classify_fitting(dat_file: str, description: str, main_nom: str,
|
||||
)
|
||||
|
||||
# 6. 최종 결과 조합
|
||||
# --- 계장용(Instrument/Swagelok) 피팅 감지 로직 추가 ---
|
||||
instrument_keywords = ["SWAGELOK", "DK-LOK", "TUBE FITTING", "UNION", "FERRULE", "MALE CONNECTOR", "FEMALE CONNECTOR"]
|
||||
is_instrument = any(kw in desc_upper for kw in instrument_keywords)
|
||||
|
||||
if is_instrument:
|
||||
fitting_type_result["category"] = "INSTRUMENT_FITTING"
|
||||
if "SWAGELOK" in desc_upper: fitting_type_result["brand"] = "SWAGELOK"
|
||||
|
||||
# Tube OD 추출 (예: 1/4", 6MM, 12MM)
|
||||
tube_match = re.search(r'(\d+(?:/\d+)?)\s*(?:\"|INCH|MM)\s*(?:OD|TUBE)', desc_upper)
|
||||
if tube_match:
|
||||
fitting_type_result["tube_od"] = tube_match.group(0)
|
||||
|
||||
return {
|
||||
"category": "FITTING",
|
||||
|
||||
# 재질 정보 (공통 모듈)
|
||||
"material": {
|
||||
"standard": material_result.get('standard', 'UNKNOWN'),
|
||||
"grade": material_result.get('grade', 'UNKNOWN'),
|
||||
"material_type": material_result.get('material_type', 'UNKNOWN'),
|
||||
"confidence": material_result.get('confidence', 0.0)
|
||||
},
|
||||
|
||||
# 피팅 특화 정보
|
||||
"fitting_type": {
|
||||
"type": fitting_type_result.get('type', 'UNKNOWN'),
|
||||
"subtype": fitting_type_result.get('subtype', 'UNKNOWN'),
|
||||
"confidence": fitting_type_result.get('confidence', 0.0),
|
||||
"evidence": fitting_type_result.get('evidence', [])
|
||||
},
|
||||
|
||||
"connection_method": {
|
||||
"method": connection_result.get('method', 'UNKNOWN'),
|
||||
"confidence": connection_result.get('confidence', 0.0),
|
||||
"matched_code": connection_result.get('matched_code', ''),
|
||||
"size_range": connection_result.get('size_range', ''),
|
||||
"pressure_range": connection_result.get('pressure_range', '')
|
||||
},
|
||||
|
||||
"pressure_rating": {
|
||||
"rating": pressure_result.get('rating', 'UNKNOWN'),
|
||||
"confidence": pressure_result.get('confidence', 0.0),
|
||||
"max_pressure": pressure_result.get('max_pressure', ''),
|
||||
"common_use": pressure_result.get('common_use', '')
|
||||
},
|
||||
|
||||
"manufacturing": {
|
||||
"method": manufacturing_result.get('method', 'UNKNOWN'),
|
||||
"confidence": manufacturing_result.get('confidence', 0.0),
|
||||
"evidence": manufacturing_result.get('evidence', []),
|
||||
"characteristics": manufacturing_result.get('characteristics', '')
|
||||
},
|
||||
|
||||
"size_info": {
|
||||
"main_size": main_nom,
|
||||
"reduced_size": red_nom,
|
||||
"size_description": format_fitting_size(main_nom, red_nom),
|
||||
"requires_two_sizes": fitting_type_result.get('requires_two_sizes', False)
|
||||
},
|
||||
|
||||
"schedule_info": {
|
||||
"schedule": schedule_result.get('schedule', 'UNKNOWN'),
|
||||
"schedule_number": schedule_result.get('schedule_number', ''),
|
||||
"wall_thickness": schedule_result.get('wall_thickness', ''),
|
||||
"pressure_class": schedule_result.get('pressure_class', ''),
|
||||
"confidence": schedule_result.get('confidence', 0.0)
|
||||
},
|
||||
|
||||
# 전체 신뢰도
|
||||
"fitting_type": fitting_type_result,
|
||||
"connection_method": connection_result,
|
||||
"pressure_rating": pressure_result,
|
||||
"schedule": schedule_result,
|
||||
"manufacturing": manufacturing_result,
|
||||
"overall_confidence": calculate_fitting_confidence({
|
||||
"material": material_result.get('confidence', 0),
|
||||
"fitting_type": fitting_type_result.get('confidence', 0),
|
||||
"connection": connection_result.get('confidence', 0),
|
||||
"pressure": pressure_result.get('confidence', 0)
|
||||
"material": material_result.get("confidence", 0),
|
||||
"fitting_type": fitting_type_result.get("confidence", 0),
|
||||
"connection": connection_result.get("confidence", 0),
|
||||
"pressure": pressure_result.get("confidence", 0)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
def analyze_size_pattern_for_fitting_type(description: str, main_nom: str, red_nom: str = None) -> Dict:
|
||||
"""
|
||||
실제 BOM 패턴 기반 TEE vs REDUCER 구분
|
||||
|
||||
실제 패턴:
|
||||
- TEE RED, SMLS, SCH 40 x SCH 80 → TEE (키워드 우선)
|
||||
- RED CONC, SMLS, SCH 80 x SCH 80 → REDUCER (키워드 우선)
|
||||
- 모두 A x B 형태 (메인 x 감소)
|
||||
"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 1. 키워드 기반 분류 (최우선) - 실제 BOM 패턴
|
||||
if "TEE RED" in desc_upper or "TEE REDUCING" in desc_upper:
|
||||
return {
|
||||
"type": "TEE",
|
||||
"subtype": "REDUCING",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["KEYWORD_TEE_RED"],
|
||||
"subtype_confidence": 0.95,
|
||||
"requires_two_sizes": False
|
||||
}
|
||||
|
||||
if "RED CONC" in desc_upper or "REDUCER CONC" in desc_upper:
|
||||
return {
|
||||
"type": "REDUCER",
|
||||
"subtype": "CONCENTRIC",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["KEYWORD_RED_CONC"],
|
||||
"subtype_confidence": 0.95,
|
||||
"requires_two_sizes": True
|
||||
}
|
||||
|
||||
if "RED ECC" in desc_upper or "REDUCER ECC" in desc_upper:
|
||||
return {
|
||||
"type": "REDUCER",
|
||||
"subtype": "ECCENTRIC",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["KEYWORD_RED_ECC"],
|
||||
"subtype_confidence": 0.95,
|
||||
"requires_two_sizes": True
|
||||
}
|
||||
|
||||
# 2. 사이즈 패턴 분석 (보조) - 기존 로직 유지
|
||||
# x 또는 × 기호로 연결된 사이즈들 찾기
|
||||
connected_sizes = re.findall(r'(\d+(?:\s+\d+/\d+)?(?:\.\d+)?)"?\s*[xX×]\s*(\d+(?:\s+\d+/\d+)?(?:\.\d+)?)"?(?:\s*[xX×]\s*(\d+(?:\s+\d+/\d+)?(?:\.\d+)?)"?)?', description)
|
||||
|
||||
if connected_sizes:
|
||||
# 연결된 사이즈들을 리스트로 변환
|
||||
sizes = []
|
||||
for size_group in connected_sizes:
|
||||
for size in size_group:
|
||||
if size.strip():
|
||||
sizes.append(size.strip())
|
||||
|
||||
# 중복 제거하되 순서 유지
|
||||
unique_sizes = []
|
||||
for size in sizes:
|
||||
if size not in unique_sizes:
|
||||
unique_sizes.append(size)
|
||||
|
||||
sizes = unique_sizes
|
||||
|
||||
if len(sizes) == 3:
|
||||
# A x B x B 패턴 → TEE REDUCING
|
||||
if sizes[1] == sizes[2]:
|
||||
return {
|
||||
"type": "TEE",
|
||||
"subtype": "REDUCING",
|
||||
"confidence": 0.85,
|
||||
"evidence": [f"SIZE_PATTERN_TEE_REDUCING: {' x '.join(sizes)}"],
|
||||
"subtype_confidence": 0.85,
|
||||
"requires_two_sizes": False
|
||||
}
|
||||
# A x B x C 패턴 → TEE REDUCING (모두 다른 사이즈)
|
||||
else:
|
||||
return {
|
||||
"type": "TEE",
|
||||
"subtype": "REDUCING",
|
||||
"confidence": 0.80,
|
||||
"evidence": [f"SIZE_PATTERN_TEE_REDUCING_UNEQUAL: {' x '.join(sizes)}"],
|
||||
"subtype_confidence": 0.80,
|
||||
"requires_two_sizes": False
|
||||
}
|
||||
elif len(sizes) == 2:
|
||||
# A x B 패턴 → 키워드가 없으면 REDUCER로 기본 분류
|
||||
if "CONC" in desc_upper or "CONCENTRIC" in desc_upper:
|
||||
return {
|
||||
"type": "REDUCER",
|
||||
"subtype": "CONCENTRIC",
|
||||
"confidence": 0.80,
|
||||
"evidence": [f"SIZE_PATTERN_REDUCER_CONC: {' x '.join(sizes)}"],
|
||||
"subtype_confidence": 0.80,
|
||||
"requires_two_sizes": True
|
||||
}
|
||||
elif "ECC" in desc_upper or "ECCENTRIC" in desc_upper:
|
||||
return {
|
||||
"type": "REDUCER",
|
||||
"subtype": "ECCENTRIC",
|
||||
"confidence": 0.80,
|
||||
"evidence": [f"SIZE_PATTERN_REDUCER_ECC: {' x '.join(sizes)}"],
|
||||
"subtype_confidence": 0.80,
|
||||
"requires_two_sizes": True
|
||||
}
|
||||
else:
|
||||
# 키워드 없는 A x B 패턴은 낮은 신뢰도로 REDUCER
|
||||
return {
|
||||
"type": "REDUCER",
|
||||
"subtype": "CONCENTRIC", # 기본값
|
||||
"confidence": 0.60,
|
||||
"evidence": [f"SIZE_PATTERN_REDUCER_DEFAULT: {' x '.join(sizes)}"],
|
||||
"subtype_confidence": 0.60,
|
||||
"requires_two_sizes": True
|
||||
}
|
||||
|
||||
return {"confidence": 0.0}
|
||||
|
||||
def classify_fitting_type(dat_file: str, description: str,
|
||||
main_nom: str, red_nom: str = None) -> Dict:
|
||||
"""피팅 타입 분류"""
|
||||
@@ -306,7 +386,28 @@ def classify_fitting_type(dat_file: str, description: str,
|
||||
dat_upper = dat_file.upper()
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 1. DAT_FILE 패턴으로 1차 분류 (가장 신뢰도 높음)
|
||||
# 0. OLET 우선 확인 (ELL과의 혼동 방지)
|
||||
olet_specific_keywords = OLET_KEYWORDS
|
||||
for keyword in olet_specific_keywords:
|
||||
if keyword in desc_upper or keyword in dat_upper:
|
||||
subtype_result = classify_fitting_subtype(
|
||||
"OLET", desc_upper, main_nom, red_nom, FITTING_TYPES["OLET"]
|
||||
)
|
||||
return {
|
||||
"type": "OLET",
|
||||
"subtype": subtype_result["subtype"],
|
||||
"confidence": 0.95,
|
||||
"evidence": [f"OLET_PRIORITY_KEYWORD: {keyword}"],
|
||||
"subtype_confidence": subtype_result["confidence"],
|
||||
"requires_two_sizes": FITTING_TYPES["OLET"].get("requires_two_sizes", False)
|
||||
}
|
||||
|
||||
# 1. 사이즈 패턴 분석으로 TEE vs REDUCER 구분
|
||||
size_pattern_result = analyze_size_pattern_for_fitting_type(desc_upper, main_nom, red_nom)
|
||||
if size_pattern_result.get("confidence", 0) > 0.85:
|
||||
return size_pattern_result
|
||||
|
||||
# 2. DAT_FILE 패턴으로 1차 분류 (가장 신뢰도 높음)
|
||||
for fitting_type, type_data in FITTING_TYPES.items():
|
||||
for pattern in type_data["dat_file_patterns"]:
|
||||
if pattern in dat_upper:
|
||||
@@ -323,7 +424,7 @@ def classify_fitting_type(dat_file: str, description: str,
|
||||
"requires_two_sizes": type_data.get("requires_two_sizes", False)
|
||||
}
|
||||
|
||||
# 2. DESCRIPTION 키워드로 2차 분류
|
||||
# 3. DESCRIPTION 키워드로 2차 분류
|
||||
for fitting_type, type_data in FITTING_TYPES.items():
|
||||
for keyword in type_data["description_keywords"]:
|
||||
if keyword in desc_upper:
|
||||
@@ -340,7 +441,7 @@ def classify_fitting_type(dat_file: str, description: str,
|
||||
"requires_two_sizes": type_data.get("requires_two_sizes", False)
|
||||
}
|
||||
|
||||
# 3. 분류 실패
|
||||
# 4. 분류 실패
|
||||
return {
|
||||
"type": "UNKNOWN",
|
||||
"subtype": "UNKNOWN",
|
||||
@@ -353,18 +454,77 @@ def classify_fitting_subtype(fitting_type: str, description: str,
|
||||
main_nom: str, red_nom: str, type_data: Dict) -> Dict:
|
||||
"""피팅 서브타입 분류"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
subtypes = type_data.get("subtypes", {})
|
||||
|
||||
# 1. 키워드 기반 서브타입 분류 (우선)
|
||||
# 1. 키워드 기반 서브타입 분류 (우선) - 대소문자 구분 없이
|
||||
for subtype, keywords in subtypes.items():
|
||||
for keyword in keywords:
|
||||
if keyword in description:
|
||||
if keyword.upper() in desc_upper:
|
||||
return {
|
||||
"subtype": subtype,
|
||||
"confidence": 0.9,
|
||||
"evidence": [f"SUBTYPE_KEYWORD: {keyword}"]
|
||||
}
|
||||
|
||||
# 1.5. ELBOW 특별 처리 - 조합 키워드 우선 확인
|
||||
if fitting_type == "ELBOW":
|
||||
# 90도 + 반경 조합
|
||||
if ("90" in desc_upper or "90°" in desc_upper or "90DEG" in desc_upper):
|
||||
if ("LR" in desc_upper or "LONG RADIUS" in desc_upper or "장반경" in desc_upper):
|
||||
return {
|
||||
"subtype": "90DEG_LONG_RADIUS",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["90DEG + LONG_RADIUS"]
|
||||
}
|
||||
elif ("SR" in desc_upper or "SHORT RADIUS" in desc_upper or "단반경" in desc_upper):
|
||||
return {
|
||||
"subtype": "90DEG_SHORT_RADIUS",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["90DEG + SHORT_RADIUS"]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"subtype": "90DEG",
|
||||
"confidence": 0.85,
|
||||
"evidence": ["90DEG_DETECTED"]
|
||||
}
|
||||
|
||||
# 45도 + 반경 조합
|
||||
elif ("45" in desc_upper or "45°" in desc_upper or "45DEG" in desc_upper):
|
||||
if ("LR" in desc_upper or "LONG RADIUS" in desc_upper or "장반경" in desc_upper):
|
||||
return {
|
||||
"subtype": "45DEG_LONG_RADIUS",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["45DEG + LONG_RADIUS"]
|
||||
}
|
||||
elif ("SR" in desc_upper or "SHORT RADIUS" in desc_upper or "단반경" in desc_upper):
|
||||
return {
|
||||
"subtype": "45DEG_SHORT_RADIUS",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["45DEG + SHORT_RADIUS"]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"subtype": "45DEG",
|
||||
"confidence": 0.85,
|
||||
"evidence": ["45DEG_DETECTED"]
|
||||
}
|
||||
|
||||
# 반경만 있는 경우 (기본 90도 가정)
|
||||
elif ("LR" in desc_upper or "LONG RADIUS" in desc_upper or "장반경" in desc_upper):
|
||||
return {
|
||||
"subtype": "90DEG_LONG_RADIUS",
|
||||
"confidence": 0.8,
|
||||
"evidence": ["LONG_RADIUS_DEFAULT_90DEG"]
|
||||
}
|
||||
elif ("SR" in desc_upper or "SHORT RADIUS" in desc_upper or "단반경" in desc_upper):
|
||||
return {
|
||||
"subtype": "90DEG_SHORT_RADIUS",
|
||||
"confidence": 0.8,
|
||||
"evidence": ["SHORT_RADIUS_DEFAULT_90DEG"]
|
||||
}
|
||||
|
||||
# 2. 사이즈 분석이 필요한 경우 (TEE, REDUCER 등)
|
||||
if type_data.get("size_analysis"):
|
||||
if red_nom and str(red_nom).strip() and red_nom != main_nom:
|
||||
@@ -674,3 +834,53 @@ def classify_fitting_schedule(description: str) -> Dict:
|
||||
"confidence": 0.0,
|
||||
"matched_pattern": ""
|
||||
}
|
||||
|
||||
def classify_fitting_schedule_with_reducing(description: str, main_nom: str, red_nom: str = None) -> Dict:
|
||||
"""
|
||||
실제 BOM 패턴 기반 분리 스케줄 처리
|
||||
|
||||
실제 패턴:
|
||||
- "TEE RED, SMLS, SCH 40 x SCH 80" → main: SCH 40, red: SCH 80
|
||||
- "RED CONC, SMLS, SCH 40S x SCH 40S" → main: SCH 40S, red: SCH 40S
|
||||
- "RED CONC, SMLS, SCH 80 x SCH 80" → main: SCH 80, red: SCH 80
|
||||
"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 1. 분리 스케줄 패턴 확인 (SCH XX x SCH YY) - 개선된 패턴
|
||||
separated_schedule_patterns = [
|
||||
r'SCH\s*(\d+S?)\s*[xX×]\s*SCH\s*(\d+S?)', # SCH 40 x SCH 80
|
||||
r'SCH\s*(\d+S?)\s*X\s*(\d+S?)', # SCH 40S X 40S (SCH 생략)
|
||||
]
|
||||
|
||||
for pattern in separated_schedule_patterns:
|
||||
separated_match = re.search(pattern, desc_upper)
|
||||
if separated_match:
|
||||
main_schedule = f"SCH {separated_match.group(1)}"
|
||||
red_schedule = f"SCH {separated_match.group(2)}"
|
||||
|
||||
return {
|
||||
"schedule": main_schedule, # 기본 스케줄 (호환성)
|
||||
"main_schedule": main_schedule,
|
||||
"red_schedule": red_schedule,
|
||||
"has_different_schedules": main_schedule != red_schedule,
|
||||
"confidence": 0.95,
|
||||
"matched_pattern": separated_match.group(0),
|
||||
"schedule_type": "SEPARATED"
|
||||
}
|
||||
|
||||
# 2. 단일 스케줄 패턴 (기존 로직 사용)
|
||||
basic_result = classify_fitting_schedule(description)
|
||||
|
||||
# 단일 스케줄을 main/red 모두에 적용
|
||||
schedule = basic_result.get("schedule", "UNKNOWN")
|
||||
|
||||
return {
|
||||
"schedule": schedule, # 기본 스케줄 (호환성)
|
||||
"main_schedule": schedule,
|
||||
"red_schedule": schedule if red_nom else None,
|
||||
"has_different_schedules": False,
|
||||
"confidence": basic_result.get("confidence", 0.0),
|
||||
"matched_pattern": basic_result.get("matched_pattern", ""),
|
||||
"schedule_type": "UNIFIED"
|
||||
}
|
||||
|
||||
@@ -181,15 +181,28 @@ def classify_flange(dat_file: str, description: str, main_nom: str,
|
||||
desc_upper = description.upper()
|
||||
dat_upper = dat_file.upper()
|
||||
|
||||
# 1. 명칭 우선 확인 (플랜지 키워드가 있으면 플랜지)
|
||||
flange_keywords = ['FLG', 'FLANGE', '플랜지', 'ORIFICE', 'SPECTACLE', 'PADDLE', 'SPACER']
|
||||
is_flange = any(keyword in desc_upper or keyword in dat_upper for keyword in flange_keywords)
|
||||
# 1. 플랜지 키워드 확인 (재질만 있어도 통합 분류기가 이미 플랜지로 분류했으므로 진행)
|
||||
# 사이트 글라스와 스트레이너는 밸브로 분류되어야 함
|
||||
if 'SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or '사이트글라스' in desc_upper or '스트레이너' in desc_upper:
|
||||
return {
|
||||
"category": "VALVE",
|
||||
"overall_confidence": 1.0,
|
||||
"reason": "SIGHT GLASS 또는 STRAINER는 밸브로 분류"
|
||||
}
|
||||
|
||||
if not is_flange:
|
||||
flange_keywords = ['FLG', 'FLANGE', '플랜지', 'ORIFICE', 'SPECTACLE', 'PADDLE', 'SPACER']
|
||||
has_flange_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in flange_keywords)
|
||||
|
||||
# 플랜지 재질 확인 (A182, A350, A105 - 범용이지만 플랜지에 많이 사용)
|
||||
flange_materials = ['A182', 'A350', 'A105']
|
||||
has_flange_material = any(material in desc_upper for material in flange_materials)
|
||||
|
||||
# 플랜지 키워드도 없고 플랜지 재질도 없으면 UNKNOWN
|
||||
if not has_flange_keyword and not has_flange_material:
|
||||
return {
|
||||
"category": "UNKNOWN",
|
||||
"overall_confidence": 0.0,
|
||||
"reason": "플랜지 키워드 없음"
|
||||
"reason": "플랜지 키워드 및 재질 없음"
|
||||
}
|
||||
|
||||
# 2. 재질 분류 (공통 모듈 사용)
|
||||
|
||||
301
backend/app/services/integrated_classifier.py
Normal file
301
backend/app/services/integrated_classifier.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""
|
||||
통합 자재 분류 시스템
|
||||
메모리에 정의된 키워드 우선순위 체계를 적용
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from .fitting_classifier import classify_fitting
|
||||
from .classifier_constants import (
|
||||
LEVEL1_TYPE_KEYWORDS,
|
||||
LEVEL2_SUBTYPE_KEYWORDS,
|
||||
LEVEL3_CONNECTION_KEYWORDS,
|
||||
LEVEL3_PRESSURE_KEYWORDS,
|
||||
LEVEL4_MATERIAL_KEYWORDS,
|
||||
GENERIC_MATERIALS
|
||||
)
|
||||
|
||||
def classify_material_integrated(description: str, main_nom: str = "",
|
||||
red_nom: str = "", length: float = None) -> Dict:
|
||||
"""
|
||||
통합 자재 분류 함수
|
||||
|
||||
Args:
|
||||
description: 자재 설명
|
||||
main_nom: 주 사이즈
|
||||
red_nom: 축소 사이즈 (플랜지/피팅용)
|
||||
length: 길이 (파이프용)
|
||||
|
||||
Returns:
|
||||
분류 결과 딕셔너리
|
||||
"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 최우선: SPECIAL 키워드 확인 (도면 업로드가 필요한 특수 자재)
|
||||
# SPECIAL이 포함된 경우 (단, SPECIFICATION은 제외)
|
||||
if 'SPECIAL' in desc_upper and 'SPECIFICATION' not in desc_upper:
|
||||
return {
|
||||
"category": "SPECIAL",
|
||||
"confidence": 1.0,
|
||||
"evidence": ["SPECIAL_KEYWORD"],
|
||||
"classification_level": "LEVEL0_SPECIAL",
|
||||
"reason": "SPECIAL 키워드 발견"
|
||||
}
|
||||
|
||||
# 스페셜 관련 한글 키워드
|
||||
if '스페셜' in desc_upper or 'SPL' in desc_upper:
|
||||
return {
|
||||
"category": "SPECIAL",
|
||||
"confidence": 1.0,
|
||||
"evidence": ["SPECIAL_KEYWORD"],
|
||||
"classification_level": "LEVEL0_SPECIAL",
|
||||
"reason": "스페셜 키워드 발견"
|
||||
}
|
||||
|
||||
|
||||
# VALVE 카테고리 우선 확인 (SIGHT GLASS, STRAINER)
|
||||
if ('SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or
|
||||
'사이트글라스' in desc_upper or '스트레이너' in desc_upper):
|
||||
return {
|
||||
"category": "VALVE",
|
||||
"confidence": 1.0,
|
||||
"evidence": ["VALVE_SPECIAL_KEYWORD"],
|
||||
"classification_level": "LEVEL0_VALVE",
|
||||
"reason": "SIGHT GLASS 또는 STRAINER 키워드 발견"
|
||||
}
|
||||
|
||||
# SUPPORT 카테고리 우선 확인 (BOLT 카테고리보다 먼저)
|
||||
# U-BOLT, CLAMP, URETHANE BLOCK 등
|
||||
if ('U-BOLT' in desc_upper or 'U BOLT' in desc_upper or '유볼트' in desc_upper or
|
||||
'URETHANE BLOCK' in desc_upper or 'BLOCK SHOE' in desc_upper or '우레탄' in desc_upper or
|
||||
'CLAMP' in desc_upper or '클램프' in desc_upper or 'PIPE CLAMP' in desc_upper):
|
||||
return {
|
||||
"category": "SUPPORT",
|
||||
"confidence": 1.0,
|
||||
"evidence": ["SUPPORT_SYSTEM_KEYWORD"],
|
||||
"classification_level": "LEVEL0_SUPPORT",
|
||||
"reason": "SUPPORT 시스템 키워드 발견"
|
||||
}
|
||||
|
||||
# [신규] Swagelok 스타일 파트 넘버 패턴 확인
|
||||
# 예: SS-400-1-4, SS-810-6, B-400-9, SS-1610-P
|
||||
swagelok_pattern = r'\b(SS|S|B|A|M)-([0-9]{3,4}|[0-9]+M[0-9]*)-([0-9A-Z])'
|
||||
if re.search(swagelok_pattern, desc_upper):
|
||||
return {
|
||||
"category": "TUBE_FITTING",
|
||||
"confidence": 0.98,
|
||||
"evidence": ["SWAGELOK_PART_NO"],
|
||||
"classification_level": "LEVEL0_PARTNO",
|
||||
"reason": "Swagelok 스타일 파트넘버 감지"
|
||||
}
|
||||
|
||||
# 쉼표로 구분된 각 부분을 별도로 체크 (예: "NIPPLE, SMLS, SCH 80")
|
||||
desc_parts = [part.strip() for part in desc_upper.split(',')]
|
||||
|
||||
# 1단계: Level 1 키워드로 타입 식별
|
||||
detected_types = []
|
||||
|
||||
# 특별 우선순위: REDUCING FLANGE 먼저 확인 (강화된 로직)
|
||||
reducing_flange_patterns = [
|
||||
"REDUCING FLANGE", "RED FLANGE", "REDUCER FLANGE",
|
||||
"REDUCING FLG", "RED FLG", "REDUCER FLG"
|
||||
]
|
||||
|
||||
# FLANGE와 REDUCING/RED/REDUCER가 함께 있는 경우도 확인
|
||||
has_flange = any(flange_word in desc_upper for flange_word in ["FLANGE", "FLG"])
|
||||
has_reducing = any(red_word in desc_upper for red_word in ["REDUCING", "RED", "REDUCER"])
|
||||
|
||||
# 직접 패턴 매칭 또는 FLANGE + REDUCING 조합
|
||||
reducing_flange_detected = False
|
||||
for pattern in reducing_flange_patterns:
|
||||
if pattern in desc_upper:
|
||||
detected_types.append(("FLANGE", "REDUCING FLANGE"))
|
||||
reducing_flange_detected = True
|
||||
break
|
||||
|
||||
# FLANGE와 REDUCING이 모두 있으면 REDUCING FLANGE로 분류
|
||||
if not reducing_flange_detected and has_flange and has_reducing:
|
||||
detected_types.append(("FLANGE", "REDUCING FLANGE"))
|
||||
reducing_flange_detected = True
|
||||
|
||||
# REDUCING FLANGE가 감지되지 않은 경우에만 일반 키워드 검사
|
||||
if not reducing_flange_detected:
|
||||
for material_type, keywords in LEVEL1_TYPE_KEYWORDS.items():
|
||||
type_found = False
|
||||
# 긴 키워드부터 확인 (FLANGE BOLT가 FLANGE보다 먼저 매칭되도록)
|
||||
sorted_keywords = sorted(keywords, key=len, reverse=True)
|
||||
for keyword in sorted_keywords:
|
||||
# [강화된 로직] 짧은 키워드나 중의적 키워드에 대한 엄격한 검사
|
||||
is_strict_match = True
|
||||
|
||||
# 1. "PL" 키워드 검사 (PLATE)
|
||||
if keyword == "PL":
|
||||
# 단독 단어이거나 숫자 뒤에 붙는 경우만 허용 (예: 10PL, 10 PL)
|
||||
# COUPLING, NIPPLE, PLUG 등에 포함된 PL은 제외
|
||||
pl_pattern = r'(\b|\d)PL\b'
|
||||
if not re.search(pl_pattern, desc_upper):
|
||||
is_strict_match = False
|
||||
|
||||
# 2. "ANGLE" 키워드 검사 (STRUCTURAL)
|
||||
elif keyword == "ANGLE" or keyword == "앵글":
|
||||
# VALVE와 함께 쓰이면 제외 (ANGLE VALVE)
|
||||
if "VALVE" in desc_upper or "밸브" in desc_upper:
|
||||
is_strict_match = False
|
||||
|
||||
# 3. "UNION" 키워드 검사 (FITTING)
|
||||
elif keyword == "UNION":
|
||||
# 계장용인지 파이프용인지 구분은 fitting_classifier에서 하되,
|
||||
# 여기서는 일단 FITTING으로 잡히도록 둠.
|
||||
pass
|
||||
|
||||
# 4. "BEAM" 키워드 검사 (STRUCTURAL)
|
||||
elif keyword == "BEAM":
|
||||
# "BEAM CLAMP" 같은 경우 SUPPORT로 가야 함 (SUPPORT가 우선순위 높으므로 괜찮음)
|
||||
pass
|
||||
|
||||
if not is_strict_match:
|
||||
continue
|
||||
|
||||
# 전체 문자열에서 찾기
|
||||
if keyword in desc_upper:
|
||||
detected_types.append((material_type, keyword))
|
||||
type_found = True
|
||||
break
|
||||
# 각 부분에서도 정확히 매칭되는지 확인
|
||||
for part in desc_parts:
|
||||
if keyword == part or keyword in part:
|
||||
detected_types.append((material_type, keyword))
|
||||
type_found = True
|
||||
break
|
||||
if type_found:
|
||||
break
|
||||
|
||||
# 2단계: 복수 타입 감지 시 Level 2로 구체화
|
||||
if len(detected_types) > 1:
|
||||
# Level 2 키워드로 우선순위 결정
|
||||
for material_type, subtype_dict in LEVEL2_SUBTYPE_KEYWORDS.items():
|
||||
for subtype, keywords in subtype_dict.items():
|
||||
for keyword in keywords:
|
||||
if keyword in desc_upper:
|
||||
return {
|
||||
"category": material_type,
|
||||
"confidence": 0.95,
|
||||
"evidence": [f"L1_KEYWORD: {detected_types}", f"L2_KEYWORD: {keyword}"],
|
||||
"classification_level": "LEVEL2"
|
||||
}
|
||||
|
||||
# Level 2 키워드가 없으면 우선순위로 결정
|
||||
# BOLT > SUPPORT > FITTING > VALVE > FLANGE > PIPE (볼트 우선, 더 구체적인 것 우선)
|
||||
type_priority = ["BOLT", "SUPPORT", "FITTING", "VALVE", "FLANGE", "PIPE", "GASKET", "INSTRUMENT"]
|
||||
for priority_type in type_priority:
|
||||
for detected_type, keyword in detected_types:
|
||||
if detected_type == priority_type:
|
||||
return {
|
||||
"category": priority_type,
|
||||
"confidence": 0.85,
|
||||
"evidence": [f"L1_MULTI_TYPE: {detected_types}", f"PRIORITY: {priority_type}"],
|
||||
"classification_level": "LEVEL1_PRIORITY"
|
||||
}
|
||||
|
||||
# 3단계: 단일 타입 확정 또는 Level 3/4로 판단
|
||||
if len(detected_types) == 1:
|
||||
material_type = detected_types[0][0]
|
||||
|
||||
# FITTING으로 분류된 경우 상세 분류기 호출
|
||||
if material_type == "FITTING":
|
||||
try:
|
||||
detailed_result = classify_fitting("", description, main_nom, red_nom, length)
|
||||
# 상세 분류 결과가 있으면 사용, 없으면 기본 FITTING 반환
|
||||
if detailed_result and detailed_result.get("category"):
|
||||
return detailed_result
|
||||
except Exception as e:
|
||||
# 상세 분류 실패 시 기본 FITTING으로 처리
|
||||
pass
|
||||
|
||||
return {
|
||||
"category": material_type,
|
||||
"confidence": 0.9,
|
||||
"evidence": [f"L1_KEYWORD: {detected_types[0][1]}"],
|
||||
"classification_level": "LEVEL1"
|
||||
}
|
||||
|
||||
# 4단계: Level 1 없으면 재질 기반 분류
|
||||
if not detected_types:
|
||||
# 전용 재질 확인
|
||||
for material_type, materials in LEVEL4_MATERIAL_KEYWORDS.items():
|
||||
for material in materials:
|
||||
if material in desc_upper:
|
||||
# 볼트 재질(A193, A194)은 다른 키워드가 있는지 확인
|
||||
if material_type == "BOLT":
|
||||
# 다른 타입 키워드가 있으면 볼트로 분류하지 않음
|
||||
other_type_found = False
|
||||
for other_type, keywords in LEVEL1_TYPE_KEYWORDS.items():
|
||||
if other_type != "BOLT":
|
||||
for keyword in keywords:
|
||||
if keyword in desc_upper:
|
||||
other_type_found = True
|
||||
break
|
||||
if other_type_found:
|
||||
break
|
||||
|
||||
if other_type_found:
|
||||
continue # 볼트로 분류하지 않음
|
||||
|
||||
# FITTING으로 분류된 경우 상세 분류기 호출
|
||||
if material_type == "FITTING":
|
||||
try:
|
||||
detailed_result = classify_fitting("", description, main_nom, red_nom, length)
|
||||
if detailed_result and detailed_result.get("category"):
|
||||
return detailed_result
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return {
|
||||
"category": material_type,
|
||||
"confidence": 0.35, # 재질만으로 분류 시 낮은 신뢰도
|
||||
"evidence": [f"L4_MATERIAL: {material}"],
|
||||
"classification_level": "LEVEL4"
|
||||
}
|
||||
|
||||
# 범용 재질 확인
|
||||
for material, priority_types in GENERIC_MATERIALS.items():
|
||||
if material in desc_upper:
|
||||
# 우선순위에 따라 타입 결정
|
||||
material_type = priority_types[0] # 첫 번째 우선순위
|
||||
|
||||
# FITTING으로 분류된 경우 상세 분류기 호출
|
||||
if material_type == "FITTING":
|
||||
try:
|
||||
detailed_result = classify_fitting("", description, main_nom, red_nom, length)
|
||||
if detailed_result and detailed_result.get("category"):
|
||||
return detailed_result
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return {
|
||||
"category": material_type,
|
||||
"confidence": 0.3,
|
||||
"evidence": [f"GENERIC_MATERIAL: {material}"],
|
||||
"classification_level": "LEVEL4_GENERIC"
|
||||
}
|
||||
|
||||
# 분류 실패
|
||||
return {
|
||||
"category": "UNCLASSIFIED",
|
||||
"confidence": 0.0,
|
||||
"evidence": ["NO_CLASSIFICATION_POSSIBLE"],
|
||||
"classification_level": "NONE"
|
||||
}
|
||||
|
||||
def should_exclude_material(description: str) -> bool:
|
||||
"""
|
||||
제외 대상 자재인지 확인
|
||||
"""
|
||||
exclude_keywords = [
|
||||
"DUMMY", "RESERVED", "SPARE", "DELETED", "CANCELED",
|
||||
"더미", "예비", "삭제", "취소", "예약"
|
||||
]
|
||||
|
||||
desc_upper = description.upper()
|
||||
return any(keyword in desc_upper for keyword in exclude_keywords)
|
||||
@@ -254,6 +254,10 @@ def check_generic_materials(description: str) -> Dict:
|
||||
def determine_material_type(standard: str, grade: str) -> str:
|
||||
"""규격과 등급으로 재질 타입 결정"""
|
||||
|
||||
# grade가 None이면 기본값 처리
|
||||
if not grade:
|
||||
grade = ""
|
||||
|
||||
# 스테인리스 등급
|
||||
stainless_patterns = ["304", "316", "321", "347", "F304", "F316", "WP304", "CF8"]
|
||||
if any(pattern in grade for pattern in stainless_patterns):
|
||||
|
||||
263
backend/app/services/material_grade_extractor.py
Normal file
263
backend/app/services/material_grade_extractor.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
전체 재질명 추출기
|
||||
원본 설명에서 완전한 재질명을 추출하여 축약되지 않은 형태로 제공
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional, Dict
|
||||
|
||||
def extract_full_material_grade(description: str) -> str:
|
||||
"""
|
||||
원본 설명에서 전체 재질명 추출
|
||||
|
||||
Args:
|
||||
description: 원본 자재 설명
|
||||
|
||||
Returns:
|
||||
전체 재질명 (예: "ASTM A312 TP304", "ASTM A106 GR B")
|
||||
"""
|
||||
if not description:
|
||||
return ""
|
||||
|
||||
desc_upper = description.upper().strip()
|
||||
|
||||
# 1. ASTM 규격 패턴들 (가장 구체적인 것부터)
|
||||
astm_patterns = [
|
||||
# A320 L7, A325, A490 등 단독 규격 (ASTM 없이)
|
||||
r'\bA320\s+L[0-9]+\b', # A320 L7
|
||||
r'\bA325\b', # A325
|
||||
r'\bA490\b', # A490
|
||||
# ASTM A193/A194 GR B7/2H (볼트용 조합 패턴) - 최우선
|
||||
r'ASTM\s+A193/A194\s+GR\s+[A-Z0-9/]+',
|
||||
r'ASTM\s+A193/A194\s+[A-Z0-9/]+',
|
||||
# ASTM A320/A194M GR B8/8 (저온용 볼트 조합 패턴)
|
||||
r'ASTM\s+A320[M]?/A194[M]?\s+GR\s+[A-Z0-9/]+',
|
||||
r'ASTM\s+A320[M]?/A194[M]?\s+[A-Z0-9/]+',
|
||||
# 단독 A193/A194 패턴 (ASTM 없이)
|
||||
r'\bA193/A194\s+GR\s+[A-Z0-9/]+\b',
|
||||
r'\bA193/A194\s+[A-Z0-9/]+\b',
|
||||
# 단독 A320/A194M 패턴 (ASTM 없이)
|
||||
r'\bA320[M]?/A194[M]?\s+GR\s+[A-Z0-9/]+\b',
|
||||
r'\bA320[M]?/A194[M]?\s+[A-Z0-9/]+\b',
|
||||
# ASTM A312 TP304, ASTM A312 TP316L 등
|
||||
r'ASTM\s+A\d{3,4}[A-Z]*\s+TP\d+[A-Z]*',
|
||||
# ASTM A182 F304, ASTM A182 F316L 등
|
||||
r'ASTM\s+A\d{3,4}[A-Z]*\s+F\d+[A-Z]*',
|
||||
# ASTM A403 WP304, ASTM A234 WPB 등
|
||||
r'ASTM\s+A\d{3,4}[A-Z]*\s+WP[A-Z0-9]+',
|
||||
# ASTM A351 CF8M, ASTM A216 WCB 등
|
||||
r'ASTM\s+A\d{3,4}[A-Z]*\s+[A-Z]{2,4}[0-9]*[A-Z]*',
|
||||
# ASTM A106 GR B, ASTM A105 등 - GR 포함
|
||||
r'ASTM\s+A\d{3,4}[A-Z]*\s+GR\s+[A-Z0-9/]+',
|
||||
r'ASTM\s+A\d{3,4}[A-Z]*\s+GRADE\s+[A-Z0-9/]+',
|
||||
# ASTM A106 B (GR 없이 바로 등급) - 단일 문자 등급
|
||||
r'ASTM\s+A\d{3,4}[A-Z]*\s+[A-Z](?=\s|$)',
|
||||
# ASTM A105, ASTM A234 등 (등급 없는 경우)
|
||||
r'ASTM\s+A\d{3,4}[A-Z]*(?!\s+[A-Z0-9])',
|
||||
# 2자리 ASTM 규격도 지원 (A10, A36 등)
|
||||
r'ASTM\s+A\d{2}(?:\s+GR\s+[A-Z0-9/]+)?',
|
||||
]
|
||||
|
||||
for pattern in astm_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
full_grade = match.group(0).strip()
|
||||
# 추가 정보가 있는지 확인 (PBE, BBE 등은 제외)
|
||||
end_pos = match.end()
|
||||
remaining = desc_upper[end_pos:].strip()
|
||||
|
||||
# 끝단 가공 정보는 제외
|
||||
end_prep_codes = ['PBE', 'BBE', 'POE', 'BOE', 'TOE']
|
||||
for code in end_prep_codes:
|
||||
remaining = re.sub(rf'\b{code}\b', '', remaining).strip()
|
||||
|
||||
# 남은 재질 관련 정보가 있으면 추가
|
||||
additional_info = []
|
||||
if remaining:
|
||||
# 일반적인 재질 추가 정보 패턴
|
||||
additional_patterns = [
|
||||
r'\bH\b', # H (고온용)
|
||||
r'\bL\b', # L (저탄소)
|
||||
r'\bN\b', # N (질소 첨가)
|
||||
r'\bS\b', # S (황 첨가)
|
||||
r'\bMOD\b', # MOD (개량형)
|
||||
]
|
||||
|
||||
for add_pattern in additional_patterns:
|
||||
if re.search(add_pattern, remaining):
|
||||
additional_info.append(re.search(add_pattern, remaining).group(0))
|
||||
|
||||
if additional_info:
|
||||
full_grade += ' ' + ' '.join(additional_info)
|
||||
|
||||
return full_grade
|
||||
|
||||
# 2. ASME 규격 패턴들
|
||||
asme_patterns = [
|
||||
r'ASME\s+SA\d+[A-Z]*\s+TP\d+[A-Z]*(?:\s+[A-Z]+)*',
|
||||
r'ASME\s+SA\d+[A-Z]*\s+GR\s+[A-Z0-9]+(?:\s+[A-Z]+)*',
|
||||
r'ASME\s+SA\d+[A-Z]*\s+F\d+[A-Z]*(?:\s+[A-Z]+)*',
|
||||
r'ASME\s+SA\d+[A-Z]*(?!\s+[A-Z0-9])',
|
||||
]
|
||||
|
||||
for pattern in asme_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
return match.group(0).strip()
|
||||
|
||||
# 3. KS 규격 패턴들
|
||||
ks_patterns = [
|
||||
r'KS\s+D\d+[A-Z]*\s+[A-Z0-9]+(?:\s+[A-Z]+)*',
|
||||
r'KS\s+D\d+[A-Z]*(?!\s+[A-Z0-9])',
|
||||
]
|
||||
|
||||
for pattern in ks_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
return match.group(0).strip()
|
||||
|
||||
# 4. JIS 규격 패턴들
|
||||
jis_patterns = [
|
||||
r'JIS\s+[A-Z]\d+[A-Z]*\s+[A-Z0-9]+(?:\s+[A-Z]+)*',
|
||||
r'JIS\s+[A-Z]\d+[A-Z]*(?!\s+[A-Z0-9])',
|
||||
]
|
||||
|
||||
for pattern in jis_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
return match.group(0).strip()
|
||||
|
||||
# 5. 특수 재질 패턴들
|
||||
special_patterns = [
|
||||
# Inconel, Hastelloy 등
|
||||
r'INCONEL\s+\d+[A-Z]*',
|
||||
r'HASTELLOY\s+[A-Z]\d*[A-Z]*',
|
||||
r'MONEL\s+\d+[A-Z]*',
|
||||
# Titanium
|
||||
r'TITANIUM\s+GRADE\s+\d+[A-Z]*',
|
||||
r'TI\s+GR\s*\d+[A-Z]*',
|
||||
# 듀플렉스 스테인리스
|
||||
r'DUPLEX\s+\d+[A-Z]*',
|
||||
r'SUPER\s+DUPLEX\s+\d+[A-Z]*',
|
||||
]
|
||||
|
||||
for pattern in special_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
return match.group(0).strip()
|
||||
|
||||
# 6. 일반 스테인리스 패턴들 (숫자만)
|
||||
stainless_patterns = [
|
||||
r'\b(304L?|316L?|321|347|310|317L?|904L?|254SMO)\b',
|
||||
r'\bSS\s*(304L?|316L?|321|347|310|317L?|904L?|254SMO)\b',
|
||||
r'\bSUS\s*(304L?|316L?|321|347|310|317L?|904L?|254SMO)\b',
|
||||
]
|
||||
|
||||
for pattern in stainless_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
grade = match.group(1) if match.groups() else match.group(0)
|
||||
if grade.startswith(('SS', 'SUS')):
|
||||
return grade
|
||||
else:
|
||||
return f"SS{grade}"
|
||||
|
||||
# 7. 탄소강 패턴들
|
||||
carbon_patterns = [
|
||||
r'\bSM\d+[A-Z]*\b', # SM400, SM490 등
|
||||
r'\bSS\d+[A-Z]*\b', # SS400, SS490 등 (스테인리스와 구분)
|
||||
r'\bS\d+C\b', # S45C, S50C 등
|
||||
]
|
||||
|
||||
for pattern in carbon_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
return match.group(0).strip()
|
||||
|
||||
# 8. 기존 material_grade가 있으면 그대로 반환
|
||||
# (분류기에서 이미 처리된 경우)
|
||||
return ""
|
||||
|
||||
def update_full_material_grades(db, batch_size: int = 1000) -> Dict:
|
||||
"""
|
||||
기존 자재들의 full_material_grade 업데이트
|
||||
|
||||
Args:
|
||||
db: 데이터베이스 세션
|
||||
batch_size: 배치 처리 크기
|
||||
|
||||
Returns:
|
||||
업데이트 결과 통계
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
try:
|
||||
# 전체 자재 수 조회
|
||||
count_query = text("SELECT COUNT(*) FROM materials WHERE full_material_grade IS NULL OR full_material_grade = ''")
|
||||
total_count = db.execute(count_query).scalar()
|
||||
|
||||
print(f"📊 업데이트 대상 자재: {total_count}개")
|
||||
|
||||
updated_count = 0
|
||||
processed_count = 0
|
||||
|
||||
# 배치 단위로 처리
|
||||
offset = 0
|
||||
while offset < total_count:
|
||||
# 배치 조회
|
||||
select_query = text("""
|
||||
SELECT id, original_description, material_grade
|
||||
FROM materials
|
||||
WHERE full_material_grade IS NULL OR full_material_grade = ''
|
||||
ORDER BY id
|
||||
LIMIT :limit OFFSET :offset
|
||||
""")
|
||||
|
||||
results = db.execute(select_query, {"limit": batch_size, "offset": offset}).fetchall()
|
||||
|
||||
if not results:
|
||||
break
|
||||
|
||||
# 배치 업데이트
|
||||
for material_id, original_description, current_grade in results:
|
||||
full_grade = extract_full_material_grade(original_description)
|
||||
|
||||
# 전체 재질명이 추출되지 않으면 기존 grade 사용
|
||||
if not full_grade and current_grade:
|
||||
full_grade = current_grade
|
||||
|
||||
if full_grade:
|
||||
update_query = text("""
|
||||
UPDATE materials
|
||||
SET full_material_grade = :full_grade
|
||||
WHERE id = :material_id
|
||||
""")
|
||||
db.execute(update_query, {
|
||||
"full_grade": full_grade,
|
||||
"material_id": material_id
|
||||
})
|
||||
updated_count += 1
|
||||
|
||||
processed_count += 1
|
||||
|
||||
# 배치 커밋
|
||||
db.commit()
|
||||
offset += batch_size
|
||||
|
||||
print(f"📈 진행률: {processed_count}/{total_count} ({processed_count/total_count*100:.1f}%)")
|
||||
|
||||
return {
|
||||
"total_processed": processed_count,
|
||||
"updated_count": updated_count,
|
||||
"success": True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"❌ 업데이트 실패: {str(e)}")
|
||||
return {
|
||||
"total_processed": 0,
|
||||
"updated_count": 0,
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
592
backend/app/services/material_service.py
Normal file
592
backend/app/services/material_service.py
Normal file
@@ -0,0 +1,592 @@
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List, Dict, Optional
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from app.services.integrated_classifier import classify_material_integrated, should_exclude_material
|
||||
from app.services.bolt_classifier import classify_bolt
|
||||
from app.services.flange_classifier import classify_flange
|
||||
from app.services.fitting_classifier import classify_fitting
|
||||
from app.services.gasket_classifier import classify_gasket
|
||||
from app.services.instrument_classifier import classify_instrument
|
||||
from app.services.valve_classifier import classify_valve
|
||||
from app.services.support_classifier import classify_support
|
||||
from app.services.plate_classifier import classify_plate
|
||||
from app.services.structural_classifier import classify_structural
|
||||
from app.services.pipe_classifier import classify_pipe_for_purchase, extract_end_preparation_info
|
||||
from app.services.material_grade_extractor import extract_full_material_grade
|
||||
|
||||
class MaterialService:
|
||||
"""자재 처리 및 저장을 담당하는 서비스"""
|
||||
|
||||
@staticmethod
|
||||
def process_and_save_materials(
|
||||
db: Session,
|
||||
file_id: int,
|
||||
materials_data: List[Dict],
|
||||
revision_comparison: Optional[Dict] = None,
|
||||
parent_file_id: Optional[int] = None,
|
||||
purchased_materials_map: Optional[Dict] = None
|
||||
) -> int:
|
||||
"""
|
||||
자재 목록을 분류하고 DB에 저장합니다.
|
||||
|
||||
Args:
|
||||
db: DB 세션
|
||||
file_id: 파일 ID
|
||||
materials_data: 파싱된 자재 데이터 목록
|
||||
revision_comparison: 리비전 비교 결과
|
||||
parent_file_id: 이전 리비전 파일 ID
|
||||
purchased_materials_map: 구매 확정된 자재 매핑 정보
|
||||
|
||||
Returns:
|
||||
저장된 자재 수
|
||||
"""
|
||||
materials_inserted = 0
|
||||
|
||||
# 변경/신규 자재 키 집합 (리비전 추적용)
|
||||
changed_materials_keys = set()
|
||||
new_materials_keys = set()
|
||||
|
||||
# 리비전 업로드인 경우 변경사항 분석
|
||||
if parent_file_id is not None:
|
||||
MaterialService._analyze_changes(
|
||||
db, parent_file_id, materials_data,
|
||||
changed_materials_keys, new_materials_keys
|
||||
)
|
||||
|
||||
# 변경 없는 자재 (확정된 자재) 먼저 처리
|
||||
if revision_comparison and revision_comparison.get("has_previous_confirmation", False):
|
||||
unchanged_materials = revision_comparison.get("unchanged_materials", [])
|
||||
for material_data in unchanged_materials:
|
||||
MaterialService._save_unchanged_material(db, file_id, material_data)
|
||||
materials_inserted += 1
|
||||
|
||||
# 분류가 필요한 자재 처리 (신규 또는 변경된 자재)
|
||||
# revision_comparison에서 필터링된 목록이 있으면 그것을 사용, 아니면 전체
|
||||
materials_to_classify = materials_data
|
||||
if revision_comparison and revision_comparison.get("materials_to_classify"):
|
||||
materials_to_classify = revision_comparison.get("materials_to_classify")
|
||||
|
||||
print(f"🔧 자재 분류 및 저장 시작: {len(materials_to_classify)}개")
|
||||
|
||||
for material_data in materials_to_classify:
|
||||
MaterialService._classify_and_save_single_material(
|
||||
db, file_id, material_data,
|
||||
changed_materials_keys, new_materials_keys,
|
||||
purchased_materials_map
|
||||
)
|
||||
materials_inserted += 1
|
||||
|
||||
return materials_inserted
|
||||
|
||||
@staticmethod
|
||||
def _analyze_changes(db: Session, parent_file_id: int, materials_data: List[Dict],
|
||||
changed_keys: set, new_keys: set):
|
||||
"""이전 리비전과 비교하여 변경/신규 자재를 식별합니다."""
|
||||
try:
|
||||
prev_materials_query = text("""
|
||||
SELECT original_description, size_spec, material_grade, main_nom,
|
||||
drawing_name, line_no, quantity
|
||||
FROM materials
|
||||
WHERE file_id = :parent_file_id
|
||||
""")
|
||||
prev_materials = db.execute(prev_materials_query, {"parent_file_id": parent_file_id}).fetchall()
|
||||
|
||||
prev_dict = {}
|
||||
for pm in prev_materials:
|
||||
key = MaterialService._generate_material_key(
|
||||
pm.drawing_name, pm.line_no, pm.original_description,
|
||||
pm.size_spec, pm.material_grade
|
||||
)
|
||||
prev_dict[key] = float(pm.quantity) if pm.quantity else 0
|
||||
|
||||
for mat in materials_data:
|
||||
new_key = MaterialService._generate_material_key(
|
||||
mat.get("dwg_name"), mat.get("line_num"), mat["original_description"],
|
||||
mat.get("size_spec"), mat.get("material_grade")
|
||||
)
|
||||
|
||||
if new_key in prev_dict:
|
||||
if abs(prev_dict[new_key] - float(mat.get("quantity", 0))) > 0.001:
|
||||
changed_keys.add(new_key)
|
||||
else:
|
||||
new_keys.add(new_key)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 변경사항 분석 실패: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _generate_material_key(dwg, line, desc, size, grade):
|
||||
"""자재 고유 키 생성"""
|
||||
parts = []
|
||||
if dwg: parts.append(str(dwg))
|
||||
elif line: parts.append(str(line))
|
||||
|
||||
parts.append(str(desc))
|
||||
parts.append(str(size or ''))
|
||||
parts.append(str(grade or ''))
|
||||
return "|".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _save_unchanged_material(db: Session, file_id: int, material_data: Dict):
|
||||
"""변경 없는(확정된) 자재 저장"""
|
||||
previous_item = material_data.get("previous_item", {})
|
||||
|
||||
query = text("""
|
||||
INSERT INTO materials (
|
||||
file_id, original_description, classified_category, confidence,
|
||||
quantity, unit, size_spec, material_grade, specification,
|
||||
reused_from_confirmation, created_at
|
||||
) VALUES (
|
||||
:file_id, :desc, :category, 1.0,
|
||||
:qty, :unit, :size, :grade, :spec,
|
||||
TRUE, :created_at
|
||||
)
|
||||
""")
|
||||
|
||||
db.execute(query, {
|
||||
"file_id": file_id,
|
||||
"desc": material_data["original_description"],
|
||||
"category": previous_item.get("category", "UNCLASSIFIED"),
|
||||
"qty": material_data["quantity"],
|
||||
"unit": material_data.get("unit", "EA"),
|
||||
"size": material_data.get("size_spec", ""),
|
||||
"grade": previous_item.get("material", ""),
|
||||
"spec": previous_item.get("specification", ""),
|
||||
"created_at": datetime.now()
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _classify_and_save_single_material(
|
||||
db: Session, file_id: int, material_data: Dict,
|
||||
changed_keys: set, new_keys: set, purchased_map: Optional[Dict]
|
||||
):
|
||||
"""단일 자재 분류 및 저장 (상세 정보 포함)"""
|
||||
description = material_data["original_description"]
|
||||
main_nom = material_data.get("main_nom", "")
|
||||
red_nom = material_data.get("red_nom", "")
|
||||
length_val = material_data.get("length")
|
||||
|
||||
# 1. 통합 분류
|
||||
integrated_result = classify_material_integrated(description, main_nom, red_nom, length_val)
|
||||
classification_result = integrated_result
|
||||
|
||||
# 2. 상세 분류
|
||||
if not should_exclude_material(description):
|
||||
category = integrated_result.get('category')
|
||||
if category == "PIPE":
|
||||
classification_result = classify_pipe_for_purchase("", description, main_nom, length_val)
|
||||
elif category == "FITTING":
|
||||
classification_result = classify_fitting("", description, main_nom, red_nom)
|
||||
elif category == "FLANGE":
|
||||
classification_result = classify_flange("", description, main_nom, red_nom)
|
||||
elif category == "VALVE":
|
||||
classification_result = classify_valve("", description, main_nom)
|
||||
elif category == "BOLT":
|
||||
classification_result = classify_bolt("", description, main_nom)
|
||||
elif category == "GASKET":
|
||||
classification_result = classify_gasket("", description, main_nom)
|
||||
elif category == "INSTRUMENT":
|
||||
classification_result = classify_instrument("", description, main_nom)
|
||||
elif category == "SUPPORT":
|
||||
classification_result = classify_support("", description, main_nom)
|
||||
elif category == "PLATE":
|
||||
classification_result = classify_plate("", description, main_nom)
|
||||
elif category == "STRUCTURAL":
|
||||
classification_result = classify_structural("", description, main_nom)
|
||||
|
||||
# 신뢰도 조정
|
||||
if integrated_result.get('confidence', 0) < 0.5:
|
||||
classification_result['overall_confidence'] = min(
|
||||
classification_result.get('overall_confidence', 1.0),
|
||||
integrated_result.get('confidence', 0.0) + 0.2
|
||||
)
|
||||
else:
|
||||
classification_result = {"category": "EXCLUDE", "overall_confidence": 0.95}
|
||||
|
||||
# 3. 구매 확정 정보 상속 확인
|
||||
is_purchase_confirmed = False
|
||||
purchase_confirmed_at = None
|
||||
purchase_confirmed_by = None
|
||||
|
||||
if purchased_map:
|
||||
key = f"{description.strip().upper()}|{material_data.get('size_spec', '')}"
|
||||
if key in purchased_map:
|
||||
info = purchased_map[key]
|
||||
is_purchase_confirmed = True
|
||||
purchase_confirmed_at = info.get("purchase_confirmed_at")
|
||||
purchase_confirmed_by = info.get("purchase_confirmed_by")
|
||||
|
||||
# 4. 자재 기본 정보 저장
|
||||
full_grade = extract_full_material_grade(description) or material_data.get("material_grade", "")
|
||||
|
||||
insert_query = text("""
|
||||
INSERT INTO materials (
|
||||
file_id, original_description, quantity, unit, size_spec,
|
||||
main_nom, red_nom, material_grade, full_material_grade, line_number, row_number,
|
||||
classified_category, classification_confidence, is_verified,
|
||||
drawing_name, line_no, created_at,
|
||||
purchase_confirmed, purchase_confirmed_at, purchase_confirmed_by,
|
||||
revision_status
|
||||
) VALUES (
|
||||
:file_id, :desc, :qty, :unit, :size,
|
||||
:main, :red, :grade, :full_grade, :line_num, :row_num,
|
||||
:category, :confidence, :verified,
|
||||
:dwg, :line, :created_at,
|
||||
:confirmed, :confirmed_at, :confirmed_by,
|
||||
:status
|
||||
) RETURNING id
|
||||
""")
|
||||
|
||||
# 리비전 상태 결정
|
||||
mat_key = MaterialService._generate_material_key(
|
||||
material_data.get("dwg_name"), material_data.get("line_num"), description,
|
||||
material_data.get("size_spec"), material_data.get("material_grade")
|
||||
)
|
||||
rev_status = 'changed' if mat_key in changed_keys else ('active' if mat_key in new_keys else None)
|
||||
|
||||
result = db.execute(insert_query, {
|
||||
"file_id": file_id,
|
||||
"desc": description,
|
||||
"qty": material_data["quantity"],
|
||||
"unit": material_data["unit"],
|
||||
"size": material_data.get("size_spec", ""),
|
||||
"main": main_nom,
|
||||
"red": red_nom,
|
||||
"grade": material_data.get("material_grade", ""),
|
||||
"full_grade": full_grade,
|
||||
"line_num": material_data.get("line_number"),
|
||||
"row_num": material_data.get("row_number"),
|
||||
"category": classification_result.get("category", "UNCLASSIFIED"),
|
||||
"confidence": classification_result.get("overall_confidence", 0.0),
|
||||
"verified": False,
|
||||
"dwg": material_data.get("dwg_name"),
|
||||
"line": material_data.get("line_num"),
|
||||
"created_at": datetime.now(),
|
||||
"confirmed": is_purchase_confirmed,
|
||||
"confirmed_at": purchase_confirmed_at,
|
||||
"confirmed_by": purchase_confirmed_by,
|
||||
"status": rev_status
|
||||
})
|
||||
|
||||
material_id = result.fetchone()[0]
|
||||
|
||||
# 5. 상세 정보 저장 (별도 메서드로 분리)
|
||||
MaterialService._save_material_details(
|
||||
db, material_id, file_id, classification_result, material_data
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _save_material_details(db: Session, material_id: int, file_id: int,
|
||||
result: Dict, data: Dict):
|
||||
"""카테고리별 상세 정보 저장"""
|
||||
category = result.get("category")
|
||||
|
||||
if category == "PIPE":
|
||||
MaterialService._save_pipe_details(db, material_id, file_id, result, data)
|
||||
elif category == "FITTING":
|
||||
MaterialService._save_fitting_details(db, material_id, file_id, result, data)
|
||||
elif category == "FLANGE":
|
||||
MaterialService._save_flange_details(db, material_id, file_id, result, data)
|
||||
elif category == "BOLT":
|
||||
MaterialService._save_bolt_details(db, material_id, file_id, result, data)
|
||||
elif category == "VALVE":
|
||||
MaterialService._save_valve_details(db, material_id, file_id, result, data)
|
||||
elif category == "GASKET":
|
||||
MaterialService._save_gasket_details(db, material_id, file_id, result, data)
|
||||
elif category == "SUPPORT":
|
||||
MaterialService._save_support_details(db, material_id, file_id, result, data)
|
||||
elif category == "PLATE":
|
||||
MaterialService._save_plate_details(db, material_id, file_id, result, data)
|
||||
elif category == "STRUCTURAL":
|
||||
MaterialService._save_structural_details(db, material_id, file_id, result, data)
|
||||
|
||||
@staticmethod
|
||||
def _save_plate_details(db: Session, mid: int, fid: int, res: Dict, data: Dict):
|
||||
"""판재 정보 업데이트 (현재는 materials 테이블에 요약 정보 저장)"""
|
||||
details = res.get("details", {})
|
||||
spec = f"{details.get('thickness')}T x {details.get('dimensions')}"
|
||||
db.execute(text("""
|
||||
UPDATE materials
|
||||
SET size_spec = :size, material_grade = :mat
|
||||
WHERE id = :id
|
||||
"""), {"size": spec, "mat": details.get("material"), "id": mid})
|
||||
|
||||
@staticmethod
|
||||
def _save_structural_details(db: Session, mid: int, fid: int, res: Dict, data: Dict):
|
||||
"""형강 정보 업데이트 (현재는 materials 테이블에 요약 정보 저장)"""
|
||||
details = res.get("details", {})
|
||||
spec = f"{details.get('type')} {details.get('dimension')}"
|
||||
db.execute(text("""
|
||||
UPDATE materials
|
||||
SET size_spec = :size
|
||||
WHERE id = :id
|
||||
"""), {"size": spec, "id": mid})
|
||||
|
||||
# --- 각 카테고리별 상세 저장 메서드들 (기존 로직 이관) ---
|
||||
|
||||
@staticmethod
|
||||
def inherit_purchase_requests(db: Session, current_file_id: int, parent_file_id: int):
|
||||
"""이전 리비전의 구매신청 정보를 상속합니다."""
|
||||
try:
|
||||
print(f"🔄 구매신청 정보 상속 처리 시작...")
|
||||
|
||||
# 1. 이전 리비전에서 그룹별 구매신청 수량 집계
|
||||
prev_purchase_summary = text("""
|
||||
SELECT
|
||||
m.original_description,
|
||||
m.size_spec,
|
||||
m.material_grade,
|
||||
m.drawing_name,
|
||||
COUNT(DISTINCT pri.material_id) as purchased_count,
|
||||
SUM(pri.quantity) as total_purchased_qty,
|
||||
MIN(pri.request_id) as request_id
|
||||
FROM materials m
|
||||
JOIN purchase_request_items pri ON m.id = pri.material_id
|
||||
WHERE m.file_id = :parent_file_id
|
||||
GROUP BY m.original_description, m.size_spec, m.material_grade, m.drawing_name
|
||||
""")
|
||||
|
||||
prev_purchases = db.execute(prev_purchase_summary, {"parent_file_id": parent_file_id}).fetchall()
|
||||
|
||||
# 2. 새 리비전에서 같은 그룹의 자재에 수량만큼 구매신청 상속
|
||||
for prev_purchase in prev_purchases:
|
||||
purchased_count = prev_purchase.purchased_count
|
||||
|
||||
# 새 리비전에서 같은 그룹의 자재 조회 (순서대로)
|
||||
new_group_materials = text("""
|
||||
SELECT id, quantity
|
||||
FROM materials
|
||||
WHERE file_id = :file_id
|
||||
AND original_description = :description
|
||||
AND COALESCE(size_spec, '') = :size_spec
|
||||
AND COALESCE(material_grade, '') = :material_grade
|
||||
AND COALESCE(drawing_name, '') = :drawing_name
|
||||
ORDER BY id
|
||||
LIMIT :limit
|
||||
""")
|
||||
|
||||
new_materials = db.execute(new_group_materials, {
|
||||
"file_id": current_file_id,
|
||||
"description": prev_purchase.original_description,
|
||||
"size_spec": prev_purchase.size_spec or '',
|
||||
"material_grade": prev_purchase.material_grade or '',
|
||||
"drawing_name": prev_purchase.drawing_name or '',
|
||||
"limit": purchased_count
|
||||
}).fetchall()
|
||||
|
||||
# 구매신청 수량만큼만 상속
|
||||
for new_mat in new_materials:
|
||||
inherit_query = text("""
|
||||
INSERT INTO purchase_request_items (
|
||||
request_id, material_id, quantity, unit, user_requirement
|
||||
) VALUES (
|
||||
:request_id, :material_id, :quantity, 'EA', ''
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
""")
|
||||
db.execute(inherit_query, {
|
||||
"request_id": prev_purchase.request_id,
|
||||
"material_id": new_mat.id,
|
||||
"quantity": new_mat.quantity
|
||||
})
|
||||
|
||||
inherited_count = len(new_materials)
|
||||
if inherited_count > 0:
|
||||
print(f" ✅ {prev_purchase.original_description[:30]}... (도면: {prev_purchase.drawing_name or 'N/A'}) → {inherited_count}/{purchased_count}개 상속")
|
||||
|
||||
# 커밋은 호출하는 쪽에서 일괄 처리하거나 여기서 처리
|
||||
# db.commit()
|
||||
print(f"✅ 구매신청 정보 상속 완료")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 구매신청 정보 상속 실패: {str(e)}")
|
||||
# 상속 실패는 전체 프로세스를 중단하지 않음
|
||||
|
||||
@staticmethod
|
||||
def _save_pipe_details(db, mid, fid, res, data):
|
||||
# PIPE 상세 저장 로직
|
||||
end_prep_info = extract_end_preparation_info(data["original_description"])
|
||||
|
||||
# 1. End Prep 정보 저장
|
||||
db.execute(text("""
|
||||
INSERT INTO pipe_end_preparations (
|
||||
material_id, file_id, end_preparation_type, end_preparation_code,
|
||||
machining_required, cutting_note, original_description, confidence
|
||||
) VALUES (
|
||||
:mid, :fid, :type, :code, :req, :note, :desc, :conf
|
||||
)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": end_prep_info["end_preparation_type"],
|
||||
"code": end_prep_info["end_preparation_code"],
|
||||
"req": end_prep_info["machining_required"],
|
||||
"note": end_prep_info["cutting_note"],
|
||||
"desc": end_prep_info["original_description"],
|
||||
"conf": end_prep_info["confidence"]
|
||||
})
|
||||
|
||||
# 2. Pipe Details 저장
|
||||
length_info = res.get("length_info", {})
|
||||
length_mm = length_info.get("length_mm") or data.get("length", 0.0)
|
||||
|
||||
mat_info = res.get("material", {})
|
||||
sch_info = res.get("schedule", {})
|
||||
|
||||
# 재질 정보 업데이트
|
||||
if mat_info.get("grade"):
|
||||
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
|
||||
{"g": mat_info.get("grade"), "id": mid})
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO pipe_details (
|
||||
material_id, file_id, outer_diameter, schedule,
|
||||
material_spec, manufacturing_method, length_mm
|
||||
) VALUES (:mid, :fid, :od, :sch, :spec, :method, :len)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"od": data.get("main_nom") or data.get("size_spec"),
|
||||
"sch": sch_info.get("schedule", "UNKNOWN") if isinstance(sch_info, dict) else str(sch_info),
|
||||
"spec": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
|
||||
"method": res.get("manufacturing", {}).get("method", "UNKNOWN") if isinstance(res.get("manufacturing"), dict) else "UNKNOWN",
|
||||
"len": length_mm or 0.0
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_fitting_details(db, mid, fid, res, data):
|
||||
fit_type = res.get("fitting_type", {})
|
||||
mat_info = res.get("material", {})
|
||||
|
||||
if mat_info.get("grade"):
|
||||
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
|
||||
{"g": mat_info.get("grade"), "id": mid})
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO fitting_details (
|
||||
material_id, file_id, fitting_type, fitting_subtype,
|
||||
connection_method, pressure_rating, material_grade,
|
||||
main_size, reduced_size
|
||||
) VALUES (:mid, :fid, :type, :subtype, :conn, :rating, :grade, :main, :red)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": fit_type.get("type", "UNKNOWN") if isinstance(fit_type, dict) else str(fit_type),
|
||||
"subtype": fit_type.get("subtype", "UNKNOWN") if isinstance(fit_type, dict) else "UNKNOWN",
|
||||
"conn": res.get("connection_method", {}).get("method", "UNKNOWN") if isinstance(res.get("connection_method"), dict) else "UNKNOWN",
|
||||
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
|
||||
"grade": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
|
||||
"main": data.get("main_nom") or data.get("size_spec"),
|
||||
"red": data.get("red_nom", "")
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_flange_details(db, mid, fid, res, data):
|
||||
flg_type = res.get("flange_type", {})
|
||||
mat_info = res.get("material", {})
|
||||
|
||||
if mat_info.get("grade"):
|
||||
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
|
||||
{"g": mat_info.get("grade"), "id": mid})
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO flange_details (
|
||||
material_id, file_id, flange_type, pressure_rating,
|
||||
facing_type, material_grade, size_inches
|
||||
) VALUES (:mid, :fid, :type, :rating, :face, :grade, :size)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": flg_type.get("type", "UNKNOWN") if isinstance(flg_type, dict) else str(flg_type),
|
||||
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
|
||||
"face": res.get("face_finish", {}).get("finish", "UNKNOWN") if isinstance(res.get("face_finish"), dict) else "UNKNOWN",
|
||||
"grade": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
|
||||
"size": data.get("main_nom") or data.get("size_spec")
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_bolt_details(db, mid, fid, res, data):
|
||||
fast_type = res.get("fastener_type", {})
|
||||
mat_info = res.get("material", {})
|
||||
dim_info = res.get("dimensions", {})
|
||||
|
||||
if mat_info.get("grade"):
|
||||
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
|
||||
{"g": mat_info.get("grade"), "id": mid})
|
||||
|
||||
# 볼트 타입 결정 (특수 용도 고려)
|
||||
bolt_type = fast_type.get("type", "UNKNOWN") if isinstance(fast_type, dict) else str(fast_type)
|
||||
special_apps = res.get("special_applications", {}).get("detected_applications", [])
|
||||
if "LT" in special_apps: bolt_type = "LT_BOLT"
|
||||
elif "PSV" in special_apps: bolt_type = "PSV_BOLT"
|
||||
|
||||
# 코팅 타입
|
||||
desc_upper = data["original_description"].upper()
|
||||
coating = "UNKNOWN"
|
||||
if "GALV" in desc_upper: coating = "GALVANIZED"
|
||||
elif "ZINC" in desc_upper: coating = "ZINC_PLATED"
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO bolt_details (
|
||||
material_id, file_id, bolt_type, thread_type,
|
||||
diameter, length, material_grade, coating_type
|
||||
) VALUES (:mid, :fid, :type, :thread, :dia, :len, :grade, :coating)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": bolt_type,
|
||||
"thread": res.get("thread_specification", {}).get("standard", "UNKNOWN") if isinstance(res.get("thread_specification"), dict) else "UNKNOWN",
|
||||
"dia": dim_info.get("nominal_size", data.get("main_nom", "")),
|
||||
"len": dim_info.get("length", ""),
|
||||
"grade": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
|
||||
"coating": coating
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_valve_details(db, mid, fid, res, data):
|
||||
val_type = res.get("valve_type", {})
|
||||
mat_info = res.get("material", {})
|
||||
|
||||
if mat_info.get("grade"):
|
||||
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
|
||||
{"g": mat_info.get("grade"), "id": mid})
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO valve_details (
|
||||
material_id, file_id, valve_type, connection_method,
|
||||
pressure_rating, body_material, size_inches
|
||||
) VALUES (:mid, :fid, :type, :conn, :rating, :body, :size)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": val_type.get("type", "UNKNOWN") if isinstance(val_type, dict) else str(val_type),
|
||||
"conn": res.get("connection_method", {}).get("method", "UNKNOWN") if isinstance(res.get("connection_method"), dict) else "UNKNOWN",
|
||||
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
|
||||
"body": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
|
||||
"size": data.get("main_nom") or data.get("size_spec")
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_gasket_details(db, mid, fid, res, data):
|
||||
gask_type = res.get("gasket_type", {})
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO gasket_details (
|
||||
material_id, file_id, gasket_type, pressure_rating, size_inches
|
||||
) VALUES (:mid, :fid, :type, :rating, :size)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": gask_type.get("type", "UNKNOWN") if isinstance(gask_type, dict) else str(gask_type),
|
||||
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
|
||||
"size": data.get("main_nom") or data.get("size_spec")
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_support_details(db, mid, fid, res, data):
|
||||
db.execute(text("""
|
||||
INSERT INTO support_details (
|
||||
material_id, file_id, support_type, pipe_size
|
||||
) VALUES (:mid, :fid, :type, :size)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": res.get("support_type", "UNKNOWN"),
|
||||
"size": res.get("size_info", {}).get("pipe_size", "")
|
||||
})
|
||||
@@ -7,6 +7,60 @@ import re
|
||||
from typing import Dict, List, Optional
|
||||
from .material_classifier import classify_material, get_manufacturing_method_from_material
|
||||
|
||||
# ========== PIPE USER 요구사항 키워드 ==========
|
||||
PIPE_USER_REQUIREMENTS = {
|
||||
"IMPACT_TEST": {
|
||||
"keywords": ["IMPACT", "충격시험", "CHARPY", "CVN", "IMPACT TEST", "충격", "NOTCH"],
|
||||
"description": "충격시험 요구",
|
||||
"confidence": 0.95
|
||||
},
|
||||
"ASME_CODE": {
|
||||
"keywords": ["ASME", "ASME CODE", "CODE", "B31.1", "B31.3", "B31.4", "B31.8", "VIII"],
|
||||
"description": "ASME 코드 준수",
|
||||
"confidence": 0.95
|
||||
},
|
||||
"STRESS_RELIEF": {
|
||||
"keywords": ["STRESS RELIEF", "SR", "응력제거", "열처리", "HEAT TREATMENT"],
|
||||
"description": "응력제거 열처리",
|
||||
"confidence": 0.90
|
||||
},
|
||||
"RADIOGRAPHIC_TEST": {
|
||||
"keywords": ["RT", "RADIOGRAPHIC", "방사선시험", "X-RAY", "엑스레이"],
|
||||
"description": "방사선 시험",
|
||||
"confidence": 0.90
|
||||
},
|
||||
"ULTRASONIC_TEST": {
|
||||
"keywords": ["UT", "ULTRASONIC", "초음파시험", "초음파"],
|
||||
"description": "초음파 시험",
|
||||
"confidence": 0.90
|
||||
},
|
||||
"MAGNETIC_PARTICLE": {
|
||||
"keywords": ["MT", "MAGNETIC PARTICLE", "자분탐상", "자분"],
|
||||
"description": "자분탐상 시험",
|
||||
"confidence": 0.90
|
||||
},
|
||||
"LIQUID_PENETRANT": {
|
||||
"keywords": ["PT", "LIQUID PENETRANT", "침투탐상", "침투"],
|
||||
"description": "침투탐상 시험",
|
||||
"confidence": 0.90
|
||||
},
|
||||
"HYDROSTATIC_TEST": {
|
||||
"keywords": ["HYDROSTATIC", "수압시험", "PRESSURE TEST", "압력시험"],
|
||||
"description": "수압 시험",
|
||||
"confidence": 0.90
|
||||
},
|
||||
"LOW_TEMPERATURE": {
|
||||
"keywords": ["LOW TEMP", "저온", "LTCS", "LOW TEMPERATURE", "CRYOGENIC"],
|
||||
"description": "저온용",
|
||||
"confidence": 0.85
|
||||
},
|
||||
"HIGH_TEMPERATURE": {
|
||||
"keywords": ["HIGH TEMP", "고온", "HTCS", "HIGH TEMPERATURE"],
|
||||
"description": "고온용",
|
||||
"confidence": 0.85
|
||||
}
|
||||
}
|
||||
|
||||
# ========== PIPE 제조 방법별 분류 ==========
|
||||
PIPE_MANUFACTURING = {
|
||||
"SEAMLESS": {
|
||||
@@ -29,13 +83,13 @@ PIPE_MANUFACTURING = {
|
||||
# ========== PIPE 끝 가공별 분류 ==========
|
||||
PIPE_END_PREP = {
|
||||
"BOTH_ENDS_BEVELED": {
|
||||
"codes": ["BOE", "BOTH END", "BOTH BEVELED", "양쪽개선"],
|
||||
"codes": ["BBE", "BOE", "BOTH END", "BOTH BEVELED", "양쪽개선"],
|
||||
"cutting_note": "양쪽 개선",
|
||||
"machining_required": True,
|
||||
"confidence": 0.95
|
||||
},
|
||||
"ONE_END_BEVELED": {
|
||||
"codes": ["BE", "BEV", "PBE", "PIPE BEVELED END"],
|
||||
"codes": ["BE", "BEV", "PBE", "PIPE BEVELED END", "POE"],
|
||||
"cutting_note": "한쪽 개선",
|
||||
"machining_required": True,
|
||||
"confidence": 0.95
|
||||
@@ -45,9 +99,85 @@ PIPE_END_PREP = {
|
||||
"cutting_note": "무 개선",
|
||||
"machining_required": False,
|
||||
"confidence": 0.95
|
||||
},
|
||||
"THREADED": {
|
||||
"codes": ["TOE", "THE", "THREADED", "나사", "스레드"],
|
||||
"cutting_note": "나사 가공",
|
||||
"machining_required": True,
|
||||
"confidence": 0.90
|
||||
}
|
||||
}
|
||||
|
||||
# ========== 구매용 파이프 분류 (끝단 가공 제외) ==========
|
||||
def get_purchase_pipe_description(description: str) -> str:
|
||||
"""구매용 파이프 설명 - 끝단 가공 정보 제거"""
|
||||
|
||||
# 모든 끝단 가공 코드들을 수집
|
||||
end_prep_codes = []
|
||||
for prep_data in PIPE_END_PREP.values():
|
||||
end_prep_codes.extend(prep_data["codes"])
|
||||
|
||||
# 설명에서 끝단 가공 코드 제거
|
||||
clean_description = description.upper()
|
||||
|
||||
# 끝단 가공 코드들을 길이 순으로 정렬 (긴 것부터 처리)
|
||||
end_prep_codes.sort(key=len, reverse=True)
|
||||
|
||||
for code in end_prep_codes:
|
||||
# 단어 경계를 고려하여 제거 (부분 매칭 방지)
|
||||
pattern = r'\b' + re.escape(code) + r'\b'
|
||||
clean_description = re.sub(pattern, '', clean_description, flags=re.IGNORECASE)
|
||||
|
||||
# 끝단 가공 관련 패턴들 추가 제거
|
||||
# BOE-POE, POE-TOE 같은 조합 패턴들
|
||||
end_prep_patterns = [
|
||||
r'\b[A-Z]{2,3}E-[A-Z]{2,3}E\b', # BOE-POE, POE-TOE 등
|
||||
r'\b[A-Z]{2,3}E-[A-Z]{2,3}\b', # BOE-TO, POE-TO 등
|
||||
r'\b[A-Z]{2,3}-[A-Z]{2,3}E\b', # BO-POE, PO-TOE 등
|
||||
r'\b[A-Z]{2,3}-[A-Z]{2,3}\b', # BO-PO, PO-TO 등
|
||||
]
|
||||
|
||||
for pattern in end_prep_patterns:
|
||||
clean_description = re.sub(pattern, '', clean_description, flags=re.IGNORECASE)
|
||||
|
||||
# 남은 하이픈과 공백 정리
|
||||
clean_description = re.sub(r'\s*-\s*', ' ', clean_description) # 하이픈 제거
|
||||
clean_description = re.sub(r'\s+', ' ', clean_description).strip() # 연속 공백 정리
|
||||
|
||||
return clean_description
|
||||
|
||||
def extract_end_preparation_info(description: str) -> Dict:
|
||||
"""파이프 설명에서 끝단 가공 정보 추출"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 끝단 가공 코드 찾기
|
||||
for prep_type, prep_data in PIPE_END_PREP.items():
|
||||
for code in prep_data["codes"]:
|
||||
if code in desc_upper:
|
||||
return {
|
||||
"end_preparation_type": prep_type,
|
||||
"end_preparation_code": code,
|
||||
"machining_required": prep_data["machining_required"],
|
||||
"cutting_note": prep_data["cutting_note"],
|
||||
"confidence": prep_data["confidence"],
|
||||
"matched_pattern": code,
|
||||
"original_description": description,
|
||||
"clean_description": get_purchase_pipe_description(description)
|
||||
}
|
||||
|
||||
# 기본값: PBE (양쪽 무개선)
|
||||
return {
|
||||
"end_preparation_type": "NO_BEVEL", # PBE로 매핑될 예정
|
||||
"end_preparation_code": "PBE",
|
||||
"machining_required": False,
|
||||
"cutting_note": "양쪽 무개선 (기본값)",
|
||||
"confidence": 0.5,
|
||||
"matched_pattern": "DEFAULT",
|
||||
"original_description": description,
|
||||
"clean_description": get_purchase_pipe_description(description)
|
||||
}
|
||||
|
||||
# ========== PIPE 스케줄별 분류 ==========
|
||||
PIPE_SCHEDULE = {
|
||||
"patterns": [
|
||||
@@ -62,6 +192,44 @@ PIPE_SCHEDULE = {
|
||||
]
|
||||
}
|
||||
|
||||
def extract_pipe_user_requirements(description: str) -> List[str]:
|
||||
"""
|
||||
파이프 설명에서 User 요구사항 추출
|
||||
|
||||
Args:
|
||||
description: 파이프 설명
|
||||
|
||||
Returns:
|
||||
발견된 요구사항 리스트
|
||||
"""
|
||||
desc_upper = description.upper()
|
||||
found_requirements = []
|
||||
|
||||
for req_type, req_data in PIPE_USER_REQUIREMENTS.items():
|
||||
for keyword in req_data["keywords"]:
|
||||
if keyword in desc_upper:
|
||||
found_requirements.append(req_data["description"])
|
||||
break # 같은 타입에서 중복 방지
|
||||
|
||||
return found_requirements
|
||||
|
||||
def classify_pipe_for_purchase(dat_file: str, description: str, main_nom: str,
|
||||
length: Optional[float] = None) -> Dict:
|
||||
"""구매용 파이프 분류 - 끝단 가공 정보 제외"""
|
||||
|
||||
# 끝단 가공 정보 제거한 설명으로 분류
|
||||
clean_description = get_purchase_pipe_description(description)
|
||||
|
||||
# 기본 파이프 분류 수행
|
||||
result = classify_pipe(dat_file, clean_description, main_nom, length)
|
||||
|
||||
# 구매용임을 표시
|
||||
result["purchase_classification"] = True
|
||||
result["original_description"] = description
|
||||
result["clean_description"] = clean_description
|
||||
|
||||
return result
|
||||
|
||||
def classify_pipe(dat_file: str, description: str, main_nom: str,
|
||||
length: Optional[float] = None) -> Dict:
|
||||
"""
|
||||
@@ -98,14 +266,19 @@ def classify_pipe(dat_file: str, description: str, main_nom: str,
|
||||
}
|
||||
|
||||
# 2. 파이프 키워드 확인
|
||||
pipe_keywords = ['PIPE', 'TUBE', '파이프', '배관']
|
||||
is_pipe = any(keyword in desc_upper for keyword in pipe_keywords)
|
||||
pipe_keywords = ['PIPE', 'TUBE', '파이프', '배관', 'SMLS', 'SEAMLESS']
|
||||
has_pipe_keyword = any(keyword in desc_upper for keyword in pipe_keywords)
|
||||
|
||||
if not is_pipe:
|
||||
# 파이프 재질 확인 (A106, A333, A312, A53)
|
||||
pipe_materials = ['A106', 'A333', 'A312', 'A53']
|
||||
has_pipe_material = any(material in desc_upper for material in pipe_materials)
|
||||
|
||||
# 파이프 키워드도 없고 파이프 재질도 없으면 UNKNOWN
|
||||
if not has_pipe_keyword and not has_pipe_material:
|
||||
return {
|
||||
"category": "UNKNOWN",
|
||||
"overall_confidence": 0.0,
|
||||
"reason": "파이프 키워드 없음"
|
||||
"reason": "파이프 키워드 및 재질 없음"
|
||||
}
|
||||
|
||||
# 3. 재질 분류 (공통 모듈 사용)
|
||||
@@ -117,13 +290,16 @@ def classify_pipe(dat_file: str, description: str, main_nom: str,
|
||||
# 3. 끝 가공 분류
|
||||
end_prep_result = classify_pipe_end_preparation(description)
|
||||
|
||||
# 4. 스케줄 분류
|
||||
schedule_result = classify_pipe_schedule(description)
|
||||
# 4. 스케줄 분류 (재질 정보 전달)
|
||||
schedule_result = classify_pipe_schedule(description, material_result)
|
||||
|
||||
# 5. 길이(절단 치수) 처리
|
||||
length_info = extract_pipe_length_info(length, description)
|
||||
|
||||
# 6. 최종 결과 조합
|
||||
# 6. User 요구사항 추출
|
||||
user_requirements = extract_pipe_user_requirements(description)
|
||||
|
||||
# 7. 최종 결과 조합
|
||||
return {
|
||||
"category": "PIPE",
|
||||
|
||||
@@ -162,6 +338,9 @@ def classify_pipe(dat_file: str, description: str, main_nom: str,
|
||||
"length_mm": length_info.get('length_mm')
|
||||
},
|
||||
|
||||
# User 요구사항
|
||||
"user_requirements": user_requirements,
|
||||
|
||||
# 전체 신뢰도
|
||||
"overall_confidence": calculate_pipe_confidence({
|
||||
"material": material_result.get('confidence', 0),
|
||||
@@ -230,19 +409,43 @@ def classify_pipe_end_preparation(description: str) -> Dict:
|
||||
"matched_code": "DEFAULT"
|
||||
}
|
||||
|
||||
def classify_pipe_schedule(description: str) -> Dict:
|
||||
"""파이프 스케줄 분류"""
|
||||
def classify_pipe_schedule(description: str, material_result: Dict = None) -> Dict:
|
||||
"""파이프 스케줄 분류 - 재질별 표현 개선"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 재질 정보 확인
|
||||
material_type = "CARBON" # 기본값
|
||||
if material_result:
|
||||
material_grade = material_result.get('grade', '').upper()
|
||||
material_standard = material_result.get('standard', '').upper()
|
||||
|
||||
# 스테인리스 스틸 판단
|
||||
if any(sus_indicator in material_grade or sus_indicator in material_standard
|
||||
for sus_indicator in ['SUS', 'SS', 'A312', 'A358', 'A376', '304', '316', '321', '347']):
|
||||
material_type = "STAINLESS"
|
||||
|
||||
# 1. 스케줄 패턴 확인
|
||||
for pattern in PIPE_SCHEDULE["patterns"]:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
schedule_num = match.group(1)
|
||||
|
||||
# 재질별 스케줄 표현
|
||||
if material_type == "STAINLESS":
|
||||
# 스테인리스 스틸: SCH 40S, SCH 80S
|
||||
if schedule_num in ["10", "20", "40", "80", "120", "160"]:
|
||||
schedule_display = f"SCH {schedule_num}S"
|
||||
else:
|
||||
schedule_display = f"SCH {schedule_num}"
|
||||
else:
|
||||
# 카본 스틸: SCH 40, SCH 80
|
||||
schedule_display = f"SCH {schedule_num}"
|
||||
|
||||
return {
|
||||
"schedule": f"SCH {schedule_num}",
|
||||
"schedule": schedule_display,
|
||||
"schedule_number": schedule_num,
|
||||
"material_type": material_type,
|
||||
"confidence": 0.95,
|
||||
"matched_pattern": pattern
|
||||
}
|
||||
@@ -255,6 +458,7 @@ def classify_pipe_schedule(description: str) -> Dict:
|
||||
return {
|
||||
"schedule": f"{thickness}mm THK",
|
||||
"wall_thickness": f"{thickness}mm",
|
||||
"material_type": material_type,
|
||||
"confidence": 0.9,
|
||||
"matched_pattern": pattern
|
||||
}
|
||||
@@ -262,6 +466,7 @@ def classify_pipe_schedule(description: str) -> Dict:
|
||||
# 3. 기본값
|
||||
return {
|
||||
"schedule": "UNKNOWN",
|
||||
"material_type": material_type,
|
||||
"confidence": 0.0
|
||||
}
|
||||
|
||||
|
||||
50
backend/app/services/plate_classifier.py
Normal file
50
backend/app/services/plate_classifier.py
Normal file
@@ -0,0 +1,50 @@
|
||||
|
||||
import re
|
||||
from typing import Dict, Optional
|
||||
|
||||
def classify_plate(tag: str, description: str, main_nom: str = "") -> Dict:
|
||||
"""
|
||||
판재(PLATE) 분류기
|
||||
규격 예: PLATE 10T x 1219 x 2438
|
||||
"""
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 1. 두께(Thickness) 추출
|
||||
# 패턴: 10T, 10.5T, THK 10, THK. 10, t=10
|
||||
thickness = None
|
||||
t_match = re.search(r'(\d+(?:\.\d+)?)\s*T\b', desc_upper)
|
||||
if not t_match:
|
||||
t_match = re.search(r'(?:THK\.?|t=)\s*(\d+(?:\.\d+)?)', desc_upper, re.IGNORECASE)
|
||||
|
||||
if t_match:
|
||||
thickness = t_match.group(1)
|
||||
|
||||
# 2. 규격(Dimensions) 추출
|
||||
# 패턴: 1219x2438, 4'x8', 1000*2000
|
||||
dimensions = ""
|
||||
dim_match = re.search(r'(\d+(?:\.\d+)?)\s*[X\*]\s*(\d+(?:\.\d+)?)(?:\s*[X\*]\s*(\d+(?:\.\d+)?))?', desc_upper)
|
||||
if dim_match:
|
||||
groups = [g for g in dim_match.groups() if g]
|
||||
dimensions = " x ".join(groups)
|
||||
|
||||
# 3. 재질 추출
|
||||
material = "UNKNOWN"
|
||||
# 압력용기용 및 일반 구조용 강판 재질 추가
|
||||
plate_materials = [
|
||||
"SUS304", "SUS316", "SUS321", "SS400", "A36", "SM490",
|
||||
"SA516", "A516", "SA283", "A283", "SA537", "A537", "POS-M"
|
||||
]
|
||||
for mat in plate_materials:
|
||||
if mat in desc_upper:
|
||||
material = mat
|
||||
break
|
||||
|
||||
return {
|
||||
"category": "PLATE",
|
||||
"overall_confidence": 0.9,
|
||||
"details": {
|
||||
"thickness": thickness,
|
||||
"dimensions": dimensions,
|
||||
"material": material
|
||||
}
|
||||
}
|
||||
@@ -10,26 +10,26 @@ from typing import Dict, List, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
# 자재별 기본 여유율
|
||||
# 자재별 기본 여유율 (올바른 규칙으로 수정)
|
||||
SAFETY_FACTORS = {
|
||||
'PIPE': 1.15, # 15% 추가 (절단 손실)
|
||||
'FITTING': 1.10, # 10% 추가 (연결 오차)
|
||||
'VALVE': 1.50, # 50% 추가 (예비품)
|
||||
'FLANGE': 1.10, # 10% 추가
|
||||
'BOLT': 1.20, # 20% 추가 (분실율)
|
||||
'GASKET': 1.25, # 25% 추가 (교체주기)
|
||||
'INSTRUMENT': 1.00, # 0% 추가 (정확한 수량)
|
||||
'DEFAULT': 1.10 # 기본 10% 추가
|
||||
'PIPE': 1.00, # 0% 추가 (절단 손실은 별도 계산)
|
||||
'FITTING': 1.00, # 0% 추가 (BOM 수량 그대로)
|
||||
'VALVE': 1.00, # 0% 추가 (BOM 수량 그대로)
|
||||
'FLANGE': 1.00, # 0% 추가 (BOM 수량 그대로)
|
||||
'BOLT': 1.05, # 5% 추가 (분실율)
|
||||
'GASKET': 1.00, # 0% 추가 (5의 배수 올림으로 처리)
|
||||
'INSTRUMENT': 1.00, # 0% 추가 (BOM 수량 그대로)
|
||||
'DEFAULT': 1.00 # 기본 0% 추가
|
||||
}
|
||||
|
||||
# 최소 주문 수량 (자재별)
|
||||
# 최소 주문 수량 (자재별) - 올바른 규칙으로 수정
|
||||
MINIMUM_ORDER_QTY = {
|
||||
'PIPE': 6000, # 6M 단위
|
||||
'FITTING': 1, # 개별 주문 가능
|
||||
'VALVE': 1, # 개별 주문 가능
|
||||
'FLANGE': 1, # 개별 주문 가능
|
||||
'BOLT': 50, # 박스 단위 (50개)
|
||||
'GASKET': 10, # 세트 단위
|
||||
'BOLT': 4, # 4의 배수 단위
|
||||
'GASKET': 5, # 5의 배수 단위
|
||||
'INSTRUMENT': 1, # 개별 주문 가능
|
||||
'DEFAULT': 1
|
||||
}
|
||||
@@ -37,7 +37,7 @@ MINIMUM_ORDER_QTY = {
|
||||
def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict:
|
||||
"""
|
||||
PIPE 구매 수량 계산
|
||||
- 각 절단마다 3mm 손실
|
||||
- 각 절단마다 2mm 손실 (올바른 규칙)
|
||||
- 6,000mm (6M) 단위로 올림
|
||||
"""
|
||||
total_bom_length = 0
|
||||
@@ -45,19 +45,23 @@ def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict:
|
||||
pipe_details = []
|
||||
|
||||
for material in materials:
|
||||
# 길이 정보 추출
|
||||
# 길이 정보 추출 (Decimal 타입 처리)
|
||||
length_mm = float(material.get('length_mm', 0) or 0)
|
||||
quantity = float(material.get('quantity', 1) or 1)
|
||||
|
||||
if length_mm > 0:
|
||||
total_bom_length += length_mm
|
||||
cutting_count += 1
|
||||
total_length = length_mm * quantity # 총 길이 = 단위길이 × 수량
|
||||
total_bom_length += total_length
|
||||
cutting_count += quantity # 절단 횟수 = 수량
|
||||
pipe_details.append({
|
||||
'description': material.get('original_description', ''),
|
||||
'length_mm': length_mm,
|
||||
'quantity': material.get('quantity', 1)
|
||||
'quantity': quantity,
|
||||
'total_length': total_length
|
||||
})
|
||||
|
||||
# 절단 손실 계산 (각 절단마다 3mm)
|
||||
cutting_loss = cutting_count * 3
|
||||
# 절단 손실 계산 (각 절단마다 2mm - 올바른 규칙)
|
||||
cutting_loss = cutting_count * 2
|
||||
|
||||
# 총 필요 길이 = BOM 길이 + 절단 손실
|
||||
required_length = total_bom_length + cutting_loss
|
||||
@@ -92,7 +96,8 @@ def calculate_standard_purchase_quantity(category: str, bom_quantity: float,
|
||||
if safety_factor is None:
|
||||
safety_factor = SAFETY_FACTORS.get(category, SAFETY_FACTORS['DEFAULT'])
|
||||
|
||||
# 1단계: 여유율 적용
|
||||
# 1단계: 여유율 적용 (Decimal 타입 처리)
|
||||
bom_quantity = float(bom_quantity) if bom_quantity else 0.0
|
||||
safety_qty = bom_quantity * safety_factor
|
||||
|
||||
# 2단계: 최소 주문 수량 확인
|
||||
@@ -101,9 +106,13 @@ def calculate_standard_purchase_quantity(category: str, bom_quantity: float,
|
||||
# 3단계: 최소 주문 수량과 비교하여 큰 값 선택
|
||||
calculated_qty = max(safety_qty, min_order_qty)
|
||||
|
||||
# 4단계: 특별 처리 (BOLT는 박스 단위로 올림)
|
||||
if category == 'BOLT' and calculated_qty > min_order_qty:
|
||||
calculated_qty = math.ceil(calculated_qty / min_order_qty) * min_order_qty
|
||||
# 4단계: 특별 처리 (올바른 규칙 적용)
|
||||
if category == 'BOLT':
|
||||
# BOLT: 5% 여유율 후 4의 배수로 올림
|
||||
calculated_qty = math.ceil(safety_qty / min_order_qty) * min_order_qty
|
||||
elif category == 'GASKET':
|
||||
# GASKET: 5의 배수로 올림 (여유율 없음)
|
||||
calculated_qty = math.ceil(bom_quantity / min_order_qty) * min_order_qty
|
||||
|
||||
return {
|
||||
'bom_quantity': bom_quantity,
|
||||
@@ -120,16 +129,19 @@ def generate_purchase_items_from_materials(db: Session, file_id: int,
|
||||
"""
|
||||
자재 데이터로부터 구매 품목 생성
|
||||
"""
|
||||
# 1. 파일의 모든 자재 조회
|
||||
# 1. 파일의 모든 자재 조회 (상세 테이블 정보 포함)
|
||||
materials_query = text("""
|
||||
SELECT m.*,
|
||||
pd.length_mm, pd.outer_diameter, pd.schedule, pd.material_spec as pipe_material_spec,
|
||||
fd.fitting_type, fd.connection_method as fitting_connection,
|
||||
fd.fitting_type, fd.connection_method as fitting_connection, fd.main_size as fitting_main_size,
|
||||
fd.reduced_size as fitting_reduced_size, fd.material_grade as fitting_material_grade,
|
||||
vd.valve_type, vd.connection_method as valve_connection, vd.pressure_rating as valve_pressure,
|
||||
fl.flange_type, fl.pressure_rating as flange_pressure,
|
||||
gd.gasket_type, gd.material_type as gasket_material,
|
||||
bd.bolt_type, bd.material_standard, bd.diameter,
|
||||
id.instrument_type
|
||||
vd.size_inches as valve_size,
|
||||
fl.flange_type, fl.pressure_rating as flange_pressure, fl.size_inches as flange_size,
|
||||
gd.gasket_type, gd.gasket_subtype, gd.material_type as gasket_material, gd.filler_material,
|
||||
gd.size_inches as gasket_size, gd.pressure_rating as gasket_pressure, gd.thickness as gasket_thickness,
|
||||
bd.bolt_type, bd.material_standard, bd.diameter as bolt_diameter, bd.length as bolt_length,
|
||||
id.instrument_type, id.connection_size as instrument_size
|
||||
FROM materials m
|
||||
LEFT JOIN pipe_details pd ON m.id = pd.material_id
|
||||
LEFT JOIN fitting_details fd ON m.id = fd.material_id
|
||||
@@ -144,13 +156,65 @@ def generate_purchase_items_from_materials(db: Session, file_id: int,
|
||||
|
||||
materials = db.execute(materials_query, {"file_id": file_id}).fetchall()
|
||||
|
||||
|
||||
|
||||
# 2. 카테고리별로 그룹핑
|
||||
grouped_materials = {}
|
||||
for material in materials:
|
||||
category = material.classified_category or 'OTHER'
|
||||
if category not in grouped_materials:
|
||||
grouped_materials[category] = []
|
||||
grouped_materials[category].append(dict(material))
|
||||
|
||||
# Row 객체를 딕셔너리로 안전하게 변환
|
||||
material_dict = {
|
||||
'id': material.id,
|
||||
'file_id': material.file_id,
|
||||
'original_description': material.original_description,
|
||||
'quantity': material.quantity,
|
||||
'unit': material.unit,
|
||||
'size_spec': material.size_spec,
|
||||
'material_grade': material.material_grade,
|
||||
'classified_category': material.classified_category,
|
||||
'line_number': material.line_number,
|
||||
# PIPE 상세 정보
|
||||
'length_mm': getattr(material, 'length_mm', None),
|
||||
'outer_diameter': getattr(material, 'outer_diameter', None),
|
||||
'schedule': getattr(material, 'schedule', None),
|
||||
'pipe_material_spec': getattr(material, 'pipe_material_spec', None),
|
||||
# FITTING 상세 정보
|
||||
'fitting_type': getattr(material, 'fitting_type', None),
|
||||
'fitting_connection': getattr(material, 'fitting_connection', None),
|
||||
'fitting_main_size': getattr(material, 'fitting_main_size', None),
|
||||
'fitting_reduced_size': getattr(material, 'fitting_reduced_size', None),
|
||||
'fitting_material_grade': getattr(material, 'fitting_material_grade', None),
|
||||
# VALVE 상세 정보
|
||||
'valve_type': getattr(material, 'valve_type', None),
|
||||
'valve_connection': getattr(material, 'valve_connection', None),
|
||||
'valve_pressure': getattr(material, 'valve_pressure', None),
|
||||
'valve_size': getattr(material, 'valve_size', None),
|
||||
# FLANGE 상세 정보
|
||||
'flange_type': getattr(material, 'flange_type', None),
|
||||
'flange_pressure': getattr(material, 'flange_pressure', None),
|
||||
'flange_size': getattr(material, 'flange_size', None),
|
||||
# GASKET 상세 정보
|
||||
'gasket_type': getattr(material, 'gasket_type', None),
|
||||
'gasket_subtype': getattr(material, 'gasket_subtype', None),
|
||||
'gasket_material': getattr(material, 'gasket_material', None),
|
||||
'filler_material': getattr(material, 'filler_material', None),
|
||||
'gasket_size': getattr(material, 'gasket_size', None),
|
||||
'gasket_pressure': getattr(material, 'gasket_pressure', None),
|
||||
'gasket_thickness': getattr(material, 'gasket_thickness', None),
|
||||
# BOLT 상세 정보
|
||||
'bolt_type': getattr(material, 'bolt_type', None),
|
||||
'material_standard': getattr(material, 'material_standard', None),
|
||||
'bolt_diameter': getattr(material, 'bolt_diameter', None),
|
||||
'bolt_length': getattr(material, 'bolt_length', None),
|
||||
# INSTRUMENT 상세 정보
|
||||
'instrument_type': getattr(material, 'instrument_type', None),
|
||||
'instrument_size': getattr(material, 'instrument_size', None)
|
||||
}
|
||||
|
||||
grouped_materials[category].append(material_dict)
|
||||
|
||||
# 3. 각 카테고리별로 구매 품목 생성
|
||||
purchase_items = []
|
||||
@@ -224,6 +288,9 @@ def generate_purchase_items_from_materials(db: Session, file_id: int,
|
||||
'specification': spec_data.get('full_spec', spec_key),
|
||||
'material_spec': spec_data.get('material_spec', ''),
|
||||
'size_spec': spec_data.get('size_display', ''),
|
||||
'size_fraction': spec_data.get('size_fraction', ''),
|
||||
'surface_treatment': spec_data.get('surface_treatment', ''),
|
||||
'special_applications': spec_data.get('special_applications', {}),
|
||||
'unit': spec_data.get('unit', 'EA'),
|
||||
**calc_result,
|
||||
'job_no': job_no,
|
||||
@@ -246,13 +313,41 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) -
|
||||
if category == 'FITTING':
|
||||
fitting_type = material.get('fitting_type', 'FITTING')
|
||||
connection_method = material.get('fitting_connection', '')
|
||||
material_spec = material.get('material_grade', '')
|
||||
main_nom = material.get('main_nom', '')
|
||||
red_nom = material.get('red_nom', '')
|
||||
size_display = f"{main_nom} x {red_nom}" if red_nom else main_nom
|
||||
# 상세 테이블의 재질 정보 우선 사용
|
||||
material_spec = material.get('fitting_material_grade') or material.get('material_grade', '')
|
||||
# 상세 테이블의 사이즈 정보 사용
|
||||
main_size = material.get('fitting_main_size', '')
|
||||
reduced_size = material.get('fitting_reduced_size', '')
|
||||
|
||||
# 사이즈 표시 생성 (축소형인 경우 main x reduced 형태)
|
||||
if main_size and reduced_size and main_size != reduced_size:
|
||||
size_display = f"{main_size} x {reduced_size}"
|
||||
else:
|
||||
size_display = main_size or material.get('size_spec', '')
|
||||
|
||||
# 기존 분류기 방식: 피팅 타입 + 연결방식 + 압력등급
|
||||
# 예: "ELBOW, SOCKET WELD, 3000LB"
|
||||
fitting_display = fitting_type.replace('_', ' ') if fitting_type else 'FITTING'
|
||||
|
||||
spec_parts = [fitting_display]
|
||||
|
||||
# 연결방식 추가
|
||||
if connection_method and connection_method != 'UNKNOWN':
|
||||
connection_display = connection_method.replace('_', ' ')
|
||||
spec_parts.append(connection_display)
|
||||
|
||||
# 압력등급 추출 (description에서)
|
||||
description = material.get('original_description', '').upper()
|
||||
import re
|
||||
pressure_match = re.search(r'(\d+)LB', description)
|
||||
if pressure_match:
|
||||
spec_parts.append(f"{pressure_match.group(1)}LB")
|
||||
|
||||
# 스케줄 정보 추출 (니플 등에 중요)
|
||||
schedule_match = re.search(r'SCH\s*(\d+)', description)
|
||||
if schedule_match:
|
||||
spec_parts.append(f"SCH {schedule_match.group(1)}")
|
||||
|
||||
spec_parts = [fitting_type]
|
||||
if connection_method: spec_parts.append(connection_method)
|
||||
full_spec = ', '.join(spec_parts)
|
||||
|
||||
spec_key = f"FITTING|{full_spec}|{material_spec}|{size_display}"
|
||||
@@ -269,19 +364,20 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) -
|
||||
connection_method = material.get('valve_connection', '')
|
||||
pressure_rating = material.get('valve_pressure', '')
|
||||
material_spec = material.get('material_grade', '')
|
||||
main_nom = material.get('main_nom', '')
|
||||
# 상세 테이블의 사이즈 정보 우선 사용
|
||||
size_display = material.get('valve_size') or material.get('size_spec', '')
|
||||
|
||||
spec_parts = [valve_type.replace('_', ' ')]
|
||||
if connection_method: spec_parts.append(connection_method.replace('_', ' '))
|
||||
if pressure_rating: spec_parts.append(pressure_rating)
|
||||
full_spec = ', '.join(spec_parts)
|
||||
|
||||
spec_key = f"VALVE|{full_spec}|{material_spec}|{main_nom}"
|
||||
spec_key = f"VALVE|{full_spec}|{material_spec}|{size_display}"
|
||||
spec_data = {
|
||||
'category': 'VALVE',
|
||||
'category': 'VALVE',
|
||||
'full_spec': full_spec,
|
||||
'material_spec': material_spec,
|
||||
'size_display': main_nom,
|
||||
'size_display': size_display,
|
||||
'unit': 'EA'
|
||||
}
|
||||
|
||||
@@ -289,72 +385,198 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) -
|
||||
flange_type = material.get('flange_type', 'FLANGE')
|
||||
pressure_rating = material.get('flange_pressure', '')
|
||||
material_spec = material.get('material_grade', '')
|
||||
main_nom = material.get('main_nom', '')
|
||||
# 상세 테이블의 사이즈 정보 우선 사용
|
||||
size_display = material.get('flange_size') or material.get('size_spec', '')
|
||||
|
||||
spec_parts = [flange_type]
|
||||
spec_parts = [flange_type.replace('_', ' ')]
|
||||
if pressure_rating: spec_parts.append(pressure_rating)
|
||||
full_spec = ', '.join(spec_parts)
|
||||
|
||||
spec_key = f"FLANGE|{full_spec}|{material_spec}|{main_nom}"
|
||||
spec_key = f"FLANGE|{full_spec}|{material_spec}|{size_display}"
|
||||
spec_data = {
|
||||
'category': 'FLANGE',
|
||||
'full_spec': full_spec,
|
||||
'material_spec': material_spec,
|
||||
'size_display': main_nom,
|
||||
'size_display': size_display,
|
||||
'unit': 'EA'
|
||||
}
|
||||
|
||||
elif category == 'BOLT':
|
||||
bolt_type = material.get('bolt_type', 'BOLT')
|
||||
material_standard = material.get('material_standard', '')
|
||||
diameter = material.get('diameter', material.get('main_nom', ''))
|
||||
# 상세 테이블의 사이즈 정보 우선 사용
|
||||
diameter = material.get('bolt_diameter') or material.get('size_spec', '')
|
||||
length = material.get('bolt_length', '')
|
||||
material_spec = material_standard or material.get('material_grade', '')
|
||||
|
||||
# 기존 분류기 방식에 따른 사이즈 표시 (분수 형태)
|
||||
# 소수점을 분수로 변환 (예: 0.625 -> 5/8)
|
||||
size_display = diameter
|
||||
if diameter and '.' in diameter:
|
||||
try:
|
||||
decimal_val = float(diameter)
|
||||
# 일반적인 볼트 사이즈 분수 변환
|
||||
fraction_map = {
|
||||
0.25: "1/4\"", 0.3125: "5/16\"", 0.375: "3/8\"",
|
||||
0.4375: "7/16\"", 0.5: "1/2\"", 0.5625: "9/16\"",
|
||||
0.625: "5/8\"", 0.6875: "11/16\"", 0.75: "3/4\"",
|
||||
0.8125: "13/16\"", 0.875: "7/8\"", 1.0: "1\""
|
||||
}
|
||||
if decimal_val in fraction_map:
|
||||
size_display = fraction_map[decimal_val]
|
||||
except:
|
||||
pass
|
||||
|
||||
# 길이 정보 포함한 사이즈 표시 (예: 5/8" x 165L)
|
||||
if length:
|
||||
# 길이에서 숫자만 추출
|
||||
import re
|
||||
length_match = re.search(r'(\d+(?:\.\d+)?)', str(length))
|
||||
if length_match:
|
||||
length_num = length_match.group(1)
|
||||
size_display_with_length = f"{size_display} x {length_num}L"
|
||||
else:
|
||||
size_display_with_length = f"{size_display} x {length}"
|
||||
else:
|
||||
size_display_with_length = size_display
|
||||
|
||||
spec_parts = [bolt_type.replace('_', ' ')]
|
||||
if material_standard: spec_parts.append(material_standard)
|
||||
full_spec = ', '.join(spec_parts)
|
||||
|
||||
spec_key = f"BOLT|{full_spec}|{material_spec}|{diameter}"
|
||||
# 사이즈+길이로 그룹핑
|
||||
spec_key = f"BOLT|{full_spec}|{material_spec}|{size_display_with_length}"
|
||||
spec_data = {
|
||||
'category': 'BOLT',
|
||||
'full_spec': full_spec,
|
||||
'material_spec': material_spec,
|
||||
'size_display': diameter,
|
||||
'size_display': size_display_with_length,
|
||||
'unit': 'EA'
|
||||
}
|
||||
|
||||
elif category == 'GASKET':
|
||||
# 상세 테이블 정보 우선 사용
|
||||
gasket_type = material.get('gasket_type', 'GASKET')
|
||||
gasket_subtype = material.get('gasket_subtype', '')
|
||||
gasket_material = material.get('gasket_material', '')
|
||||
material_spec = gasket_material or material.get('material_grade', '')
|
||||
main_nom = material.get('main_nom', '')
|
||||
filler_material = material.get('filler_material', '')
|
||||
gasket_pressure = material.get('gasket_pressure', '')
|
||||
gasket_thickness = material.get('gasket_thickness', '')
|
||||
|
||||
# 상세 테이블의 사이즈 정보 우선 사용
|
||||
size_display = material.get('gasket_size') or material.get('size_spec', '')
|
||||
|
||||
# 기존 분류기 방식: 가스켓 타입 + 압력등급 + 재질
|
||||
# 예: "SPIRAL WOUND, 150LB, 304SS + GRAPHITE"
|
||||
spec_parts = [gasket_type.replace('_', ' ')]
|
||||
|
||||
# 서브타입 추가 (있는 경우)
|
||||
if gasket_subtype and gasket_subtype != gasket_type:
|
||||
spec_parts.append(gasket_subtype.replace('_', ' '))
|
||||
|
||||
# 상세 테이블의 압력등급 우선 사용, 없으면 description에서 추출
|
||||
if gasket_pressure:
|
||||
spec_parts.append(gasket_pressure)
|
||||
else:
|
||||
description = material.get('original_description', '').upper()
|
||||
import re
|
||||
pressure_match = re.search(r'(\d+)LB', description)
|
||||
if pressure_match:
|
||||
spec_parts.append(f"{pressure_match.group(1)}LB")
|
||||
|
||||
# 재질 정보 구성 (상세 테이블 정보 활용)
|
||||
material_spec_parts = []
|
||||
|
||||
# SWG의 경우 메탈 + 필러 형태로 구성
|
||||
if gasket_type == 'SPIRAL_WOUND':
|
||||
# 기존 저장된 데이터가 부정확한 경우, 원본 description에서 직접 파싱
|
||||
description = material.get('original_description', '').upper()
|
||||
|
||||
# SS304/GRAPHITE/CS/CS 패턴 파싱 (H/F/I/O 다음에 오는 재질 정보)
|
||||
import re
|
||||
material_spec = None
|
||||
|
||||
# H/F/I/O SS304/GRAPHITE/CS/CS 패턴 (전체 구성 표시)
|
||||
hfio_material_match = re.search(r'H/F/I/O\s+([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)', description)
|
||||
if hfio_material_match:
|
||||
part1 = hfio_material_match.group(1) # SS304
|
||||
part2 = hfio_material_match.group(2) # GRAPHITE
|
||||
part3 = hfio_material_match.group(3) # CS
|
||||
part4 = hfio_material_match.group(4) # CS
|
||||
material_spec = f"{part1}/{part2}/{part3}/{part4}"
|
||||
else:
|
||||
# 단순 SS304/GRAPHITE/CS/CS 패턴 (전체 구성 표시)
|
||||
simple_material_match = re.search(r'([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)', description)
|
||||
if simple_material_match:
|
||||
part1 = simple_material_match.group(1) # SS304
|
||||
part2 = simple_material_match.group(2) # GRAPHITE
|
||||
part3 = simple_material_match.group(3) # CS
|
||||
part4 = simple_material_match.group(4) # CS
|
||||
material_spec = f"{part1}/{part2}/{part3}/{part4}"
|
||||
|
||||
if not material_spec:
|
||||
# 상세 테이블 정보 사용
|
||||
if gasket_material and gasket_material != 'GRAPHITE': # 메탈 부분
|
||||
material_spec_parts.append(gasket_material)
|
||||
elif gasket_material == 'GRAPHITE':
|
||||
# GRAPHITE만 있는 경우 description에서 메탈 부분 찾기
|
||||
metal_match = re.search(r'(SS\d+|CS|INCONEL\d*)', description)
|
||||
if metal_match:
|
||||
material_spec_parts.append(metal_match.group(1))
|
||||
|
||||
if filler_material and filler_material != gasket_material: # 필러 부분
|
||||
material_spec_parts.append(filler_material)
|
||||
elif 'GRAPHITE' in description and 'GRAPHITE' not in material_spec_parts:
|
||||
material_spec_parts.append('GRAPHITE')
|
||||
|
||||
if material_spec_parts:
|
||||
material_spec = ' + '.join(material_spec_parts) # SS304 + GRAPHITE
|
||||
else:
|
||||
material_spec = material.get('material_grade', '')
|
||||
else:
|
||||
# 일반 가스켓의 경우
|
||||
if gasket_material:
|
||||
material_spec_parts.append(gasket_material)
|
||||
if filler_material and filler_material != gasket_material:
|
||||
material_spec_parts.append(filler_material)
|
||||
|
||||
if material_spec_parts:
|
||||
material_spec = ', '.join(material_spec_parts)
|
||||
else:
|
||||
material_spec = material.get('material_grade', '')
|
||||
|
||||
if material_spec:
|
||||
spec_parts.append(material_spec)
|
||||
|
||||
# 두께 정보 추가 (있는 경우)
|
||||
if gasket_thickness:
|
||||
spec_parts.append(f"THK {gasket_thickness}")
|
||||
|
||||
spec_parts = [gasket_type]
|
||||
if gasket_material: spec_parts.append(gasket_material)
|
||||
full_spec = ', '.join(spec_parts)
|
||||
|
||||
spec_key = f"GASKET|{full_spec}|{material_spec}|{main_nom}"
|
||||
spec_key = f"GASKET|{full_spec}|{material_spec}|{size_display}"
|
||||
spec_data = {
|
||||
'category': 'GASKET',
|
||||
'full_spec': full_spec,
|
||||
'material_spec': material_spec,
|
||||
'size_display': main_nom,
|
||||
'size_display': size_display,
|
||||
'unit': 'EA'
|
||||
}
|
||||
|
||||
elif category == 'INSTRUMENT':
|
||||
instrument_type = material.get('instrument_type', 'INSTRUMENT')
|
||||
material_spec = material.get('material_grade', '')
|
||||
main_nom = material.get('main_nom', '')
|
||||
# 상세 테이블의 사이즈 정보 우선 사용
|
||||
size_display = material.get('instrument_size') or material.get('size_spec', '')
|
||||
|
||||
full_spec = instrument_type.replace('_', ' ')
|
||||
|
||||
spec_key = f"INSTRUMENT|{full_spec}|{material_spec}|{main_nom}"
|
||||
spec_key = f"INSTRUMENT|{full_spec}|{material_spec}|{size_display}"
|
||||
spec_data = {
|
||||
'category': 'INSTRUMENT',
|
||||
'full_spec': full_spec,
|
||||
'material_spec': material_spec,
|
||||
'size_display': main_nom,
|
||||
'size_display': size_display,
|
||||
'unit': 'EA'
|
||||
}
|
||||
|
||||
@@ -378,12 +600,18 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) -
|
||||
**spec_data,
|
||||
'totalQuantity': 0,
|
||||
'count': 0,
|
||||
'items': []
|
||||
'items': [],
|
||||
'special_applications': {'PSV': 0, 'LT': 0, 'CK': 0} if category == 'BOLT' else None
|
||||
}
|
||||
|
||||
specs[spec_key]['totalQuantity'] += material.get('quantity', 0)
|
||||
specs[spec_key]['count'] += 1
|
||||
specs[spec_key]['items'].append(material)
|
||||
|
||||
# 볼트의 경우 특수 용도 정보 누적
|
||||
if category == 'BOLT' and 'special_applications' in locals():
|
||||
for app_type, count in special_applications.items():
|
||||
specs[spec_key]['special_applications'][app_type] += count
|
||||
|
||||
return specs
|
||||
|
||||
|
||||
417
backend/app/services/revision_comparator.py
Normal file
417
backend/app/services/revision_comparator.py
Normal file
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
리비전 비교 서비스
|
||||
- 기존 확정 자재와 신규 자재 비교
|
||||
- 변경된 자재만 분류 처리
|
||||
- 리비전 업로드 최적화
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RevisionComparator:
|
||||
"""리비전 비교 및 차이 분석 클래스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_previous_confirmed_materials(self, job_no: str, current_revision: str) -> Optional[Dict]:
|
||||
"""
|
||||
이전 확정된 자재 목록 조회
|
||||
|
||||
Args:
|
||||
job_no: 프로젝트 번호
|
||||
current_revision: 현재 리비전 (예: Rev.1)
|
||||
|
||||
Returns:
|
||||
확정된 자재 정보 딕셔너리 또는 None
|
||||
"""
|
||||
try:
|
||||
# 현재 리비전 번호 추출
|
||||
current_rev_num = self._extract_revision_number(current_revision)
|
||||
|
||||
# 이전 리비전들 중 확정된 것 찾기 (역순으로 검색)
|
||||
for prev_rev_num in range(current_rev_num - 1, -1, -1):
|
||||
prev_revision = f"Rev.{prev_rev_num}"
|
||||
|
||||
# 해당 리비전의 확정 데이터 조회
|
||||
query = text("""
|
||||
SELECT pc.id, pc.revision, pc.confirmed_at, pc.confirmed_by,
|
||||
COUNT(cpi.id) as confirmed_items_count
|
||||
FROM purchase_confirmations pc
|
||||
LEFT JOIN confirmed_purchase_items cpi ON pc.id = cpi.confirmation_id
|
||||
WHERE pc.job_no = :job_no
|
||||
AND pc.revision = :revision
|
||||
AND pc.is_active = TRUE
|
||||
GROUP BY pc.id, pc.revision, pc.confirmed_at, pc.confirmed_by
|
||||
ORDER BY pc.confirmed_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
result = self.db.execute(query, {
|
||||
"job_no": job_no,
|
||||
"revision": prev_revision
|
||||
}).fetchone()
|
||||
|
||||
if result and result.confirmed_items_count > 0:
|
||||
logger.info(f"이전 확정 자료 발견: {job_no} {prev_revision} ({result.confirmed_items_count}개 품목)")
|
||||
|
||||
# 확정된 품목들 상세 조회
|
||||
items_query = text("""
|
||||
SELECT cpi.item_code, cpi.category, cpi.specification,
|
||||
cpi.size, cpi.material, cpi.bom_quantity,
|
||||
cpi.calculated_qty, cpi.unit, cpi.safety_factor
|
||||
FROM confirmed_purchase_items cpi
|
||||
WHERE cpi.confirmation_id = :confirmation_id
|
||||
ORDER BY cpi.category, cpi.specification
|
||||
""")
|
||||
|
||||
items_result = self.db.execute(items_query, {
|
||||
"confirmation_id": result.id
|
||||
}).fetchall()
|
||||
|
||||
return {
|
||||
"confirmation_id": result.id,
|
||||
"revision": result.revision,
|
||||
"confirmed_at": result.confirmed_at,
|
||||
"confirmed_by": result.confirmed_by,
|
||||
"items": [dict(item) for item in items_result],
|
||||
"items_count": len(items_result)
|
||||
}
|
||||
|
||||
logger.info(f"이전 확정 자료 없음: {job_no} (현재: {current_revision})")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"이전 확정 자료 조회 실패: {str(e)}")
|
||||
return None
|
||||
|
||||
def compare_materials(self, previous_confirmed: Dict, new_materials: List[Dict]) -> Dict:
|
||||
"""
|
||||
기존 확정 자재와 신규 자재 비교
|
||||
"""
|
||||
try:
|
||||
from rapidfuzz import fuzz
|
||||
|
||||
# 이전 확정 자재 해시맵 생성
|
||||
confirmed_materials = {}
|
||||
for item in previous_confirmed["items"]:
|
||||
material_hash = self._generate_material_hash(
|
||||
item["specification"],
|
||||
item["size"],
|
||||
item["material"]
|
||||
)
|
||||
confirmed_materials[material_hash] = item
|
||||
|
||||
# 해시 역참조 맵 (유사도 비교용)
|
||||
# 해시 -> 정규화된 설명 문자열 (비교 대상)
|
||||
# 여기서는 specification 자체를 비교 대상으로 사용 (가장 정보량이 많음)
|
||||
confirmed_specs = {
|
||||
h: item["specification"] for h, item in confirmed_materials.items()
|
||||
}
|
||||
|
||||
# 신규 자재 분석
|
||||
unchanged_materials = []
|
||||
changed_materials = []
|
||||
new_materials_list = []
|
||||
|
||||
for new_material in new_materials:
|
||||
description = new_material.get("description", "")
|
||||
size = self._extract_size_from_description(description)
|
||||
material = self._extract_material_from_description(description)
|
||||
|
||||
material_hash = self._generate_material_hash(description, size, material)
|
||||
|
||||
if material_hash in confirmed_materials:
|
||||
# 정확히 일치하는 자재 발견 (해시 일치)
|
||||
confirmed_item = confirmed_materials[material_hash]
|
||||
|
||||
new_qty = float(new_material.get("quantity", 0))
|
||||
confirmed_qty = float(confirmed_item["bom_quantity"])
|
||||
|
||||
if abs(new_qty - confirmed_qty) > 0.001:
|
||||
changed_materials.append({
|
||||
**new_material,
|
||||
"change_type": "QUANTITY_CHANGED",
|
||||
"previous_quantity": confirmed_qty,
|
||||
"previous_item": confirmed_item
|
||||
})
|
||||
else:
|
||||
unchanged_materials.append({
|
||||
**new_material,
|
||||
"reuse_classification": True,
|
||||
"previous_item": confirmed_item
|
||||
})
|
||||
else:
|
||||
# 해시 불일치 - 유사도 검사 (Fuzzy Matching)
|
||||
# 신규 자재 설명과 기존 확정 자재들의 스펙 비교
|
||||
best_match_hash = None
|
||||
best_match_score = 0
|
||||
|
||||
# 성능을 위해 간단한 필터링 후 정밀 비교 권장되나,
|
||||
# 현재는 전체 비교 (데이터량이 많지 않다고 가정)
|
||||
for h, spec in confirmed_specs.items():
|
||||
score = fuzz.ratio(description.lower(), spec.lower())
|
||||
if score > 85: # 85점 이상이면 매우 유사
|
||||
if score > best_match_score:
|
||||
best_match_score = score
|
||||
best_match_hash = h
|
||||
|
||||
if best_match_hash:
|
||||
# 유사한 자재 발견 (오타 또는 미세 변경 가능성)
|
||||
similar_item = confirmed_materials[best_match_hash]
|
||||
new_materials_list.append({
|
||||
**new_material,
|
||||
"change_type": "NEW_BUT_SIMILAR",
|
||||
"similarity_score": best_match_score,
|
||||
"similar_to": similar_item
|
||||
})
|
||||
else:
|
||||
# 완전히 새로운 자재
|
||||
new_materials_list.append({
|
||||
**new_material,
|
||||
"change_type": "NEW_MATERIAL"
|
||||
})
|
||||
|
||||
# 삭제된 자재 찾기
|
||||
new_material_hashes = set()
|
||||
for material in new_materials:
|
||||
d = material.get("description", "")
|
||||
s = self._extract_size_from_description(d)
|
||||
m = self._extract_material_from_description(d)
|
||||
new_material_hashes.add(self._generate_material_hash(d, s, m))
|
||||
|
||||
removed_materials = []
|
||||
for hash_key, confirmed_item in confirmed_materials.items():
|
||||
if hash_key not in new_material_hashes:
|
||||
removed_materials.append({
|
||||
"change_type": "REMOVED",
|
||||
"previous_item": confirmed_item
|
||||
})
|
||||
|
||||
comparison_result = {
|
||||
"has_previous_confirmation": True,
|
||||
"previous_revision": previous_confirmed["revision"],
|
||||
"previous_confirmed_at": previous_confirmed["confirmed_at"],
|
||||
"unchanged_count": len(unchanged_materials),
|
||||
"changed_count": len(changed_materials),
|
||||
"new_count": len(new_materials_list),
|
||||
"removed_count": len(removed_materials),
|
||||
"total_materials": len(new_materials),
|
||||
"classification_needed": len(changed_materials) + len(new_materials_list),
|
||||
"unchanged_materials": unchanged_materials,
|
||||
"changed_materials": changed_materials,
|
||||
"new_materials": new_materials_list,
|
||||
"removed_materials": removed_materials
|
||||
}
|
||||
|
||||
logger.info(f"리비전 비교 완료 (Fuzzy 적용): 변경없음 {len(unchanged_materials)}, "
|
||||
f"변경됨 {len(changed_materials)}, 신규 {len(new_materials_list)}, "
|
||||
f"삭제됨 {len(removed_materials)}")
|
||||
|
||||
return comparison_result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"자재 비교 실패: {str(e)}")
|
||||
raise
|
||||
|
||||
def _extract_revision_number(self, revision: str) -> int:
|
||||
"""리비전 문자열에서 숫자 추출 (Rev.1 → 1)"""
|
||||
try:
|
||||
if revision.startswith("Rev."):
|
||||
return int(revision.replace("Rev.", ""))
|
||||
return 0
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
def _generate_material_hash(self, description: str, size: str, material: str) -> str:
|
||||
"""
|
||||
자재 고유성 판단을 위한 해시 생성
|
||||
|
||||
Args:
|
||||
description: 자재 설명
|
||||
size: 자재 규격/크기
|
||||
material: 자재 재질
|
||||
|
||||
Returns:
|
||||
MD5 해시 문자열
|
||||
"""
|
||||
import re
|
||||
|
||||
def normalize(s: Optional[str]) -> str:
|
||||
if s is None:
|
||||
return ""
|
||||
# 다중 공백을 단일 공백으로 치환하고 앞뒤 공백 제거
|
||||
s = re.sub(r'\s+', ' ', str(s))
|
||||
return s.strip().lower()
|
||||
|
||||
# 각 컴포넌트 정규화
|
||||
d_norm = normalize(description)
|
||||
s_norm = normalize(size)
|
||||
m_norm = normalize(material)
|
||||
|
||||
# RULES.md의 코딩 컨벤션 준수 (pipe separator 사용)
|
||||
# 값이 없는 경우에도 구분자를 포함하여 구조 유지 (예: "desc||mat")
|
||||
hash_input = f"{d_norm}|{s_norm}|{m_norm}"
|
||||
|
||||
return hashlib.md5(hash_input.encode()).hexdigest()
|
||||
|
||||
def _extract_size_from_description(self, description: str) -> str:
|
||||
"""
|
||||
자재 설명에서 사이즈 정보 추출
|
||||
|
||||
지원하는 패턴 (단어 경계 \b 추가하여 정확도 향상):
|
||||
- 1/2" (인치)
|
||||
- 100A (A단위)
|
||||
- 50mm (밀리미터)
|
||||
- 10x20 (가로x세로)
|
||||
- DN100 (DN단위)
|
||||
"""
|
||||
if not description:
|
||||
return ""
|
||||
|
||||
import re
|
||||
size_patterns = [
|
||||
# 인치 패턴 (분수 포함): 1/2", 1.5", 1-1/2"
|
||||
r'\b(\d+(?:[-/.]\d+)?)\s*(?:inch|인치|")',
|
||||
# 밀리미터 패턴: 100mm, 100.5 MM
|
||||
r'\b(\d+(?:\.\d+)?)\s*(?:mm|MM)\b',
|
||||
# A단위 패턴: 100A, 100 A
|
||||
r'\b(\d+)\s*A\b',
|
||||
# DN단위 패턴: DN100, DN 100
|
||||
r'DN\s*(\d+)\b',
|
||||
# 치수 패턴: 10x20, 10*20
|
||||
r'\b(\d+(?:\.\d+)?)\s*[xX*]\s*(\d+(?:\.\d+)?)\b'
|
||||
]
|
||||
|
||||
for pattern in size_patterns:
|
||||
match = re.search(pattern, description, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(0).strip()
|
||||
|
||||
return ""
|
||||
|
||||
def _load_materials_from_db(self) -> List[str]:
|
||||
"""DB에서 자재 목록 동적 로딩 (캐싱 적용 고려 가능)"""
|
||||
try:
|
||||
# MaterialSpecification 및 SpecialMaterial 테이블에서 자재 코드 조회
|
||||
query = text("""
|
||||
SELECT spec_code FROM material_specifications
|
||||
WHERE is_active = TRUE
|
||||
UNION
|
||||
SELECT grade_code FROM material_grades
|
||||
WHERE is_active = TRUE
|
||||
UNION
|
||||
SELECT material_name FROM special_materials
|
||||
WHERE is_active = TRUE
|
||||
""")
|
||||
result = self.db.execute(query).fetchall()
|
||||
db_materials = [row[0] for row in result]
|
||||
|
||||
# 기본 하드코딩 리스트 (DB 조회 실패 시 또는 보완용)
|
||||
default_materials = [
|
||||
"SUS316L", "SUS316", "SUS304L", "SUS304",
|
||||
"SS316L", "SS316", "SS304L", "SS304",
|
||||
"A105N", "A105",
|
||||
"A234 WPB", "A234",
|
||||
"A106 Gr.B", "A106",
|
||||
"WCB", "CF8M", "CF8",
|
||||
"CS", "STS", "PVC", "PP", "PE"
|
||||
]
|
||||
|
||||
# 합치고 중복 제거 후 길이 역순 정렬 (긴 단어 우선 매칭)
|
||||
combined = list(set(db_materials + default_materials))
|
||||
combined.sort(key=len, reverse=True)
|
||||
|
||||
return combined
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"DB 자재 로딩 실패 (기본값 사용): {str(e)}")
|
||||
materials = [
|
||||
"SUS316L", "SUS316", "SUS304L", "SUS304",
|
||||
"SS316L", "SS316", "SS304L", "SS304",
|
||||
"A105N", "A105",
|
||||
"A234 WPB", "A234",
|
||||
"A106 Gr.B", "A106",
|
||||
"WCB", "CF8M", "CF8",
|
||||
"CS", "STS", "PVC", "PP", "PE"
|
||||
]
|
||||
return materials
|
||||
|
||||
def _extract_material_from_description(self, description: str) -> str:
|
||||
"""
|
||||
자재 설명에서 재질 정보 추출
|
||||
우선순위에 따라 매칭 (구체적인 재질 먼저)
|
||||
"""
|
||||
if not description:
|
||||
return ""
|
||||
|
||||
# 자재 목록 로딩 (메모리 캐싱을 위해 클래스 속성으로 저장 고려 가능하지만 여기선 매번 호출로 단순화)
|
||||
# 성능이 중요하다면 __init__ 시점에 로드하거나 lru_cache 사용 권장
|
||||
materials = self._load_materials_from_db()
|
||||
|
||||
description_upper = description.upper()
|
||||
|
||||
for material in materials:
|
||||
# 단어 매칭을 위해 간단한 검사 수행 (부분 문자열이 다른 단어의 일부가 아닌지)
|
||||
if material.upper() in description_upper:
|
||||
return material
|
||||
|
||||
return ""
|
||||
|
||||
def get_revision_comparison(db: Session, job_no: str, current_revision: str,
|
||||
new_materials: List[Dict]) -> Dict:
|
||||
"""
|
||||
리비전 비교 수행 (편의 함수)
|
||||
|
||||
Args:
|
||||
db: 데이터베이스 세션
|
||||
job_no: 프로젝트 번호
|
||||
current_revision: 현재 리비전
|
||||
new_materials: 신규 자재 목록
|
||||
|
||||
Returns:
|
||||
비교 결과 또는 전체 분류 필요 정보
|
||||
"""
|
||||
comparator = RevisionComparator(db)
|
||||
|
||||
# 이전 확정 자료 조회
|
||||
previous_confirmed = comparator.get_previous_confirmed_materials(job_no, current_revision)
|
||||
|
||||
if previous_confirmed is None:
|
||||
# 이전 확정 자료가 없으면 전체 분류 필요
|
||||
return {
|
||||
"has_previous_confirmation": False,
|
||||
"classification_needed": len(new_materials),
|
||||
"all_materials_need_classification": True,
|
||||
"materials_to_classify": new_materials,
|
||||
"message": "이전 확정 자료가 없어 전체 자재를 분류합니다."
|
||||
}
|
||||
|
||||
# 이전 확정 자료가 있으면 비교 수행
|
||||
return comparator.compare_materials(previous_confirmed, new_materials)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
457
backend/app/services/revision_comparison_service.py
Normal file
457
backend/app/services/revision_comparison_service.py
Normal file
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
리비전 비교 및 변경 처리 서비스
|
||||
- 자재 비교 로직 (구매된/미구매 자재 구분)
|
||||
- 리비전 액션 결정 (추가구매, 재고이관, 수량변경 등)
|
||||
- GASKET/BOLT 특별 처리 (BOM 원본 수량 기준)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
from decimal import Decimal
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, and_, or_
|
||||
|
||||
from ..models import Material
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RevisionComparisonService:
|
||||
"""리비전 비교 및 변경 처리 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def compare_materials_by_category(
|
||||
self,
|
||||
current_file_id: int,
|
||||
previous_file_id: int,
|
||||
category: str,
|
||||
session_id: int
|
||||
) -> Dict[str, Any]:
|
||||
"""카테고리별 자재 비교 및 변경사항 기록"""
|
||||
|
||||
try:
|
||||
logger.info(f"카테고리 {category} 자재 비교 시작 (현재: {current_file_id}, 이전: {previous_file_id})")
|
||||
|
||||
# 현재 파일의 자재 조회
|
||||
current_materials = self._get_materials_by_category(current_file_id, category)
|
||||
previous_materials = self._get_materials_by_category(previous_file_id, category)
|
||||
|
||||
logger.info(f"현재 자재: {len(current_materials)}개, 이전 자재: {len(previous_materials)}개")
|
||||
|
||||
# 자재 그룹화 (동일 자재 식별)
|
||||
current_grouped = self._group_materials_by_key(current_materials, category)
|
||||
previous_grouped = self._group_materials_by_key(previous_materials, category)
|
||||
|
||||
# 비교 결과 저장
|
||||
comparison_results = {
|
||||
"added": [],
|
||||
"removed": [],
|
||||
"changed": [],
|
||||
"unchanged": []
|
||||
}
|
||||
|
||||
# 현재 자재 기준으로 비교
|
||||
for key, current_group in current_grouped.items():
|
||||
if key in previous_grouped:
|
||||
previous_group = previous_grouped[key]
|
||||
|
||||
# 수량 비교 (GASKET/BOLT는 BOM 원본 수량 기준)
|
||||
current_qty = self._get_comparison_quantity(current_group, category)
|
||||
previous_qty = self._get_comparison_quantity(previous_group, category)
|
||||
|
||||
if current_qty != previous_qty:
|
||||
# 수량 변경됨
|
||||
change_record = self._create_change_record(
|
||||
current_group, previous_group, "quantity_changed",
|
||||
current_qty, previous_qty, category, session_id
|
||||
)
|
||||
comparison_results["changed"].append(change_record)
|
||||
else:
|
||||
# 수량 동일
|
||||
unchanged_record = self._create_change_record(
|
||||
current_group, previous_group, "unchanged",
|
||||
current_qty, previous_qty, category, session_id
|
||||
)
|
||||
comparison_results["unchanged"].append(unchanged_record)
|
||||
else:
|
||||
# 새로 추가된 자재
|
||||
current_qty = self._get_comparison_quantity(current_group, category)
|
||||
added_record = self._create_change_record(
|
||||
current_group, None, "added",
|
||||
current_qty, 0, category, session_id
|
||||
)
|
||||
comparison_results["added"].append(added_record)
|
||||
|
||||
# 제거된 자재 확인
|
||||
for key, previous_group in previous_grouped.items():
|
||||
if key not in current_grouped:
|
||||
previous_qty = self._get_comparison_quantity(previous_group, category)
|
||||
removed_record = self._create_change_record(
|
||||
None, previous_group, "removed",
|
||||
0, previous_qty, category, session_id
|
||||
)
|
||||
comparison_results["removed"].append(removed_record)
|
||||
|
||||
# DB에 변경사항 저장
|
||||
self._save_material_changes(comparison_results, session_id)
|
||||
|
||||
# 통계 정보
|
||||
summary = {
|
||||
"category": category,
|
||||
"added_count": len(comparison_results["added"]),
|
||||
"removed_count": len(comparison_results["removed"]),
|
||||
"changed_count": len(comparison_results["changed"]),
|
||||
"unchanged_count": len(comparison_results["unchanged"]),
|
||||
"total_changes": len(comparison_results["added"]) + len(comparison_results["removed"]) + len(comparison_results["changed"])
|
||||
}
|
||||
|
||||
logger.info(f"카테고리 {category} 비교 완료: {summary}")
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"changes": comparison_results
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"카테고리 {category} 자재 비교 실패: {e}")
|
||||
raise
|
||||
|
||||
def _get_materials_by_category(self, file_id: int, category: str) -> List[Material]:
|
||||
"""파일의 특정 카테고리 자재 조회"""
|
||||
|
||||
return self.db.query(Material).filter(
|
||||
and_(
|
||||
Material.file_id == file_id,
|
||||
Material.classified_category == category,
|
||||
Material.is_active == True
|
||||
)
|
||||
).all()
|
||||
|
||||
def _group_materials_by_key(self, materials: List[Material], category: str) -> Dict[str, Dict]:
|
||||
"""자재를 고유 키로 그룹화"""
|
||||
|
||||
grouped = {}
|
||||
|
||||
for material in materials:
|
||||
# 카테고리별 고유 키 생성 전략
|
||||
if category == "PIPE":
|
||||
# PIPE: description + material_grade + main_nom
|
||||
key_parts = [
|
||||
material.original_description.strip().upper(),
|
||||
material.material_grade or '',
|
||||
material.main_nom or ''
|
||||
]
|
||||
elif category in ["GASKET", "BOLT"]:
|
||||
# GASKET/BOLT: description + main_nom (집계 공식 적용 전 원본 기준)
|
||||
key_parts = [
|
||||
material.original_description.strip().upper(),
|
||||
material.main_nom or ''
|
||||
]
|
||||
else:
|
||||
# 기타: description + drawing + main_nom + red_nom
|
||||
key_parts = [
|
||||
material.original_description.strip().upper(),
|
||||
material.drawing_name or '',
|
||||
material.main_nom or '',
|
||||
material.red_nom or ''
|
||||
]
|
||||
|
||||
key = "|".join(key_parts)
|
||||
|
||||
if key in grouped:
|
||||
# 동일한 자재가 있으면 수량 합산
|
||||
grouped[key]['total_quantity'] += float(material.quantity)
|
||||
grouped[key]['materials'].append(material)
|
||||
|
||||
# 구매 확정 상태 확인 (하나라도 구매 확정되면 전체가 구매 확정)
|
||||
if getattr(material, 'purchase_confirmed', False):
|
||||
grouped[key]['purchase_confirmed'] = True
|
||||
grouped[key]['purchase_confirmed_at'] = getattr(material, 'purchase_confirmed_at', None)
|
||||
|
||||
else:
|
||||
grouped[key] = {
|
||||
'key': key,
|
||||
'representative_material': material,
|
||||
'materials': [material],
|
||||
'total_quantity': float(material.quantity),
|
||||
'purchase_confirmed': getattr(material, 'purchase_confirmed', False),
|
||||
'purchase_confirmed_at': getattr(material, 'purchase_confirmed_at', None),
|
||||
'category': category
|
||||
}
|
||||
|
||||
return grouped
|
||||
|
||||
def _get_comparison_quantity(self, material_group: Dict, category: str) -> Decimal:
|
||||
"""비교용 수량 계산 (GASKET/BOLT는 집계 공식 적용 전 원본 수량)"""
|
||||
|
||||
if category in ["GASKET", "BOLT"]:
|
||||
# GASKET/BOLT: BOM 원본 수량 기준 (집계 공식 적용 전)
|
||||
# 실제 BOM에서 읽은 원본 수량을 사용
|
||||
original_quantity = 0
|
||||
for material in material_group['materials']:
|
||||
# classification_details에서 원본 수량 추출 시도
|
||||
details = getattr(material, 'classification_details', {})
|
||||
if isinstance(details, dict) and 'original_quantity' in details:
|
||||
original_quantity += float(details['original_quantity'])
|
||||
else:
|
||||
# 원본 수량 정보가 없으면 현재 수량 사용
|
||||
original_quantity += float(material.quantity)
|
||||
|
||||
return Decimal(str(original_quantity))
|
||||
else:
|
||||
# 기타 카테고리: 현재 수량 사용
|
||||
return Decimal(str(material_group['total_quantity']))
|
||||
|
||||
def _create_change_record(
|
||||
self,
|
||||
current_group: Optional[Dict],
|
||||
previous_group: Optional[Dict],
|
||||
change_type: str,
|
||||
current_qty: Decimal,
|
||||
previous_qty: Decimal,
|
||||
category: str,
|
||||
session_id: int
|
||||
) -> Dict[str, Any]:
|
||||
"""변경 기록 생성"""
|
||||
|
||||
# 대표 자재 정보
|
||||
if current_group:
|
||||
material = current_group['representative_material']
|
||||
material_id = material.id
|
||||
description = material.original_description
|
||||
purchase_status = 'purchased' if current_group['purchase_confirmed'] else 'not_purchased'
|
||||
purchase_confirmed_at = current_group.get('purchase_confirmed_at')
|
||||
else:
|
||||
material = previous_group['representative_material']
|
||||
material_id = None # 제거된 자재는 현재 material_id가 없음
|
||||
description = material.original_description
|
||||
purchase_status = 'purchased' if previous_group['purchase_confirmed'] else 'not_purchased'
|
||||
purchase_confirmed_at = previous_group.get('purchase_confirmed_at')
|
||||
|
||||
# 리비전 액션 결정
|
||||
revision_action = self._determine_revision_action(
|
||||
change_type, current_qty, previous_qty, purchase_status, category
|
||||
)
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"material_id": material_id,
|
||||
"previous_material_id": material.id if previous_group else None,
|
||||
"material_description": description,
|
||||
"category": category,
|
||||
"change_type": change_type,
|
||||
"current_quantity": float(current_qty),
|
||||
"previous_quantity": float(previous_qty),
|
||||
"quantity_difference": float(current_qty - previous_qty),
|
||||
"purchase_status": purchase_status,
|
||||
"purchase_confirmed_at": purchase_confirmed_at,
|
||||
"revision_action": revision_action
|
||||
}
|
||||
|
||||
def _determine_revision_action(
|
||||
self,
|
||||
change_type: str,
|
||||
current_qty: Decimal,
|
||||
previous_qty: Decimal,
|
||||
purchase_status: str,
|
||||
category: str
|
||||
) -> str:
|
||||
"""리비전 액션 결정 로직"""
|
||||
|
||||
if change_type == "added":
|
||||
return "new_material"
|
||||
elif change_type == "removed":
|
||||
if purchase_status == "purchased":
|
||||
return "inventory_transfer" # 구매된 자재 → 재고 이관
|
||||
else:
|
||||
return "purchase_cancel" # 미구매 자재 → 구매 취소
|
||||
elif change_type == "quantity_changed":
|
||||
quantity_diff = current_qty - previous_qty
|
||||
|
||||
if purchase_status == "purchased":
|
||||
if quantity_diff > 0:
|
||||
return "additional_purchase" # 구매된 자재 수량 증가 → 추가 구매
|
||||
else:
|
||||
return "inventory_transfer" # 구매된 자재 수량 감소 → 재고 이관
|
||||
else:
|
||||
return "quantity_update" # 미구매 자재 → 수량 업데이트
|
||||
else:
|
||||
return "maintain" # 변경 없음
|
||||
|
||||
def _save_material_changes(self, comparison_results: Dict, session_id: int):
|
||||
"""변경사항을 DB에 저장"""
|
||||
|
||||
try:
|
||||
all_changes = []
|
||||
for change_type, changes in comparison_results.items():
|
||||
all_changes.extend(changes)
|
||||
|
||||
if not all_changes:
|
||||
return
|
||||
|
||||
# 배치 삽입
|
||||
insert_query = """
|
||||
INSERT INTO revision_material_changes (
|
||||
session_id, material_id, previous_material_id, material_description,
|
||||
category, change_type, current_quantity, previous_quantity,
|
||||
quantity_difference, purchase_status, purchase_confirmed_at, revision_action
|
||||
) VALUES (
|
||||
:session_id, :material_id, :previous_material_id, :material_description,
|
||||
:category, :change_type, :current_quantity, :previous_quantity,
|
||||
:quantity_difference, :purchase_status, :purchase_confirmed_at, :revision_action
|
||||
)
|
||||
"""
|
||||
|
||||
self.db.execute(text(insert_query), all_changes)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"변경사항 {len(all_changes)}건 저장 완료 (세션: {session_id})")
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"변경사항 저장 실패: {e}")
|
||||
raise
|
||||
|
||||
def get_session_changes(self, session_id: int, category: str = None) -> List[Dict[str, Any]]:
|
||||
"""세션의 변경사항 조회"""
|
||||
|
||||
try:
|
||||
query = """
|
||||
SELECT
|
||||
id, material_id, material_description, category,
|
||||
change_type, current_quantity, previous_quantity, quantity_difference,
|
||||
purchase_status, revision_action, action_status,
|
||||
processed_by, processed_at, processing_notes
|
||||
FROM revision_material_changes
|
||||
WHERE session_id = :session_id
|
||||
"""
|
||||
params = {"session_id": session_id}
|
||||
|
||||
if category:
|
||||
query += " AND category = :category"
|
||||
params["category"] = category
|
||||
|
||||
query += " ORDER BY category, material_description"
|
||||
|
||||
changes = self.db.execute(text(query), params).fetchall()
|
||||
|
||||
return [dict(change._mapping) for change in changes]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"세션 변경사항 조회 실패: {e}")
|
||||
raise
|
||||
|
||||
def process_revision_action(
|
||||
self,
|
||||
change_id: int,
|
||||
action: str,
|
||||
username: str,
|
||||
notes: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""리비전 액션 처리"""
|
||||
|
||||
try:
|
||||
# 변경사항 조회
|
||||
change = self.db.execute(text("""
|
||||
SELECT * FROM revision_material_changes WHERE id = :change_id
|
||||
"""), {"change_id": change_id}).fetchone()
|
||||
|
||||
if not change:
|
||||
raise ValueError(f"변경사항을 찾을 수 없습니다: {change_id}")
|
||||
|
||||
result = {"success": False, "message": ""}
|
||||
|
||||
# 액션별 처리
|
||||
if action == "additional_purchase":
|
||||
result = self._process_additional_purchase(change, username, notes)
|
||||
elif action == "inventory_transfer":
|
||||
result = self._process_inventory_transfer(change, username, notes)
|
||||
elif action == "purchase_cancel":
|
||||
result = self._process_purchase_cancel(change, username, notes)
|
||||
elif action == "quantity_update":
|
||||
result = self._process_quantity_update(change, username, notes)
|
||||
else:
|
||||
result = {"success": True, "message": "처리 완료"}
|
||||
|
||||
# 처리 상태 업데이트
|
||||
status = "completed" if result["success"] else "failed"
|
||||
self.db.execute(text("""
|
||||
UPDATE revision_material_changes
|
||||
SET action_status = :status, processed_by = :username,
|
||||
processed_at = CURRENT_TIMESTAMP, processing_notes = :notes
|
||||
WHERE id = :change_id
|
||||
"""), {
|
||||
"change_id": change_id,
|
||||
"status": status,
|
||||
"username": username,
|
||||
"notes": notes or result["message"]
|
||||
})
|
||||
|
||||
# 액션 로그 기록
|
||||
self.db.execute(text("""
|
||||
INSERT INTO revision_action_logs (
|
||||
session_id, revision_change_id, action_type, action_description,
|
||||
executed_by, result, result_message
|
||||
) VALUES (
|
||||
:session_id, :change_id, :action, :description,
|
||||
:username, :result, :message
|
||||
)
|
||||
"""), {
|
||||
"session_id": change.session_id,
|
||||
"change_id": change_id,
|
||||
"action": action,
|
||||
"description": f"{change.material_description} - {action}",
|
||||
"username": username,
|
||||
"result": "success" if result["success"] else "failed",
|
||||
"message": result["message"]
|
||||
})
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"리비전 액션 처리 실패: {e}")
|
||||
raise
|
||||
|
||||
def _process_additional_purchase(self, change, username: str, notes: str) -> Dict[str, Any]:
|
||||
"""추가 구매 처리"""
|
||||
# 구매 요청 생성 로직 구현
|
||||
return {"success": True, "message": f"추가 구매 요청 생성: {change.quantity_difference}개"}
|
||||
|
||||
def _process_inventory_transfer(self, change, username: str, notes: str) -> Dict[str, Any]:
|
||||
"""재고 이관 처리"""
|
||||
# 재고 이관 로직 구현
|
||||
try:
|
||||
self.db.execute(text("""
|
||||
INSERT INTO inventory_transfers (
|
||||
revision_change_id, material_description, category,
|
||||
quantity, unit, transferred_by, storage_notes
|
||||
) VALUES (
|
||||
:change_id, :description, :category,
|
||||
:quantity, 'EA', :username, :notes
|
||||
)
|
||||
"""), {
|
||||
"change_id": change.id,
|
||||
"description": change.material_description,
|
||||
"category": change.category,
|
||||
"quantity": abs(change.quantity_difference),
|
||||
"username": username,
|
||||
"notes": notes
|
||||
})
|
||||
|
||||
return {"success": True, "message": f"재고 이관 완료: {abs(change.quantity_difference)}개"}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"재고 이관 실패: {str(e)}"}
|
||||
|
||||
def _process_purchase_cancel(self, change, username: str, notes: str) -> Dict[str, Any]:
|
||||
"""구매 취소 처리"""
|
||||
return {"success": True, "message": "구매 취소 완료"}
|
||||
|
||||
def _process_quantity_update(self, change, username: str, notes: str) -> Dict[str, Any]:
|
||||
"""수량 업데이트 처리"""
|
||||
return {"success": True, "message": f"수량 업데이트 완료: {change.current_quantity}개"}
|
||||
289
backend/app/services/revision_session_service.py
Normal file
289
backend/app/services/revision_session_service.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
리비전 세션 관리 서비스
|
||||
- 리비전 세션 생성, 관리, 완료 처리
|
||||
- 자재 변경 사항 추적 및 처리
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, and_, or_
|
||||
|
||||
from ..models import File, Material
|
||||
from ..database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RevisionSessionService:
|
||||
"""리비전 세션 관리 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create_revision_session(
|
||||
self,
|
||||
job_no: str,
|
||||
current_file_id: int,
|
||||
previous_file_id: int,
|
||||
username: str
|
||||
) -> Dict[str, Any]:
|
||||
"""새로운 리비전 세션 생성"""
|
||||
|
||||
try:
|
||||
# 파일 정보 조회
|
||||
current_file = self.db.query(File).filter(File.id == current_file_id).first()
|
||||
previous_file = self.db.query(File).filter(File.id == previous_file_id).first()
|
||||
|
||||
if not current_file or not previous_file:
|
||||
raise ValueError("파일 정보를 찾을 수 없습니다")
|
||||
|
||||
# 기존 진행 중인 세션이 있는지 확인
|
||||
existing_session = self.db.execute(text("""
|
||||
SELECT id FROM revision_sessions
|
||||
WHERE job_no = :job_no AND status = 'processing'
|
||||
"""), {"job_no": job_no}).fetchone()
|
||||
|
||||
if existing_session:
|
||||
logger.warning(f"기존 진행 중인 리비전 세션이 있습니다: {existing_session[0]}")
|
||||
return {"session_id": existing_session[0], "status": "existing"}
|
||||
|
||||
# 새 세션 생성
|
||||
session_data = {
|
||||
"job_no": job_no,
|
||||
"current_file_id": current_file_id,
|
||||
"previous_file_id": previous_file_id,
|
||||
"current_revision": current_file.revision,
|
||||
"previous_revision": previous_file.revision,
|
||||
"status": "processing",
|
||||
"created_by": username
|
||||
}
|
||||
|
||||
result = self.db.execute(text("""
|
||||
INSERT INTO revision_sessions (
|
||||
job_no, current_file_id, previous_file_id,
|
||||
current_revision, previous_revision, status, created_by
|
||||
) VALUES (
|
||||
:job_no, :current_file_id, :previous_file_id,
|
||||
:current_revision, :previous_revision, :status, :created_by
|
||||
) RETURNING id
|
||||
"""), session_data)
|
||||
|
||||
session_id = result.fetchone()[0]
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"새 리비전 세션 생성: {session_id} (Job: {job_no})")
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "created",
|
||||
"job_no": job_no,
|
||||
"current_revision": current_file.revision,
|
||||
"previous_revision": previous_file.revision
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"리비전 세션 생성 실패: {e}")
|
||||
raise
|
||||
|
||||
def get_session_status(self, session_id: int) -> Dict[str, Any]:
|
||||
"""리비전 세션 상태 조회"""
|
||||
|
||||
try:
|
||||
session_info = self.db.execute(text("""
|
||||
SELECT
|
||||
id, job_no, current_file_id, previous_file_id,
|
||||
current_revision, previous_revision, status,
|
||||
total_materials, processed_materials,
|
||||
added_count, removed_count, changed_count, unchanged_count,
|
||||
purchase_cancel_count, inventory_transfer_count, additional_purchase_count,
|
||||
created_by, created_at, completed_at
|
||||
FROM revision_sessions
|
||||
WHERE id = :session_id
|
||||
"""), {"session_id": session_id}).fetchone()
|
||||
|
||||
if not session_info:
|
||||
raise ValueError(f"리비전 세션을 찾을 수 없습니다: {session_id}")
|
||||
|
||||
# 변경 사항 상세 조회
|
||||
changes = self.db.execute(text("""
|
||||
SELECT
|
||||
category, change_type, revision_action, action_status,
|
||||
COUNT(*) as count,
|
||||
SUM(CASE WHEN purchase_status = 'purchased' THEN 1 ELSE 0 END) as purchased_count,
|
||||
SUM(CASE WHEN purchase_status = 'not_purchased' THEN 1 ELSE 0 END) as unpurchased_count
|
||||
FROM revision_material_changes
|
||||
WHERE session_id = :session_id
|
||||
GROUP BY category, change_type, revision_action, action_status
|
||||
ORDER BY category, change_type
|
||||
"""), {"session_id": session_id}).fetchall()
|
||||
|
||||
return {
|
||||
"session_info": dict(session_info._mapping),
|
||||
"changes_summary": [dict(change._mapping) for change in changes],
|
||||
"progress_percentage": (
|
||||
(session_info.processed_materials / session_info.total_materials * 100)
|
||||
if session_info.total_materials > 0 else 0
|
||||
)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"리비전 세션 상태 조회 실패: {e}")
|
||||
raise
|
||||
|
||||
def update_session_progress(
|
||||
self,
|
||||
session_id: int,
|
||||
total_materials: int = None,
|
||||
processed_materials: int = None,
|
||||
**counts
|
||||
) -> bool:
|
||||
"""리비전 세션 진행 상황 업데이트"""
|
||||
|
||||
try:
|
||||
update_fields = []
|
||||
update_values = {"session_id": session_id}
|
||||
|
||||
if total_materials is not None:
|
||||
update_fields.append("total_materials = :total_materials")
|
||||
update_values["total_materials"] = total_materials
|
||||
|
||||
if processed_materials is not None:
|
||||
update_fields.append("processed_materials = :processed_materials")
|
||||
update_values["processed_materials"] = processed_materials
|
||||
|
||||
# 카운트 필드들 업데이트
|
||||
count_fields = [
|
||||
"added_count", "removed_count", "changed_count", "unchanged_count",
|
||||
"purchase_cancel_count", "inventory_transfer_count", "additional_purchase_count"
|
||||
]
|
||||
|
||||
for field in count_fields:
|
||||
if field in counts:
|
||||
update_fields.append(f"{field} = :{field}")
|
||||
update_values[field] = counts[field]
|
||||
|
||||
if not update_fields:
|
||||
return True # 업데이트할 내용이 없음
|
||||
|
||||
query = f"""
|
||||
UPDATE revision_sessions
|
||||
SET {', '.join(update_fields)}
|
||||
WHERE id = :session_id
|
||||
"""
|
||||
|
||||
self.db.execute(text(query), update_values)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"리비전 세션 진행 상황 업데이트: {session_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"리비전 세션 진행 상황 업데이트 실패: {e}")
|
||||
raise
|
||||
|
||||
def complete_session(self, session_id: int, username: str) -> Dict[str, Any]:
|
||||
"""리비전 세션 완료 처리"""
|
||||
|
||||
try:
|
||||
# 세션 상태를 완료로 변경
|
||||
self.db.execute(text("""
|
||||
UPDATE revision_sessions
|
||||
SET status = 'completed', completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :session_id AND status = 'processing'
|
||||
"""), {"session_id": session_id})
|
||||
|
||||
# 완료 로그 기록
|
||||
self.db.execute(text("""
|
||||
INSERT INTO revision_action_logs (
|
||||
session_id, action_type, action_description,
|
||||
executed_by, result, result_message
|
||||
) VALUES (
|
||||
:session_id, 'session_complete', '리비전 세션 완료',
|
||||
:username, 'success', '모든 리비전 처리 완료'
|
||||
)
|
||||
"""), {
|
||||
"session_id": session_id,
|
||||
"username": username
|
||||
})
|
||||
|
||||
self.db.commit()
|
||||
|
||||
# 최종 상태 조회
|
||||
final_status = self.get_session_status(session_id)
|
||||
|
||||
logger.info(f"리비전 세션 완료: {session_id}")
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"session_id": session_id,
|
||||
"final_status": final_status
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"리비전 세션 완료 처리 실패: {e}")
|
||||
raise
|
||||
|
||||
def cancel_session(self, session_id: int, username: str, reason: str = None) -> bool:
|
||||
"""리비전 세션 취소"""
|
||||
|
||||
try:
|
||||
# 세션 상태를 취소로 변경
|
||||
self.db.execute(text("""
|
||||
UPDATE revision_sessions
|
||||
SET status = 'cancelled', completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :session_id AND status = 'processing'
|
||||
"""), {"session_id": session_id})
|
||||
|
||||
# 취소 로그 기록
|
||||
self.db.execute(text("""
|
||||
INSERT INTO revision_action_logs (
|
||||
session_id, action_type, action_description,
|
||||
executed_by, result, result_message
|
||||
) VALUES (
|
||||
:session_id, 'session_cancel', '리비전 세션 취소',
|
||||
:username, 'cancelled', :reason
|
||||
)
|
||||
"""), {
|
||||
"session_id": session_id,
|
||||
"username": username,
|
||||
"reason": reason or "사용자 요청에 의한 취소"
|
||||
})
|
||||
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"리비전 세션 취소: {session_id}, 사유: {reason}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"리비전 세션 취소 실패: {e}")
|
||||
raise
|
||||
|
||||
def get_job_revision_history(self, job_no: str) -> List[Dict[str, Any]]:
|
||||
"""Job의 리비전 히스토리 조회"""
|
||||
|
||||
try:
|
||||
sessions = self.db.execute(text("""
|
||||
SELECT
|
||||
rs.id, rs.current_revision, rs.previous_revision,
|
||||
rs.status, rs.created_by, rs.created_at, rs.completed_at,
|
||||
rs.added_count, rs.removed_count, rs.changed_count,
|
||||
cf.filename as current_filename,
|
||||
pf.filename as previous_filename
|
||||
FROM revision_sessions rs
|
||||
LEFT JOIN files cf ON rs.current_file_id = cf.id
|
||||
LEFT JOIN files pf ON rs.previous_file_id = pf.id
|
||||
WHERE rs.job_no = :job_no
|
||||
ORDER BY rs.created_at DESC
|
||||
"""), {"job_no": job_no}).fetchall()
|
||||
|
||||
return [dict(session._mapping) for session in sessions]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"리비전 히스토리 조회 실패: {e}")
|
||||
raise
|
||||
34
backend/app/services/structural_classifier.py
Normal file
34
backend/app/services/structural_classifier.py
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
import re
|
||||
from typing import Dict
|
||||
|
||||
def classify_structural(tag: str, description: str, main_nom: str = "") -> Dict:
|
||||
"""
|
||||
형강(STRUCTURAL) 분류기
|
||||
규격 예: H-BEAM 100x100x6x8
|
||||
"""
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 1. 타입 식별
|
||||
struct_type = "UNKNOWN"
|
||||
if "H-BEAM" in desc_upper or "H-SECTION" in desc_upper: struct_type = "H-BEAM"
|
||||
elif "ANGLE" in desc_upper or "L-SECTION" in desc_upper: struct_type = "ANGLE"
|
||||
elif "CHANNEL" in desc_upper or "C-SECTION" in desc_upper: struct_type = "CHANNEL"
|
||||
elif "BEAM" in desc_upper: struct_type = "I-BEAM"
|
||||
|
||||
# 2. 규격 추출
|
||||
# 패턴: 100x100, 100x100x6x8, 50x50x5T, H-200x200
|
||||
dimension = ""
|
||||
# 숫자와 x 또는 *로 구성된 패턴, 앞에 H- 등이 붙을 수 있음
|
||||
dim_match = re.search(r'(?:H-|L-|C-)?(\d+(?:\.\d+)?(?:\s*[X\*]\s*\d+(?:\.\d+)?){1,3})', desc_upper)
|
||||
if dim_match:
|
||||
dimension = dim_match.group(1).replace("*", "x")
|
||||
|
||||
return {
|
||||
"category": "STRUCTURAL",
|
||||
"overall_confidence": 0.9,
|
||||
"details": {
|
||||
"type": struct_type,
|
||||
"dimension": dimension
|
||||
}
|
||||
}
|
||||
329
backend/app/services/support_classifier.py
Normal file
329
backend/app/services/support_classifier.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
SUPPORT 분류 시스템
|
||||
배관 지지재, 우레탄 블록, 클램프 등 지지 부품 분류
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
from .material_classifier import classify_material
|
||||
|
||||
# ========== 서포트 타입별 분류 ==========
|
||||
SUPPORT_TYPES = {
|
||||
"URETHANE_BLOCK": {
|
||||
"dat_file_patterns": ["URETHANE", "BLOCK", "SHOE"],
|
||||
"description_keywords": ["URETHANE BLOCK", "BLOCK SHOE", "우레탄 블록", "우레탄", "URETHANE"],
|
||||
"characteristics": "우레탄 블록 슈",
|
||||
"applications": "배관 지지, 진동 흡수",
|
||||
"material_type": "URETHANE"
|
||||
},
|
||||
|
||||
"CLAMP": {
|
||||
"dat_file_patterns": ["CLAMP", "CL-"],
|
||||
"description_keywords": ["CLAMP", "클램프", "CL-1", "CL-2", "CL-3"],
|
||||
"characteristics": "배관 클램프",
|
||||
"applications": "배관 고정, 지지",
|
||||
"material_type": "STEEL"
|
||||
},
|
||||
|
||||
"HANGER": {
|
||||
"dat_file_patterns": ["HANGER", "HANG", "SUPP"],
|
||||
"description_keywords": ["HANGER", "SUPPORT", "행거", "서포트", "PIPE HANGER"],
|
||||
"characteristics": "배관 행거",
|
||||
"applications": "배관 매달기, 지지",
|
||||
"material_type": "STEEL"
|
||||
},
|
||||
|
||||
"SPRING_HANGER": {
|
||||
"dat_file_patterns": ["SPRING", "SPR_"],
|
||||
"description_keywords": ["SPRING HANGER", "SPRING", "스프링", "스프링 행거"],
|
||||
"characteristics": "스프링 행거",
|
||||
"applications": "가변 하중 지지",
|
||||
"material_type": "STEEL"
|
||||
},
|
||||
|
||||
"GUIDE": {
|
||||
"dat_file_patterns": ["GUIDE", "GD_"],
|
||||
"description_keywords": ["GUIDE", "가이드", "PIPE GUIDE"],
|
||||
"characteristics": "배관 가이드",
|
||||
"applications": "배관 방향 제어",
|
||||
"material_type": "STEEL"
|
||||
},
|
||||
|
||||
"ANCHOR": {
|
||||
"dat_file_patterns": ["ANCHOR", "ANCH"],
|
||||
"description_keywords": ["ANCHOR", "앵커", "PIPE ANCHOR"],
|
||||
"characteristics": "배관 앵커",
|
||||
"applications": "배관 고정점",
|
||||
"material_type": "STEEL"
|
||||
}
|
||||
}
|
||||
|
||||
# ========== 하중 등급 분류 ==========
|
||||
LOAD_RATINGS = {
|
||||
"LIGHT": {
|
||||
"patterns": [r"(\d+)T", r"(\d+)TON"],
|
||||
"range": (0, 5), # 5톤 이하
|
||||
"description": "경하중용"
|
||||
},
|
||||
"MEDIUM": {
|
||||
"patterns": [r"(\d+)T", r"(\d+)TON"],
|
||||
"range": (5, 20), # 5-20톤
|
||||
"description": "중하중용"
|
||||
},
|
||||
"HEAVY": {
|
||||
"patterns": [r"(\d+)T", r"(\d+)TON"],
|
||||
"range": (20, 100), # 20-100톤
|
||||
"description": "중하중용"
|
||||
}
|
||||
}
|
||||
|
||||
def classify_support(dat_file: str, description: str, main_nom: str,
|
||||
length: Optional[float] = None) -> Dict:
|
||||
"""
|
||||
SUPPORT 분류 메인 함수
|
||||
|
||||
Args:
|
||||
dat_file: DAT 파일명
|
||||
description: 자재 설명
|
||||
main_nom: 주 사이즈
|
||||
length: 길이 (옵션)
|
||||
|
||||
Returns:
|
||||
분류 결과 딕셔너리
|
||||
"""
|
||||
|
||||
dat_upper = dat_file.upper()
|
||||
desc_upper = description.upper()
|
||||
combined_text = f"{dat_upper} {desc_upper}"
|
||||
|
||||
# 1. 서포트 타입 분류
|
||||
support_type_result = classify_support_type(dat_file, description)
|
||||
|
||||
# 2. 재질 분류 (공통 모듈 사용)
|
||||
material_result = classify_material(description)
|
||||
|
||||
# 3. 하중 등급 분류
|
||||
load_result = classify_load_rating(description)
|
||||
|
||||
# 4. 사이즈 정보 추출
|
||||
size_result = extract_support_size(description, main_nom)
|
||||
|
||||
# 5. 사용자 요구사항 추출
|
||||
user_requirements = extract_support_user_requirements(description)
|
||||
|
||||
# 6. 우레탄 블럭슈 두께 정보 추출 및 Material Grade 보강
|
||||
enhanced_material_grade = material_result.get('grade', 'UNKNOWN')
|
||||
if support_type_result.get("support_type") == "URETHANE_BLOCK":
|
||||
# 두께 정보 추출 (40t, 27t 등)
|
||||
thickness_match = re.search(r'(\d+)\s*[tT](?![oO])', description.upper())
|
||||
if thickness_match:
|
||||
thickness = f"{thickness_match.group(1)}t"
|
||||
if enhanced_material_grade == 'UNKNOWN' or not enhanced_material_grade:
|
||||
enhanced_material_grade = thickness
|
||||
elif thickness not in enhanced_material_grade:
|
||||
enhanced_material_grade = f"{enhanced_material_grade} {thickness}"
|
||||
|
||||
# 7. 최종 결과 조합
|
||||
return {
|
||||
"category": "SUPPORT",
|
||||
|
||||
# 서포트 특화 정보
|
||||
"support_type": support_type_result.get("support_type", "UNKNOWN"),
|
||||
"support_subtype": support_type_result.get("subtype", ""),
|
||||
"load_rating": load_result.get("load_rating", ""),
|
||||
"load_capacity": load_result.get("capacity", ""),
|
||||
|
||||
# 재질 정보 (공통 모듈) - 우레탄 블럭슈 두께 정보 포함
|
||||
"material": {
|
||||
"standard": material_result.get('standard', 'UNKNOWN'),
|
||||
"grade": enhanced_material_grade,
|
||||
"material_type": material_result.get('material_type', 'UNKNOWN'),
|
||||
"confidence": material_result.get('confidence', 0.0)
|
||||
},
|
||||
|
||||
# 사이즈 정보
|
||||
"size_info": size_result,
|
||||
|
||||
# 사용자 요구사항
|
||||
"user_requirements": user_requirements,
|
||||
|
||||
# 전체 신뢰도
|
||||
"overall_confidence": calculate_support_confidence({
|
||||
"type": support_type_result.get('confidence', 0),
|
||||
"material": material_result.get('confidence', 0),
|
||||
"load": load_result.get('confidence', 0),
|
||||
"size": size_result.get('confidence', 0)
|
||||
}),
|
||||
|
||||
# 증거
|
||||
"evidence": [
|
||||
f"SUPPORT_TYPE: {support_type_result.get('support_type', 'UNKNOWN')}",
|
||||
f"MATERIAL: {material_result.get('standard', 'UNKNOWN')}",
|
||||
f"LOAD: {load_result.get('load_rating', 'UNKNOWN')}"
|
||||
]
|
||||
}
|
||||
|
||||
def classify_support_type(dat_file: str, description: str) -> Dict:
|
||||
"""서포트 타입 분류"""
|
||||
|
||||
dat_upper = dat_file.upper()
|
||||
desc_upper = description.upper()
|
||||
combined_text = f"{dat_upper} {desc_upper}"
|
||||
|
||||
for support_type, type_data in SUPPORT_TYPES.items():
|
||||
# DAT 파일 패턴 확인
|
||||
for pattern in type_data["dat_file_patterns"]:
|
||||
if pattern in dat_upper:
|
||||
return {
|
||||
"support_type": support_type,
|
||||
"subtype": type_data["characteristics"],
|
||||
"applications": type_data["applications"],
|
||||
"confidence": 0.95,
|
||||
"evidence": [f"DAT_PATTERN: {pattern}"]
|
||||
}
|
||||
|
||||
# 설명 키워드 확인
|
||||
for keyword in type_data["description_keywords"]:
|
||||
if keyword in desc_upper:
|
||||
return {
|
||||
"support_type": support_type,
|
||||
"subtype": type_data["characteristics"],
|
||||
"applications": type_data["applications"],
|
||||
"confidence": 0.9,
|
||||
"evidence": [f"DESC_KEYWORD: {keyword}"]
|
||||
}
|
||||
|
||||
return {
|
||||
"support_type": "UNKNOWN",
|
||||
"subtype": "",
|
||||
"applications": "",
|
||||
"confidence": 0.0,
|
||||
"evidence": ["NO_SUPPORT_TYPE_FOUND"]
|
||||
}
|
||||
|
||||
def extract_support_user_requirements(description: str) -> List[str]:
|
||||
"""서포트 사용자 요구사항 추출"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
requirements = []
|
||||
|
||||
# 표면처리 관련
|
||||
if 'GALV' in desc_upper or 'GALVANIZED' in desc_upper:
|
||||
requirements.append('GALVANIZED')
|
||||
if 'HDG' in desc_upper or 'HOT DIP' in desc_upper:
|
||||
requirements.append('HOT DIP GALVANIZED')
|
||||
if 'PAINT' in desc_upper or 'PAINTED' in desc_upper:
|
||||
requirements.append('PAINTED')
|
||||
|
||||
# 재질 관련
|
||||
if 'SS' in desc_upper or 'STAINLESS' in desc_upper:
|
||||
requirements.append('STAINLESS STEEL')
|
||||
if 'CARBON' in desc_upper:
|
||||
requirements.append('CARBON STEEL')
|
||||
|
||||
# 특수 요구사항
|
||||
if 'FIRE SAFE' in desc_upper:
|
||||
requirements.append('FIRE SAFE')
|
||||
if 'SEISMIC' in desc_upper or '내진' in desc_upper:
|
||||
requirements.append('SEISMIC')
|
||||
|
||||
return requirements
|
||||
|
||||
def classify_load_rating(description: str) -> Dict:
|
||||
"""하중 등급 분류"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 하중 패턴 찾기 (40T, 50TON 등)
|
||||
for rating, rating_data in LOAD_RATINGS.items():
|
||||
for pattern in rating_data["patterns"]:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
capacity = int(match.group(1))
|
||||
min_load, max_load = rating_data["range"]
|
||||
|
||||
if min_load <= capacity <= max_load:
|
||||
return {
|
||||
"load_rating": rating,
|
||||
"capacity": f"{capacity}T",
|
||||
"description": rating_data["description"],
|
||||
"confidence": 0.9,
|
||||
"evidence": [f"LOAD_PATTERN: {match.group(0)}"]
|
||||
}
|
||||
|
||||
# 특정 하중 값이 있지만 등급을 모르는 경우
|
||||
load_match = re.search(r'(\d+)\s*[T톤]', desc_upper)
|
||||
if load_match:
|
||||
capacity = int(load_match.group(1))
|
||||
return {
|
||||
"load_rating": "CUSTOM",
|
||||
"capacity": f"{capacity}T",
|
||||
"description": f"{capacity}톤 하중",
|
||||
"confidence": 0.7,
|
||||
"evidence": [f"CUSTOM_LOAD: {load_match.group(0)}"]
|
||||
}
|
||||
|
||||
return {
|
||||
"load_rating": "UNKNOWN",
|
||||
"capacity": "",
|
||||
"description": "",
|
||||
"confidence": 0.0,
|
||||
"evidence": ["NO_LOAD_RATING_FOUND"]
|
||||
}
|
||||
|
||||
def extract_support_size(description: str, main_nom: str) -> Dict:
|
||||
"""서포트 사이즈 정보 추출"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 파이프 사이즈 (서포트가 지지하는 파이프 크기)
|
||||
pipe_size = main_nom if main_nom else ""
|
||||
|
||||
# 서포트 자체 치수 (길이x폭x높이 등)
|
||||
dimension_patterns = [
|
||||
r'(\d+)\s*[X×]\s*(\d+)\s*[X×]\s*(\d+)', # 100x50x20
|
||||
r'(\d+)\s*[X×]\s*(\d+)', # 100x50
|
||||
r'L\s*(\d+)', # L100 (길이)
|
||||
r'W\s*(\d+)', # W50 (폭)
|
||||
r'H\s*(\d+)' # H20 (높이)
|
||||
]
|
||||
|
||||
dimensions = {}
|
||||
for pattern in dimension_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
if len(match.groups()) == 3:
|
||||
dimensions = {
|
||||
"length": f"{match.group(1)}mm",
|
||||
"width": f"{match.group(2)}mm",
|
||||
"height": f"{match.group(3)}mm"
|
||||
}
|
||||
elif len(match.groups()) == 2:
|
||||
dimensions = {
|
||||
"length": f"{match.group(1)}mm",
|
||||
"width": f"{match.group(2)}mm"
|
||||
}
|
||||
break
|
||||
|
||||
return {
|
||||
"pipe_size": pipe_size,
|
||||
"dimensions": dimensions,
|
||||
"confidence": 0.8 if dimensions else 0.3
|
||||
}
|
||||
|
||||
def calculate_support_confidence(confidence_scores: Dict) -> float:
|
||||
"""서포트 분류 전체 신뢰도 계산"""
|
||||
|
||||
weights = {
|
||||
"type": 0.4, # 타입이 가장 중요
|
||||
"material": 0.2, # 재질
|
||||
"load": 0.2, # 하중
|
||||
"size": 0.2 # 사이즈
|
||||
}
|
||||
|
||||
weighted_sum = sum(
|
||||
confidence_scores.get(key, 0) * weight
|
||||
for key, weight in weights.items()
|
||||
)
|
||||
|
||||
return round(weighted_sum, 2)
|
||||
@@ -89,6 +89,24 @@ VALVE_TYPES = {
|
||||
"typical_connections": ["FLANGED", "THREADED"],
|
||||
"pressure_range": "150LB ~ 600LB",
|
||||
"special_features": ["LUBRICATED", "NON_LUBRICATED"]
|
||||
},
|
||||
|
||||
"SIGHT_GLASS": {
|
||||
"dat_file_patterns": ["SIGHT_", "SG_"],
|
||||
"description_keywords": ["SIGHT GLASS", "SIGHT", "사이트글라스", "사이트 글라스"],
|
||||
"characteristics": "유체 확인용 관찰창",
|
||||
"typical_connections": ["FLANGED", "THREADED"],
|
||||
"pressure_range": "150LB ~ 600LB",
|
||||
"special_features": ["TRANSPARENT", "VISUAL_INSPECTION"]
|
||||
},
|
||||
|
||||
"STRAINER": {
|
||||
"dat_file_patterns": ["STRAINER_", "STR_"],
|
||||
"description_keywords": ["STRAINER", "스트레이너", "여과기"],
|
||||
"characteristics": "이물질 여과용",
|
||||
"typical_connections": ["FLANGED", "THREADED"],
|
||||
"pressure_range": "150LB ~ 600LB",
|
||||
"special_features": ["MESH_FILTER", "Y_TYPE", "BASKET_TYPE"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,15 +230,25 @@ def classify_valve(dat_file: str, description: str, main_nom: str, length: float
|
||||
desc_upper = description.upper()
|
||||
dat_upper = dat_file.upper()
|
||||
|
||||
# 1. 명칭 우선 확인 (밸브 키워드가 있으면 밸브)
|
||||
valve_keywords = ['VALVE', 'GATE', 'BALL', 'GLOBE', 'CHECK', 'BUTTERFLY', 'NEEDLE', 'RELIEF', 'SOLENOID', 'PLUG', '밸브', '게이트', '볼', '글로브', '체크', '버터플라이', '니들', '릴리프', '솔레노이드', '플러그']
|
||||
is_valve = any(keyword in desc_upper or keyword in dat_upper for keyword in valve_keywords)
|
||||
# 1. 사이트 글라스와 스트레이너 우선 확인
|
||||
if 'SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or '사이트글라스' in desc_upper or '스트레이너' in desc_upper:
|
||||
# 사이트 글라스와 스트레이너는 항상 밸브로 분류
|
||||
pass
|
||||
|
||||
if not is_valve:
|
||||
# 밸브 키워드 확인 (재질만 있어도 통합 분류기가 이미 밸브로 분류했으므로 진행)
|
||||
valve_keywords = ['VALVE', 'GATE', 'BALL', 'GLOBE', 'CHECK', 'BUTTERFLY', 'NEEDLE', 'RELIEF', 'SOLENOID', 'SIGHT GLASS', 'STRAINER', '밸브', '게이트', '볼', '글로브', '체크', '버터플라이', '니들', '릴리프', '솔레노이드', '사이트글라스', '스트레이너']
|
||||
has_valve_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in valve_keywords)
|
||||
|
||||
# 밸브 재질 확인 (A216, A217, A351, A352)
|
||||
valve_materials = ['A216', 'A217', 'A351', 'A352']
|
||||
has_valve_material = any(material in desc_upper for material in valve_materials)
|
||||
|
||||
# 밸브 키워드도 없고 밸브 재질도 없으면 UNKNOWN
|
||||
if not has_valve_keyword and not has_valve_material:
|
||||
return {
|
||||
"category": "UNKNOWN",
|
||||
"overall_confidence": 0.0,
|
||||
"reason": "밸브 키워드 없음"
|
||||
"reason": "밸브 키워드 및 재질 없음"
|
||||
}
|
||||
|
||||
# 2. 재질 분류 (공통 모듈 사용)
|
||||
|
||||
12
backend/app/utils/__init__.py
Normal file
12
backend/app/utils/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
유틸리티 모듈
|
||||
"""
|
||||
from .logger import get_logger, setup_logger, app_logger
|
||||
from .file_validator import file_validator, validate_uploaded_file
|
||||
from .error_handlers import ErrorResponse, TKMPException, setup_error_handlers
|
||||
|
||||
__all__ = [
|
||||
"get_logger", "setup_logger", "app_logger",
|
||||
"file_validator", "validate_uploaded_file",
|
||||
"ErrorResponse", "TKMPException", "setup_error_handlers"
|
||||
]
|
||||
266
backend/app/utils/cache_manager.py
Normal file
266
backend/app/utils/cache_manager.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Redis 캐시 관리 유틸리티
|
||||
성능 향상을 위한 캐싱 전략 구현
|
||||
"""
|
||||
import json
|
||||
import redis
|
||||
from typing import Any, Optional, Dict, List
|
||||
from datetime import timedelta
|
||||
import hashlib
|
||||
import pickle
|
||||
|
||||
from ..config import get_settings
|
||||
from .logger import get_logger
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CacheManager:
|
||||
"""Redis 캐시 관리 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
try:
|
||||
# Redis 연결 설정
|
||||
self.redis_client = redis.from_url(
|
||||
settings.redis.url,
|
||||
decode_responses=False, # 바이너리 데이터 지원
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5,
|
||||
retry_on_timeout=True
|
||||
)
|
||||
|
||||
# 연결 테스트
|
||||
self.redis_client.ping()
|
||||
logger.info("Redis 연결 성공")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Redis 연결 실패: {e}")
|
||||
self.redis_client = None
|
||||
|
||||
def _generate_key(self, prefix: str, *args, **kwargs) -> str:
|
||||
"""캐시 키 생성"""
|
||||
# 인자들을 문자열로 변환하여 해시 생성
|
||||
key_parts = [str(arg) for arg in args]
|
||||
key_parts.extend([f"{k}:{v}" for k, v in sorted(kwargs.items())])
|
||||
|
||||
if key_parts:
|
||||
key_hash = hashlib.md5("|".join(key_parts).encode()).hexdigest()[:8]
|
||||
return f"tkmp:{prefix}:{key_hash}"
|
||||
else:
|
||||
return f"tkmp:{prefix}"
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""캐시에서 데이터 조회"""
|
||||
if not self.redis_client:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = self.redis_client.get(key)
|
||||
if data:
|
||||
return pickle.loads(data)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"캐시 조회 실패 - key: {key}, error: {e}")
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any, expire: int = 3600) -> bool:
|
||||
"""캐시에 데이터 저장"""
|
||||
if not self.redis_client:
|
||||
return False
|
||||
|
||||
try:
|
||||
serialized_data = pickle.dumps(value)
|
||||
result = self.redis_client.setex(key, expire, serialized_data)
|
||||
logger.debug(f"캐시 저장 - key: {key}, expire: {expire}s")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"캐시 저장 실패 - key: {key}, error: {e}")
|
||||
return False
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""캐시에서 데이터 삭제"""
|
||||
if not self.redis_client:
|
||||
return False
|
||||
|
||||
try:
|
||||
result = self.redis_client.delete(key)
|
||||
logger.debug(f"캐시 삭제 - key: {key}")
|
||||
return bool(result)
|
||||
except Exception as e:
|
||||
logger.warning(f"캐시 삭제 실패 - key: {key}, error: {e}")
|
||||
return False
|
||||
|
||||
def delete_pattern(self, pattern: str) -> int:
|
||||
"""패턴에 맞는 캐시 키들 삭제"""
|
||||
if not self.redis_client:
|
||||
return 0
|
||||
|
||||
try:
|
||||
keys = self.redis_client.keys(pattern)
|
||||
if keys:
|
||||
deleted = self.redis_client.delete(*keys)
|
||||
logger.info(f"패턴 캐시 삭제 - pattern: {pattern}, deleted: {deleted}")
|
||||
return deleted
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.warning(f"패턴 캐시 삭제 실패 - pattern: {pattern}, error: {e}")
|
||||
return 0
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""캐시 키 존재 여부 확인"""
|
||||
if not self.redis_client:
|
||||
return False
|
||||
|
||||
try:
|
||||
return bool(self.redis_client.exists(key))
|
||||
except Exception as e:
|
||||
logger.warning(f"캐시 존재 확인 실패 - key: {key}, error: {e}")
|
||||
return False
|
||||
|
||||
def get_ttl(self, key: str) -> int:
|
||||
"""캐시 TTL 조회"""
|
||||
if not self.redis_client:
|
||||
return -1
|
||||
|
||||
try:
|
||||
return self.redis_client.ttl(key)
|
||||
except Exception as e:
|
||||
logger.warning(f"캐시 TTL 조회 실패 - key: {key}, error: {e}")
|
||||
return -1
|
||||
|
||||
|
||||
class TKMPCache:
|
||||
"""TK-MP 프로젝트 전용 캐시 래퍼"""
|
||||
|
||||
def __init__(self):
|
||||
self.cache = CacheManager()
|
||||
|
||||
# 캐시 TTL 설정 (초 단위)
|
||||
self.ttl_config = {
|
||||
"file_list": 300, # 5분 - 파일 목록
|
||||
"material_list": 600, # 10분 - 자재 목록
|
||||
"job_list": 1800, # 30분 - 작업 목록
|
||||
"classification": 3600, # 1시간 - 분류 결과
|
||||
"statistics": 900, # 15분 - 통계 데이터
|
||||
"comparison": 1800, # 30분 - 리비전 비교
|
||||
}
|
||||
|
||||
def get_file_list(self, job_no: Optional[str] = None, show_history: bool = False) -> Optional[List[Dict]]:
|
||||
"""파일 목록 캐시 조회"""
|
||||
key = self.cache._generate_key("files", job_no=job_no, history=show_history)
|
||||
return self.cache.get(key)
|
||||
|
||||
def set_file_list(self, files: List[Dict], job_no: Optional[str] = None, show_history: bool = False) -> bool:
|
||||
"""파일 목록 캐시 저장"""
|
||||
key = self.cache._generate_key("files", job_no=job_no, history=show_history)
|
||||
return self.cache.set(key, files, self.ttl_config["file_list"])
|
||||
|
||||
def get_material_list(self, file_id: int) -> Optional[List[Dict]]:
|
||||
"""자재 목록 캐시 조회"""
|
||||
key = self.cache._generate_key("materials", file_id=file_id)
|
||||
return self.cache.get(key)
|
||||
|
||||
def set_material_list(self, materials: List[Dict], file_id: int) -> bool:
|
||||
"""자재 목록 캐시 저장"""
|
||||
key = self.cache._generate_key("materials", file_id=file_id)
|
||||
return self.cache.set(key, materials, self.ttl_config["material_list"])
|
||||
|
||||
def get_job_list(self) -> Optional[List[Dict]]:
|
||||
"""작업 목록 캐시 조회"""
|
||||
key = self.cache._generate_key("jobs")
|
||||
return self.cache.get(key)
|
||||
|
||||
def set_job_list(self, jobs: List[Dict]) -> bool:
|
||||
"""작업 목록 캐시 저장"""
|
||||
key = self.cache._generate_key("jobs")
|
||||
return self.cache.set(key, jobs, self.ttl_config["job_list"])
|
||||
|
||||
def get_classification_result(self, description: str, category: str) -> Optional[Dict]:
|
||||
"""분류 결과 캐시 조회"""
|
||||
key = self.cache._generate_key("classification", desc=description, cat=category)
|
||||
return self.cache.get(key)
|
||||
|
||||
def set_classification_result(self, result: Dict, description: str, category: str) -> bool:
|
||||
"""분류 결과 캐시 저장"""
|
||||
key = self.cache._generate_key("classification", desc=description, cat=category)
|
||||
return self.cache.set(key, result, self.ttl_config["classification"])
|
||||
|
||||
def get_statistics(self, job_no: str, stat_type: str) -> Optional[Dict]:
|
||||
"""통계 데이터 캐시 조회"""
|
||||
key = self.cache._generate_key("stats", job_no=job_no, type=stat_type)
|
||||
return self.cache.get(key)
|
||||
|
||||
def set_statistics(self, stats: Dict, job_no: str, stat_type: str) -> bool:
|
||||
"""통계 데이터 캐시 저장"""
|
||||
key = self.cache._generate_key("stats", job_no=job_no, type=stat_type)
|
||||
return self.cache.set(key, stats, self.ttl_config["statistics"])
|
||||
|
||||
def get_revision_comparison(self, job_no: str, rev1: str, rev2: str) -> Optional[Dict]:
|
||||
"""리비전 비교 결과 캐시 조회"""
|
||||
key = self.cache._generate_key("comparison", job_no=job_no, rev1=rev1, rev2=rev2)
|
||||
return self.cache.get(key)
|
||||
|
||||
def set_revision_comparison(self, comparison: Dict, job_no: str, rev1: str, rev2: str) -> bool:
|
||||
"""리비전 비교 결과 캐시 저장"""
|
||||
key = self.cache._generate_key("comparison", job_no=job_no, rev1=rev1, rev2=rev2)
|
||||
return self.cache.set(key, comparison, self.ttl_config["comparison"])
|
||||
|
||||
def invalidate_job_cache(self, job_no: str):
|
||||
"""특정 작업의 모든 캐시 무효화"""
|
||||
patterns = [
|
||||
f"tkmp:files:*job_no:{job_no}*",
|
||||
f"tkmp:materials:*job_no:{job_no}*",
|
||||
f"tkmp:stats:*job_no:{job_no}*",
|
||||
f"tkmp:comparison:*job_no:{job_no}*"
|
||||
]
|
||||
|
||||
total_deleted = 0
|
||||
for pattern in patterns:
|
||||
deleted = self.cache.delete_pattern(pattern)
|
||||
total_deleted += deleted
|
||||
|
||||
logger.info(f"작업 캐시 무효화 완료 - job_no: {job_no}, deleted: {total_deleted}")
|
||||
return total_deleted
|
||||
|
||||
def invalidate_file_cache(self, file_id: int):
|
||||
"""특정 파일의 모든 캐시 무효화"""
|
||||
patterns = [
|
||||
f"tkmp:materials:*file_id:{file_id}*",
|
||||
f"tkmp:files:*" # 파일 목록도 갱신 필요
|
||||
]
|
||||
|
||||
total_deleted = 0
|
||||
for pattern in patterns:
|
||||
deleted = self.cache.delete_pattern(pattern)
|
||||
total_deleted += deleted
|
||||
|
||||
logger.info(f"파일 캐시 무효화 완료 - file_id: {file_id}, deleted: {total_deleted}")
|
||||
return total_deleted
|
||||
|
||||
def get_cache_info(self) -> Dict[str, Any]:
|
||||
"""캐시 상태 정보 조회"""
|
||||
if not self.cache.redis_client:
|
||||
return {"status": "disconnected"}
|
||||
|
||||
try:
|
||||
info = self.cache.redis_client.info()
|
||||
return {
|
||||
"status": "connected",
|
||||
"used_memory": info.get("used_memory_human", "N/A"),
|
||||
"connected_clients": info.get("connected_clients", 0),
|
||||
"total_commands_processed": info.get("total_commands_processed", 0),
|
||||
"keyspace_hits": info.get("keyspace_hits", 0),
|
||||
"keyspace_misses": info.get("keyspace_misses", 0),
|
||||
"hit_rate": round(
|
||||
info.get("keyspace_hits", 0) /
|
||||
max(info.get("keyspace_hits", 0) + info.get("keyspace_misses", 0), 1) * 100, 2
|
||||
)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"캐시 정보 조회 실패: {e}")
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
# 전역 캐시 인스턴스
|
||||
tkmp_cache = TKMPCache()
|
||||
139
backend/app/utils/error_handlers.py
Normal file
139
backend/app/utils/error_handlers.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
에러 처리 유틸리티
|
||||
표준화된 에러 응답 및 예외 처리
|
||||
"""
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from typing import Dict, Any
|
||||
import traceback
|
||||
|
||||
from .logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class TKMPException(Exception):
|
||||
"""TK-MP 프로젝트 커스텀 예외"""
|
||||
|
||||
def __init__(self, message: str, error_code: str = "TKMP_ERROR", status_code: int = 500):
|
||||
self.message = message
|
||||
self.error_code = error_code
|
||||
self.status_code = status_code
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class ErrorResponse:
|
||||
"""표준화된 에러 응답 생성기"""
|
||||
|
||||
@staticmethod
|
||||
def create_error_response(
|
||||
message: str,
|
||||
error_code: str = "INTERNAL_ERROR",
|
||||
status_code: int = 500,
|
||||
details: Dict[str, Any] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""표준화된 에러 응답 생성"""
|
||||
response = {
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": error_code,
|
||||
"message": message,
|
||||
"timestamp": "2025-01-01T00:00:00Z" # 실제로는 datetime.utcnow().isoformat()
|
||||
}
|
||||
}
|
||||
|
||||
if details:
|
||||
response["error"]["details"] = details
|
||||
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def validation_error_response(errors: list) -> Dict[str, Any]:
|
||||
"""검증 에러 응답"""
|
||||
return ErrorResponse.create_error_response(
|
||||
message="입력 데이터 검증에 실패했습니다.",
|
||||
error_code="VALIDATION_ERROR",
|
||||
status_code=422,
|
||||
details={"validation_errors": errors}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def database_error_response(error: str) -> Dict[str, Any]:
|
||||
"""데이터베이스 에러 응답"""
|
||||
return ErrorResponse.create_error_response(
|
||||
message="데이터베이스 작업 중 오류가 발생했습니다.",
|
||||
error_code="DATABASE_ERROR",
|
||||
status_code=500,
|
||||
details={"db_error": error}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def file_error_response(error: str) -> Dict[str, Any]:
|
||||
"""파일 처리 에러 응답"""
|
||||
return ErrorResponse.create_error_response(
|
||||
message="파일 처리 중 오류가 발생했습니다.",
|
||||
error_code="FILE_ERROR",
|
||||
status_code=400,
|
||||
details={"file_error": error}
|
||||
)
|
||||
|
||||
|
||||
async def tkmp_exception_handler(request: Request, exc: TKMPException):
|
||||
"""TK-MP 커스텀 예외 핸들러"""
|
||||
logger.error(f"TK-MP 예외 발생: {exc.message} (코드: {exc.error_code})")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=ErrorResponse.create_error_response(
|
||||
message=exc.message,
|
||||
error_code=exc.error_code,
|
||||
status_code=exc.status_code
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""검증 예외 핸들러"""
|
||||
logger.warning(f"검증 오류: {exc.errors()}")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=ErrorResponse.validation_error_response(exc.errors())
|
||||
)
|
||||
|
||||
|
||||
async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError):
|
||||
"""SQLAlchemy 예외 핸들러"""
|
||||
logger.error(f"데이터베이스 오류: {str(exc)}", exc_info=True)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=ErrorResponse.database_error_response(str(exc))
|
||||
)
|
||||
|
||||
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
"""일반 예외 핸들러"""
|
||||
logger.error(f"예상치 못한 오류: {str(exc)}", exc_info=True)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=ErrorResponse.create_error_response(
|
||||
message="서버 내부 오류가 발생했습니다.",
|
||||
error_code="INTERNAL_SERVER_ERROR",
|
||||
status_code=500,
|
||||
details={"error": str(exc)} if logger.level <= 10 else None # DEBUG 레벨일 때만 상세 에러 표시
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def setup_error_handlers(app):
|
||||
"""FastAPI 앱에 에러 핸들러 등록"""
|
||||
app.add_exception_handler(TKMPException, tkmp_exception_handler)
|
||||
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||
app.add_exception_handler(SQLAlchemyError, sqlalchemy_exception_handler)
|
||||
app.add_exception_handler(Exception, general_exception_handler)
|
||||
|
||||
logger.info("에러 핸들러 등록 완료")
|
||||
335
backend/app/utils/file_processor.py
Normal file
335
backend/app/utils/file_processor.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
대용량 파일 처리 최적화 유틸리티
|
||||
메모리 효율적인 파일 처리 및 청크 기반 처리
|
||||
"""
|
||||
import pandas as pd
|
||||
import asyncio
|
||||
from typing import Iterator, List, Dict, Any, Optional, Callable
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import gc
|
||||
|
||||
from .logger import get_logger
|
||||
from ..config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class FileProcessor:
|
||||
"""대용량 파일 처리 최적화 클래스"""
|
||||
|
||||
def __init__(self, chunk_size: int = 1000, max_workers: int = 4):
|
||||
self.chunk_size = chunk_size
|
||||
self.max_workers = max_workers
|
||||
self.executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||
|
||||
def read_excel_chunks(self, file_path: str, sheet_name: str = None) -> Iterator[pd.DataFrame]:
|
||||
"""
|
||||
엑셀 파일을 청크 단위로 읽기
|
||||
|
||||
Args:
|
||||
file_path: 파일 경로
|
||||
sheet_name: 시트명 (None이면 첫 번째 시트)
|
||||
|
||||
Yields:
|
||||
DataFrame: 청크 단위 데이터
|
||||
"""
|
||||
try:
|
||||
# 파일 크기 확인
|
||||
file_size = os.path.getsize(file_path)
|
||||
logger.info(f"엑셀 파일 처리 시작 - 파일: {file_path}, 크기: {file_size} bytes")
|
||||
|
||||
# 전체 행 수 확인 (메모리 효율적으로)
|
||||
with pd.ExcelFile(file_path) as xls:
|
||||
if sheet_name is None:
|
||||
sheet_name = xls.sheet_names[0]
|
||||
|
||||
# 첫 번째 청크로 컬럼 정보 확인
|
||||
first_chunk = pd.read_excel(xls, sheet_name=sheet_name, nrows=self.chunk_size)
|
||||
total_rows = len(first_chunk)
|
||||
|
||||
# 전체 데이터를 청크로 나누어 처리
|
||||
processed_rows = 0
|
||||
chunk_num = 0
|
||||
|
||||
while processed_rows < total_rows:
|
||||
try:
|
||||
# 청크 읽기
|
||||
chunk = pd.read_excel(
|
||||
xls,
|
||||
sheet_name=sheet_name,
|
||||
skiprows=processed_rows + 1 if processed_rows > 0 else 0,
|
||||
nrows=self.chunk_size,
|
||||
header=0 if processed_rows == 0 else None
|
||||
)
|
||||
|
||||
if chunk.empty:
|
||||
break
|
||||
|
||||
# 첫 번째 청크가 아닌 경우 컬럼명 설정
|
||||
if processed_rows > 0:
|
||||
chunk.columns = first_chunk.columns
|
||||
|
||||
chunk_num += 1
|
||||
processed_rows += len(chunk)
|
||||
|
||||
logger.debug(f"청크 {chunk_num} 처리 - 행 수: {len(chunk)}, 누적: {processed_rows}")
|
||||
|
||||
yield chunk
|
||||
|
||||
# 메모리 정리
|
||||
del chunk
|
||||
gc.collect()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"청크 {chunk_num} 처리 중 오류: {e}")
|
||||
break
|
||||
|
||||
logger.info(f"엑셀 파일 처리 완료 - 총 {chunk_num}개 청크, {processed_rows}행 처리")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"엑셀 파일 읽기 실패: {e}")
|
||||
raise
|
||||
|
||||
def read_csv_chunks(self, file_path: str, encoding: str = 'utf-8') -> Iterator[pd.DataFrame]:
|
||||
"""
|
||||
CSV 파일을 청크 단위로 읽기
|
||||
|
||||
Args:
|
||||
file_path: 파일 경로
|
||||
encoding: 인코딩 (기본: utf-8)
|
||||
|
||||
Yields:
|
||||
DataFrame: 청크 단위 데이터
|
||||
"""
|
||||
try:
|
||||
file_size = os.path.getsize(file_path)
|
||||
logger.info(f"CSV 파일 처리 시작 - 파일: {file_path}, 크기: {file_size} bytes")
|
||||
|
||||
chunk_num = 0
|
||||
total_rows = 0
|
||||
|
||||
# pandas의 chunksize 옵션 사용
|
||||
for chunk in pd.read_csv(file_path, chunksize=self.chunk_size, encoding=encoding):
|
||||
chunk_num += 1
|
||||
total_rows += len(chunk)
|
||||
|
||||
logger.debug(f"CSV 청크 {chunk_num} 처리 - 행 수: {len(chunk)}, 누적: {total_rows}")
|
||||
|
||||
yield chunk
|
||||
|
||||
# 메모리 정리
|
||||
gc.collect()
|
||||
|
||||
logger.info(f"CSV 파일 처리 완료 - 총 {chunk_num}개 청크, {total_rows}행 처리")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CSV 파일 읽기 실패: {e}")
|
||||
raise
|
||||
|
||||
async def process_file_async(
|
||||
self,
|
||||
file_path: str,
|
||||
processor_func: Callable[[pd.DataFrame], List[Dict]],
|
||||
file_type: str = "excel"
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
파일을 비동기적으로 처리
|
||||
|
||||
Args:
|
||||
file_path: 파일 경로
|
||||
processor_func: 각 청크를 처리할 함수
|
||||
file_type: 파일 타입 ("excel" 또는 "csv")
|
||||
|
||||
Returns:
|
||||
List[Dict]: 처리된 결과 리스트
|
||||
"""
|
||||
try:
|
||||
logger.info(f"비동기 파일 처리 시작 - {file_path}")
|
||||
|
||||
results = []
|
||||
chunk_futures = []
|
||||
|
||||
# 파일 타입에 따른 청크 리더 선택
|
||||
if file_type.lower() == "csv":
|
||||
chunk_reader = self.read_csv_chunks(file_path)
|
||||
else:
|
||||
chunk_reader = self.read_excel_chunks(file_path)
|
||||
|
||||
# 청크별 비동기 처리
|
||||
for chunk in chunk_reader:
|
||||
# 스레드 풀에서 청크 처리
|
||||
future = asyncio.get_event_loop().run_in_executor(
|
||||
self.executor,
|
||||
processor_func,
|
||||
chunk
|
||||
)
|
||||
chunk_futures.append(future)
|
||||
|
||||
# 너무 많은 청크가 동시에 처리되지 않도록 제한
|
||||
if len(chunk_futures) >= self.max_workers:
|
||||
# 완료된 작업들 수집
|
||||
completed_results = await asyncio.gather(*chunk_futures)
|
||||
for result in completed_results:
|
||||
if result:
|
||||
results.extend(result)
|
||||
|
||||
chunk_futures = []
|
||||
gc.collect()
|
||||
|
||||
# 남은 청크들 처리
|
||||
if chunk_futures:
|
||||
completed_results = await asyncio.gather(*chunk_futures)
|
||||
for result in completed_results:
|
||||
if result:
|
||||
results.extend(result)
|
||||
|
||||
logger.info(f"비동기 파일 처리 완료 - 총 {len(results)}개 항목 처리")
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"비동기 파일 처리 실패: {e}")
|
||||
raise
|
||||
|
||||
def optimize_dataframe_memory(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
DataFrame 메모리 사용량 최적화
|
||||
|
||||
Args:
|
||||
df: 최적화할 DataFrame
|
||||
|
||||
Returns:
|
||||
DataFrame: 최적화된 DataFrame
|
||||
"""
|
||||
try:
|
||||
original_memory = df.memory_usage(deep=True).sum()
|
||||
|
||||
# 수치형 컬럼 최적화
|
||||
for col in df.select_dtypes(include=['int64']).columns:
|
||||
col_min = df[col].min()
|
||||
col_max = df[col].max()
|
||||
|
||||
if col_min >= -128 and col_max <= 127:
|
||||
df[col] = df[col].astype('int8')
|
||||
elif col_min >= -32768 and col_max <= 32767:
|
||||
df[col] = df[col].astype('int16')
|
||||
elif col_min >= -2147483648 and col_max <= 2147483647:
|
||||
df[col] = df[col].astype('int32')
|
||||
|
||||
# 실수형 컬럼 최적화
|
||||
for col in df.select_dtypes(include=['float64']).columns:
|
||||
df[col] = pd.to_numeric(df[col], downcast='float')
|
||||
|
||||
# 문자열 컬럼 최적화 (카테고리형으로 변환)
|
||||
for col in df.select_dtypes(include=['object']).columns:
|
||||
if df[col].nunique() / len(df) < 0.5: # 고유값이 50% 미만인 경우
|
||||
df[col] = df[col].astype('category')
|
||||
|
||||
optimized_memory = df.memory_usage(deep=True).sum()
|
||||
memory_reduction = (original_memory - optimized_memory) / original_memory * 100
|
||||
|
||||
logger.debug(f"DataFrame 메모리 최적화 완료 - 감소율: {memory_reduction:.1f}%")
|
||||
|
||||
return df
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"DataFrame 메모리 최적화 실패: {e}")
|
||||
return df
|
||||
|
||||
def create_temp_file(self, suffix: str = '.tmp') -> str:
|
||||
"""
|
||||
임시 파일 생성
|
||||
|
||||
Args:
|
||||
suffix: 파일 확장자
|
||||
|
||||
Returns:
|
||||
str: 임시 파일 경로
|
||||
"""
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
||||
temp_file.close()
|
||||
logger.debug(f"임시 파일 생성: {temp_file.name}")
|
||||
return temp_file.name
|
||||
|
||||
def cleanup_temp_file(self, file_path: str):
|
||||
"""
|
||||
임시 파일 정리
|
||||
|
||||
Args:
|
||||
file_path: 삭제할 파일 경로
|
||||
"""
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.unlink(file_path)
|
||||
logger.debug(f"임시 파일 삭제: {file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"임시 파일 삭제 실패: {file_path}, error: {e}")
|
||||
|
||||
def get_file_info(self, file_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
파일 정보 조회
|
||||
|
||||
Args:
|
||||
file_path: 파일 경로
|
||||
|
||||
Returns:
|
||||
Dict: 파일 정보
|
||||
"""
|
||||
try:
|
||||
file_stat = os.stat(file_path)
|
||||
file_ext = Path(file_path).suffix.lower()
|
||||
|
||||
info = {
|
||||
"file_path": file_path,
|
||||
"file_size": file_stat.st_size,
|
||||
"file_size_mb": round(file_stat.st_size / (1024 * 1024), 2),
|
||||
"file_extension": file_ext,
|
||||
"is_large_file": file_stat.st_size > 10 * 1024 * 1024, # 10MB 이상
|
||||
"recommended_chunk_size": self._calculate_optimal_chunk_size(file_stat.st_size)
|
||||
}
|
||||
|
||||
# 파일 타입별 추가 정보
|
||||
if file_ext in ['.xlsx', '.xls']:
|
||||
info["file_type"] = "excel"
|
||||
info["processing_method"] = "chunk_based" if info["is_large_file"] else "full_load"
|
||||
elif file_ext == '.csv':
|
||||
info["file_type"] = "csv"
|
||||
info["processing_method"] = "chunk_based" if info["is_large_file"] else "full_load"
|
||||
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 정보 조회 실패: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def _calculate_optimal_chunk_size(self, file_size: int) -> int:
|
||||
"""
|
||||
파일 크기에 따른 최적 청크 크기 계산
|
||||
|
||||
Args:
|
||||
file_size: 파일 크기 (bytes)
|
||||
|
||||
Returns:
|
||||
int: 최적 청크 크기
|
||||
"""
|
||||
# 파일 크기에 따른 청크 크기 조정
|
||||
if file_size < 1024 * 1024: # 1MB 미만
|
||||
return 500
|
||||
elif file_size < 10 * 1024 * 1024: # 10MB 미만
|
||||
return 1000
|
||||
elif file_size < 50 * 1024 * 1024: # 50MB 미만
|
||||
return 2000
|
||||
else: # 50MB 이상
|
||||
return 5000
|
||||
|
||||
def __del__(self):
|
||||
"""소멸자 - 스레드 풀 정리"""
|
||||
if hasattr(self, 'executor'):
|
||||
self.executor.shutdown(wait=True)
|
||||
|
||||
|
||||
# 전역 파일 프로세서 인스턴스
|
||||
file_processor = FileProcessor()
|
||||
169
backend/app/utils/file_validator.py
Normal file
169
backend/app/utils/file_validator.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
파일 업로드 검증 유틸리티
|
||||
보안 강화를 위한 파일 검증 로직
|
||||
"""
|
||||
import os
|
||||
import magic
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from fastapi import UploadFile, HTTPException
|
||||
|
||||
from ..config import get_settings
|
||||
from .logger import get_logger
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class FileValidator:
|
||||
"""파일 업로드 검증 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
self.max_file_size = settings.security.max_file_size
|
||||
self.allowed_extensions = settings.security.allowed_file_extensions
|
||||
|
||||
# MIME 타입 매핑
|
||||
self.mime_type_mapping = {
|
||||
'.xlsx': [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/octet-stream' # 일부 브라우저에서 xlsx를 이렇게 인식
|
||||
],
|
||||
'.xls': [
|
||||
'application/vnd.ms-excel',
|
||||
'application/octet-stream'
|
||||
],
|
||||
'.csv': [
|
||||
'text/csv',
|
||||
'text/plain',
|
||||
'application/csv'
|
||||
]
|
||||
}
|
||||
|
||||
def validate_file_extension(self, filename: str) -> bool:
|
||||
"""파일 확장자 검증"""
|
||||
file_ext = Path(filename).suffix.lower()
|
||||
is_valid = file_ext in self.allowed_extensions
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(f"허용되지 않은 파일 확장자: {file_ext}, 파일: {filename}")
|
||||
|
||||
return is_valid
|
||||
|
||||
def validate_file_size(self, file_size: int) -> bool:
|
||||
"""파일 크기 검증"""
|
||||
is_valid = file_size <= self.max_file_size
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(f"파일 크기 초과: {file_size} bytes (최대: {self.max_file_size} bytes)")
|
||||
|
||||
return is_valid
|
||||
|
||||
def validate_filename(self, filename: str) -> bool:
|
||||
"""파일명 검증 (보안 위험 문자 체크)"""
|
||||
# 위험한 문자들
|
||||
dangerous_chars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|']
|
||||
|
||||
for char in dangerous_chars:
|
||||
if char in filename:
|
||||
logger.warning(f"위험한 문자 포함된 파일명: {filename}")
|
||||
return False
|
||||
|
||||
# 파일명 길이 체크 (255자 제한)
|
||||
if len(filename) > 255:
|
||||
logger.warning(f"파일명이 너무 긺: {len(filename)} 문자")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def validate_mime_type(self, file_content: bytes, filename: str) -> bool:
|
||||
"""MIME 타입 검증 (파일 내용 기반)"""
|
||||
try:
|
||||
# python-magic을 사용한 MIME 타입 검증
|
||||
detected_mime = magic.from_buffer(file_content, mime=True)
|
||||
file_ext = Path(filename).suffix.lower()
|
||||
|
||||
expected_mimes = self.mime_type_mapping.get(file_ext, [])
|
||||
|
||||
if detected_mime in expected_mimes:
|
||||
return True
|
||||
|
||||
logger.warning(f"MIME 타입 불일치 - 파일: {filename}, 감지된 타입: {detected_mime}, 예상 타입: {expected_mimes}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"MIME 타입 검증 실패: {e}")
|
||||
# magic 라이브러리 오류 시 확장자 검증으로 대체
|
||||
return self.validate_file_extension(filename)
|
||||
|
||||
def sanitize_filename(self, filename: str) -> str:
|
||||
"""파일명 정화 (안전한 파일명으로 변환)"""
|
||||
# 위험한 문자들을 언더스코어로 대체
|
||||
dangerous_chars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|']
|
||||
sanitized = filename
|
||||
|
||||
for char in dangerous_chars:
|
||||
sanitized = sanitized.replace(char, '_')
|
||||
|
||||
# 연속된 언더스코어 제거
|
||||
while '__' in sanitized:
|
||||
sanitized = sanitized.replace('__', '_')
|
||||
|
||||
# 앞뒤 공백 및 점 제거
|
||||
sanitized = sanitized.strip(' .')
|
||||
|
||||
return sanitized
|
||||
|
||||
async def validate_upload_file(self, file: UploadFile) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
업로드 파일 종합 검증
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (검증 성공 여부, 에러 메시지)
|
||||
"""
|
||||
try:
|
||||
# 1. 파일명 검증
|
||||
if not self.validate_filename(file.filename):
|
||||
return False, f"유효하지 않은 파일명: {file.filename}"
|
||||
|
||||
# 2. 확장자 검증
|
||||
if not self.validate_file_extension(file.filename):
|
||||
return False, f"허용되지 않은 파일 형식입니다. 허용 형식: {', '.join(self.allowed_extensions)}"
|
||||
|
||||
# 3. 파일 내용 읽기
|
||||
file_content = await file.read()
|
||||
await file.seek(0) # 파일 포인터 리셋
|
||||
|
||||
# 4. 파일 크기 검증
|
||||
if not self.validate_file_size(len(file_content)):
|
||||
return False, f"파일 크기가 너무 큽니다. 최대 크기: {self.max_file_size // (1024*1024)}MB"
|
||||
|
||||
# 5. MIME 타입 검증
|
||||
if not self.validate_mime_type(file_content, file.filename):
|
||||
return False, "파일 형식이 올바르지 않습니다."
|
||||
|
||||
logger.info(f"파일 검증 성공: {file.filename} ({len(file_content)} bytes)")
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 검증 중 오류 발생: {e}", exc_info=True)
|
||||
return False, f"파일 검증 중 오류가 발생했습니다: {str(e)}"
|
||||
|
||||
|
||||
# 전역 파일 검증기 인스턴스
|
||||
file_validator = FileValidator()
|
||||
|
||||
|
||||
async def validate_uploaded_file(file: UploadFile) -> None:
|
||||
"""
|
||||
파일 검증 헬퍼 함수 (HTTPException 발생)
|
||||
|
||||
Args:
|
||||
file: 업로드된 파일
|
||||
|
||||
Raises:
|
||||
HTTPException: 검증 실패 시
|
||||
"""
|
||||
is_valid, error_message = await file_validator.validate_upload_file(file)
|
||||
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail=error_message)
|
||||
87
backend/app/utils/logger.py
Normal file
87
backend/app/utils/logger.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
로깅 유틸리티 모듈
|
||||
중앙화된 로깅 설정 및 관리
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Optional
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def setup_logger(
|
||||
name: str,
|
||||
log_file: Optional[str] = None,
|
||||
level: str = None
|
||||
) -> logging.Logger:
|
||||
"""
|
||||
로거 설정 및 반환
|
||||
|
||||
Args:
|
||||
name: 로거 이름
|
||||
log_file: 로그 파일 경로 (선택사항)
|
||||
level: 로그 레벨 (선택사항)
|
||||
|
||||
Returns:
|
||||
설정된 로거 인스턴스
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
# 이미 핸들러가 설정된 경우 중복 방지
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
# 로그 레벨 설정
|
||||
log_level = level or settings.logging.level
|
||||
logger.setLevel(getattr(logging, log_level.upper()))
|
||||
|
||||
# 포맷터 설정
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s'
|
||||
)
|
||||
|
||||
# 콘솔 핸들러
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 파일 핸들러 (선택사항)
|
||||
if log_file or settings.logging.file_path:
|
||||
file_path = log_file or settings.logging.file_path
|
||||
|
||||
# 로그 디렉토리 생성
|
||||
log_dir = os.path.dirname(file_path)
|
||||
if log_dir and not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
# 로테이팅 파일 핸들러 (10MB, 5개 파일 유지)
|
||||
file_handler = RotatingFileHandler(
|
||||
file_path,
|
||||
maxBytes=10*1024*1024, # 10MB
|
||||
backupCount=5,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""
|
||||
로거 인스턴스 반환 (간편 함수)
|
||||
|
||||
Args:
|
||||
name: 로거 이름
|
||||
|
||||
Returns:
|
||||
로거 인스턴스
|
||||
"""
|
||||
return setup_logger(name)
|
||||
|
||||
|
||||
# 애플리케이션 전역 로거
|
||||
app_logger = setup_logger("tk_mp_app", settings.logging.file_path)
|
||||
355
backend/app/utils/transaction_manager.py
Normal file
355
backend/app/utils/transaction_manager.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""
|
||||
트랜잭션 관리 유틸리티
|
||||
데이터 일관성을 위한 트랜잭션 관리 및 데코레이터
|
||||
"""
|
||||
import functools
|
||||
from typing import Any, Callable, Optional, TypeVar, Generic
|
||||
from contextlib import contextmanager
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
import asyncio
|
||||
|
||||
from .logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class TransactionManager:
|
||||
"""트랜잭션 관리 클래스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
@contextmanager
|
||||
def transaction(self, rollback_on_exception: bool = True):
|
||||
"""
|
||||
트랜잭션 컨텍스트 매니저
|
||||
|
||||
Args:
|
||||
rollback_on_exception: 예외 발생 시 롤백 여부
|
||||
"""
|
||||
try:
|
||||
logger.debug("트랜잭션 시작")
|
||||
yield self.db
|
||||
self.db.commit()
|
||||
logger.debug("트랜잭션 커밋 완료")
|
||||
|
||||
except Exception as e:
|
||||
if rollback_on_exception:
|
||||
self.db.rollback()
|
||||
logger.warning(f"트랜잭션 롤백 - 에러: {str(e)}")
|
||||
else:
|
||||
logger.error(f"트랜잭션 에러 (롤백 안함) - 에러: {str(e)}")
|
||||
raise
|
||||
|
||||
@contextmanager
|
||||
def savepoint(self, name: Optional[str] = None):
|
||||
"""
|
||||
세이브포인트 컨텍스트 매니저
|
||||
|
||||
Args:
|
||||
name: 세이브포인트 이름
|
||||
"""
|
||||
savepoint_name = name or f"sp_{id(self)}"
|
||||
|
||||
try:
|
||||
# 세이브포인트 생성
|
||||
savepoint = self.db.begin_nested()
|
||||
logger.debug(f"세이브포인트 생성: {savepoint_name}")
|
||||
|
||||
yield self.db
|
||||
|
||||
# 세이브포인트 커밋
|
||||
savepoint.commit()
|
||||
logger.debug(f"세이브포인트 커밋: {savepoint_name}")
|
||||
|
||||
except Exception as e:
|
||||
# 세이브포인트 롤백
|
||||
savepoint.rollback()
|
||||
logger.warning(f"세이브포인트 롤백: {savepoint_name} - 에러: {str(e)}")
|
||||
raise
|
||||
|
||||
def execute_in_transaction(self, func: Callable[..., T], *args, **kwargs) -> T:
|
||||
"""
|
||||
함수를 트랜잭션 내에서 실행
|
||||
|
||||
Args:
|
||||
func: 실행할 함수
|
||||
*args: 함수 인자
|
||||
**kwargs: 함수 키워드 인자
|
||||
|
||||
Returns:
|
||||
함수 실행 결과
|
||||
"""
|
||||
with self.transaction():
|
||||
return func(*args, **kwargs)
|
||||
|
||||
async def execute_in_transaction_async(self, func: Callable[..., T], *args, **kwargs) -> T:
|
||||
"""
|
||||
비동기 함수를 트랜잭션 내에서 실행
|
||||
|
||||
Args:
|
||||
func: 실행할 비동기 함수
|
||||
*args: 함수 인자
|
||||
**kwargs: 함수 키워드 인자
|
||||
|
||||
Returns:
|
||||
함수 실행 결과
|
||||
"""
|
||||
with self.transaction():
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return await func(*args, **kwargs)
|
||||
else:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
|
||||
def transactional(rollback_on_exception: bool = True):
|
||||
"""
|
||||
트랜잭션 데코레이터
|
||||
|
||||
Args:
|
||||
rollback_on_exception: 예외 발생 시 롤백 여부
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# 첫 번째 인자가 Session인지 확인
|
||||
if args and isinstance(args[0], Session):
|
||||
db = args[0]
|
||||
transaction_manager = TransactionManager(db)
|
||||
|
||||
try:
|
||||
with transaction_manager.transaction(rollback_on_exception):
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"트랜잭션 함수 실행 실패: {func.__name__} - {str(e)}")
|
||||
raise
|
||||
else:
|
||||
# Session이 없으면 일반 함수로 실행
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def async_transactional(rollback_on_exception: bool = True):
|
||||
"""
|
||||
비동기 트랜잭션 데코레이터
|
||||
|
||||
Args:
|
||||
rollback_on_exception: 예외 발생 시 롤백 여부
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# 첫 번째 인자가 Session인지 확인
|
||||
if args and isinstance(args[0], Session):
|
||||
db = args[0]
|
||||
transaction_manager = TransactionManager(db)
|
||||
|
||||
try:
|
||||
with transaction_manager.transaction(rollback_on_exception):
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return await func(*args, **kwargs)
|
||||
else:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"비동기 트랜잭션 함수 실행 실패: {func.__name__} - {str(e)}")
|
||||
raise
|
||||
else:
|
||||
# Session이 없으면 일반 함수로 실행
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return await func(*args, **kwargs)
|
||||
else:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
class BatchProcessor:
|
||||
"""배치 처리를 위한 트랜잭션 관리"""
|
||||
|
||||
def __init__(self, db: Session, batch_size: int = 1000):
|
||||
self.db = db
|
||||
self.batch_size = batch_size
|
||||
self.transaction_manager = TransactionManager(db)
|
||||
|
||||
def process_in_batches(
|
||||
self,
|
||||
items: list,
|
||||
process_func: Callable,
|
||||
commit_per_batch: bool = True
|
||||
):
|
||||
"""
|
||||
아이템들을 배치 단위로 처리
|
||||
|
||||
Args:
|
||||
items: 처리할 아이템 리스트
|
||||
process_func: 각 아이템을 처리할 함수
|
||||
commit_per_batch: 배치마다 커밋 여부
|
||||
"""
|
||||
total_items = len(items)
|
||||
processed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
logger.info(f"배치 처리 시작 - 총 {total_items}개 아이템, 배치 크기: {self.batch_size}")
|
||||
|
||||
for i in range(0, total_items, self.batch_size):
|
||||
batch = items[i:i + self.batch_size]
|
||||
batch_num = (i // self.batch_size) + 1
|
||||
|
||||
try:
|
||||
if commit_per_batch:
|
||||
with self.transaction_manager.transaction():
|
||||
self._process_batch(batch, process_func)
|
||||
else:
|
||||
self._process_batch(batch, process_func)
|
||||
|
||||
processed_count += len(batch)
|
||||
logger.debug(f"배치 {batch_num} 처리 완료 - {len(batch)}개 아이템")
|
||||
|
||||
except Exception as e:
|
||||
failed_count += len(batch)
|
||||
logger.error(f"배치 {batch_num} 처리 실패 - {str(e)}")
|
||||
|
||||
# 개별 아이템 처리 시도
|
||||
if commit_per_batch:
|
||||
self._process_batch_individually(batch, process_func)
|
||||
|
||||
# 전체 커밋 (배치마다 커밋하지 않은 경우)
|
||||
if not commit_per_batch:
|
||||
try:
|
||||
self.db.commit()
|
||||
logger.info("전체 배치 처리 커밋 완료")
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"전체 배치 처리 커밋 실패: {str(e)}")
|
||||
raise
|
||||
|
||||
logger.info(f"배치 처리 완료 - 성공: {processed_count}, 실패: {failed_count}")
|
||||
|
||||
return {
|
||||
"total_items": total_items,
|
||||
"processed_count": processed_count,
|
||||
"failed_count": failed_count,
|
||||
"success_rate": (processed_count / total_items) * 100 if total_items > 0 else 0
|
||||
}
|
||||
|
||||
def _process_batch(self, batch: list, process_func: Callable):
|
||||
"""배치 처리"""
|
||||
for item in batch:
|
||||
process_func(item)
|
||||
|
||||
def _process_batch_individually(self, batch: list, process_func: Callable):
|
||||
"""배치 내 아이템을 개별적으로 처리 (에러 복구용)"""
|
||||
for item in batch:
|
||||
try:
|
||||
with self.transaction_manager.savepoint():
|
||||
process_func(item)
|
||||
except Exception as e:
|
||||
logger.warning(f"개별 아이템 처리 실패: {str(e)}")
|
||||
|
||||
|
||||
class DatabaseLock:
|
||||
"""데이터베이스 레벨 락 관리"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
@contextmanager
|
||||
def advisory_lock(self, lock_id: int):
|
||||
"""
|
||||
PostgreSQL Advisory Lock
|
||||
|
||||
Args:
|
||||
lock_id: 락 ID
|
||||
"""
|
||||
try:
|
||||
# Advisory Lock 획득
|
||||
result = self.db.execute(f"SELECT pg_advisory_lock({lock_id})")
|
||||
logger.debug(f"Advisory Lock 획득: {lock_id}")
|
||||
|
||||
yield
|
||||
|
||||
finally:
|
||||
# Advisory Lock 해제
|
||||
self.db.execute(f"SELECT pg_advisory_unlock({lock_id})")
|
||||
logger.debug(f"Advisory Lock 해제: {lock_id}")
|
||||
|
||||
@contextmanager
|
||||
def table_lock(self, table_name: str, lock_mode: str = "ACCESS EXCLUSIVE"):
|
||||
"""
|
||||
테이블 레벨 락
|
||||
|
||||
Args:
|
||||
table_name: 테이블명
|
||||
lock_mode: 락 모드
|
||||
"""
|
||||
try:
|
||||
# 테이블 락 획득
|
||||
self.db.execute(f"LOCK TABLE {table_name} IN {lock_mode} MODE")
|
||||
logger.debug(f"테이블 락 획득: {table_name} ({lock_mode})")
|
||||
|
||||
yield
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"테이블 락 실패: {table_name} - {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
class TransactionStats:
|
||||
"""트랜잭션 통계 수집"""
|
||||
|
||||
def __init__(self):
|
||||
self.stats = {
|
||||
"total_transactions": 0,
|
||||
"successful_transactions": 0,
|
||||
"failed_transactions": 0,
|
||||
"rollback_count": 0,
|
||||
"savepoint_count": 0
|
||||
}
|
||||
|
||||
def record_transaction_start(self):
|
||||
"""트랜잭션 시작 기록"""
|
||||
self.stats["total_transactions"] += 1
|
||||
|
||||
def record_transaction_success(self):
|
||||
"""트랜잭션 성공 기록"""
|
||||
self.stats["successful_transactions"] += 1
|
||||
|
||||
def record_transaction_failure(self):
|
||||
"""트랜잭션 실패 기록"""
|
||||
self.stats["failed_transactions"] += 1
|
||||
|
||||
def record_rollback(self):
|
||||
"""롤백 기록"""
|
||||
self.stats["rollback_count"] += 1
|
||||
|
||||
def record_savepoint(self):
|
||||
"""세이브포인트 기록"""
|
||||
self.stats["savepoint_count"] += 1
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""통계 반환"""
|
||||
total = self.stats["total_transactions"]
|
||||
if total > 0:
|
||||
self.stats["success_rate"] = (self.stats["successful_transactions"] / total) * 100
|
||||
self.stats["failure_rate"] = (self.stats["failed_transactions"] / total) * 100
|
||||
else:
|
||||
self.stats["success_rate"] = 0
|
||||
self.stats["failure_rate"] = 0
|
||||
|
||||
return self.stats.copy()
|
||||
|
||||
def reset_stats(self):
|
||||
"""통계 초기화"""
|
||||
for key in self.stats:
|
||||
if key not in ["success_rate", "failure_rate"]:
|
||||
self.stats[key] = 0
|
||||
|
||||
|
||||
# 전역 트랜잭션 통계 인스턴스
|
||||
transaction_stats = TransactionStats()
|
||||
13
backend/entrypoint.sh
Executable file
13
backend/entrypoint.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Wait for DB to be ready (optional, but good practice if not handled by docker-compose)
|
||||
# /wait-for-it.sh db:5432 --
|
||||
|
||||
# Run migrations
|
||||
echo "Running database migrations..."
|
||||
alembic upgrade head
|
||||
|
||||
# Start application
|
||||
echo "Starting application..."
|
||||
exec "$@"
|
||||
25
backend/env.example
Normal file
25
backend/env.example
Normal file
@@ -0,0 +1,25 @@
|
||||
# TK-MP-Project 환경변수 설정 예시
|
||||
# 실제 사용 시 .env 파일로 복사하여 사용
|
||||
|
||||
# 환경 설정 (development, production, synology)
|
||||
ENVIRONMENT=development
|
||||
|
||||
# 애플리케이션 설정
|
||||
APP_NAME=TK-MP BOM Management API
|
||||
APP_VERSION=1.0.0
|
||||
DEBUG=true
|
||||
|
||||
# 데이터베이스 설정
|
||||
DATABASE_URL=postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom
|
||||
|
||||
# Redis 설정
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# 보안 설정
|
||||
# CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"] # 필요시 직접 설정
|
||||
MAX_FILE_SIZE=52428800 # 50MB in bytes
|
||||
ALLOWED_FILE_EXTENSIONS=[".xlsx",".xls",".csv"]
|
||||
|
||||
# 로깅 설정
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/app.log
|
||||
@@ -1,30 +0,0 @@
|
||||
"""
|
||||
수정된 스풀 시스템 사용 예시
|
||||
"""
|
||||
|
||||
# 시나리오: A-1 도면에서 파이프 3개 발견
|
||||
examples = [
|
||||
{
|
||||
"dwg_name": "A-1",
|
||||
"pipes": [
|
||||
{"description": "PIPE 1", "user_input_spool": "A"}, # A-1-A
|
||||
{"description": "PIPE 2", "user_input_spool": "A"}, # A-1-A (같은 스풀)
|
||||
{"description": "PIPE 3", "user_input_spool": "B"} # A-1-B (다른 스풀)
|
||||
],
|
||||
"area_assignment": "#01" # 별도: A-1 도면은 #01 구역에 위치
|
||||
}
|
||||
]
|
||||
|
||||
# 결과:
|
||||
spool_identifiers = [
|
||||
"A-1-A", # 파이프 1, 2가 속함
|
||||
"A-1-B" # 파이프 3이 속함
|
||||
]
|
||||
|
||||
area_assignment = {
|
||||
"#01": ["A-1"] # A-1 도면은 #01 구역에 물리적으로 위치
|
||||
}
|
||||
|
||||
print("✅ 수정된 스풀 구조가 적용되었습니다!")
|
||||
print(f"스풀 식별자: {spool_identifiers}")
|
||||
print(f"에리어 할당: {area_assignment}")
|
||||
576
backend/exports/PR-20251014-001.json
Normal file
576
backend/exports/PR-20251014-001.json
Normal file
@@ -0,0 +1,576 @@
|
||||
{
|
||||
"request_no": "PR-20251014-001",
|
||||
"job_no": "테스트용",
|
||||
"created_at": "2025-10-14T22:16:10.998006",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 60768,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 11,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 60776,
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 92,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": [
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|1/2\"|undefined|ASTM A312 TP304",
|
||||
"material_ids": [
|
||||
60768
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 11,
|
||||
"unit": "m",
|
||||
"total_length": 1395.1,
|
||||
"pipe_lengths": [
|
||||
{
|
||||
"length": 70,
|
||||
"quantity": 1,
|
||||
"totalLength": 70
|
||||
},
|
||||
{
|
||||
"length": 70,
|
||||
"quantity": 1,
|
||||
"totalLength": 70
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 155,
|
||||
"quantity": 1,
|
||||
"totalLength": 155
|
||||
},
|
||||
{
|
||||
"length": 155,
|
||||
"quantity": 1,
|
||||
"totalLength": 155
|
||||
},
|
||||
{
|
||||
"length": 200,
|
||||
"quantity": 1,
|
||||
"totalLength": 200
|
||||
},
|
||||
{
|
||||
"length": 245.1,
|
||||
"quantity": 1,
|
||||
"totalLength": 245.1
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
}
|
||||
],
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|3/4\"|undefined|ASTM A106 B",
|
||||
"material_ids": [
|
||||
60776
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 92,
|
||||
"unit": "m",
|
||||
"total_length": 7920.2,
|
||||
"pipe_lengths": [
|
||||
{
|
||||
"length": 60,
|
||||
"quantity": 1,
|
||||
"totalLength": 60
|
||||
},
|
||||
{
|
||||
"length": 60,
|
||||
"quantity": 1,
|
||||
"totalLength": 60
|
||||
},
|
||||
{
|
||||
"length": 60,
|
||||
"quantity": 1,
|
||||
"totalLength": 60
|
||||
},
|
||||
{
|
||||
"length": 60,
|
||||
"quantity": 1,
|
||||
"totalLength": 60
|
||||
},
|
||||
{
|
||||
"length": 43.3,
|
||||
"quantity": 1,
|
||||
"totalLength": 43.3
|
||||
},
|
||||
{
|
||||
"length": 43.3,
|
||||
"quantity": 1,
|
||||
"totalLength": 43.3
|
||||
},
|
||||
{
|
||||
"length": 43.3,
|
||||
"quantity": 1,
|
||||
"totalLength": 43.3
|
||||
},
|
||||
{
|
||||
"length": 43.3,
|
||||
"quantity": 1,
|
||||
"totalLength": 43.3
|
||||
},
|
||||
{
|
||||
"length": 43.3,
|
||||
"quantity": 1,
|
||||
"totalLength": 43.3
|
||||
},
|
||||
{
|
||||
"length": 43.3,
|
||||
"quantity": 1,
|
||||
"totalLength": 43.3
|
||||
},
|
||||
{
|
||||
"length": 50,
|
||||
"quantity": 1,
|
||||
"totalLength": 50
|
||||
},
|
||||
{
|
||||
"length": 50,
|
||||
"quantity": 1,
|
||||
"totalLength": 50
|
||||
},
|
||||
{
|
||||
"length": 50,
|
||||
"quantity": 1,
|
||||
"totalLength": 50
|
||||
},
|
||||
{
|
||||
"length": 50,
|
||||
"quantity": 1,
|
||||
"totalLength": 50
|
||||
},
|
||||
{
|
||||
"length": 50,
|
||||
"quantity": 1,
|
||||
"totalLength": 50
|
||||
},
|
||||
{
|
||||
"length": 50,
|
||||
"quantity": 1,
|
||||
"totalLength": 50
|
||||
},
|
||||
{
|
||||
"length": 50,
|
||||
"quantity": 1,
|
||||
"totalLength": 50
|
||||
},
|
||||
{
|
||||
"length": 70,
|
||||
"quantity": 1,
|
||||
"totalLength": 70
|
||||
},
|
||||
{
|
||||
"length": 70,
|
||||
"quantity": 1,
|
||||
"totalLength": 70
|
||||
},
|
||||
{
|
||||
"length": 70,
|
||||
"quantity": 1,
|
||||
"totalLength": 70
|
||||
},
|
||||
{
|
||||
"length": 70,
|
||||
"quantity": 1,
|
||||
"totalLength": 70
|
||||
},
|
||||
{
|
||||
"length": 70,
|
||||
"quantity": 1,
|
||||
"totalLength": 70
|
||||
},
|
||||
{
|
||||
"length": 70,
|
||||
"quantity": 1,
|
||||
"totalLength": 70
|
||||
},
|
||||
{
|
||||
"length": 70,
|
||||
"quantity": 1,
|
||||
"totalLength": 70
|
||||
},
|
||||
{
|
||||
"length": 70,
|
||||
"quantity": 1,
|
||||
"totalLength": 70
|
||||
},
|
||||
{
|
||||
"length": 70,
|
||||
"quantity": 1,
|
||||
"totalLength": 70
|
||||
},
|
||||
{
|
||||
"length": 70,
|
||||
"quantity": 1,
|
||||
"totalLength": 70
|
||||
},
|
||||
{
|
||||
"length": 70,
|
||||
"quantity": 1,
|
||||
"totalLength": 70
|
||||
},
|
||||
{
|
||||
"length": 70,
|
||||
"quantity": 1,
|
||||
"totalLength": 70
|
||||
},
|
||||
{
|
||||
"length": 76.2,
|
||||
"quantity": 1,
|
||||
"totalLength": 76.2
|
||||
},
|
||||
{
|
||||
"length": 76.2,
|
||||
"quantity": 1,
|
||||
"totalLength": 76.2
|
||||
},
|
||||
{
|
||||
"length": 76.2,
|
||||
"quantity": 1,
|
||||
"totalLength": 76.2
|
||||
},
|
||||
{
|
||||
"length": 76.2,
|
||||
"quantity": 1,
|
||||
"totalLength": 76.2
|
||||
},
|
||||
{
|
||||
"length": 76.2,
|
||||
"quantity": 1,
|
||||
"totalLength": 76.2
|
||||
},
|
||||
{
|
||||
"length": 76.2,
|
||||
"quantity": 1,
|
||||
"totalLength": 76.2
|
||||
},
|
||||
{
|
||||
"length": 77.6,
|
||||
"quantity": 1,
|
||||
"totalLength": 77.6
|
||||
},
|
||||
{
|
||||
"length": 77.6,
|
||||
"quantity": 1,
|
||||
"totalLength": 77.6
|
||||
},
|
||||
{
|
||||
"length": 77.6,
|
||||
"quantity": 1,
|
||||
"totalLength": 77.6
|
||||
},
|
||||
{
|
||||
"length": 77.6,
|
||||
"quantity": 1,
|
||||
"totalLength": 77.6
|
||||
},
|
||||
{
|
||||
"length": 77.6,
|
||||
"quantity": 1,
|
||||
"totalLength": 77.6
|
||||
},
|
||||
{
|
||||
"length": 77.6,
|
||||
"quantity": 1,
|
||||
"totalLength": 77.6
|
||||
},
|
||||
{
|
||||
"length": 80,
|
||||
"quantity": 1,
|
||||
"totalLength": 80
|
||||
},
|
||||
{
|
||||
"length": 80,
|
||||
"quantity": 1,
|
||||
"totalLength": 80
|
||||
},
|
||||
{
|
||||
"length": 80,
|
||||
"quantity": 1,
|
||||
"totalLength": 80
|
||||
},
|
||||
{
|
||||
"length": 80,
|
||||
"quantity": 1,
|
||||
"totalLength": 80
|
||||
},
|
||||
{
|
||||
"length": 80,
|
||||
"quantity": 1,
|
||||
"totalLength": 80
|
||||
},
|
||||
{
|
||||
"length": 80,
|
||||
"quantity": 1,
|
||||
"totalLength": 80
|
||||
},
|
||||
{
|
||||
"length": 80,
|
||||
"quantity": 1,
|
||||
"totalLength": 80
|
||||
},
|
||||
{
|
||||
"length": 80,
|
||||
"quantity": 1,
|
||||
"totalLength": 80
|
||||
},
|
||||
{
|
||||
"length": 80,
|
||||
"quantity": 1,
|
||||
"totalLength": 80
|
||||
},
|
||||
{
|
||||
"length": 88.6,
|
||||
"quantity": 1,
|
||||
"totalLength": 88.6
|
||||
},
|
||||
{
|
||||
"length": 88.6,
|
||||
"quantity": 1,
|
||||
"totalLength": 88.6
|
||||
},
|
||||
{
|
||||
"length": 98.4,
|
||||
"quantity": 1,
|
||||
"totalLength": 98.4
|
||||
},
|
||||
{
|
||||
"length": 98.4,
|
||||
"quantity": 1,
|
||||
"totalLength": 98.4
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 100,
|
||||
"quantity": 1,
|
||||
"totalLength": 100
|
||||
},
|
||||
{
|
||||
"length": 120,
|
||||
"quantity": 1,
|
||||
"totalLength": 120
|
||||
},
|
||||
{
|
||||
"length": 120,
|
||||
"quantity": 1,
|
||||
"totalLength": 120
|
||||
},
|
||||
{
|
||||
"length": 150,
|
||||
"quantity": 1,
|
||||
"totalLength": 150
|
||||
},
|
||||
{
|
||||
"length": 150,
|
||||
"quantity": 1,
|
||||
"totalLength": 150
|
||||
},
|
||||
{
|
||||
"length": 150,
|
||||
"quantity": 1,
|
||||
"totalLength": 150
|
||||
},
|
||||
{
|
||||
"length": 150,
|
||||
"quantity": 1,
|
||||
"totalLength": 150
|
||||
},
|
||||
{
|
||||
"length": 150,
|
||||
"quantity": 1,
|
||||
"totalLength": 150
|
||||
},
|
||||
{
|
||||
"length": 223.6,
|
||||
"quantity": 1,
|
||||
"totalLength": 223.6
|
||||
}
|
||||
],
|
||||
"user_requirement": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
101
backend/exports/PR-20251014-002.json
Normal file
101
backend/exports/PR-20251014-002.json
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"request_no": "PR-20251014-002",
|
||||
"job_no": "TKG-25000P",
|
||||
"created_at": "2025-10-14T06:54:44.585437",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 5552,
|
||||
"description": "SIGHT GLASS, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "1\"",
|
||||
"material_grade": "SS",
|
||||
"quantity": 6,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5558,
|
||||
"description": "SIGHT GLASS, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "SS",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5563,
|
||||
"description": "SIGHT GLASS, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "SS",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5566,
|
||||
"description": "STRAINER, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "-",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": [
|
||||
{
|
||||
"group_key": "SIGHT GLASS, FLG, 150LB|1\"|undefined|SS",
|
||||
"material_ids": [
|
||||
5552
|
||||
],
|
||||
"description": "SIGHT GLASS, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "1\"",
|
||||
"material_grade": "SS",
|
||||
"quantity": 6,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "SIGHT GLASS, FLG, 150LB|1/2\"|undefined|SS",
|
||||
"material_ids": [
|
||||
5558
|
||||
],
|
||||
"description": "SIGHT GLASS, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "SS",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "SIGHT GLASS, FLG, 150LB|2\"|undefined|SS",
|
||||
"material_ids": [
|
||||
5563
|
||||
],
|
||||
"description": "SIGHT GLASS, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "SS",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "STRAINER, FLG, 150LB|2\"|undefined|-",
|
||||
"material_ids": [
|
||||
5566
|
||||
],
|
||||
"description": "STRAINER, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "-",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
1744
backend/exports/PR-20251015-001.json
Normal file
1744
backend/exports/PR-20251015-001.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
backend/exports/PR-20251015-001.xlsx
Normal file
BIN
backend/exports/PR-20251015-001.xlsx
Normal file
Binary file not shown.
745
backend/exports/PR-20251015-002.json
Normal file
745
backend/exports/PR-20251015-002.json
Normal file
@@ -0,0 +1,745 @@
|
||||
{
|
||||
"request_no": "PR-20251015-002",
|
||||
"job_no": "TKG-20000P",
|
||||
"created_at": "2025-10-15T05:53:13.449375",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 76366,
|
||||
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "10\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76371,
|
||||
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "12\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76372,
|
||||
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 36,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76408,
|
||||
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 6,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76414,
|
||||
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 7,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76422,
|
||||
"description": "FLG WELD NECK RF SCH 40, 600LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 8,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76427,
|
||||
"description": "FLG WELD NECK RF SCH 40S, 600LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76429,
|
||||
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 12,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76441,
|
||||
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76446,
|
||||
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 9,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76455,
|
||||
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 3,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76458,
|
||||
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "6\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 10,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76468,
|
||||
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 14,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76480,
|
||||
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 15,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76484,
|
||||
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 9,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76485,
|
||||
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 66,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76489,
|
||||
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 6,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76491,
|
||||
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 8,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76499,
|
||||
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 40,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76535,
|
||||
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76540,
|
||||
"description": "RED. FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\" x 3/4\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76542,
|
||||
"description": "RED. FLG SWRF SCH 80, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\" x 3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 4,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76546,
|
||||
"description": "RED. FLG SWRF SCH 80, 600LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\" x 3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76556,
|
||||
"description": "FLG SWRF SCH 40S, 600LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76624,
|
||||
"description": "RED. FLG SWRF SCH 80, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1\" x 3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76629,
|
||||
"description": "FLG SWRF SCH 80, 300LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 3,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76634,
|
||||
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 57,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76691,
|
||||
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 7,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76698,
|
||||
"description": "FLG SWRF SCH 40S, 300LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76699,
|
||||
"description": "FLG SWRF SCH 40S, 600LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76711,
|
||||
"description": "FLG SWRF SCH 80, 300LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 76713,
|
||||
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": [
|
||||
{
|
||||
"group_key": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304|10\"|undefined|ASTM A182 F304",
|
||||
"material_ids": [
|
||||
76366
|
||||
],
|
||||
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "10\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304|12\"|undefined|ASTM A182 F304",
|
||||
"material_ids": [
|
||||
76371
|
||||
],
|
||||
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "12\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105|2\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76372
|
||||
],
|
||||
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 36,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105|2\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76408
|
||||
],
|
||||
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 6,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304|2\"|undefined|ASTM A182 F304",
|
||||
"material_ids": [
|
||||
76414
|
||||
],
|
||||
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 7,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG WELD NECK RF SCH 40, 600LB, ASTM A105|3\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76422
|
||||
],
|
||||
"description": "FLG WELD NECK RF SCH 40, 600LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 8,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG WELD NECK RF SCH 40S, 600LB, ASTM A182 F304|3\"|undefined|ASTM A182 F304",
|
||||
"material_ids": [
|
||||
76427
|
||||
],
|
||||
"description": "FLG WELD NECK RF SCH 40S, 600LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105|3\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76429
|
||||
],
|
||||
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 12,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105|3\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76441
|
||||
],
|
||||
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304|3\"|undefined|ASTM A182 F304",
|
||||
"material_ids": [
|
||||
76446
|
||||
],
|
||||
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 9,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105|4\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76455
|
||||
],
|
||||
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 3,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105|6\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76458
|
||||
],
|
||||
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "6\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 10,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304|1/2\"|undefined|ASTM A182 F304",
|
||||
"material_ids": [
|
||||
76468
|
||||
],
|
||||
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 14,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG SWRF SCH 80, 150LB, ASTM A105|3/4\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76480
|
||||
],
|
||||
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 15,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304|1\"|undefined|ASTM A182 F304",
|
||||
"material_ids": [
|
||||
76484
|
||||
],
|
||||
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 9,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG SWRF SCH 80, 150LB, ASTM A105|1\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76485
|
||||
],
|
||||
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 66,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG SWRF SCH 80, 600LB, ASTM A105|1\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76489
|
||||
],
|
||||
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 6,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304|1 1/2\"|undefined|ASTM A182 F304",
|
||||
"material_ids": [
|
||||
76491
|
||||
],
|
||||
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 8,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG SWRF SCH 80, 150LB, ASTM A105|1 1/2\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76499
|
||||
],
|
||||
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 40,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG SWRF SCH 80, 600LB, ASTM A105|1 1/2\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76535
|
||||
],
|
||||
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "RED. FLG SWRF SCH 40S, 150LB, ASTM A182 F304|1 1/2\" x 3/4\"|undefined|ASTM A182 F304",
|
||||
"material_ids": [
|
||||
76540
|
||||
],
|
||||
"description": "RED. FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\" x 3/4\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "RED. FLG SWRF SCH 80, 150LB, ASTM A105|1 1/2\" x 3/4\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76542
|
||||
],
|
||||
"description": "RED. FLG SWRF SCH 80, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\" x 3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 4,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "RED. FLG SWRF SCH 80, 600LB, ASTM A105|1 1/2\" x 3/4\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76546
|
||||
],
|
||||
"description": "RED. FLG SWRF SCH 80, 600LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\" x 3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG SWRF SCH 40S, 600LB, ASTM A182 F304|1\"|undefined|ASTM A182 F304",
|
||||
"material_ids": [
|
||||
76556
|
||||
],
|
||||
"description": "FLG SWRF SCH 40S, 600LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "RED. FLG SWRF SCH 80, 150LB, ASTM A105|1\" x 3/4\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76624
|
||||
],
|
||||
"description": "RED. FLG SWRF SCH 80, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1\" x 3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG SWRF SCH 80, 300LB, ASTM A105|1 1/2\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76629
|
||||
],
|
||||
"description": "FLG SWRF SCH 80, 300LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 3,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG SWRF SCH 80, 150LB, ASTM A105|1/2\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76634
|
||||
],
|
||||
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 57,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304|3/4\"|undefined|ASTM A182 F304",
|
||||
"material_ids": [
|
||||
76691
|
||||
],
|
||||
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 7,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG SWRF SCH 40S, 300LB, ASTM A182 F304|3/4\"|undefined|ASTM A182 F304",
|
||||
"material_ids": [
|
||||
76698
|
||||
],
|
||||
"description": "FLG SWRF SCH 40S, 300LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG SWRF SCH 40S, 600LB, ASTM A182 F304|3/4\"|undefined|ASTM A182 F304",
|
||||
"material_ids": [
|
||||
76699
|
||||
],
|
||||
"description": "FLG SWRF SCH 40S, 600LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG SWRF SCH 80, 300LB, ASTM A105|3/4\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76711
|
||||
],
|
||||
"description": "FLG SWRF SCH 80, 300LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "FLG SWRF SCH 80, 600LB, ASTM A105|3/4\"|undefined|ASTM A105",
|
||||
"material_ids": [
|
||||
76713
|
||||
],
|
||||
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
backend/exports/PR-20251015-002.xlsx
Normal file
BIN
backend/exports/PR-20251015-002.xlsx
Normal file
Binary file not shown.
1836
backend/exports/PR-20251015-003.json
Normal file
1836
backend/exports/PR-20251015-003.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
backend/exports/PR-20251015-003.xlsx
Normal file
BIN
backend/exports/PR-20251015-003.xlsx
Normal file
Binary file not shown.
168
backend/exports/PR-20251016-001.json
Normal file
168
backend/exports/PR-20251016-001.json
Normal file
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"request_no": "PR-20251016-001",
|
||||
"job_no": "1",
|
||||
"created_at": "2025-10-16T05:40:46.947440",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 3543,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 11,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 3551,
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 92,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 3555,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 23,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 3565,
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 139,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 3574,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 14,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 3588,
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 98,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 3844,
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 82,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 3926,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "10\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 4,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 3930,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "12\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 3931,
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 50,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 3981,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 9,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 3990,
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 25,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 3998,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 8,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4023,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 15,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4126,
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "4\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 12,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4138,
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "6\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 13,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": []
|
||||
}
|
||||
BIN
backend/exports/PR-20251016-001.xlsx
Normal file
BIN
backend/exports/PR-20251016-001.xlsx
Normal file
Binary file not shown.
778
backend/exports/PR-20251016-002.json
Normal file
778
backend/exports/PR-20251016-002.json
Normal file
@@ -0,0 +1,778 @@
|
||||
{
|
||||
"request_no": "PR-20251016-002",
|
||||
"job_no": "1",
|
||||
"created_at": "2025-10-16T05:44:08.264221",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 3540,
|
||||
"description": "HALF NIPPLE, SMLS, SCH 80S, ASTM A312 TP304 SW X NPT",
|
||||
"category": "FITTING",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 3542,
|
||||
"description": "HALF NIPPLE, SMLS, SCH 80S, ASTM A312 TP304 SW X NPT",
|
||||
"category": "FITTING",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 3682,
|
||||
"description": "HALF NIPPLE, SMLS, SCH 160, ASTM A106 B SW * NPT",
|
||||
"category": "FITTING",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 3831,
|
||||
"description": "HALF NIPPLE, SMLS, SCH 160, ASTM A106 B SW X NPT",
|
||||
"category": "FITTING",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 4,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 3835,
|
||||
"description": "HALF NIPPLE, SMLS, SCH 160, ASTM A106 B SW X NPT",
|
||||
"category": "FITTING",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 3,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 3838,
|
||||
"description": "HALF NIPPLE, SMLS, SCH 160, ASTM A106 B SW X NPT",
|
||||
"category": "FITTING",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 3840,
|
||||
"description": "NIPPLE, SMLS, SCH 160, ASTM A106 B",
|
||||
"category": "FITTING",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4151,
|
||||
"description": "90 LR ELL, SMLS, SCH 40S, ASTM A403 WP304",
|
||||
"category": "FITTING",
|
||||
"size": "10\"",
|
||||
"material_grade": "ASTM A403 WP304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4152,
|
||||
"description": "90 LR ELL, SMLS, SCH 40, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 25,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4177,
|
||||
"description": "90 LR ELL, SMLS, SCH 40S, ASTM A403 WP304",
|
||||
"category": "FITTING",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A403 WP304",
|
||||
"quantity": 6,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4183,
|
||||
"description": "90 LR ELL, SMLS, SCH 40, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 12,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4195,
|
||||
"description": "90 LR ELL, SMLS, SCH 40S, ASTM A403 WP304",
|
||||
"category": "FITTING",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A403 WP304",
|
||||
"quantity": 4,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4199,
|
||||
"description": "90 LR ELL, SMLS, SCH 40, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "4\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 7,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4206,
|
||||
"description": "90 SR ELL, SMLS, SCH 40, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "4\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4207,
|
||||
"description": "90 LR ELL, SMLS, SCH 40, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "6\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 7,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4214,
|
||||
"description": "45 ELL, SMLS, SCH 40, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "6\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4216,
|
||||
"description": "TEE, SMLS, SCH 40, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 4,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4220,
|
||||
"description": "TEE, SMLS, SCH 40S, ASTM A403 WP304",
|
||||
"category": "FITTING",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A403 WP304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4221,
|
||||
"description": "TEE, SMLS, SCH 40, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4222,
|
||||
"description": "TEE, SMLS, SCH 40S, ASTM A403 WP304",
|
||||
"category": "FITTING",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A403 WP304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4223,
|
||||
"description": "TEE RED, SMLS, SCH 40 X SCH 80, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "2\" x 1 1/2\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 3,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4226,
|
||||
"description": "TEE RED, SMLS, SCH 40S X SCH 40S, ASTM A403 WP304",
|
||||
"category": "FITTING",
|
||||
"size": "2\" x 1 1/2\"",
|
||||
"material_grade": "ASTM A403 WP304",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4228,
|
||||
"description": "TEE RED, SMLS, SCH 40 X SCH 80, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "3\" x 1 1/2\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 3,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4231,
|
||||
"description": "TEE RED, SMLS, SCH 40 X SCH 80, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "4\" x 1 1/2\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4233,
|
||||
"description": "RED CONC, SMLS, SCH 80 X SCH 80, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "1 1/2\" x 1\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4238,
|
||||
"description": "RED CONC, SMLS, SCH 80 X SCH 80, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "1 1/2\" x 3/4\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4240,
|
||||
"description": "RED CONC, SMLS, SCH 80 X SCH 80, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "1\" x 3/4\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4245,
|
||||
"description": "RED CONC, SMLS, SCH 40S X SCH 40S, ASTM A403 WP304",
|
||||
"category": "FITTING",
|
||||
"size": "12\" x 10\"",
|
||||
"material_grade": "ASTM A403 WP304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4246,
|
||||
"description": "RED CONC, SMLS, SCH 40 X SCH 80, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "2\" x 1 1/2\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 6,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4252,
|
||||
"description": "RED CONC, SMLS, SCH 40S X SCH 40S, ASTM A403 WP304",
|
||||
"category": "FITTING",
|
||||
"size": "2\" x 1 1/2\"",
|
||||
"material_grade": "ASTM A403 WP304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4253,
|
||||
"description": "RED CONC, SMLS, SCH 40 X SCH 80, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "2\" x 1\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4254,
|
||||
"description": "RED CONC, SMLS, SCH 40 X SCH 80, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "3\" x 1 1/2\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4256,
|
||||
"description": "RED CONC, SMLS, SCH 40S X SCH 40S, ASTM A403 WP304",
|
||||
"category": "FITTING",
|
||||
"size": "3\" x 1\"",
|
||||
"material_grade": "ASTM A403 WP304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4257,
|
||||
"description": "RED CONC, SMLS, SCH 40 X SCH 40, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "3\" x 2\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4258,
|
||||
"description": "RED CONC, SMLS, SCH 40S X SCH 40S, ASTM A403 WP304",
|
||||
"category": "FITTING",
|
||||
"size": "3\" x 2\"",
|
||||
"material_grade": "ASTM A403 WP304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 4259,
|
||||
"description": "RED CONC, SMLS, SCH 80 X SCH 80, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "3/4\" x 1/2\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5136,
|
||||
"description": "90 ELL, SW, 3000LB, ASTM A182 F304",
|
||||
"category": "FITTING",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5138,
|
||||
"description": "90 ELL, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 57,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5142,
|
||||
"description": "90 ELL, SW, 3000LB, ASTM A182 F304",
|
||||
"category": "FITTING",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 9,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5146,
|
||||
"description": "90 ELL, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 32,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5178,
|
||||
"description": "90 ELL, SW, 3000LB, ASTM A182 F304",
|
||||
"category": "FITTING",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 9,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5245,
|
||||
"description": "90 ELL, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 32,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5277,
|
||||
"description": "90 ELL, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 24,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5301,
|
||||
"description": "TEE, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 7,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5308,
|
||||
"description": "TEE, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 15,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5323,
|
||||
"description": "TEE, SW, 3000LB, ASTM A182 F304",
|
||||
"category": "FITTING",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5324,
|
||||
"description": "TEE, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5326,
|
||||
"description": "TEE RED, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "1 1/2\" x 1\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5331,
|
||||
"description": "TEE RED, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "1 1/2\" x 1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5333,
|
||||
"description": "TEE RED, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "1 1/2\" x 3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 6,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5339,
|
||||
"description": "TEE RED, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "1\" x 1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 7,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5346,
|
||||
"description": "TEE RED, SW, 3000LB, ASTM A182 F304",
|
||||
"category": "FITTING",
|
||||
"size": "1\" x 1/2\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 6,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5349,
|
||||
"description": "TEE RED, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "1\" x 3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 3,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5355,
|
||||
"description": "TEE RED, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "2\" x 1\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 4,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5359,
|
||||
"description": "TEE RED, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "3/4\" x 1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5364,
|
||||
"description": "CAP, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5365,
|
||||
"description": "SOLID HEX. PLUG, NPT(M), 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5366,
|
||||
"description": "CAP, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5367,
|
||||
"description": "CAP, SW, 3000LB, ASTM A182 F304",
|
||||
"category": "FITTING",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5368,
|
||||
"description": "SOLID HEX. PLUG, NPT(M), 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5370,
|
||||
"description": "SOLID HEX. PLUG, NPT(M), 3000LB, ASTM A182 F304",
|
||||
"category": "FITTING",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5371,
|
||||
"description": "CAP, SMLS, SCH 40, BW, ASTM A234 WPB",
|
||||
"category": "FITTING",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A234 WPB",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5372,
|
||||
"description": "CAP, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5373,
|
||||
"description": "SOLID HEX. PLUG, NPT(M), 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 36,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5409,
|
||||
"description": "SOLID HEX. PLUG, NPT(M), 3000LB, ASTM A182 F304",
|
||||
"category": "FITTING",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5426,
|
||||
"description": "SOCK O LET, SW, 3000LB, ASTM A182 F304",
|
||||
"category": "FITTING",
|
||||
"size": "10\" x 1 1/2\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5427,
|
||||
"description": "SOCK O LET, SW, 3000LB, ASTM A182 F304",
|
||||
"category": "FITTING",
|
||||
"size": "10\" x 1\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5428,
|
||||
"description": "SOCK O LET, SW, 3000LB, ASTM A182 F304",
|
||||
"category": "FITTING",
|
||||
"size": "10\" x 3/4\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5429,
|
||||
"description": "SOCK O LET, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "2\" x 1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5431,
|
||||
"description": "ELL O LET, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "2\" x 3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5432,
|
||||
"description": "ELL O LET, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "3\" x 3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5433,
|
||||
"description": "SOCK O LET, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "3\" x 3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 9,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5442,
|
||||
"description": "SOCK O LET, SW, 3000LB, ASTM A182 F304",
|
||||
"category": "FITTING",
|
||||
"size": "3\" x 3/4\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 3,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5445,
|
||||
"description": "SOCK O LET, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "3\" x 1\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5446,
|
||||
"description": "SOCK O LET, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "4\" x 3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5447,
|
||||
"description": "SOCK O LET, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "6\" x 1 1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5449,
|
||||
"description": "SOCK O LET, SW, 3000LB, ASTM A105",
|
||||
"category": "FITTING",
|
||||
"size": "6\" x 3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": []
|
||||
}
|
||||
BIN
backend/exports/PR-20251016-002.xlsx
Normal file
BIN
backend/exports/PR-20251016-002.xlsx
Normal file
Binary file not shown.
168
backend/exports/PR-20251016-003.json
Normal file
168
backend/exports/PR-20251016-003.json
Normal file
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"request_no": "PR-20251016-003",
|
||||
"job_no": "2",
|
||||
"created_at": "2025-10-16T06:01:25.896639",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 7082,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 11,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 7090,
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 92,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 7094,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 23,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 7104,
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 139,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 7113,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 14,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 7127,
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 98,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 7383,
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 82,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 7465,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "10\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 4,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 7469,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "12\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 7470,
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 50,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 7520,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 9,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 7529,
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 25,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 7537,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 8,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 7562,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 15,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 7665,
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "4\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 12,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 7677,
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "6\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 13,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": []
|
||||
}
|
||||
BIN
backend/exports/PR-20251016-003.xlsx
Normal file
BIN
backend/exports/PR-20251016-003.xlsx
Normal file
Binary file not shown.
408
backend/exports/PR-20251016-004.json
Normal file
408
backend/exports/PR-20251016-004.json
Normal file
@@ -0,0 +1,408 @@
|
||||
{
|
||||
"request_no": "PR-20251016-004",
|
||||
"job_no": "5",
|
||||
"created_at": "2025-10-16T05:24:45.921468",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 118834,
|
||||
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "10\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 118839,
|
||||
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "12\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 118840,
|
||||
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 36,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 118876,
|
||||
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 6,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 118882,
|
||||
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 7,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 118890,
|
||||
"description": "FLG WELD NECK RF SCH 40, 600LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 8,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 118895,
|
||||
"description": "FLG WELD NECK RF SCH 40S, 600LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 118897,
|
||||
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 12,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 118909,
|
||||
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 118914,
|
||||
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 9,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 118923,
|
||||
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 3,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 118926,
|
||||
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "6\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 10,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 118936,
|
||||
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 14,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 118948,
|
||||
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 15,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 118952,
|
||||
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 9,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 118953,
|
||||
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 66,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 118957,
|
||||
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 6,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 118959,
|
||||
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 8,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 118967,
|
||||
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 40,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119003,
|
||||
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119008,
|
||||
"description": "RED. FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\" x 3/4\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119010,
|
||||
"description": "RED. FLG SWRF SCH 80, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\" x 3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 4,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119014,
|
||||
"description": "RED. FLG SWRF SCH 80, 600LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\" x 3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119024,
|
||||
"description": "FLG SWRF SCH 40S, 600LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119092,
|
||||
"description": "RED. FLG SWRF SCH 80, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1\" x 3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119097,
|
||||
"description": "FLG SWRF SCH 80, 300LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 3,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119102,
|
||||
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 57,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119159,
|
||||
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 7,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119166,
|
||||
"description": "FLG SWRF SCH 40S, 300LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119167,
|
||||
"description": "FLG SWRF SCH 40S, 600LB, ASTM A182 F304",
|
||||
"category": "FLANGE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A182 F304",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119179,
|
||||
"description": "FLG SWRF SCH 80, 300LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119181,
|
||||
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
|
||||
"category": "FLANGE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A105",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119985,
|
||||
"description": "ORIFICE, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "10\"",
|
||||
"material_grade": "-",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119987,
|
||||
"description": "WOOD ORIFICE, 300LB",
|
||||
"category": "FLANGE",
|
||||
"size": "10\"",
|
||||
"material_grade": "-",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119989,
|
||||
"description": "WOOD ORIFICE, 600LB",
|
||||
"category": "FLANGE",
|
||||
"size": "3\"",
|
||||
"material_grade": "-",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119990,
|
||||
"description": "WOOD ORIFICE, 300LB",
|
||||
"category": "FLANGE",
|
||||
"size": "4\"",
|
||||
"material_grade": "-",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119992,
|
||||
"description": "WOOD ORIFICE, 300LB",
|
||||
"category": "FLANGE",
|
||||
"size": "5\"",
|
||||
"material_grade": "-",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119993,
|
||||
"description": "WOOD ORIFICE, 600LB",
|
||||
"category": "FLANGE",
|
||||
"size": "5\"",
|
||||
"material_grade": "-",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119994,
|
||||
"description": "WOOD ORIFICE, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "6\"",
|
||||
"material_grade": "-",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 119996,
|
||||
"description": "WOOD ORIFICE, 300LB",
|
||||
"category": "FLANGE",
|
||||
"size": "8\"",
|
||||
"material_grade": "-",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": []
|
||||
}
|
||||
BIN
backend/exports/PR-20251016-004.xlsx
Normal file
BIN
backend/exports/PR-20251016-004.xlsx
Normal file
Binary file not shown.
60
backend/pytest.ini
Normal file
60
backend/pytest.ini
Normal file
@@ -0,0 +1,60 @@
|
||||
[tool:pytest]
|
||||
# pytest 설정 파일
|
||||
|
||||
# 테스트 디렉토리
|
||||
testpaths = tests
|
||||
|
||||
# 테스트 파일 패턴
|
||||
python_files = test_*.py *_test.py
|
||||
|
||||
# 테스트 클래스 패턴
|
||||
python_classes = Test*
|
||||
|
||||
# 테스트 함수 패턴
|
||||
python_functions = test_*
|
||||
|
||||
# 마커 정의
|
||||
markers =
|
||||
unit: 단위 테스트
|
||||
integration: 통합 테스트
|
||||
performance: 성능 테스트
|
||||
slow: 느린 테스트 (시간이 오래 걸리는 테스트)
|
||||
api: API 테스트
|
||||
database: 데이터베이스 테스트
|
||||
cache: 캐시 테스트
|
||||
classifier: 분류기 테스트
|
||||
|
||||
# 출력 설정
|
||||
addopts =
|
||||
-v
|
||||
--tb=short
|
||||
--strict-markers
|
||||
--disable-warnings
|
||||
--color=yes
|
||||
--durations=10
|
||||
--cov=app
|
||||
--cov-report=term-missing
|
||||
--cov-report=html:htmlcov
|
||||
--cov-fail-under=80
|
||||
|
||||
# 최소 커버리지 (80%)
|
||||
# --cov-fail-under=80
|
||||
|
||||
# 로그 설정
|
||||
log_cli = true
|
||||
log_cli_level = INFO
|
||||
log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s
|
||||
log_cli_date_format = %Y-%m-%d %H:%M:%S
|
||||
|
||||
# 경고 필터
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
ignore::PendingDeprecationWarning
|
||||
ignore::UserWarning:sqlalchemy.*
|
||||
|
||||
# 테스트 발견 설정
|
||||
minversion = 6.0
|
||||
required_plugins =
|
||||
pytest-cov
|
||||
pytest-asyncio
|
||||
pytest-mock
|
||||
@@ -1,32 +1,63 @@
|
||||
# FastAPI 웹 프레임워크
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
|
||||
# 데이터베이스
|
||||
sqlalchemy==2.0.23
|
||||
psycopg2-binary==2.9.9
|
||||
alembic==1.13.1
|
||||
|
||||
# 파일 처리
|
||||
pandas==2.1.4
|
||||
annotated-types==0.7.0
|
||||
anyio==3.7.1
|
||||
async-timeout==5.0.1
|
||||
bcrypt==4.1.2
|
||||
black==23.11.0
|
||||
certifi==2026.1.4
|
||||
click==8.1.8
|
||||
coverage==7.10.7
|
||||
dnspython==2.7.0
|
||||
email-validator==2.3.0
|
||||
et_xmlfile==2.0.0
|
||||
exceptiongroup==1.3.1
|
||||
fastapi==0.104.1
|
||||
flake8==6.1.0
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httptools==0.7.1
|
||||
httpx==0.25.2
|
||||
idna==3.11
|
||||
iniconfig==2.1.0
|
||||
Mako==1.3.10
|
||||
MarkupSafe==3.0.3
|
||||
mccabe==0.7.0
|
||||
mypy_extensions==1.1.0
|
||||
numpy==1.26.4
|
||||
openpyxl==3.1.2
|
||||
xlrd>=2.0.1
|
||||
python-multipart==0.0.6
|
||||
|
||||
# 데이터 검증
|
||||
packaging==25.0
|
||||
pandas==2.1.4
|
||||
pathspec==1.0.1
|
||||
platformdirs==4.4.0
|
||||
pluggy==1.6.0
|
||||
psycopg2-binary==2.9.9
|
||||
pycodestyle==2.11.1
|
||||
pydantic==2.5.2
|
||||
pydantic-settings==2.1.0
|
||||
|
||||
# 기타 유틸리티
|
||||
python-dotenv==1.0.0
|
||||
httpx==0.25.2
|
||||
redis==5.0.1
|
||||
|
||||
# 개발 도구
|
||||
pydantic_core==2.14.5
|
||||
pyflakes==3.1.0
|
||||
PyJWT==2.8.0
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
black==23.11.0
|
||||
flake8==6.1.0
|
||||
pytest-cov==4.1.0
|
||||
pytest-mock==3.12.0
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.0.0
|
||||
python-magic==0.4.27
|
||||
python-multipart==0.0.6
|
||||
openpyxl==3.1.2
|
||||
pytz==2025.2
|
||||
PyYAML==6.0.3
|
||||
RapidFuzz==3.13.0
|
||||
redis==5.0.1
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
SQLAlchemy==2.0.23
|
||||
starlette==0.27.0
|
||||
tomli==2.3.0
|
||||
typing_extensions==4.15.0
|
||||
tzdata==2025.3
|
||||
uvicorn==0.24.0
|
||||
uvloop==0.22.1
|
||||
watchfiles==1.1.1
|
||||
websockets==15.0.1
|
||||
xlrd==2.0.1
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
더미 프로젝트 데이터 생성 스크립트
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
# 프로젝트 루트를 Python path에 추가
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
def create_dummy_jobs():
|
||||
"""더미 Job 데이터 생성"""
|
||||
|
||||
# 간단한 SQLite 연결 (실제 DB 설정에 맞게 수정)
|
||||
try:
|
||||
# 실제 프로젝트의 database.py 설정 사용
|
||||
from app.database import engine
|
||||
print("✅ 데이터베이스 연결 성공")
|
||||
except ImportError:
|
||||
# 직접 연결 (개발용)
|
||||
DATABASE_URL = "sqlite:///./test.db" # 실제 DB URL로 변경
|
||||
engine = create_engine(DATABASE_URL)
|
||||
print("⚠️ 직접 데이터베이스 연결")
|
||||
|
||||
# 더미 데이터 정의
|
||||
dummy_jobs = [
|
||||
{
|
||||
'job_no': 'J24-001',
|
||||
'job_name': '울산 SK에너지 정유시설 증설 배관공사',
|
||||
'client_name': '삼성엔지니어링',
|
||||
'end_user': 'SK에너지',
|
||||
'epc_company': '삼성엔지니어링',
|
||||
'project_site': '울산광역시 온산공단 SK에너지 정유공장',
|
||||
'contract_date': '2024-03-15',
|
||||
'delivery_date': '2024-08-30',
|
||||
'delivery_terms': 'FOB 울산항',
|
||||
'status': '진행중',
|
||||
'description': '정유시설 증설을 위한 배관 자재 공급 프로젝트. 고온고압 배관 및 특수 밸브 포함.',
|
||||
'created_by': 'admin'
|
||||
},
|
||||
{
|
||||
'job_no': 'J24-002',
|
||||
'job_name': '포스코 광양 제철소 배관 정비공사',
|
||||
'client_name': '포스코',
|
||||
'end_user': '포스코',
|
||||
'epc_company': None,
|
||||
'project_site': '전남 광양시 포스코 광양제철소',
|
||||
'contract_date': '2024-04-02',
|
||||
'delivery_date': '2024-07-15',
|
||||
'delivery_terms': 'DDP 광양제철소 현장',
|
||||
'status': '진행중',
|
||||
'description': '제철소 정기 정비를 위한 배관 부품 교체. 내열성 특수강 배관 포함.',
|
||||
'created_by': 'admin'
|
||||
}
|
||||
]
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
# 기존 더미 데이터 삭제 (개발용)
|
||||
print("🧹 기존 더미 데이터 정리...")
|
||||
conn.execute(text("DELETE FROM jobs WHERE job_no IN ('J24-001', 'J24-002')"))
|
||||
|
||||
# 새 더미 데이터 삽입
|
||||
print("📝 더미 데이터 삽입 중...")
|
||||
|
||||
for job in dummy_jobs:
|
||||
insert_query = text("""
|
||||
INSERT INTO jobs (
|
||||
job_no, job_name, client_name, end_user, epc_company,
|
||||
project_site, contract_date, delivery_date, delivery_terms,
|
||||
status, description, created_by, is_active
|
||||
) VALUES (
|
||||
:job_no, :job_name, :client_name, :end_user, :epc_company,
|
||||
:project_site, :contract_date, :delivery_date, :delivery_terms,
|
||||
:status, :description, :created_by, :is_active
|
||||
)
|
||||
""")
|
||||
|
||||
conn.execute(insert_query, {**job, 'is_active': True})
|
||||
print(f"✅ {job['job_no']}: {job['job_name']}")
|
||||
|
||||
# 커밋
|
||||
conn.commit()
|
||||
|
||||
# 결과 확인
|
||||
result = conn.execute(text("""
|
||||
SELECT job_no, job_name, client_name, status
|
||||
FROM jobs
|
||||
WHERE job_no IN ('J24-001', 'J24-002')
|
||||
"""))
|
||||
jobs = result.fetchall()
|
||||
|
||||
print(f"\n🎉 총 {len(jobs)}개 더미 Job 생성 완료!")
|
||||
print("\n📋 생성된 더미 데이터:")
|
||||
for job in jobs:
|
||||
print(f" • {job[0]}: {job[1]} ({job[2]}) - {job[3]}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 더미 데이터 생성 실패: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_dummy_jobs()
|
||||
184
backend/scripts/15_create_tubing_system.sql
Normal file
184
backend/scripts/15_create_tubing_system.sql
Normal file
@@ -0,0 +1,184 @@
|
||||
-- ================================
|
||||
-- Tubing 제품 관리 시스템
|
||||
-- 실행일: 2025.08.01
|
||||
-- ================================
|
||||
|
||||
-- 1. Tubing 카테고리 테이블 (일반, VCR, 기타 등)
|
||||
CREATE TABLE IF NOT EXISTS tubing_categories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
category_code VARCHAR(20) UNIQUE NOT NULL,
|
||||
category_name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 2. Tubing 규격 마스터 테이블
|
||||
CREATE TABLE IF NOT EXISTS tubing_specifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
category_id INTEGER REFERENCES tubing_categories(id),
|
||||
spec_code VARCHAR(50) UNIQUE NOT NULL,
|
||||
spec_name VARCHAR(200) NOT NULL,
|
||||
|
||||
-- 물리적 규격
|
||||
outer_diameter_mm DECIMAL(8,3), -- 외경 (mm)
|
||||
wall_thickness_mm DECIMAL(6,3), -- 두께 (mm)
|
||||
inner_diameter_mm DECIMAL(8,3), -- 내경 (mm, 계산 또는 실측)
|
||||
|
||||
-- 재질 정보
|
||||
material_grade VARCHAR(100), -- SS316, SS316L, Inconel625 등
|
||||
material_standard VARCHAR(100), -- ASTM A269, JIS G3463 등
|
||||
|
||||
-- 압력/온도 등급
|
||||
max_pressure_bar DECIMAL(8,2), -- 최대 압력 (bar)
|
||||
max_temperature_c DECIMAL(6,2), -- 최대 온도 (°C)
|
||||
min_temperature_c DECIMAL(6,2), -- 최소 온도 (°C)
|
||||
|
||||
-- 표준 규격
|
||||
standard_length_m DECIMAL(8,3), -- 표준 길이 (m)
|
||||
bend_radius_min_mm DECIMAL(8,2), -- 최소 벤딩 반경 (mm)
|
||||
|
||||
-- 기타 정보
|
||||
surface_finish VARCHAR(100), -- 표면 마감 (BA, #4, 2B 등)
|
||||
hardness VARCHAR(50), -- 경도
|
||||
notes TEXT,
|
||||
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 3. 제조사 정보 테이블
|
||||
CREATE TABLE IF NOT EXISTS tubing_manufacturers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
manufacturer_code VARCHAR(20) UNIQUE NOT NULL,
|
||||
manufacturer_name VARCHAR(200) NOT NULL,
|
||||
country VARCHAR(100),
|
||||
website VARCHAR(500),
|
||||
contact_info JSONB, -- 연락처 정보 (JSON)
|
||||
quality_certs JSONB, -- 품질 인증서 정보 (ISO, API 등)
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 4. 제조사별 제품 테이블 (품목번호 매핑)
|
||||
CREATE TABLE IF NOT EXISTS tubing_products (
|
||||
id SERIAL PRIMARY KEY,
|
||||
specification_id INTEGER REFERENCES tubing_specifications(id),
|
||||
manufacturer_id INTEGER REFERENCES tubing_manufacturers(id),
|
||||
|
||||
-- 제조사 품목번호 정보
|
||||
manufacturer_part_number VARCHAR(200) NOT NULL, -- 제조사 품목번호
|
||||
manufacturer_product_name VARCHAR(300), -- 제조사 제품명
|
||||
|
||||
-- 가격/공급 정보
|
||||
list_price DECIMAL(12,2), -- 정가
|
||||
currency VARCHAR(10) DEFAULT 'KRW', -- 통화
|
||||
lead_time_days INTEGER, -- 리드타임 (일)
|
||||
minimum_order_qty DECIMAL(10,3), -- 최소 주문 수량
|
||||
standard_packaging_qty DECIMAL(10,3), -- 표준 포장 수량
|
||||
|
||||
-- 가용성 정보
|
||||
availability_status VARCHAR(50), -- 재고 상태
|
||||
last_price_update DATE, -- 마지막 가격 업데이트
|
||||
|
||||
-- 추가 정보
|
||||
datasheet_url VARCHAR(500), -- 데이터시트 URL
|
||||
catalog_page VARCHAR(100), -- 카탈로그 페이지
|
||||
notes TEXT,
|
||||
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 유니크 제약 (같은 규격의 같은 제조사 제품은 하나만)
|
||||
UNIQUE(specification_id, manufacturer_id, manufacturer_part_number)
|
||||
);
|
||||
|
||||
-- 5. BOM에서 사용되는 Tubing 매핑 테이블
|
||||
CREATE TABLE IF NOT EXISTS material_tubing_mapping (
|
||||
id SERIAL PRIMARY KEY,
|
||||
material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE,
|
||||
tubing_product_id INTEGER REFERENCES tubing_products(id),
|
||||
|
||||
-- 매핑 정보
|
||||
confidence_score DECIMAL(3,2), -- 매핑 신뢰도 (0.00-1.00)
|
||||
mapping_method VARCHAR(50), -- 매핑 방법 (auto/manual)
|
||||
mapped_by VARCHAR(100), -- 매핑한 사용자
|
||||
mapped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 수량 정보
|
||||
required_length_m DECIMAL(10,3), -- 필요 길이 (m)
|
||||
calculated_quantity DECIMAL(10,3), -- 계산된 주문 수량
|
||||
|
||||
-- 검증 정보
|
||||
is_verified BOOLEAN DEFAULT FALSE,
|
||||
verified_by VARCHAR(100),
|
||||
verified_at TIMESTAMP,
|
||||
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ================================
|
||||
-- 인덱스 생성
|
||||
-- ================================
|
||||
|
||||
-- Tubing 규격 관련 인덱스
|
||||
CREATE INDEX idx_tubing_specs_category ON tubing_specifications(category_id);
|
||||
CREATE INDEX idx_tubing_specs_material ON tubing_specifications(material_grade);
|
||||
CREATE INDEX idx_tubing_specs_diameter ON tubing_specifications(outer_diameter_mm, wall_thickness_mm);
|
||||
|
||||
-- 제품 관련 인덱스
|
||||
CREATE INDEX idx_tubing_products_spec ON tubing_products(specification_id);
|
||||
CREATE INDEX idx_tubing_products_manufacturer ON tubing_products(manufacturer_id);
|
||||
CREATE INDEX idx_tubing_products_part_number ON tubing_products(manufacturer_part_number);
|
||||
|
||||
-- 매핑 관련 인덱스
|
||||
CREATE INDEX idx_material_tubing_mapping_material ON material_tubing_mapping(material_id);
|
||||
CREATE INDEX idx_material_tubing_mapping_product ON material_tubing_mapping(tubing_product_id);
|
||||
|
||||
-- ================================
|
||||
-- 기초 데이터 입력
|
||||
-- ================================
|
||||
|
||||
-- Tubing 카테고리 기초 데이터
|
||||
INSERT INTO tubing_categories (category_code, category_name, description) VALUES
|
||||
('GENERAL', '일반 Tubing', '일반적인 스테인리스 스틸 튜빙'),
|
||||
('VCR', 'VCR Tubing', 'VCR (Vacuum Coupling Radiation) 연결용 튜빙'),
|
||||
('SANITARY', 'Sanitary Tubing', '위생용 튜빙 (식품, 제약 등)'),
|
||||
('HVAC', 'HVAC Tubing', '공조용 튜빙'),
|
||||
('HYDRAULIC', 'Hydraulic Tubing', '유압용 튜빙'),
|
||||
('PNEUMATIC', 'Pneumatic Tubing', '공압용 튜빙'),
|
||||
('PROCESS', 'Process Tubing', '공정용 특수 튜빙'),
|
||||
('EXOTIC', 'Exotic Material', '특수 재질 튜빙 (Hastelloy, Inconel 등)')
|
||||
ON CONFLICT (category_code) DO NOTHING;
|
||||
|
||||
-- 주요 제조사 기초 데이터
|
||||
INSERT INTO tubing_manufacturers (manufacturer_code, manufacturer_name, country) VALUES
|
||||
('SWAGELOK', 'Swagelok Company', 'USA'),
|
||||
('PARKER', 'Parker Hannifin', 'USA'),
|
||||
('HAM_LET', 'Ham-Let Group', 'Israel'),
|
||||
('SUPERLOK', 'Superlok USA', 'USA'),
|
||||
('FITOK', 'Fitok Group', 'China'),
|
||||
('DK_LOK', 'DK-Lok Corporation', 'South Korea'),
|
||||
('GYROLOK', 'Gyrolok (Oliver Valves)', 'UK'),
|
||||
('AS_ONE', 'AS ONE Corporation', 'Japan')
|
||||
ON CONFLICT (manufacturer_code) DO NOTHING;
|
||||
|
||||
-- 기본 스테인리스 스틸 튜빙 규격 예시
|
||||
INSERT INTO tubing_specifications (
|
||||
category_id, spec_code, spec_name,
|
||||
outer_diameter_mm, wall_thickness_mm, inner_diameter_mm,
|
||||
material_grade, material_standard,
|
||||
max_pressure_bar, max_temperature_c, min_temperature_c,
|
||||
standard_length_m
|
||||
) VALUES
|
||||
(1, 'SS316-6MM-1MM', '6mm OD x 1mm WT SS316 Tubing', 6.0, 1.0, 4.0, 'SS316', 'ASTM A269', 413, 815, -196, 6.0),
|
||||
(1, 'SS316-8MM-1MM', '8mm OD x 1mm WT SS316 Tubing', 8.0, 1.0, 6.0, 'SS316', 'ASTM A269', 310, 815, -196, 6.0),
|
||||
(1, 'SS316-10MM-1MM', '10mm OD x 1mm WT SS316 Tubing', 10.0, 1.0, 8.0, 'SS316', 'ASTM A269', 248, 815, -196, 6.0),
|
||||
(1, 'SS316-12MM-1.5MM', '12mm OD x 1.5mm WT SS316 Tubing', 12.0, 1.5, 9.0, 'SS316', 'ASTM A269', 310, 815, -196, 6.0),
|
||||
(1, 'SS316L-6MM-1MM', '6mm OD x 1mm WT SS316L Tubing', 6.0, 1.0, 4.0, 'SS316L', 'ASTM A269', 413, 815, -196, 6.0)
|
||||
ON CONFLICT (spec_code) DO NOTHING;
|
||||
163
backend/scripts/16_performance_indexes.sql
Normal file
163
backend/scripts/16_performance_indexes.sql
Normal file
@@ -0,0 +1,163 @@
|
||||
-- ================================
|
||||
-- 성능 최적화를 위한 추가 인덱스
|
||||
-- 생성일: 2025.01 (Phase 2)
|
||||
-- ================================
|
||||
|
||||
-- 1. 복합 인덱스 (자주 함께 사용되는 컬럼들)
|
||||
-- ================================
|
||||
|
||||
-- files 테이블: job_no + revision 조합 (리비전 비교 시 자주 사용)
|
||||
CREATE INDEX IF NOT EXISTS idx_files_job_revision
|
||||
ON files(job_no, revision)
|
||||
WHERE is_active = true;
|
||||
|
||||
-- files 테이블: job_no + upload_date (최신 파일 조회)
|
||||
CREATE INDEX IF NOT EXISTS idx_files_job_date
|
||||
ON files(job_no, upload_date DESC)
|
||||
WHERE is_active = true;
|
||||
|
||||
-- materials 테이블: file_id + category (자재 분류별 조회)
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_file_category
|
||||
ON materials(file_id, classified_category);
|
||||
|
||||
-- materials 테이블: category + material_grade (자재 종류별 재질 검색)
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_category_grade
|
||||
ON materials(classified_category, material_grade);
|
||||
|
||||
-- 2. 검색 성능 향상 인덱스
|
||||
-- ================================
|
||||
|
||||
-- materials 테이블: description 텍스트 검색 (GIN 인덱스)
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_description_gin
|
||||
ON materials USING gin(to_tsvector('english', original_description));
|
||||
|
||||
-- materials 테이블: 해시 기반 중복 검색
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_hash
|
||||
ON materials(material_hash)
|
||||
WHERE material_hash IS NOT NULL;
|
||||
|
||||
-- 3. 정렬 성능 향상 인덱스
|
||||
-- ================================
|
||||
|
||||
-- jobs 테이블: 상태별 생성일 정렬
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_status_created
|
||||
ON jobs(status, created_at DESC)
|
||||
WHERE is_active = true;
|
||||
|
||||
-- materials 테이블: 수량별 정렬 (대용량 자재 우선 표시)
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_quantity_desc
|
||||
ON materials(quantity DESC);
|
||||
|
||||
-- 4. 조건부 인덱스 (특정 조건에서만 사용)
|
||||
-- ================================
|
||||
|
||||
-- 검증되지 않은 자재만 (분류 검토 필요한 항목)
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_unverified
|
||||
ON materials(classified_category, classification_confidence)
|
||||
WHERE is_verified = false;
|
||||
|
||||
-- 신뢰도가 낮은 분류 (0.8 미만)
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_low_confidence
|
||||
ON materials(file_id, classified_category)
|
||||
WHERE classification_confidence < 0.8;
|
||||
|
||||
-- 5. 외래키 성능 향상
|
||||
-- ================================
|
||||
|
||||
-- pipe_details 테이블
|
||||
CREATE INDEX IF NOT EXISTS idx_pipe_details_material
|
||||
ON pipe_details(material_id);
|
||||
|
||||
-- fitting_details 테이블
|
||||
CREATE INDEX IF NOT EXISTS idx_fitting_details_material
|
||||
ON fitting_details(material_id);
|
||||
|
||||
-- valve_details 테이블
|
||||
CREATE INDEX IF NOT EXISTS idx_valve_details_material
|
||||
ON valve_details(material_id);
|
||||
|
||||
-- flange_details 테이블
|
||||
CREATE INDEX IF NOT EXISTS idx_flange_details_material
|
||||
ON flange_details(material_id);
|
||||
|
||||
-- bolt_details 테이블
|
||||
CREATE INDEX IF NOT EXISTS idx_bolt_details_material
|
||||
ON bolt_details(material_id);
|
||||
|
||||
-- gasket_details 테이블
|
||||
CREATE INDEX IF NOT EXISTS idx_gasket_details_material
|
||||
ON gasket_details(material_id);
|
||||
|
||||
-- instrument_details 테이블
|
||||
CREATE INDEX IF NOT EXISTS idx_instrument_details_material
|
||||
ON instrument_details(material_id);
|
||||
|
||||
-- 6. 통계 및 집계 성능 향상
|
||||
-- ================================
|
||||
|
||||
-- 프로젝트별 자재 통계 (job_no 기준)
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_job_stats
|
||||
ON materials(
|
||||
(SELECT job_no FROM files WHERE files.id = materials.file_id),
|
||||
classified_category
|
||||
);
|
||||
|
||||
-- 파이프 길이 집계용 (파이프 cutting 계산)
|
||||
CREATE INDEX IF NOT EXISTS idx_pipe_length_aggregation
|
||||
ON pipe_details(material_id, length_mm)
|
||||
WHERE length_mm > 0;
|
||||
|
||||
-- 7. 성능 모니터링을 위한 뷰 생성
|
||||
-- ================================
|
||||
|
||||
-- 인덱스 사용률 모니터링 뷰
|
||||
CREATE OR REPLACE VIEW index_usage_stats AS
|
||||
SELECT
|
||||
schemaname,
|
||||
tablename,
|
||||
indexname,
|
||||
idx_tup_read,
|
||||
idx_tup_fetch,
|
||||
idx_scan,
|
||||
CASE
|
||||
WHEN idx_scan = 0 THEN 'UNUSED'
|
||||
WHEN idx_scan < 10 THEN 'LOW_USAGE'
|
||||
WHEN idx_scan < 100 THEN 'MEDIUM_USAGE'
|
||||
ELSE 'HIGH_USAGE'
|
||||
END as usage_level
|
||||
FROM pg_stat_user_indexes
|
||||
WHERE schemaname = 'public'
|
||||
ORDER BY idx_scan DESC;
|
||||
|
||||
-- 테이블 크기 및 성능 모니터링 뷰
|
||||
CREATE OR REPLACE VIEW table_performance_stats AS
|
||||
SELECT
|
||||
schemaname,
|
||||
tablename,
|
||||
n_tup_ins as inserts,
|
||||
n_tup_upd as updates,
|
||||
n_tup_del as deletes,
|
||||
seq_scan as sequential_scans,
|
||||
seq_tup_read as sequential_reads,
|
||||
idx_scan as index_scans,
|
||||
idx_tup_fetch as index_reads,
|
||||
CASE
|
||||
WHEN seq_scan + idx_scan = 0 THEN 0
|
||||
ELSE ROUND((idx_scan::numeric / (seq_scan + idx_scan)) * 100, 2)
|
||||
END as index_usage_percentage
|
||||
FROM pg_stat_user_tables
|
||||
WHERE schemaname = 'public'
|
||||
ORDER BY seq_scan + idx_scan DESC;
|
||||
|
||||
-- ================================
|
||||
-- 인덱스 생성 완료 로그
|
||||
-- ================================
|
||||
|
||||
-- 성능 최적화 인덱스 생성 완료 확인
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '성능 최적화 인덱스 생성 완료 - Phase 2 (2025.01)';
|
||||
RAISE NOTICE '총 생성된 인덱스: 복합 인덱스 4개, 검색 인덱스 2개, 정렬 인덱스 2개';
|
||||
RAISE NOTICE '조건부 인덱스 2개, 외래키 인덱스 7개, 집계 인덱스 2개';
|
||||
RAISE NOTICE '모니터링 뷰 2개 생성';
|
||||
END $$;
|
||||
29
backend/scripts/17_add_project_type_column.sql
Normal file
29
backend/scripts/17_add_project_type_column.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- jobs 테이블에 project_type 컬럼 추가
|
||||
-- TK-MP-Project 프로젝트 유형 관리를 위한 스키마 업데이트
|
||||
|
||||
-- project_type 컬럼 추가 (기존 데이터가 있을 수 있으므로 안전하게 추가)
|
||||
DO $$
|
||||
BEGIN
|
||||
-- project_type 컬럼이 존재하지 않으면 추가
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'jobs'
|
||||
AND column_name = 'project_type'
|
||||
) THEN
|
||||
ALTER TABLE jobs ADD COLUMN project_type VARCHAR(50) DEFAULT '냉동기';
|
||||
|
||||
-- 기존 데이터에 대한 기본값 설정
|
||||
UPDATE jobs SET project_type = '냉동기' WHERE project_type IS NULL;
|
||||
|
||||
-- NOT NULL 제약 조건 추가
|
||||
ALTER TABLE jobs ALTER COLUMN project_type SET NOT NULL;
|
||||
|
||||
-- 인덱스 추가 (프로젝트 유형별 조회 성능 향상)
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_project_type ON jobs(project_type);
|
||||
|
||||
RAISE NOTICE 'project_type 컬럼이 성공적으로 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'project_type 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
242
backend/scripts/18_create_auth_tables.sql
Normal file
242
backend/scripts/18_create_auth_tables.sql
Normal file
@@ -0,0 +1,242 @@
|
||||
-- TK-MP-Project 인증 시스템을 위한 사용자 및 로그인 테이블 생성
|
||||
-- TK-FB-Project 인증 시스템을 참고하여 구현
|
||||
|
||||
-- 1. 사용자 테이블 생성
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(100),
|
||||
|
||||
-- 권한 관리
|
||||
role VARCHAR(20) DEFAULT 'user' CHECK (role IN ('admin', 'system', 'leader', 'support', 'user')),
|
||||
access_level VARCHAR(20) DEFAULT 'worker' CHECK (access_level IN ('admin', 'system', 'group_leader', 'support_team', 'worker')),
|
||||
|
||||
-- 계정 상태 관리
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
failed_login_attempts INT DEFAULT 0,
|
||||
locked_until TIMESTAMP NULL,
|
||||
|
||||
-- 추가 정보
|
||||
department VARCHAR(50),
|
||||
position VARCHAR(50),
|
||||
phone VARCHAR(20),
|
||||
|
||||
-- 타임스탬프
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at TIMESTAMP NULL
|
||||
);
|
||||
|
||||
-- 2. 로그인 이력 테이블 생성
|
||||
CREATE TABLE IF NOT EXISTS login_logs (
|
||||
log_id SERIAL PRIMARY KEY,
|
||||
user_id INT REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
login_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
login_status VARCHAR(20) CHECK (login_status IN ('success', 'failed')),
|
||||
failure_reason VARCHAR(100),
|
||||
session_duration INT, -- 세션 지속 시간 (초)
|
||||
|
||||
-- 인덱스를 위한 컬럼
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 3. 사용자 세션 테이블 (JWT Refresh Token 관리)
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
session_id SERIAL PRIMARY KEY,
|
||||
user_id INT REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
refresh_token VARCHAR(500) NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 4. 권한 테이블 (확장 가능한 권한 시스템)
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
permission_id SERIAL PRIMARY KEY,
|
||||
permission_name VARCHAR(50) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
module VARCHAR(30), -- 모듈별 권한 관리 (bom, project, purchase 등)
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 5. 역할-권한 매핑 테이블
|
||||
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||
role_permission_id SERIAL PRIMARY KEY,
|
||||
role VARCHAR(20) NOT NULL,
|
||||
permission_id INT REFERENCES permissions(permission_id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(role, permission_id)
|
||||
);
|
||||
|
||||
-- 6. 인덱스 생성 (성능 최적화)
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_login_logs_user_id ON login_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_logs_login_time ON login_logs(login_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_logs_ip_address ON login_logs(ip_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_logs_status ON login_logs(login_status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_refresh_token ON user_sessions(refresh_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_expires_at ON user_sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_is_active ON user_sessions(is_active);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_permissions_module ON permissions(module);
|
||||
CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON role_permissions(role);
|
||||
|
||||
-- 7. 기본 권한 데이터 삽입
|
||||
INSERT INTO permissions (permission_name, description, module) VALUES
|
||||
-- BOM 관리 권한
|
||||
('bom.view', 'BOM 조회 권한', 'bom'),
|
||||
('bom.create', 'BOM 생성 권한', 'bom'),
|
||||
('bom.edit', 'BOM 수정 권한', 'bom'),
|
||||
('bom.delete', 'BOM 삭제 권한', 'bom'),
|
||||
('bom.approve', 'BOM 승인 권한', 'bom'),
|
||||
|
||||
-- 프로젝트 관리 권한
|
||||
('project.view', '프로젝트 조회 권한', 'project'),
|
||||
('project.create', '프로젝트 생성 권한', 'project'),
|
||||
('project.edit', '프로젝트 수정 권한', 'project'),
|
||||
('project.delete', '프로젝트 삭제 권한', 'project'),
|
||||
('project.manage', '프로젝트 관리 권한', 'project'),
|
||||
|
||||
-- 파일 관리 권한
|
||||
('file.upload', '파일 업로드 권한', 'file'),
|
||||
('file.download', '파일 다운로드 권한', 'file'),
|
||||
('file.delete', '파일 삭제 권한', 'file'),
|
||||
|
||||
-- 사용자 관리 권한
|
||||
('user.view', '사용자 조회 권한', 'user'),
|
||||
('user.create', '사용자 생성 권한', 'user'),
|
||||
('user.edit', '사용자 수정 권한', 'user'),
|
||||
('user.delete', '사용자 삭제 권한', 'user'),
|
||||
|
||||
-- 시스템 관리 권한
|
||||
('system.admin', '시스템 관리 권한', 'system'),
|
||||
('system.logs', '로그 조회 권한', 'system'),
|
||||
('system.settings', '시스템 설정 권한', 'system')
|
||||
|
||||
ON CONFLICT (permission_name) DO NOTHING;
|
||||
|
||||
-- 8. 역할별 기본 권한 할당
|
||||
INSERT INTO role_permissions (role, permission_id)
|
||||
SELECT 'admin', permission_id FROM permissions
|
||||
ON CONFLICT (role, permission_id) DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role, permission_id)
|
||||
SELECT 'system', permission_id FROM permissions
|
||||
ON CONFLICT (role, permission_id) DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role, permission_id)
|
||||
SELECT 'leader', permission_id FROM permissions
|
||||
WHERE permission_name IN (
|
||||
'bom.view', 'bom.create', 'bom.edit', 'bom.approve',
|
||||
'project.view', 'project.create', 'project.edit', 'project.manage',
|
||||
'file.upload', 'file.download', 'file.delete',
|
||||
'user.view'
|
||||
)
|
||||
ON CONFLICT (role, permission_id) DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role, permission_id)
|
||||
SELECT 'support', permission_id FROM permissions
|
||||
WHERE permission_name IN (
|
||||
'bom.view', 'bom.create', 'bom.edit',
|
||||
'project.view', 'project.create', 'project.edit',
|
||||
'file.upload', 'file.download'
|
||||
)
|
||||
ON CONFLICT (role, permission_id) DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role, permission_id)
|
||||
SELECT 'user', permission_id FROM permissions
|
||||
WHERE permission_name IN (
|
||||
'bom.view',
|
||||
'project.view',
|
||||
'file.upload', 'file.download'
|
||||
)
|
||||
ON CONFLICT (role, permission_id) DO NOTHING;
|
||||
|
||||
-- 9. 기본 관리자 계정 생성 (비밀번호: admin123)
|
||||
-- bcrypt 해시: $2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi
|
||||
INSERT INTO users (username, password, name, email, role, access_level, department, position) VALUES
|
||||
('admin', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '시스템 관리자', 'admin@tkmp.com', 'admin', 'admin', 'IT', '시스템 관리자'),
|
||||
('system', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '시스템 계정', 'system@tkmp.com', 'system', 'system', 'IT', '시스템 계정')
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
|
||||
-- 10. 트리거 함수 생성 (updated_at 자동 업데이트)
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- 11. 트리거 적용
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_user_sessions_updated_at BEFORE UPDATE ON user_sessions
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- 12. 뷰 생성 (사용자 정보 조회용)
|
||||
CREATE OR REPLACE VIEW user_info_view AS
|
||||
SELECT
|
||||
u.user_id,
|
||||
u.username,
|
||||
u.name,
|
||||
u.email,
|
||||
u.role,
|
||||
u.access_level,
|
||||
u.department,
|
||||
u.position,
|
||||
u.is_active,
|
||||
u.created_at,
|
||||
u.last_login_at,
|
||||
COUNT(ll.log_id) as login_count,
|
||||
MAX(ll.login_time) as last_successful_login
|
||||
FROM users u
|
||||
LEFT JOIN login_logs ll ON u.user_id = ll.user_id AND ll.login_status = 'success'
|
||||
GROUP BY u.user_id, u.username, u.name, u.email, u.role, u.access_level,
|
||||
u.department, u.position, u.is_active, u.created_at, u.last_login_at;
|
||||
|
||||
-- 완료 메시지
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '✅ TK-MP-Project 인증 시스템 데이터베이스 스키마가 성공적으로 생성되었습니다!';
|
||||
RAISE NOTICE '📋 생성된 테이블: users, login_logs, user_sessions, permissions, role_permissions';
|
||||
RAISE NOTICE '👤 기본 계정: admin/admin123, system/admin123';
|
||||
RAISE NOTICE '🔐 권한 시스템: 5단계 역할 + 모듈별 세분화된 권한';
|
||||
END $$;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
142
backend/scripts/19_add_user_tracking_fields.sql
Normal file
142
backend/scripts/19_add_user_tracking_fields.sql
Normal file
@@ -0,0 +1,142 @@
|
||||
-- 사용자 추적 및 담당자 기록 필드 추가
|
||||
-- 생성일: 2025.01
|
||||
-- 목적: RULES 가이드라인에 따른 사용자 추적 시스템 구축
|
||||
|
||||
-- ================================
|
||||
-- 1. 기존 테이블에 담당자 필드 추가
|
||||
-- ================================
|
||||
|
||||
-- files 테이블 수정 (uploaded_by는 이미 존재)
|
||||
ALTER TABLE files
|
||||
ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
-- jobs 테이블 수정
|
||||
ALTER TABLE jobs
|
||||
ADD COLUMN IF NOT EXISTS created_by VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS assigned_to VARCHAR(100);
|
||||
|
||||
-- materials 테이블 수정
|
||||
ALTER TABLE materials
|
||||
ADD COLUMN IF NOT EXISTS classified_by VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS classified_at TIMESTAMP,
|
||||
ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100);
|
||||
|
||||
-- ================================
|
||||
-- 2. 사용자 활동 로그 테이블 생성
|
||||
-- ================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_activity_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER, -- users 테이블 참조 (외래키 제약 없음 - 유연성)
|
||||
username VARCHAR(100) NOT NULL, -- 사용자명 (필수)
|
||||
|
||||
-- 활동 정보
|
||||
activity_type VARCHAR(50) NOT NULL, -- 'FILE_UPLOAD', 'PROJECT_CREATE', 'PURCHASE_CONFIRM' 등
|
||||
activity_description TEXT, -- 상세 활동 내용
|
||||
|
||||
-- 대상 정보
|
||||
target_id INTEGER, -- 대상 ID (파일, 프로젝트 등)
|
||||
target_type VARCHAR(50), -- 'FILE', 'PROJECT', 'MATERIAL', 'PURCHASE' 등
|
||||
|
||||
-- 세션 정보
|
||||
ip_address VARCHAR(45), -- IP 주소
|
||||
user_agent TEXT, -- 브라우저 정보
|
||||
|
||||
-- 추가 메타데이터 (JSON)
|
||||
metadata JSONB, -- 추가 정보 (파일 크기, 처리 시간 등)
|
||||
|
||||
-- 시간 정보
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ================================
|
||||
-- 3. 구매 관련 테이블 수정
|
||||
-- ================================
|
||||
|
||||
-- purchase_items 테이블 수정 (이미 created_by 존재하는지 확인 후 추가)
|
||||
ALTER TABLE purchase_items
|
||||
ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS approved_by VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMP;
|
||||
|
||||
-- material_purchase_tracking 테이블 수정 (이미 confirmed_by 존재)
|
||||
ALTER TABLE material_purchase_tracking
|
||||
ADD COLUMN IF NOT EXISTS ordered_by VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS ordered_at TIMESTAMP,
|
||||
ADD COLUMN IF NOT EXISTS approved_by VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMP;
|
||||
|
||||
-- ================================
|
||||
-- 4. 인덱스 생성 (성능 최적화)
|
||||
-- ================================
|
||||
|
||||
-- 사용자 활동 로그 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_username ON user_activity_logs(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_activity_type ON user_activity_logs(activity_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_created_at ON user_activity_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_target ON user_activity_logs(target_type, target_id);
|
||||
|
||||
-- 담당자 필드 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_files_uploaded_by ON files(uploaded_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_updated_by ON files(updated_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_created_by ON jobs(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_assigned_to ON jobs(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_classified_by ON materials(classified_by);
|
||||
|
||||
-- ================================
|
||||
-- 5. 트리거 생성 (자동 updated_at 갱신)
|
||||
-- ================================
|
||||
|
||||
-- files 테이블 updated_at 자동 갱신
|
||||
CREATE OR REPLACE FUNCTION update_files_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_files_updated_at ON files;
|
||||
CREATE TRIGGER trigger_files_updated_at
|
||||
BEFORE UPDATE ON files
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_files_updated_at();
|
||||
|
||||
-- jobs 테이블 updated_at 자동 갱신
|
||||
CREATE OR REPLACE FUNCTION update_jobs_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_jobs_updated_at ON jobs;
|
||||
CREATE TRIGGER trigger_jobs_updated_at
|
||||
BEFORE UPDATE ON jobs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_jobs_updated_at();
|
||||
|
||||
-- ================================
|
||||
-- 6. 기본 데이터 설정
|
||||
-- ================================
|
||||
|
||||
-- 기존 데이터에 기본 담당자 설정 (시스템 마이그레이션용)
|
||||
UPDATE files SET uploaded_by = 'system' WHERE uploaded_by IS NULL;
|
||||
UPDATE jobs SET created_by = 'system' WHERE created_by IS NULL;
|
||||
|
||||
-- ================================
|
||||
-- 7. 권한 및 보안 설정
|
||||
-- ================================
|
||||
|
||||
-- 활동 로그 테이블은 INSERT만 허용 (수정/삭제 방지)
|
||||
-- 실제 운영에서는 별도 권한 관리 필요
|
||||
|
||||
COMMENT ON TABLE user_activity_logs IS '사용자 활동 로그 - 모든 업무 활동 추적';
|
||||
COMMENT ON COLUMN user_activity_logs.activity_type IS '활동 유형: FILE_UPLOAD, PROJECT_CREATE, PURCHASE_CONFIRM, MATERIAL_CLASSIFY 등';
|
||||
COMMENT ON COLUMN user_activity_logs.metadata IS '추가 정보 JSON: 파일크기, 처리시간, 변경내용 등';
|
||||
|
||||
-- 완료 메시지
|
||||
SELECT 'User tracking system tables created successfully!' as result;
|
||||
50
backend/scripts/20_add_pipe_end_preparation_table.sql
Normal file
50
backend/scripts/20_add_pipe_end_preparation_table.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
-- 파이프 끝단 가공 정보 테이블 생성
|
||||
-- 각 파이프별로 끝단 가공 정보를 별도 저장
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pipe_end_preparations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
material_id INTEGER NOT NULL REFERENCES materials(id) ON DELETE CASCADE,
|
||||
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||
|
||||
-- 끝단 가공 정보
|
||||
end_preparation_type VARCHAR(50) DEFAULT 'PBE', -- PBE(양쪽무개선), BBE(양쪽개선), POE(한쪽개선), PE(무개선)
|
||||
end_preparation_code VARCHAR(20), -- 원본 코드 (BBE, POE, PBE 등)
|
||||
machining_required BOOLEAN DEFAULT FALSE, -- 가공 필요 여부
|
||||
cutting_note TEXT, -- 가공 메모
|
||||
|
||||
-- 원본 정보 보존
|
||||
original_description TEXT NOT NULL, -- 끝단 가공 포함된 원본 설명
|
||||
clean_description TEXT NOT NULL, -- 끝단 가공 제외한 구매용 설명
|
||||
|
||||
-- 메타데이터
|
||||
confidence FLOAT DEFAULT 0.0, -- 분류 신뢰도
|
||||
matched_pattern VARCHAR(100), -- 매칭된 패턴
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX IF NOT EXISTS idx_pipe_end_preparations_material_id ON pipe_end_preparations(material_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pipe_end_preparations_file_id ON pipe_end_preparations(file_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pipe_end_preparations_type ON pipe_end_preparations(end_preparation_type);
|
||||
|
||||
-- 기본 끝단 가공 타입 정의
|
||||
COMMENT ON COLUMN pipe_end_preparations.end_preparation_type IS 'PBE: 양쪽무개선(기본값), BBE: 양쪽개선, POE: 한쪽개선, PE: 무개선';
|
||||
COMMENT ON COLUMN pipe_end_preparations.machining_required IS '가공이 필요한지 여부 (개선 작업 등)';
|
||||
COMMENT ON COLUMN pipe_end_preparations.clean_description IS '구매 시 사용할 끝단 가공 정보가 제거된 설명';
|
||||
|
||||
-- 트리거: updated_at 자동 업데이트
|
||||
CREATE OR REPLACE FUNCTION update_pipe_end_preparations_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TRIGGER update_pipe_end_preparations_updated_at
|
||||
BEFORE UPDATE ON pipe_end_preparations
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_pipe_end_preparations_updated_at();
|
||||
|
||||
19
backend/scripts/21_add_material_id_to_user_requirements.sql
Normal file
19
backend/scripts/21_add_material_id_to_user_requirements.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- 사용자 요구사항 테이블에 material_id 컬럼 추가
|
||||
-- 2025.09.24 - 사용자 피드백 기반 개선사항 #1
|
||||
|
||||
-- material_id 컬럼 추가 (nullable로 시작)
|
||||
ALTER TABLE user_requirements
|
||||
ADD COLUMN IF NOT EXISTS material_id INTEGER;
|
||||
|
||||
-- 외래키 제약조건 추가
|
||||
ALTER TABLE user_requirements
|
||||
ADD CONSTRAINT fk_user_requirements_material_id
|
||||
FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE CASCADE;
|
||||
|
||||
-- 인덱스 추가 (성능 향상)
|
||||
CREATE INDEX IF NOT EXISTS idx_user_requirements_material_id ON user_requirements(material_id);
|
||||
|
||||
-- 기존 데이터 정리 (필요시)
|
||||
-- DELETE FROM user_requirements WHERE material_id IS NULL;
|
||||
|
||||
COMMENT ON COLUMN user_requirements.material_id IS '자재 ID (개별 자재별 요구사항 연결)';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user