Compare commits
31 Commits
521446d56b
...
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 |
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
|
||||||
175
RULES.md
175
RULES.md
@@ -1255,6 +1255,8 @@ const purchaseQuantity = Math.ceil(bomQuantity / 5) * 5;
|
|||||||
- 자동 에러 핸들링 (검증, DB, 일반 예외)
|
- 자동 에러 핸들링 (검증, DB, 일반 예외)
|
||||||
|
|
||||||
### 📊 구현된 페이지들
|
### 📊 구현된 페이지들
|
||||||
|
|
||||||
|
#### **📋 기존 페이지들**
|
||||||
- MainPage: 메인 대시보드
|
- MainPage: 메인 대시보드
|
||||||
- JobSelectionPage: 프로젝트 선택
|
- JobSelectionPage: 프로젝트 선택
|
||||||
- JobRegistrationPage: 프로젝트 등록
|
- JobRegistrationPage: 프로젝트 등록
|
||||||
@@ -1264,6 +1266,179 @@ const purchaseQuantity = Math.ceil(bomQuantity / 5) * 5;
|
|||||||
- PurchaseConfirmationPage: 구매 확인
|
- PurchaseConfirmationPage: 구매 확인
|
||||||
- RevisionPurchasePage: 리비전별 구매
|
- RevisionPurchasePage: 리비전별 구매
|
||||||
|
|
||||||
|
#### **🎨 신규 모던 UI 페이지들 (2025.10.16 추가)**
|
||||||
|
|
||||||
|
##### **DashboardPage.jsx** - 프로젝트 중심 대시보드
|
||||||
|
```jsx
|
||||||
|
// 위치: frontend/src/pages/DashboardPage.jsx
|
||||||
|
// 특징: 데본씽크 스타일의 모던한 디자인
|
||||||
|
// 기능:
|
||||||
|
// - 프로젝트 선택 및 관리 (카드 형태)
|
||||||
|
// - 권한별 기능 카드 (BOM 관리, 자재 관리, 구매 관리)
|
||||||
|
// - 관리자 전용 기능 (사용자 관리, 시스템 설정)
|
||||||
|
// - 시스템 현황 대시보드
|
||||||
|
// - 프로젝트 생성 모달
|
||||||
|
|
||||||
|
// 디자인 특징:
|
||||||
|
// - 글래스모피즘 효과 (backdrop-filter: blur(10px))
|
||||||
|
// - 그라데이션 배경 및 버튼
|
||||||
|
// - 카드 호버 애니메이션
|
||||||
|
// - 타이포그래피 중심 디자인 (이모지 제거)
|
||||||
|
// - 반응형 그리드 레이아웃
|
||||||
|
|
||||||
|
// 주요 기능:
|
||||||
|
// 1. 프로젝트 선택 시스템
|
||||||
|
// - 프로젝트 목록을 카드 형태로 표시
|
||||||
|
// - 선택된 프로젝트 하이라이트
|
||||||
|
// - 프로젝트 정보 (코드, 이름, 고객사) 표시
|
||||||
|
// 2. 권한 기반 기능 접근
|
||||||
|
// - 프로젝트 선택 후에만 BOM/자재 관리 접근 가능
|
||||||
|
// - 관리자 전용 메뉴 분리 표시
|
||||||
|
// 3. 프로젝트 생성 기능
|
||||||
|
// - 모달 형태의 프로젝트 생성 폼
|
||||||
|
// - 프로젝트 코드, 이름, 고객사 입력
|
||||||
|
```
|
||||||
|
|
||||||
|
##### **UserMenu.jsx** - 사용자 메뉴 컴포넌트
|
||||||
|
```jsx
|
||||||
|
// 위치: frontend/src/components/UserMenu.jsx
|
||||||
|
// 특징: 드롭다운 형태의 사용자 메뉴
|
||||||
|
// 기능:
|
||||||
|
// - 사용자 프로필 표시 (아바타, 이름, 역할)
|
||||||
|
// - 계정 설정 링크
|
||||||
|
// - 관리자 전용 메뉴 (권한별 표시)
|
||||||
|
// - 로그아웃 기능
|
||||||
|
|
||||||
|
// 디자인 특징:
|
||||||
|
// - 원형 아바타 (그라데이션 배경)
|
||||||
|
// - 드롭다운 애니메이션
|
||||||
|
// - 호버 효과
|
||||||
|
// - 역할별 색상 구분
|
||||||
|
|
||||||
|
// 주요 기능:
|
||||||
|
// 1. 사용자 정보 표시
|
||||||
|
// - 이름 첫 글자로 아바타 생성
|
||||||
|
// - 역할 표시 (시스템 관리자, 관리자, 사용자)
|
||||||
|
// 2. 권한별 메뉴
|
||||||
|
// - 관리자: 사용자 관리, 시스템 설정, 시스템 로그
|
||||||
|
// - 일반 사용자: 계정 설정만
|
||||||
|
// 3. 네비게이션 연동
|
||||||
|
// - onNavigate 콜백을 통한 페이지 이동
|
||||||
|
// - onLogout 콜백을 통한 로그아웃 처리
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **🎨 UI/UX 디자인 시스템**
|
||||||
|
|
||||||
|
##### **색상 팔레트**
|
||||||
|
```css
|
||||||
|
/* 주요 색상 */
|
||||||
|
--primary-gradient: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||||
|
--background-gradient: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||||
|
--glass-background: rgba(255, 255, 255, 0.9);
|
||||||
|
--text-primary: #0f172a;
|
||||||
|
--text-secondary: #64748b;
|
||||||
|
--border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
|
/* 그림자 */
|
||||||
|
--shadow-card: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-button: 0 4px 14px 0 rgba(59, 130, 246, 0.39);
|
||||||
|
--shadow-hover: 0 8px 25px 0 rgba(59, 130, 246, 0.5);
|
||||||
|
```
|
||||||
|
|
||||||
|
##### **타이포그래피**
|
||||||
|
```css
|
||||||
|
/* 폰트 시스템 */
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
|
||||||
|
/* 제목 */
|
||||||
|
--heading-1: 36px, weight: 800, letter-spacing: -0.025em;
|
||||||
|
--heading-2: 24px, weight: 700, letter-spacing: -0.025em;
|
||||||
|
--heading-3: 18px, weight: 600;
|
||||||
|
|
||||||
|
/* 본문 */
|
||||||
|
--body-large: 18px, weight: 400;
|
||||||
|
--body-medium: 16px, weight: 400;
|
||||||
|
--body-small: 14px, weight: 400;
|
||||||
|
--caption: 12px, weight: 400;
|
||||||
|
```
|
||||||
|
|
||||||
|
##### **컴포넌트 스타일**
|
||||||
|
```css
|
||||||
|
/* 카드 */
|
||||||
|
.modern-card {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 32px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 버튼 */
|
||||||
|
.modern-button {
|
||||||
|
background: var(--primary-gradient);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: var(--shadow-button);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **🔧 컴포넌트 사용 가이드**
|
||||||
|
|
||||||
|
##### **DashboardPage 사용법**
|
||||||
|
```jsx
|
||||||
|
import DashboardPage from './pages/DashboardPage';
|
||||||
|
|
||||||
|
// App.jsx에서 사용
|
||||||
|
case 'dashboard':
|
||||||
|
return (
|
||||||
|
<DashboardPage
|
||||||
|
user={user}
|
||||||
|
onNavigate={navigateToPage}
|
||||||
|
pendingSignupCount={pendingSignupCount}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
##### **UserMenu 사용법**
|
||||||
|
```jsx
|
||||||
|
import UserMenu from './components/UserMenu';
|
||||||
|
|
||||||
|
// 헤더에서 사용
|
||||||
|
<UserMenu
|
||||||
|
user={user}
|
||||||
|
onNavigate={navigateToPage}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **📱 반응형 디자인**
|
||||||
|
- **데스크톱**: 1200px 이상 - 3-4열 그리드
|
||||||
|
- **태블릿**: 768px-1199px - 2열 그리드
|
||||||
|
- **모바일**: 767px 이하 - 1열 스택
|
||||||
|
|
||||||
|
#### **♿ 접근성 고려사항**
|
||||||
|
- 키보드 네비게이션 지원
|
||||||
|
- 충분한 색상 대비 (WCAG 2.1 AA 준수)
|
||||||
|
- 스크린 리더 호환성
|
||||||
|
- 포커스 표시 명확화
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌐 시놀로지 NAS 배포 가이드 ⭐
|
## 🌐 시놀로지 NAS 배포 가이드 ⭐
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ RUN apt-get update && apt-get install -y \
|
|||||||
libpq-dev \
|
libpq-dev \
|
||||||
libmagic1 \
|
libmagic1 \
|
||||||
libmagic-dev \
|
libmagic-dev \
|
||||||
|
netcat-openbsd \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# requirements.txt 복사 및 의존성 설치
|
# requirements.txt 복사 및 의존성 설치
|
||||||
@@ -27,4 +28,4 @@ EXPOSE 8000
|
|||||||
ENV PYTHONPATH=/app
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
# 서버 실행
|
# 서버 실행
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["bash", "start.sh"]
|
||||||
116
backend/alembic.ini
Normal file
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 ###
|
||||||
@@ -323,6 +323,75 @@ async def verify_token(
|
|||||||
|
|
||||||
|
|
||||||
# 관리자 전용 엔드포인트들
|
# 관리자 전용 엔드포인트들
|
||||||
|
@router.get("/users/suspended")
|
||||||
|
async def get_suspended_users(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
정지된 사용자 목록 조회 (관리자 전용)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
credentials: JWT 토큰
|
||||||
|
db: 데이터베이스 세션
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: 정지된 사용자 목록
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 토큰 검증 및 권한 확인
|
||||||
|
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||||
|
if payload['role'] not in ['admin', 'system']:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="관리자 이상의 권한이 필요합니다"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 정지된 사용자 조회
|
||||||
|
from sqlalchemy import text
|
||||||
|
query = text("""
|
||||||
|
SELECT
|
||||||
|
user_id, username, name, email, role, department, position,
|
||||||
|
phone, status, created_at, updated_at
|
||||||
|
FROM users
|
||||||
|
WHERE status = 'suspended'
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
results = db.execute(query).fetchall()
|
||||||
|
|
||||||
|
suspended_users = []
|
||||||
|
for row in results:
|
||||||
|
suspended_users.append({
|
||||||
|
"user_id": row.user_id,
|
||||||
|
"username": row.username,
|
||||||
|
"name": row.name,
|
||||||
|
"email": row.email,
|
||||||
|
"role": row.role,
|
||||||
|
"department": row.department,
|
||||||
|
"position": row.position,
|
||||||
|
"phone": row.phone,
|
||||||
|
"status": row.status,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"users": suspended_users,
|
||||||
|
"count": len(suspended_users)
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get suspended users: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="정지된 사용자 목록 조회 중 오류가 발생했습니다"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users")
|
@router.get("/users")
|
||||||
async def get_all_users(
|
async def get_all_users(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
@@ -371,6 +440,155 @@ async def get_all_users(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/users/{user_id}/suspend")
|
||||||
|
async def suspend_user(
|
||||||
|
user_id: int,
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
사용자 정지 (관리자 전용)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: 정지할 사용자 ID
|
||||||
|
credentials: JWT 토큰
|
||||||
|
db: 데이터베이스 세션
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: 정지 결과
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 토큰 검증 및 권한 확인
|
||||||
|
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||||
|
if payload['role'] not in ['system', 'admin']:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="관리자만 사용자를 정지할 수 있습니다"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 자기 자신 정지 방지
|
||||||
|
if payload['user_id'] == user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="자기 자신은 정지할 수 없습니다"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 사용자 정지
|
||||||
|
from sqlalchemy import text
|
||||||
|
update_query = text("""
|
||||||
|
UPDATE users
|
||||||
|
SET status = 'suspended',
|
||||||
|
is_active = FALSE,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = :user_id AND status = 'active'
|
||||||
|
RETURNING user_id, username, name, status
|
||||||
|
""")
|
||||||
|
|
||||||
|
result = db.execute(update_query, {"user_id": user_id}).fetchone()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="사용자를 찾을 수 없거나 이미 정지된 상태입니다"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"User {result.username} suspended by {payload['username']}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"{result.name} 사용자가 정지되었습니다",
|
||||||
|
"user": {
|
||||||
|
"user_id": result.user_id,
|
||||||
|
"username": result.username,
|
||||||
|
"name": result.name,
|
||||||
|
"status": result.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to suspend user {user_id}: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"사용자 정지 중 오류가 발생했습니다: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/users/{user_id}/reactivate")
|
||||||
|
async def reactivate_user(
|
||||||
|
user_id: int,
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
사용자 재활성화 (관리자 전용)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: 재활성화할 사용자 ID
|
||||||
|
credentials: JWT 토큰
|
||||||
|
db: 데이터베이스 세션
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: 재활성화 결과
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 토큰 검증 및 권한 확인
|
||||||
|
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||||
|
if payload['role'] not in ['system', 'admin']:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="관리자만 사용자를 재활성화할 수 있습니다"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 사용자 재활성화
|
||||||
|
from sqlalchemy import text
|
||||||
|
update_query = text("""
|
||||||
|
UPDATE users
|
||||||
|
SET status = 'active',
|
||||||
|
is_active = TRUE,
|
||||||
|
failed_login_attempts = 0,
|
||||||
|
locked_until = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = :user_id AND status = 'suspended'
|
||||||
|
RETURNING user_id, username, name, status
|
||||||
|
""")
|
||||||
|
|
||||||
|
result = db.execute(update_query, {"user_id": user_id}).fetchone()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="사용자를 찾을 수 없거나 정지 상태가 아닙니다"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"User {result.username} reactivated by {payload['username']}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"{result.name} 사용자가 재활성화되었습니다",
|
||||||
|
"user": {
|
||||||
|
"user_id": result.user_id,
|
||||||
|
"username": result.username,
|
||||||
|
"name": result.name,
|
||||||
|
"status": result.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to reactivate user {user_id}: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"사용자 재활성화 중 오류가 발생했습니다: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/users/{user_id}")
|
@router.delete("/users/{user_id}")
|
||||||
async def delete_user(
|
async def delete_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -391,10 +609,11 @@ async def delete_user(
|
|||||||
try:
|
try:
|
||||||
# 토큰 검증 및 권한 확인
|
# 토큰 검증 및 권한 확인
|
||||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||||
if payload['role'] != 'system':
|
# admin role도 사용자 삭제 가능하도록 수정
|
||||||
|
if payload['role'] not in ['system', 'admin']:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="사용자 삭제는 시스템 관리자만 가능합니다"
|
detail="사용자 삭제는 관리자만 가능합니다"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 자기 자신 삭제 방지
|
# 자기 자신 삭제 방지
|
||||||
@@ -404,7 +623,30 @@ async def delete_user(
|
|||||||
detail="자기 자신은 삭제할 수 없습니다"
|
detail="자기 자신은 삭제할 수 없습니다"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 사용자 조회 및 삭제
|
# BOM 데이터 존재 여부 확인
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# files 테이블에서 uploaded_by가 이 사용자인 레코드 확인
|
||||||
|
check_files = text("""
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM files
|
||||||
|
WHERE uploaded_by = :user_id
|
||||||
|
""")
|
||||||
|
files_result = db.execute(check_files, {"user_id": user_id}).fetchone()
|
||||||
|
has_files = files_result.count > 0 if files_result else False
|
||||||
|
|
||||||
|
# user_requirements 테이블 확인
|
||||||
|
check_requirements = text("""
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM user_requirements
|
||||||
|
WHERE created_by = :user_id
|
||||||
|
""")
|
||||||
|
requirements_result = db.execute(check_requirements, {"user_id": user_id}).fetchone()
|
||||||
|
has_requirements = requirements_result.count > 0 if requirements_result else False
|
||||||
|
|
||||||
|
has_bom_data = has_files or has_requirements
|
||||||
|
|
||||||
|
# 사용자 조회
|
||||||
user_repo = UserRepository(db)
|
user_repo = UserRepository(db)
|
||||||
user = user_repo.find_by_id(user_id)
|
user = user_repo.find_by_id(user_id)
|
||||||
|
|
||||||
@@ -414,13 +656,37 @@ async def delete_user(
|
|||||||
detail="해당 사용자를 찾을 수 없습니다"
|
detail="해당 사용자를 찾을 수 없습니다"
|
||||||
)
|
)
|
||||||
|
|
||||||
user_repo.delete_user(user)
|
if has_bom_data:
|
||||||
|
# BOM 데이터가 있으면 소프트 삭제 (status='deleted')
|
||||||
|
soft_delete = text("""
|
||||||
|
UPDATE users
|
||||||
|
SET status = 'deleted',
|
||||||
|
is_active = FALSE,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = :user_id
|
||||||
|
RETURNING username, name
|
||||||
|
""")
|
||||||
|
result = db.execute(soft_delete, {"user_id": user_id}).fetchone()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
logger.info(f"User deleted by admin: {user.username} (deleted by: {payload['username']})")
|
logger.info(f"User soft-deleted (has BOM data): {result.username} (deleted by: {payload['username']})")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': '사용자가 삭제되었습니다',
|
'message': f'{result.name} 사용자가 비활성화되었습니다 (BOM 데이터 보존)',
|
||||||
|
'soft_deleted': True,
|
||||||
|
'deleted_user_id': user_id
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# BOM 데이터가 없으면 완전 삭제
|
||||||
|
user_repo.delete_user(user)
|
||||||
|
|
||||||
|
logger.info(f"User hard-deleted (no BOM data): {user.username} (deleted by: {payload['username']})")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': '사용자가 완전히 삭제되었습니다',
|
||||||
|
'soft_deleted': False,
|
||||||
'deleted_user_id': user_id
|
'deleted_user_id': user_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,31 @@ class AuthService:
|
|||||||
message="아이디 또는 비밀번호가 올바르지 않습니다"
|
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:
|
if not user.is_active:
|
||||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_disabled')
|
await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_disabled')
|
||||||
logger.warning(f"Login failed - account disabled: {username}")
|
logger.warning(f"Login failed - account disabled: {username}")
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ class User(Base):
|
|||||||
access_level = Column(String(20), default='worker', nullable=False) # 호환성 유지
|
access_level = Column(String(20), default='worker', nullable=False) # 호환성 유지
|
||||||
|
|
||||||
# 계정 상태 관리
|
# 계정 상태 관리
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False) # DEPRECATED: Use status instead
|
||||||
|
status = Column(String(20), default='active', nullable=False) # pending, active, suspended, deleted
|
||||||
failed_login_attempts = Column(Integer, default=0)
|
failed_login_attempts = Column(Integer, default=0)
|
||||||
locked_until = Column(DateTime, nullable=True)
|
locked_until = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
@@ -302,9 +303,15 @@ class UserRepository:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
def get_all_users(self, skip: int = 0, limit: int = 100) -> List[User]:
|
def get_all_users(self, skip: int = 0, limit: int = 100) -> List[User]:
|
||||||
"""모든 사용자 조회"""
|
"""활성 사용자만 조회 (status='active')"""
|
||||||
try:
|
try:
|
||||||
return self.db.query(User).offset(skip).limit(limit).all()
|
# status 필드가 있으면 status='active', 없으면 is_active=True (하위 호환성)
|
||||||
|
users = self.db.query(User)
|
||||||
|
if hasattr(User, 'status'):
|
||||||
|
users = users.filter(User.status == 'active')
|
||||||
|
else:
|
||||||
|
users = users.filter(User.is_active == True)
|
||||||
|
return users.offset(skip).limit(limit).all()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get all users: {str(e)}")
|
logger.error(f"Failed to get all users: {str(e)}")
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ async def signup_request(
|
|||||||
'position': signup_data.position,
|
'position': signup_data.position,
|
||||||
'phone': signup_data.phone,
|
'phone': signup_data.phone,
|
||||||
'role': 'user',
|
'role': 'user',
|
||||||
'is_active': False # 비활성 상태로 승인 대기 표시
|
'is_active': False, # 하위 호환성
|
||||||
|
'status': 'pending' # 새로운 status 체계: 승인 대기
|
||||||
})
|
})
|
||||||
|
|
||||||
# 가입 사유 저장 (notes 컬럼 활용)
|
# 가입 사유 저장 (notes 컬럼 활용)
|
||||||
@@ -130,13 +131,13 @@ async def get_signup_requests(
|
|||||||
detail="관리자만 접근 가능합니다"
|
detail="관리자만 접근 가능합니다"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 승인 대기 중인 사용자 조회 (is_active=False인 사용자)
|
# 승인 대기 중인 사용자 조회 (status='pending'인 사용자)
|
||||||
query = text("""
|
query = text("""
|
||||||
SELECT
|
SELECT
|
||||||
user_id as id, username, name, email, department, position,
|
user_id, username, name, email, department, position,
|
||||||
phone, notes, created_at
|
phone, created_at, role, is_active, status
|
||||||
FROM users
|
FROM users
|
||||||
WHERE is_active = FALSE
|
WHERE status = 'pending'
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -145,15 +146,18 @@ async def get_signup_requests(
|
|||||||
pending_users = []
|
pending_users = []
|
||||||
for row in results:
|
for row in results:
|
||||||
pending_users.append({
|
pending_users.append({
|
||||||
"id": row.id,
|
"user_id": row.user_id,
|
||||||
|
"id": row.user_id, # 호환성을 위해 둘 다 제공
|
||||||
"username": row.username,
|
"username": row.username,
|
||||||
"name": row.name,
|
"name": row.name,
|
||||||
"email": row.email,
|
"email": row.email,
|
||||||
"department": row.department,
|
"department": row.department,
|
||||||
"position": row.position,
|
"position": row.position,
|
||||||
"phone": row.phone,
|
"phone": row.phone,
|
||||||
"reason": row.notes,
|
"role": row.role,
|
||||||
"requested_at": row.created_at.isoformat() if row.created_at else None
|
"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 {
|
return {
|
||||||
@@ -172,6 +176,39 @@ async def get_signup_requests(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending-signups/count")
|
||||||
|
async def get_pending_signups_count(
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
승인 대기 중인 회원가입 수 조회 (관리자 전용)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 승인 대기 중인 사용자 수
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 관리자 권한 확인
|
||||||
|
if current_user.get('role') not in ['admin', 'system']:
|
||||||
|
return {"count": 0} # 관리자가 아니면 0 반환
|
||||||
|
|
||||||
|
# 승인 대기 중인 사용자 수 조회
|
||||||
|
query = text("""
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM users
|
||||||
|
WHERE status = 'pending'
|
||||||
|
""")
|
||||||
|
|
||||||
|
result = db.execute(query).fetchone()
|
||||||
|
count = result.count if result else 0
|
||||||
|
|
||||||
|
return {"count": count}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"승인 대기 회원가입 수 조회 실패: {str(e)}")
|
||||||
|
return {"count": 0} # 오류 시 0 반환
|
||||||
|
|
||||||
|
|
||||||
@router.post("/approve-signup/{user_id}")
|
@router.post("/approve-signup/{user_id}")
|
||||||
async def approve_signup(
|
async def approve_signup(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -201,9 +238,10 @@ async def approve_signup(
|
|||||||
update_query = text("""
|
update_query = text("""
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET is_active = TRUE,
|
SET is_active = TRUE,
|
||||||
|
status = 'active',
|
||||||
access_level = :access_level,
|
access_level = :access_level,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE user_id = :user_id AND is_active = FALSE
|
WHERE user_id = :user_id AND status = 'pending'
|
||||||
RETURNING user_id as id, username, name
|
RETURNING user_id as id, username, name
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class SecuritySettings(BaseSettings):
|
|||||||
"""보안 설정"""
|
"""보안 설정"""
|
||||||
cors_origins: List[str] = Field(default=[], description="CORS 허용 도메인")
|
cors_origins: List[str] = Field(default=[], description="CORS 허용 도메인")
|
||||||
cors_methods: List[str] = Field(
|
cors_methods: List[str] = Field(
|
||||||
default=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
default=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
description="CORS 허용 메서드"
|
description="CORS 허용 메서드"
|
||||||
)
|
)
|
||||||
cors_headers: List[str] = Field(
|
cors_headers: List[str] = Field(
|
||||||
@@ -147,6 +147,7 @@ class Settings(BaseSettings):
|
|||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
env_file_encoding = "utf-8"
|
env_file_encoding = "utf-8"
|
||||||
case_sensitive = False
|
case_sensitive = False
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|||||||
@@ -9,8 +9,16 @@ DATABASE_URL = os.getenv(
|
|||||||
"postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom"
|
"postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom"
|
||||||
)
|
)
|
||||||
|
|
||||||
# SQLAlchemy 엔진 생성
|
# SQLAlchemy 엔진 생성 (UTF-8 인코딩 설정)
|
||||||
engine = create_engine(DATABASE_URL)
|
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)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|||||||
@@ -91,12 +91,49 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("dashboard 라우터를 찾을 수 없습니다")
|
logger.warning("dashboard 라우터를 찾을 수 없습니다")
|
||||||
|
|
||||||
|
# 리비전 관리 라우터 (임시 비활성화)
|
||||||
|
# try:
|
||||||
|
# from .routers import revision_management
|
||||||
|
# app.include_router(revision_management.router, tags=["revision-management"])
|
||||||
|
# except ImportError:
|
||||||
|
# logger.warning("revision_management 라우터를 찾을 수 없습니다")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .routers import tubing
|
from .routers import tubing
|
||||||
app.include_router(tubing.router, prefix="/tubing", tags=["tubing"])
|
app.include_router(tubing.router, prefix="/tubing", tags=["tubing"])
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("tubing 라우터를 찾을 수 없습니다")
|
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 라우터와 충돌 방지)
|
# 파일 관리 API 라우터 등록 (비활성화 - files 라우터와 충돌 방지)
|
||||||
# try:
|
# try:
|
||||||
# from .api import file_management
|
# from .api import file_management
|
||||||
@@ -204,6 +241,14 @@ async def root():
|
|||||||
# print(f"Jobs 조회 에러: {str(e)}")
|
# print(f"Jobs 조회 에러: {str(e)}")
|
||||||
# return {"error": 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)
|
# 파일 업로드는 /files/upload 엔드포인트를 사용하세요 (routers/files.py)
|
||||||
|
|
||||||
# parse_file과 classify_material_item 함수는 routers/files.py로 이동되었습니다
|
# parse_file과 classify_material_item 함수는 routers/files.py로 이동되었습니다
|
||||||
|
|||||||
@@ -70,6 +70,25 @@ class Material(Base):
|
|||||||
drawing_reference = Column(String(100))
|
drawing_reference = Column(String(100))
|
||||||
notes = Column(Text)
|
notes = Column(Text)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
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")
|
file = relationship("File", back_populates="materials")
|
||||||
|
|||||||
@@ -526,6 +526,7 @@ async def get_projects(
|
|||||||
projects.append({
|
projects.append({
|
||||||
"id": row.id,
|
"id": row.id,
|
||||||
"official_project_code": row.official_project_code,
|
"official_project_code": row.official_project_code,
|
||||||
|
"job_no": row.official_project_code, # job_no 필드 추가 (프론트엔드 호환성)
|
||||||
"project_name": row.project_name,
|
"project_name": row.project_name,
|
||||||
"job_name": row.project_name, # 호환성을 위해 추가
|
"job_name": row.project_name, # 호환성을 위해 추가
|
||||||
"client_name": row.client_name,
|
"client_name": row.client_name,
|
||||||
|
|||||||
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
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)}"
|
||||||
|
)
|
||||||
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)}")
|
||||||
@@ -706,7 +706,8 @@ def classify_bolt(dat_file: str, description: str, main_nom: str, length: Option
|
|||||||
"nominal_size_fraction": dimensions_result.get('nominal_size_fraction', main_nom),
|
"nominal_size_fraction": dimensions_result.get('nominal_size_fraction', main_nom),
|
||||||
"length": dimensions_result.get('length', ''),
|
"length": dimensions_result.get('length', ''),
|
||||||
"diameter": dimensions_result.get('diameter', ''),
|
"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": {
|
"grade_strength": {
|
||||||
@@ -966,12 +967,19 @@ def extract_bolt_dimensions(main_nom: str, description: str) -> Dict:
|
|||||||
except:
|
except:
|
||||||
nominal_size_fraction = actual_bolt_size
|
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 = {
|
dimensions = {
|
||||||
"nominal_size": actual_bolt_size, # 실제 볼트 사이즈
|
"nominal_size": actual_bolt_size, # 실제 볼트 사이즈
|
||||||
"nominal_size_fraction": nominal_size_fraction, # 분수 변환값
|
"nominal_size_fraction": nominal_size_fraction, # 분수 변환값
|
||||||
"length": "",
|
"length": "",
|
||||||
"diameter": "",
|
"diameter": "",
|
||||||
"dimension_description": nominal_size_fraction # 분수로 표시
|
"dimension_description": nominal_size_fraction, # 분수로 표시
|
||||||
|
"bolts_per_flange": bolts_per_flange # 플랜지당 볼트 세트 수
|
||||||
}
|
}
|
||||||
|
|
||||||
# 길이 정보 추출 (개선된 패턴)
|
# 길이 정보 추출 (개선된 패턴)
|
||||||
@@ -984,6 +992,8 @@ def extract_bolt_dimensions(main_nom: str, description: str) -> Dict:
|
|||||||
r'X\s*(\d+(?:\.\d+)?)\s*MM', # M8 X 20MM 형태
|
r'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*LG', # ", 145.0000 LG" 형태 (PSV, LT 볼트용)
|
||||||
r',\s*(\d+(?:\.\d+)?)\s+(?:CK|PSV|LT)', # ", 140 CK" 형태 (PSV 볼트용)
|
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 형태 (단독)
|
||||||
r'(\d+(?:\.\d+)?)\s*mm(?:\s|,|$)' # 75mm 형태 (단독)
|
r'(\d+(?:\.\d+)?)\s*mm(?:\s|,|$)' # 75mm 형태 (단독)
|
||||||
]
|
]
|
||||||
|
|||||||
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 = {
|
EXCLUDE_TYPES = {
|
||||||
"WELD_GAP": {
|
|
||||||
"description_keywords": ["WELD GAP", "WELDING GAP", "GAP", "용접갭", "웰드갭"],
|
|
||||||
"characteristics": "용접 시 수축 고려용 계산 항목",
|
|
||||||
"reason": "실제 자재 아님 - 용접 갭 계산용"
|
|
||||||
},
|
|
||||||
"CUTTING_LOSS": {
|
"CUTTING_LOSS": {
|
||||||
"description_keywords": ["CUTTING LOSS", "CUT LOSS", "절단로스", "컷팅로스"],
|
"description_keywords": ["CUTTING LOSS", "CUT LOSS", "절단로스", "컷팅로스"],
|
||||||
"characteristics": "절단 시 손실 고려용 계산 항목",
|
"characteristics": "절단 시 손실 고려용 계산 항목",
|
||||||
|
|||||||
@@ -6,13 +6,18 @@ FITTING 분류 시스템 V2
|
|||||||
import re
|
import re
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
from .material_classifier import classify_material, get_manufacturing_method_from_material
|
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 타입별 분류 (실제 BOM 기반) ==========
|
||||||
FITTING_TYPES = {
|
FITTING_TYPES = {
|
||||||
"ELBOW": {
|
"ELBOW": {
|
||||||
"dat_file_patterns": ["90L_", "45L_", "ELL_", "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": {
|
"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도"],
|
"90DEG": ["90", "90°", "90DEG", "90도"],
|
||||||
"45DEG": ["45", "45°", "45DEG", "45도"],
|
"45DEG": ["45", "45°", "45DEG", "45도"],
|
||||||
"LONG_RADIUS": ["LR", "LONG RADIUS", "장반경"],
|
"LONG_RADIUS": ["LR", "LONG RADIUS", "장반경"],
|
||||||
@@ -98,11 +103,12 @@ FITTING_TYPES = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
"OLET": {
|
"OLET": {
|
||||||
"dat_file_patterns": ["SOL_", "WOL_", "TOL_", "OLET_", "SOCK-O-LET", "WELD-O-LET"],
|
"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", "올렛", "O-LET", "SOCK-O-LET", "WELD-O-LET", "SOCKOLET", "WELDOLET", "THREAD-O-LET", "THREADOLET", "SOCKLET", "SOCKET"],
|
"description_keywords": OLET_KEYWORDS,
|
||||||
"subtypes": {
|
"subtypes": {
|
||||||
"SOCKOLET": ["SOCK-O-LET", "SOCKOLET", "SOL", "SOCK O-LET", "SOCKET-O-LET", "SOCKLET"],
|
"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"],
|
"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"],
|
"THREADOLET": ["THREAD-O-LET", "THREADOLET", "TOL", "THREADED-O-LET"],
|
||||||
"ELBOLET": ["ELB-O-LET", "ELBOLET", "EOL", "ELBOW-O-LET"],
|
"ELBOLET": ["ELB-O-LET", "ELBOLET", "EOL", "ELBOW-O-LET"],
|
||||||
"NIPOLET": ["NIP-O-LET", "NIPOLET", "NOL", "NIPPLE-O-LET"],
|
"NIPOLET": ["NIP-O-LET", "NIPOLET", "NOL", "NIPPLE-O-LET"],
|
||||||
@@ -164,24 +170,8 @@ CONNECTION_METHODS = {
|
|||||||
|
|
||||||
# ========== 압력 등급별 분류 ==========
|
# ========== 압력 등급별 분류 ==========
|
||||||
PRESSURE_RATINGS = {
|
PRESSURE_RATINGS = {
|
||||||
"patterns": [
|
"patterns": PRESSURE_PATTERNS,
|
||||||
r"(\d+)LB",
|
"standard_ratings": PRESSURE_RATINGS_SPECS
|
||||||
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": "소구경 극고압용"}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def classify_fitting(dat_file: str, description: str, main_nom: str,
|
def classify_fitting(dat_file: str, description: str, main_nom: str,
|
||||||
@@ -203,7 +193,11 @@ def classify_fitting(dat_file: str, description: str, main_nom: str,
|
|||||||
dat_upper = dat_file.upper()
|
dat_upper = dat_file.upper()
|
||||||
|
|
||||||
# 1. 피팅 키워드 확인 (재질만 있어도 통합 분류기가 이미 피팅으로 분류했으므로 진행)
|
# 1. 피팅 키워드 확인 (재질만 있어도 통합 분류기가 이미 피팅으로 분류했으므로 진행)
|
||||||
fitting_keywords = ['ELBOW', 'ELL', 'TEE', 'REDUCER', 'RED', 'CAP', 'NIPPLE', 'SWAGE', 'OLET', 'COUPLING', 'PLUG', 'SOCKLET', 'SOCKET', '엘보', '티', '리듀서', '캡', '니플', '스웨지', '올렛', '커플링', '플러그', 'SOCK-O-LET', 'WELD-O-LET', 'SOCKOLET', 'WELDOLET']
|
# OLET 키워드를 우선 확인하여 정확한 분류 수행
|
||||||
|
olet_keywords = OLET_KEYWORDS
|
||||||
|
has_olet_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in olet_keywords)
|
||||||
|
|
||||||
|
fitting_keywords = ['ELBOW', 'ELL', 'TEE', 'REDUCER', 'RED', 'CAP', 'NIPPLE', 'SWAGE', 'COUPLING', 'PLUG', '엘보', '티', '리듀서', '캡', '니플', '스웨지', '올렛', '커플링', '플러그'] + olet_keywords
|
||||||
has_fitting_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in fitting_keywords)
|
has_fitting_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in fitting_keywords)
|
||||||
|
|
||||||
# 피팅 재질 확인 (A234, A403, A420)
|
# 피팅 재질 확인 (A234, A403, A420)
|
||||||
@@ -239,71 +233,35 @@ def classify_fitting(dat_file: str, description: str, main_nom: str,
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 6. 최종 결과 조합
|
# 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 {
|
return {
|
||||||
"category": "FITTING",
|
"category": "FITTING",
|
||||||
|
"fitting_type": fitting_type_result,
|
||||||
# 재질 정보 (공통 모듈)
|
"connection_method": connection_result,
|
||||||
"material": {
|
"pressure_rating": pressure_result,
|
||||||
"standard": material_result.get('standard', 'UNKNOWN'),
|
"schedule": schedule_result,
|
||||||
"grade": material_result.get('grade', 'UNKNOWN'),
|
"manufacturing": manufacturing_result,
|
||||||
"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)
|
|
||||||
},
|
|
||||||
|
|
||||||
# 전체 신뢰도
|
|
||||||
"overall_confidence": calculate_fitting_confidence({
|
"overall_confidence": calculate_fitting_confidence({
|
||||||
"material": material_result.get('confidence', 0),
|
"material": material_result.get("confidence", 0),
|
||||||
"fitting_type": fitting_type_result.get('confidence', 0),
|
"fitting_type": fitting_type_result.get("confidence", 0),
|
||||||
"connection": connection_result.get('confidence', 0),
|
"connection": connection_result.get("confidence", 0),
|
||||||
"pressure": pressure_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:
|
def analyze_size_pattern_for_fitting_type(description: str, main_nom: str, red_nom: str = None) -> Dict:
|
||||||
"""
|
"""
|
||||||
실제 BOM 패턴 기반 TEE vs REDUCER 구분
|
실제 BOM 패턴 기반 TEE vs REDUCER 구분
|
||||||
@@ -428,12 +386,28 @@ def classify_fitting_type(dat_file: str, description: str,
|
|||||||
dat_upper = dat_file.upper()
|
dat_upper = dat_file.upper()
|
||||||
desc_upper = description.upper()
|
desc_upper = description.upper()
|
||||||
|
|
||||||
# 0. 사이즈 패턴 분석으로 TEE vs REDUCER 구분 (최우선)
|
# 0. OLET 우선 확인 (ELL과의 혼동 방지)
|
||||||
|
olet_specific_keywords = OLET_KEYWORDS
|
||||||
|
for keyword in olet_specific_keywords:
|
||||||
|
if keyword in desc_upper or keyword in dat_upper:
|
||||||
|
subtype_result = classify_fitting_subtype(
|
||||||
|
"OLET", desc_upper, main_nom, red_nom, FITTING_TYPES["OLET"]
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"type": "OLET",
|
||||||
|
"subtype": subtype_result["subtype"],
|
||||||
|
"confidence": 0.95,
|
||||||
|
"evidence": [f"OLET_PRIORITY_KEYWORD: {keyword}"],
|
||||||
|
"subtype_confidence": subtype_result["confidence"],
|
||||||
|
"requires_two_sizes": FITTING_TYPES["OLET"].get("requires_two_sizes", False)
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. 사이즈 패턴 분석으로 TEE vs REDUCER 구분
|
||||||
size_pattern_result = analyze_size_pattern_for_fitting_type(desc_upper, main_nom, red_nom)
|
size_pattern_result = analyze_size_pattern_for_fitting_type(desc_upper, main_nom, red_nom)
|
||||||
if size_pattern_result.get("confidence", 0) > 0.85:
|
if size_pattern_result.get("confidence", 0) > 0.85:
|
||||||
return size_pattern_result
|
return size_pattern_result
|
||||||
|
|
||||||
# 1. DAT_FILE 패턴으로 1차 분류 (가장 신뢰도 높음)
|
# 2. DAT_FILE 패턴으로 1차 분류 (가장 신뢰도 높음)
|
||||||
for fitting_type, type_data in FITTING_TYPES.items():
|
for fitting_type, type_data in FITTING_TYPES.items():
|
||||||
for pattern in type_data["dat_file_patterns"]:
|
for pattern in type_data["dat_file_patterns"]:
|
||||||
if pattern in dat_upper:
|
if pattern in dat_upper:
|
||||||
@@ -450,7 +424,7 @@ def classify_fitting_type(dat_file: str, description: str,
|
|||||||
"requires_two_sizes": type_data.get("requires_two_sizes", False)
|
"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 fitting_type, type_data in FITTING_TYPES.items():
|
||||||
for keyword in type_data["description_keywords"]:
|
for keyword in type_data["description_keywords"]:
|
||||||
if keyword in desc_upper:
|
if keyword in desc_upper:
|
||||||
@@ -467,7 +441,7 @@ def classify_fitting_type(dat_file: str, description: str,
|
|||||||
"requires_two_sizes": type_data.get("requires_two_sizes", False)
|
"requires_two_sizes": type_data.get("requires_two_sizes", False)
|
||||||
}
|
}
|
||||||
|
|
||||||
# 3. 분류 실패
|
# 4. 분류 실패
|
||||||
return {
|
return {
|
||||||
"type": "UNKNOWN",
|
"type": "UNKNOWN",
|
||||||
"subtype": "UNKNOWN",
|
"subtype": "UNKNOWN",
|
||||||
@@ -480,18 +454,77 @@ def classify_fitting_subtype(fitting_type: str, description: str,
|
|||||||
main_nom: str, red_nom: str, type_data: Dict) -> Dict:
|
main_nom: str, red_nom: str, type_data: Dict) -> Dict:
|
||||||
"""피팅 서브타입 분류"""
|
"""피팅 서브타입 분류"""
|
||||||
|
|
||||||
|
desc_upper = description.upper()
|
||||||
subtypes = type_data.get("subtypes", {})
|
subtypes = type_data.get("subtypes", {})
|
||||||
|
|
||||||
# 1. 키워드 기반 서브타입 분류 (우선)
|
# 1. 키워드 기반 서브타입 분류 (우선) - 대소문자 구분 없이
|
||||||
for subtype, keywords in subtypes.items():
|
for subtype, keywords in subtypes.items():
|
||||||
for keyword in keywords:
|
for keyword in keywords:
|
||||||
if keyword in description:
|
if keyword.upper() in desc_upper:
|
||||||
return {
|
return {
|
||||||
"subtype": subtype,
|
"subtype": subtype,
|
||||||
"confidence": 0.9,
|
"confidence": 0.9,
|
||||||
"evidence": [f"SUBTYPE_KEYWORD: {keyword}"]
|
"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 등)
|
# 2. 사이즈 분석이 필요한 경우 (TEE, REDUCER 등)
|
||||||
if type_data.get("size_analysis"):
|
if type_data.get("size_analysis"):
|
||||||
if red_nom and str(red_nom).strip() and red_nom != main_nom:
|
if red_nom and str(red_nom).strip() and red_nom != main_nom:
|
||||||
|
|||||||
@@ -182,6 +182,14 @@ def classify_flange(dat_file: str, description: str, main_nom: str,
|
|||||||
dat_upper = dat_file.upper()
|
dat_upper = dat_file.upper()
|
||||||
|
|
||||||
# 1. 플랜지 키워드 확인 (재질만 있어도 통합 분류기가 이미 플랜지로 분류했으므로 진행)
|
# 1. 플랜지 키워드 확인 (재질만 있어도 통합 분류기가 이미 플랜지로 분류했으므로 진행)
|
||||||
|
# 사이트 글라스와 스트레이너는 밸브로 분류되어야 함
|
||||||
|
if 'SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or '사이트글라스' in desc_upper or '스트레이너' in desc_upper:
|
||||||
|
return {
|
||||||
|
"category": "VALVE",
|
||||||
|
"overall_confidence": 1.0,
|
||||||
|
"reason": "SIGHT GLASS 또는 STRAINER는 밸브로 분류"
|
||||||
|
}
|
||||||
|
|
||||||
flange_keywords = ['FLG', 'FLANGE', '플랜지', 'ORIFICE', 'SPECTACLE', 'PADDLE', 'SPACER']
|
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)
|
has_flange_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in flange_keywords)
|
||||||
|
|
||||||
|
|||||||
@@ -6,71 +6,14 @@
|
|||||||
import re
|
import re
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
from .fitting_classifier import classify_fitting
|
from .fitting_classifier import classify_fitting
|
||||||
|
from .classifier_constants import (
|
||||||
# Level 1: 명확한 타입 키워드 (최우선)
|
LEVEL1_TYPE_KEYWORDS,
|
||||||
LEVEL1_TYPE_KEYWORDS = {
|
LEVEL2_SUBTYPE_KEYWORDS,
|
||||||
"BOLT": ["FLANGE BOLT", "U-BOLT", "U BOLT", "BOLT", "STUD", "NUT", "SCREW", "WASHER", "볼트", "너트", "스터드", "나사", "와셔", "유볼트"],
|
LEVEL3_CONNECTION_KEYWORDS,
|
||||||
"VALVE": ["VALVE", "GATE", "BALL", "GLOBE", "CHECK", "BUTTERFLY", "NEEDLE", "RELIEF", "밸브", "게이트", "볼", "글로브", "체크", "버터플라이", "니들", "릴리프"],
|
LEVEL3_PRESSURE_KEYWORDS,
|
||||||
"FLANGE": ["FLG", "FLANGE", "플랜지", "프랜지", "ORIFICE", "SPECTACLE", "PADDLE", "SPACER", "BLIND"],
|
LEVEL4_MATERIAL_KEYWORDS,
|
||||||
"PIPE": ["PIPE", "TUBE", "파이프", "배관", "SMLS", "SEAMLESS"],
|
GENERIC_MATERIALS
|
||||||
"FITTING": ["ELBOW", "ELL", "TEE", "REDUCER", "RED", "CAP", "COUPLING", "NIPPLE", "SWAGE", "OLET", "PLUG", "엘보", "티", "리듀서", "캡", "니플", "커플링", "플러그", "CONC", "ECC", "SOCK-O-LET", "WELD-O-LET", "SOCKOLET", "WELDOLET", "THREADOLET"],
|
)
|
||||||
"GASKET": ["GASKET", "GASK", "가스켓", "SWG", "SPIRAL"],
|
|
||||||
"INSTRUMENT": ["GAUGE", "TRANSMITTER", "SENSOR", "THERMOMETER", "계기", "게이지", "트랜스미터", "센서"],
|
|
||||||
"SUPPORT": ["URETHANE BLOCK", "URETHANE", "BLOCK SHOE", "CLAMP", "SUPPORT", "HANGER", "SPRING", "우레탄", "블록", "클램프", "서포트", "행거", "스프링"]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Level 2: 서브타입 키워드 (구체화)
|
|
||||||
LEVEL2_SUBTYPE_KEYWORDS = {
|
|
||||||
"VALVE": {
|
|
||||||
"GATE": ["GATE VALVE", "GATE", "게이트 밸브"],
|
|
||||||
"BALL": ["BALL VALVE", "BALL", "볼 밸브"],
|
|
||||||
"GLOBE": ["GLOBE VALVE", "GLOBE", "글로브 밸브"],
|
|
||||||
"CHECK": ["CHECK VALVE", "CHECK", "체크 밸브", "역지 밸브"]
|
|
||||||
},
|
|
||||||
"FLANGE": {
|
|
||||||
"WELD_NECK": ["WELD NECK", "WN", "웰드넥"],
|
|
||||||
"SLIP_ON": ["SLIP ON", "SO", "슬립온"],
|
|
||||||
"BLIND": ["BLIND", "BL", "막음", "차단"],
|
|
||||||
"SOCKET_WELD": ["SOCKET WELD", "소켓웰드"]
|
|
||||||
},
|
|
||||||
"BOLT": {
|
|
||||||
"HEX_BOLT": ["HEX BOLT", "HEXAGON", "육각 볼트"],
|
|
||||||
"STUD_BOLT": ["STUD BOLT", "STUD", "스터드 볼트"],
|
|
||||||
"U_BOLT": ["U-BOLT", "U BOLT", "유볼트"]
|
|
||||||
},
|
|
||||||
"SUPPORT": {
|
|
||||||
"URETHANE_BLOCK": ["URETHANE BLOCK", "BLOCK SHOE", "우레탄 블록"],
|
|
||||||
"CLAMP": ["CLAMP", "클램프"],
|
|
||||||
"HANGER": ["HANGER", "SUPPORT", "행거", "서포트"],
|
|
||||||
"SPRING": ["SPRING", "스프링"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Level 3: 연결/압력 키워드 (공용)
|
|
||||||
LEVEL3_CONNECTION_KEYWORDS = {
|
|
||||||
"SW": ["SW", "SOCKET WELD", "소켓웰드"],
|
|
||||||
"THD": ["THD", "THREADED", "NPT", "나사"],
|
|
||||||
"FL": ["FL", "FLANGED", "플랜지형"],
|
|
||||||
"BW": ["BW", "BUTT WELD", "맞대기용접"]
|
|
||||||
}
|
|
||||||
|
|
||||||
LEVEL3_PRESSURE_KEYWORDS = ["150LB", "300LB", "600LB", "900LB", "1500LB", "2500LB", "3000LB", "6000LB"]
|
|
||||||
|
|
||||||
# Level 4: 재질 키워드 (최후 판단)
|
|
||||||
LEVEL4_MATERIAL_KEYWORDS = {
|
|
||||||
"PIPE": ["A106", "A333", "A312", "A53"],
|
|
||||||
"FITTING": ["A234", "A403", "A420"],
|
|
||||||
"FLANGE": ["A182", "A350"], # A105 제거 (범용 재질로 이동)
|
|
||||||
"VALVE": ["A216", "A217", "A351", "A352"],
|
|
||||||
"BOLT": ["A193", "A194", "A320", "A325", "A490"]
|
|
||||||
}
|
|
||||||
|
|
||||||
# 범용 재질 (여러 타입에 사용 가능)
|
|
||||||
GENERIC_MATERIALS = {
|
|
||||||
"A105": ["VALVE", "FLANGE", "FITTING"], # 우선순위 순서
|
|
||||||
"316": ["VALVE", "FLANGE", "FITTING", "PIPE", "BOLT"],
|
|
||||||
"304": ["VALVE", "FLANGE", "FITTING", "PIPE", "BOLT"]
|
|
||||||
}
|
|
||||||
|
|
||||||
def classify_material_integrated(description: str, main_nom: str = "",
|
def classify_material_integrated(description: str, main_nom: str = "",
|
||||||
red_nom: str = "", length: float = None) -> Dict:
|
red_nom: str = "", length: float = None) -> Dict:
|
||||||
@@ -90,26 +33,61 @@ def classify_material_integrated(description: str, main_nom: str = "",
|
|||||||
desc_upper = description.upper()
|
desc_upper = description.upper()
|
||||||
|
|
||||||
# 최우선: SPECIAL 키워드 확인 (도면 업로드가 필요한 특수 자재)
|
# 최우선: SPECIAL 키워드 확인 (도면 업로드가 필요한 특수 자재)
|
||||||
special_keywords = ['SPECIAL', '스페셜', 'SPEC', 'SPL']
|
# SPECIAL이 포함된 경우 (단, SPECIFICATION은 제외)
|
||||||
for keyword in special_keywords:
|
if 'SPECIAL' in desc_upper and 'SPECIFICATION' not in desc_upper:
|
||||||
if keyword in desc_upper:
|
|
||||||
return {
|
return {
|
||||||
"category": "SPECIAL",
|
"category": "SPECIAL",
|
||||||
"confidence": 1.0,
|
"confidence": 1.0,
|
||||||
"evidence": [f"SPECIAL_KEYWORD: {keyword}"],
|
"evidence": ["SPECIAL_KEYWORD"],
|
||||||
"classification_level": "LEVEL0_SPECIAL",
|
"classification_level": "LEVEL0_SPECIAL",
|
||||||
"reason": f"스페셜 키워드 발견: {keyword}"
|
"reason": "SPECIAL 키워드 발견"
|
||||||
}
|
}
|
||||||
|
|
||||||
# U-BOLT 및 관련 부품 우선 확인 (BOLT 카테고리보다 먼저)
|
# 스페셜 관련 한글 키워드
|
||||||
if ('U-BOLT' in desc_upper or 'U BOLT' in desc_upper or '유볼트' in desc_upper or
|
if '스페셜' in desc_upper or 'SPL' in desc_upper:
|
||||||
'URETHANE BLOCK' in desc_upper or 'BLOCK SHOE' in desc_upper or '우레탄' in desc_upper):
|
|
||||||
return {
|
return {
|
||||||
"category": "U_BOLT",
|
"category": "SPECIAL",
|
||||||
"confidence": 1.0,
|
"confidence": 1.0,
|
||||||
"evidence": ["U_BOLT_SYSTEM_KEYWORD"],
|
"evidence": ["SPECIAL_KEYWORD"],
|
||||||
"classification_level": "LEVEL0_U_BOLT",
|
"classification_level": "LEVEL0_SPECIAL",
|
||||||
"reason": "U-BOLT 시스템 키워드 발견"
|
"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")
|
# 쉼표로 구분된 각 부분을 별도로 체크 (예: "NIPPLE, SMLS, SCH 80")
|
||||||
@@ -117,11 +95,68 @@ def classify_material_integrated(description: str, main_nom: str = "",
|
|||||||
|
|
||||||
# 1단계: Level 1 키워드로 타입 식별
|
# 1단계: Level 1 키워드로 타입 식별
|
||||||
detected_types = []
|
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():
|
for material_type, keywords in LEVEL1_TYPE_KEYWORDS.items():
|
||||||
type_found = False
|
type_found = False
|
||||||
# 긴 키워드부터 확인 (FLANGE BOLT가 FLANGE보다 먼저 매칭되도록)
|
# 긴 키워드부터 확인 (FLANGE BOLT가 FLANGE보다 먼저 매칭되도록)
|
||||||
sorted_keywords = sorted(keywords, key=len, reverse=True)
|
sorted_keywords = sorted(keywords, key=len, reverse=True)
|
||||||
for keyword in sorted_keywords:
|
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:
|
if keyword in desc_upper:
|
||||||
detected_types.append((material_type, keyword))
|
detected_types.append((material_type, keyword))
|
||||||
@@ -247,7 +282,7 @@ def classify_material_integrated(description: str, main_nom: str = "",
|
|||||||
|
|
||||||
# 분류 실패
|
# 분류 실패
|
||||||
return {
|
return {
|
||||||
"category": "UNKNOWN",
|
"category": "UNCLASSIFIED",
|
||||||
"confidence": 0.0,
|
"confidence": 0.0,
|
||||||
"evidence": ["NO_CLASSIFICATION_POSSIBLE"],
|
"evidence": ["NO_CLASSIFICATION_POSSIBLE"],
|
||||||
"classification_level": "NONE"
|
"classification_level": "NONE"
|
||||||
|
|||||||
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 typing import Dict, List, Optional
|
||||||
from .material_classifier import classify_material, get_manufacturing_method_from_material
|
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 제조 방법별 분류 ==========
|
||||||
PIPE_MANUFACTURING = {
|
PIPE_MANUFACTURING = {
|
||||||
"SEAMLESS": {
|
"SEAMLESS": {
|
||||||
@@ -138,6 +192,27 @@ PIPE_SCHEDULE = {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def extract_pipe_user_requirements(description: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
파이프 설명에서 User 요구사항 추출
|
||||||
|
|
||||||
|
Args:
|
||||||
|
description: 파이프 설명
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
발견된 요구사항 리스트
|
||||||
|
"""
|
||||||
|
desc_upper = description.upper()
|
||||||
|
found_requirements = []
|
||||||
|
|
||||||
|
for req_type, req_data in PIPE_USER_REQUIREMENTS.items():
|
||||||
|
for keyword in req_data["keywords"]:
|
||||||
|
if keyword in desc_upper:
|
||||||
|
found_requirements.append(req_data["description"])
|
||||||
|
break # 같은 타입에서 중복 방지
|
||||||
|
|
||||||
|
return found_requirements
|
||||||
|
|
||||||
def classify_pipe_for_purchase(dat_file: str, description: str, main_nom: str,
|
def classify_pipe_for_purchase(dat_file: str, description: str, main_nom: str,
|
||||||
length: Optional[float] = None) -> Dict:
|
length: Optional[float] = None) -> Dict:
|
||||||
"""구매용 파이프 분류 - 끝단 가공 정보 제외"""
|
"""구매용 파이프 분류 - 끝단 가공 정보 제외"""
|
||||||
@@ -215,13 +290,16 @@ def classify_pipe(dat_file: str, description: str, main_nom: str,
|
|||||||
# 3. 끝 가공 분류
|
# 3. 끝 가공 분류
|
||||||
end_prep_result = classify_pipe_end_preparation(description)
|
end_prep_result = classify_pipe_end_preparation(description)
|
||||||
|
|
||||||
# 4. 스케줄 분류
|
# 4. 스케줄 분류 (재질 정보 전달)
|
||||||
schedule_result = classify_pipe_schedule(description)
|
schedule_result = classify_pipe_schedule(description, material_result)
|
||||||
|
|
||||||
# 5. 길이(절단 치수) 처리
|
# 5. 길이(절단 치수) 처리
|
||||||
length_info = extract_pipe_length_info(length, description)
|
length_info = extract_pipe_length_info(length, description)
|
||||||
|
|
||||||
# 6. 최종 결과 조합
|
# 6. User 요구사항 추출
|
||||||
|
user_requirements = extract_pipe_user_requirements(description)
|
||||||
|
|
||||||
|
# 7. 최종 결과 조합
|
||||||
return {
|
return {
|
||||||
"category": "PIPE",
|
"category": "PIPE",
|
||||||
|
|
||||||
@@ -260,6 +338,9 @@ def classify_pipe(dat_file: str, description: str, main_nom: str,
|
|||||||
"length_mm": length_info.get('length_mm')
|
"length_mm": length_info.get('length_mm')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# User 요구사항
|
||||||
|
"user_requirements": user_requirements,
|
||||||
|
|
||||||
# 전체 신뢰도
|
# 전체 신뢰도
|
||||||
"overall_confidence": calculate_pipe_confidence({
|
"overall_confidence": calculate_pipe_confidence({
|
||||||
"material": material_result.get('confidence', 0),
|
"material": material_result.get('confidence', 0),
|
||||||
@@ -328,19 +409,43 @@ def classify_pipe_end_preparation(description: str) -> Dict:
|
|||||||
"matched_code": "DEFAULT"
|
"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()
|
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. 스케줄 패턴 확인
|
# 1. 스케줄 패턴 확인
|
||||||
for pattern in PIPE_SCHEDULE["patterns"]:
|
for pattern in PIPE_SCHEDULE["patterns"]:
|
||||||
match = re.search(pattern, desc_upper)
|
match = re.search(pattern, desc_upper)
|
||||||
if match:
|
if match:
|
||||||
schedule_num = match.group(1)
|
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 {
|
return {
|
||||||
"schedule": f"SCH {schedule_num}",
|
"schedule": schedule_display,
|
||||||
"schedule_number": schedule_num,
|
"schedule_number": schedule_num,
|
||||||
|
"material_type": material_type,
|
||||||
"confidence": 0.95,
|
"confidence": 0.95,
|
||||||
"matched_pattern": pattern
|
"matched_pattern": pattern
|
||||||
}
|
}
|
||||||
@@ -353,6 +458,7 @@ def classify_pipe_schedule(description: str) -> Dict:
|
|||||||
return {
|
return {
|
||||||
"schedule": f"{thickness}mm THK",
|
"schedule": f"{thickness}mm THK",
|
||||||
"wall_thickness": f"{thickness}mm",
|
"wall_thickness": f"{thickness}mm",
|
||||||
|
"material_type": material_type,
|
||||||
"confidence": 0.9,
|
"confidence": 0.9,
|
||||||
"matched_pattern": pattern
|
"matched_pattern": pattern
|
||||||
}
|
}
|
||||||
@@ -360,6 +466,7 @@ def classify_pipe_schedule(description: str) -> Dict:
|
|||||||
# 3. 기본값
|
# 3. 기본값
|
||||||
return {
|
return {
|
||||||
"schedule": "UNKNOWN",
|
"schedule": "UNKNOWN",
|
||||||
|
"material_type": material_type,
|
||||||
"confidence": 0.0
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,16 +93,11 @@ class RevisionComparator:
|
|||||||
def compare_materials(self, previous_confirmed: Dict, new_materials: List[Dict]) -> Dict:
|
def compare_materials(self, previous_confirmed: Dict, new_materials: List[Dict]) -> Dict:
|
||||||
"""
|
"""
|
||||||
기존 확정 자재와 신규 자재 비교
|
기존 확정 자재와 신규 자재 비교
|
||||||
|
|
||||||
Args:
|
|
||||||
previous_confirmed: 이전 확정 자재 정보
|
|
||||||
new_materials: 신규 업로드된 자재 목록
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
비교 결과 딕셔너리
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 이전 확정 자재를 해시맵으로 변환 (빠른 검색을 위해)
|
from rapidfuzz import fuzz
|
||||||
|
|
||||||
|
# 이전 확정 자재 해시맵 생성
|
||||||
confirmed_materials = {}
|
confirmed_materials = {}
|
||||||
for item in previous_confirmed["items"]:
|
for item in previous_confirmed["items"]:
|
||||||
material_hash = self._generate_material_hash(
|
material_hash = self._generate_material_hash(
|
||||||
@@ -112,13 +107,19 @@ class RevisionComparator:
|
|||||||
)
|
)
|
||||||
confirmed_materials[material_hash] = item
|
confirmed_materials[material_hash] = item
|
||||||
|
|
||||||
|
# 해시 역참조 맵 (유사도 비교용)
|
||||||
|
# 해시 -> 정규화된 설명 문자열 (비교 대상)
|
||||||
|
# 여기서는 specification 자체를 비교 대상으로 사용 (가장 정보량이 많음)
|
||||||
|
confirmed_specs = {
|
||||||
|
h: item["specification"] for h, item in confirmed_materials.items()
|
||||||
|
}
|
||||||
|
|
||||||
# 신규 자재 분석
|
# 신규 자재 분석
|
||||||
unchanged_materials = [] # 변경 없음 (분류 불필요)
|
unchanged_materials = []
|
||||||
changed_materials = [] # 변경됨 (재분류 필요)
|
changed_materials = []
|
||||||
new_materials_list = [] # 신규 추가 (분류 필요)
|
new_materials_list = []
|
||||||
|
|
||||||
for new_material in new_materials:
|
for new_material in new_materials:
|
||||||
# 자재 해시 생성 (description 기반)
|
|
||||||
description = new_material.get("description", "")
|
description = new_material.get("description", "")
|
||||||
size = self._extract_size_from_description(description)
|
size = self._extract_size_from_description(description)
|
||||||
material = self._extract_material_from_description(description)
|
material = self._extract_material_from_description(description)
|
||||||
@@ -126,13 +127,13 @@ class RevisionComparator:
|
|||||||
material_hash = self._generate_material_hash(description, size, material)
|
material_hash = self._generate_material_hash(description, size, material)
|
||||||
|
|
||||||
if material_hash in confirmed_materials:
|
if material_hash in confirmed_materials:
|
||||||
|
# 정확히 일치하는 자재 발견 (해시 일치)
|
||||||
confirmed_item = confirmed_materials[material_hash]
|
confirmed_item = confirmed_materials[material_hash]
|
||||||
|
|
||||||
# 수량 비교
|
|
||||||
new_qty = float(new_material.get("quantity", 0))
|
new_qty = float(new_material.get("quantity", 0))
|
||||||
confirmed_qty = float(confirmed_item["bom_quantity"])
|
confirmed_qty = float(confirmed_item["bom_quantity"])
|
||||||
|
|
||||||
if abs(new_qty - confirmed_qty) > 0.001: # 수량 변경
|
if abs(new_qty - confirmed_qty) > 0.001:
|
||||||
changed_materials.append({
|
changed_materials.append({
|
||||||
**new_material,
|
**new_material,
|
||||||
"change_type": "QUANTITY_CHANGED",
|
"change_type": "QUANTITY_CHANGED",
|
||||||
@@ -140,27 +141,49 @@ class RevisionComparator:
|
|||||||
"previous_item": confirmed_item
|
"previous_item": confirmed_item
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# 수량 동일 - 기존 분류 결과 재사용
|
|
||||||
unchanged_materials.append({
|
unchanged_materials.append({
|
||||||
**new_material,
|
**new_material,
|
||||||
"reuse_classification": True,
|
"reuse_classification": True,
|
||||||
"previous_item": confirmed_item
|
"previous_item": confirmed_item
|
||||||
})
|
})
|
||||||
else:
|
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_materials_list.append({
|
||||||
**new_material,
|
**new_material,
|
||||||
"change_type": "NEW_MATERIAL"
|
"change_type": "NEW_MATERIAL"
|
||||||
})
|
})
|
||||||
|
|
||||||
# 삭제된 자재 찾기 (이전에는 있었지만 현재는 없는 것)
|
# 삭제된 자재 찾기
|
||||||
new_material_hashes = set()
|
new_material_hashes = set()
|
||||||
for material in new_materials:
|
for material in new_materials:
|
||||||
description = material.get("description", "")
|
d = material.get("description", "")
|
||||||
size = self._extract_size_from_description(description)
|
s = self._extract_size_from_description(d)
|
||||||
material_grade = self._extract_material_from_description(description)
|
m = self._extract_material_from_description(d)
|
||||||
hash_key = self._generate_material_hash(description, size, material_grade)
|
new_material_hashes.add(self._generate_material_hash(d, s, m))
|
||||||
new_material_hashes.add(hash_key)
|
|
||||||
|
|
||||||
removed_materials = []
|
removed_materials = []
|
||||||
for hash_key, confirmed_item in confirmed_materials.items():
|
for hash_key, confirmed_item in confirmed_materials.items():
|
||||||
@@ -186,7 +209,7 @@ class RevisionComparator:
|
|||||||
"removed_materials": removed_materials
|
"removed_materials": removed_materials
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(f"리비전 비교 완료: 변경없음 {len(unchanged_materials)}, "
|
logger.info(f"리비전 비교 완료 (Fuzzy 적용): 변경없음 {len(unchanged_materials)}, "
|
||||||
f"변경됨 {len(changed_materials)}, 신규 {len(new_materials_list)}, "
|
f"변경됨 {len(changed_materials)}, 신규 {len(new_materials_list)}, "
|
||||||
f"삭제됨 {len(removed_materials)}")
|
f"삭제됨 {len(removed_materials)}")
|
||||||
|
|
||||||
@@ -206,37 +229,136 @@ class RevisionComparator:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
def _generate_material_hash(self, description: str, size: str, material: str) -> str:
|
def _generate_material_hash(self, description: str, size: str, material: str) -> str:
|
||||||
"""자재 고유성 판단을 위한 해시 생성"""
|
"""
|
||||||
# RULES.md의 코딩 컨벤션 준수
|
자재 고유성 판단을 위한 해시 생성
|
||||||
hash_input = f"{description}|{size}|{material}".lower().strip()
|
|
||||||
|
Args:
|
||||||
|
description: 자재 설명
|
||||||
|
size: 자재 규격/크기
|
||||||
|
material: 자재 재질
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MD5 해시 문자열
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
def normalize(s: Optional[str]) -> str:
|
||||||
|
if s is None:
|
||||||
|
return ""
|
||||||
|
# 다중 공백을 단일 공백으로 치환하고 앞뒤 공백 제거
|
||||||
|
s = re.sub(r'\s+', ' ', str(s))
|
||||||
|
return s.strip().lower()
|
||||||
|
|
||||||
|
# 각 컴포넌트 정규화
|
||||||
|
d_norm = normalize(description)
|
||||||
|
s_norm = normalize(size)
|
||||||
|
m_norm = normalize(material)
|
||||||
|
|
||||||
|
# RULES.md의 코딩 컨벤션 준수 (pipe separator 사용)
|
||||||
|
# 값이 없는 경우에도 구분자를 포함하여 구조 유지 (예: "desc||mat")
|
||||||
|
hash_input = f"{d_norm}|{s_norm}|{m_norm}"
|
||||||
|
|
||||||
return hashlib.md5(hash_input.encode()).hexdigest()
|
return hashlib.md5(hash_input.encode()).hexdigest()
|
||||||
|
|
||||||
def _extract_size_from_description(self, description: str) -> str:
|
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
|
import re
|
||||||
size_patterns = [
|
size_patterns = [
|
||||||
r'(\d+(?:\.\d+)?)\s*(?:mm|MM|인치|inch|")',
|
# 인치 패턴 (분수 포함): 1/2", 1.5", 1-1/2"
|
||||||
r'(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)',
|
r'\b(\d+(?:[-/.]\d+)?)\s*(?:inch|인치|")',
|
||||||
r'DN\s*(\d+)',
|
# 밀리미터 패턴: 100mm, 100.5 MM
|
||||||
r'(\d+)\s*A'
|
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:
|
for pattern in size_patterns:
|
||||||
match = re.search(pattern, description, re.IGNORECASE)
|
match = re.search(pattern, description, re.IGNORECASE)
|
||||||
if match:
|
if match:
|
||||||
return match.group(0)
|
return match.group(0).strip()
|
||||||
|
|
||||||
return ""
|
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:
|
def _extract_material_from_description(self, description: str) -> str:
|
||||||
"""자재 설명에서 재질 정보 추출"""
|
"""
|
||||||
# 일반적인 재질 패턴
|
자재 설명에서 재질 정보 추출
|
||||||
materials = ["SS304", "SS316", "SS316L", "A105", "WCB", "CF8M", "CF8", "CS"]
|
우선순위에 따라 매칭 (구체적인 재질 먼저)
|
||||||
|
"""
|
||||||
|
if not description:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 자재 목록 로딩 (메모리 캐싱을 위해 클래스 속성으로 저장 고려 가능하지만 여기선 매번 호출로 단순화)
|
||||||
|
# 성능이 중요하다면 __init__ 시점에 로드하거나 lru_cache 사용 권장
|
||||||
|
materials = self._load_materials_from_db()
|
||||||
|
|
||||||
description_upper = description.upper()
|
description_upper = description.upper()
|
||||||
|
|
||||||
for material in materials:
|
for material in materials:
|
||||||
if material in description_upper:
|
# 단어 매칭을 위해 간단한 검사 수행 (부분 문자열이 다른 단어의 일부가 아닌지)
|
||||||
|
if material.upper() in description_upper:
|
||||||
return material
|
return material
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,7 +108,22 @@ def classify_support(dat_file: str, description: str, main_nom: str,
|
|||||||
# 4. 사이즈 정보 추출
|
# 4. 사이즈 정보 추출
|
||||||
size_result = extract_support_size(description, main_nom)
|
size_result = extract_support_size(description, main_nom)
|
||||||
|
|
||||||
# 5. 최종 결과 조합
|
# 5. 사용자 요구사항 추출
|
||||||
|
user_requirements = extract_support_user_requirements(description)
|
||||||
|
|
||||||
|
# 6. 우레탄 블럭슈 두께 정보 추출 및 Material Grade 보강
|
||||||
|
enhanced_material_grade = material_result.get('grade', 'UNKNOWN')
|
||||||
|
if support_type_result.get("support_type") == "URETHANE_BLOCK":
|
||||||
|
# 두께 정보 추출 (40t, 27t 등)
|
||||||
|
thickness_match = re.search(r'(\d+)\s*[tT](?![oO])', description.upper())
|
||||||
|
if thickness_match:
|
||||||
|
thickness = f"{thickness_match.group(1)}t"
|
||||||
|
if enhanced_material_grade == 'UNKNOWN' or not enhanced_material_grade:
|
||||||
|
enhanced_material_grade = thickness
|
||||||
|
elif thickness not in enhanced_material_grade:
|
||||||
|
enhanced_material_grade = f"{enhanced_material_grade} {thickness}"
|
||||||
|
|
||||||
|
# 7. 최종 결과 조합
|
||||||
return {
|
return {
|
||||||
"category": "SUPPORT",
|
"category": "SUPPORT",
|
||||||
|
|
||||||
@@ -118,10 +133,10 @@ def classify_support(dat_file: str, description: str, main_nom: str,
|
|||||||
"load_rating": load_result.get("load_rating", ""),
|
"load_rating": load_result.get("load_rating", ""),
|
||||||
"load_capacity": load_result.get("capacity", ""),
|
"load_capacity": load_result.get("capacity", ""),
|
||||||
|
|
||||||
# 재질 정보 (공통 모듈)
|
# 재질 정보 (공통 모듈) - 우레탄 블럭슈 두께 정보 포함
|
||||||
"material": {
|
"material": {
|
||||||
"standard": material_result.get('standard', 'UNKNOWN'),
|
"standard": material_result.get('standard', 'UNKNOWN'),
|
||||||
"grade": material_result.get('grade', 'UNKNOWN'),
|
"grade": enhanced_material_grade,
|
||||||
"material_type": material_result.get('material_type', 'UNKNOWN'),
|
"material_type": material_result.get('material_type', 'UNKNOWN'),
|
||||||
"confidence": material_result.get('confidence', 0.0)
|
"confidence": material_result.get('confidence', 0.0)
|
||||||
},
|
},
|
||||||
@@ -129,6 +144,9 @@ def classify_support(dat_file: str, description: str, main_nom: str,
|
|||||||
# 사이즈 정보
|
# 사이즈 정보
|
||||||
"size_info": size_result,
|
"size_info": size_result,
|
||||||
|
|
||||||
|
# 사용자 요구사항
|
||||||
|
"user_requirements": user_requirements,
|
||||||
|
|
||||||
# 전체 신뢰도
|
# 전체 신뢰도
|
||||||
"overall_confidence": calculate_support_confidence({
|
"overall_confidence": calculate_support_confidence({
|
||||||
"type": support_type_result.get('confidence', 0),
|
"type": support_type_result.get('confidence', 0),
|
||||||
@@ -183,6 +201,34 @@ def classify_support_type(dat_file: str, description: str) -> Dict:
|
|||||||
"evidence": ["NO_SUPPORT_TYPE_FOUND"]
|
"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:
|
def classify_load_rating(description: str) -> Dict:
|
||||||
"""하중 등급 분류"""
|
"""하중 등급 분류"""
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,24 @@ VALVE_TYPES = {
|
|||||||
"typical_connections": ["FLANGED", "THREADED"],
|
"typical_connections": ["FLANGED", "THREADED"],
|
||||||
"pressure_range": "150LB ~ 600LB",
|
"pressure_range": "150LB ~ 600LB",
|
||||||
"special_features": ["LUBRICATED", "NON_LUBRICATED"]
|
"special_features": ["LUBRICATED", "NON_LUBRICATED"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"SIGHT_GLASS": {
|
||||||
|
"dat_file_patterns": ["SIGHT_", "SG_"],
|
||||||
|
"description_keywords": ["SIGHT GLASS", "SIGHT", "사이트글라스", "사이트 글라스"],
|
||||||
|
"characteristics": "유체 확인용 관찰창",
|
||||||
|
"typical_connections": ["FLANGED", "THREADED"],
|
||||||
|
"pressure_range": "150LB ~ 600LB",
|
||||||
|
"special_features": ["TRANSPARENT", "VISUAL_INSPECTION"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"STRAINER": {
|
||||||
|
"dat_file_patterns": ["STRAINER_", "STR_"],
|
||||||
|
"description_keywords": ["STRAINER", "스트레이너", "여과기"],
|
||||||
|
"characteristics": "이물질 여과용",
|
||||||
|
"typical_connections": ["FLANGED", "THREADED"],
|
||||||
|
"pressure_range": "150LB ~ 600LB",
|
||||||
|
"special_features": ["MESH_FILTER", "Y_TYPE", "BASKET_TYPE"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,8 +230,13 @@ def classify_valve(dat_file: str, description: str, main_nom: str, length: float
|
|||||||
desc_upper = description.upper()
|
desc_upper = description.upper()
|
||||||
dat_upper = dat_file.upper()
|
dat_upper = dat_file.upper()
|
||||||
|
|
||||||
# 1. 밸브 키워드 확인 (재질만 있어도 통합 분류기가 이미 밸브로 분류했으므로 진행)
|
# 1. 사이트 글라스와 스트레이너 우선 확인
|
||||||
valve_keywords = ['VALVE', 'GATE', 'BALL', 'GLOBE', 'CHECK', 'BUTTERFLY', 'NEEDLE', 'RELIEF', 'SOLENOID', '밸브', '게이트', '볼', '글로브', '체크', '버터플라이', '니들', '릴리프', '솔레노이드']
|
if 'SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or '사이트글라스' in desc_upper or '스트레이너' in desc_upper:
|
||||||
|
# 사이트 글라스와 스트레이너는 항상 밸브로 분류
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 밸브 키워드 확인 (재질만 있어도 통합 분류기가 이미 밸브로 분류했으므로 진행)
|
||||||
|
valve_keywords = ['VALVE', 'GATE', 'BALL', 'GLOBE', 'CHECK', 'BUTTERFLY', 'NEEDLE', 'RELIEF', 'SOLENOID', 'SIGHT GLASS', 'STRAINER', '밸브', '게이트', '볼', '글로브', '체크', '버터플라이', '니들', '릴리프', '솔레노이드', '사이트글라스', '스트레이너']
|
||||||
has_valve_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in valve_keywords)
|
has_valve_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in valve_keywords)
|
||||||
|
|
||||||
# 밸브 재질 확인 (A216, A217, A351, A352)
|
# 밸브 재질 확인 (A216, A217, A351, A352)
|
||||||
|
|||||||
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 "$@"
|
||||||
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.
@@ -1,41 +1,63 @@
|
|||||||
# FastAPI 웹 프레임워크
|
|
||||||
fastapi==0.104.1
|
|
||||||
uvicorn[standard]==0.24.0
|
|
||||||
|
|
||||||
# 데이터베이스
|
|
||||||
sqlalchemy==2.0.23
|
|
||||||
psycopg2-binary==2.9.9
|
|
||||||
alembic==1.13.1
|
alembic==1.13.1
|
||||||
|
annotated-types==0.7.0
|
||||||
# 파일 처리
|
anyio==3.7.1
|
||||||
pandas==2.1.4
|
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
|
openpyxl==3.1.2
|
||||||
xlrd>=2.0.1
|
packaging==25.0
|
||||||
python-multipart==0.0.6
|
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==2.5.2
|
||||||
pydantic-settings==2.1.0
|
pydantic-settings==2.1.0
|
||||||
|
pydantic_core==2.14.5
|
||||||
# 기타 유틸리티
|
pyflakes==3.1.0
|
||||||
python-dotenv==1.0.0
|
|
||||||
httpx==0.25.2
|
|
||||||
redis==5.0.1
|
|
||||||
python-magic==0.4.27
|
|
||||||
|
|
||||||
# 인증 시스템
|
|
||||||
PyJWT==2.8.0
|
PyJWT==2.8.0
|
||||||
bcrypt==4.1.2
|
|
||||||
python-multipart==0.0.6
|
|
||||||
email-validator==2.3.0
|
|
||||||
|
|
||||||
# 개발 도구
|
|
||||||
pytest==7.4.3
|
pytest==7.4.3
|
||||||
pytest-asyncio==0.21.1
|
pytest-asyncio==0.21.1
|
||||||
pytest-cov==4.1.0
|
pytest-cov==4.1.0
|
||||||
pytest-mock==3.12.0
|
pytest-mock==3.12.0
|
||||||
black==23.11.0
|
python-dateutil==2.9.0.post0
|
||||||
flake8==6.1.0
|
python-dotenv==1.0.0
|
||||||
|
python-magic==0.4.27
|
||||||
python-multipart==0.0.6
|
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
|
xlrd==2.0.1
|
||||||
|
|||||||
28
backend/scripts/26_add_user_status_column.sql
Normal file
28
backend/scripts/26_add_user_status_column.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- users 테이블에 status 컬럼 추가 및 기존 데이터 마이그레이션
|
||||||
|
|
||||||
|
-- 1. status 컬럼 추가 (기본값은 'active')
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'active';
|
||||||
|
|
||||||
|
-- 2. status 컬럼에 CHECK 제약 조건 추가
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD CONSTRAINT users_status_check
|
||||||
|
CHECK (status IN ('pending', 'active', 'suspended', 'deleted'));
|
||||||
|
|
||||||
|
-- 3. 기존 데이터 마이그레이션
|
||||||
|
-- is_active가 false인 사용자는 'pending'으로
|
||||||
|
-- is_active가 true인 사용자는 'active'로
|
||||||
|
UPDATE users
|
||||||
|
SET status = CASE
|
||||||
|
WHEN is_active = FALSE THEN 'pending'
|
||||||
|
WHEN is_active = TRUE THEN 'active'
|
||||||
|
ELSE 'active'
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- 4. status 컬럼에 인덱스 추가 (조회 성능 향상)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
|
||||||
|
|
||||||
|
-- 5. 향후 is_active 컬럼은 deprecated로 간주
|
||||||
|
-- 하지만 하위 호환성을 위해 당분간 유지
|
||||||
|
COMMENT ON COLUMN users.status IS 'User account status: pending, active, suspended, deleted';
|
||||||
|
COMMENT ON COLUMN users.is_active IS 'DEPRECATED: Use status column instead. Kept for backward compatibility.';
|
||||||
135
backend/scripts/27_add_purchase_tracking.sql
Normal file
135
backend/scripts/27_add_purchase_tracking.sql
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
-- 엑셀 내보내기 이력 및 구매 상태 관리 테이블
|
||||||
|
|
||||||
|
-- 1. 엑셀 내보내기 이력 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS excel_export_history (
|
||||||
|
export_id SERIAL PRIMARY KEY,
|
||||||
|
file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
|
||||||
|
job_no VARCHAR(50) REFERENCES jobs(job_no),
|
||||||
|
exported_by INTEGER REFERENCES users(user_id),
|
||||||
|
export_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
export_type VARCHAR(50), -- 'full', 'category', 'filtered'
|
||||||
|
category VARCHAR(50), -- PIPE, FLANGE, VALVE 등
|
||||||
|
material_count INTEGER,
|
||||||
|
file_name VARCHAR(255),
|
||||||
|
notes TEXT,
|
||||||
|
-- 메타데이터
|
||||||
|
filters_applied JSONB, -- 적용된 필터 조건들
|
||||||
|
export_options JSONB -- 내보내기 옵션들
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. 내보낸 자재 상세 (어떤 자재들이 내보내졌는지 추적)
|
||||||
|
CREATE TABLE IF NOT EXISTS exported_materials (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
export_id INTEGER REFERENCES excel_export_history(export_id) ON DELETE CASCADE,
|
||||||
|
material_id INTEGER REFERENCES materials(id),
|
||||||
|
purchase_status VARCHAR(50) DEFAULT 'pending', -- pending, requested, ordered, received, cancelled
|
||||||
|
purchase_request_no VARCHAR(100), -- 구매요청 번호
|
||||||
|
purchase_order_no VARCHAR(100), -- 구매주문 번호
|
||||||
|
requested_date TIMESTAMP,
|
||||||
|
ordered_date TIMESTAMP,
|
||||||
|
expected_date DATE,
|
||||||
|
received_date TIMESTAMP,
|
||||||
|
quantity_exported INTEGER, -- 내보낸 수량
|
||||||
|
quantity_ordered INTEGER, -- 주문 수량
|
||||||
|
quantity_received INTEGER, -- 입고 수량
|
||||||
|
unit_price DECIMAL(15, 2),
|
||||||
|
total_price DECIMAL(15, 2),
|
||||||
|
vendor_name VARCHAR(255),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by INTEGER REFERENCES users(user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. 구매 상태 이력 (상태 변경 추적)
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_status_history (
|
||||||
|
history_id SERIAL PRIMARY KEY,
|
||||||
|
exported_material_id INTEGER REFERENCES exported_materials(id) ON DELETE CASCADE,
|
||||||
|
material_id INTEGER REFERENCES materials(id),
|
||||||
|
previous_status VARCHAR(50),
|
||||||
|
new_status VARCHAR(50),
|
||||||
|
changed_by INTEGER REFERENCES users(user_id),
|
||||||
|
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
reason TEXT,
|
||||||
|
metadata JSONB -- 추가 정보 (예: 문서 번호, 승인자 등)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. 구매 문서 관리
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_documents (
|
||||||
|
document_id SERIAL PRIMARY KEY,
|
||||||
|
export_id INTEGER REFERENCES excel_export_history(export_id),
|
||||||
|
document_type VARCHAR(50), -- 'purchase_request', 'purchase_order', 'invoice', 'receipt'
|
||||||
|
document_no VARCHAR(100),
|
||||||
|
document_date DATE,
|
||||||
|
file_path VARCHAR(500),
|
||||||
|
uploaded_by INTEGER REFERENCES users(user_id),
|
||||||
|
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 인덱스 추가
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_export_history_file_id ON excel_export_history(file_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_export_history_job_no ON excel_export_history(job_no);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_export_history_date ON excel_export_history(export_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exported_materials_export_id ON exported_materials(export_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exported_materials_material_id ON exported_materials(material_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exported_materials_status ON exported_materials(purchase_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exported_materials_pr_no ON exported_materials(purchase_request_no);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exported_materials_po_no ON exported_materials(purchase_order_no);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_history_material ON purchase_status_history(material_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_history_date ON purchase_status_history(changed_at);
|
||||||
|
|
||||||
|
-- 뷰 생성: 구매 상태별 자재 현황
|
||||||
|
CREATE OR REPLACE VIEW v_purchase_status_summary AS
|
||||||
|
SELECT
|
||||||
|
em.purchase_status,
|
||||||
|
COUNT(DISTINCT em.material_id) as material_count,
|
||||||
|
COUNT(DISTINCT em.export_id) as export_count,
|
||||||
|
SUM(em.quantity_exported) as total_quantity_exported,
|
||||||
|
SUM(em.quantity_ordered) as total_quantity_ordered,
|
||||||
|
SUM(em.quantity_received) as total_quantity_received,
|
||||||
|
SUM(em.total_price) as total_amount,
|
||||||
|
MAX(em.updated_at) as last_updated
|
||||||
|
FROM exported_materials em
|
||||||
|
GROUP BY em.purchase_status;
|
||||||
|
|
||||||
|
-- 뷰 생성: 자재별 최신 구매 상태
|
||||||
|
CREATE OR REPLACE VIEW v_material_latest_purchase_status AS
|
||||||
|
SELECT DISTINCT ON (m.id)
|
||||||
|
m.id as material_id,
|
||||||
|
m.original_description,
|
||||||
|
m.classified_category,
|
||||||
|
em.purchase_status,
|
||||||
|
em.purchase_request_no,
|
||||||
|
em.purchase_order_no,
|
||||||
|
em.vendor_name,
|
||||||
|
em.expected_date,
|
||||||
|
em.quantity_ordered,
|
||||||
|
em.quantity_received,
|
||||||
|
em.updated_at as status_updated_at,
|
||||||
|
eeh.export_date as last_exported_date
|
||||||
|
FROM materials m
|
||||||
|
LEFT JOIN exported_materials em ON m.id = em.material_id
|
||||||
|
LEFT JOIN excel_export_history eeh ON em.export_id = eeh.export_id
|
||||||
|
ORDER BY m.id, em.updated_at DESC;
|
||||||
|
|
||||||
|
-- 트리거: updated_at 자동 업데이트
|
||||||
|
CREATE OR REPLACE FUNCTION update_exported_materials_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER update_exported_materials_updated_at_trigger
|
||||||
|
BEFORE UPDATE ON exported_materials
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_exported_materials_updated_at();
|
||||||
|
|
||||||
|
-- 코멘트 추가
|
||||||
|
COMMENT ON TABLE excel_export_history IS '엑셀 내보내기 이력 관리';
|
||||||
|
COMMENT ON TABLE exported_materials IS '내보낸 자재의 구매 상태 추적';
|
||||||
|
COMMENT ON TABLE purchase_status_history IS '구매 상태 변경 이력';
|
||||||
|
COMMENT ON TABLE purchase_documents IS '구매 관련 문서 관리';
|
||||||
|
COMMENT ON COLUMN exported_materials.purchase_status IS 'pending: 구매신청 전, requested: 구매신청, ordered: 구매주문, received: 입고완료, cancelled: 취소';
|
||||||
44
backend/scripts/28_add_purchase_requests.sql
Normal file
44
backend/scripts/28_add_purchase_requests.sql
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
-- 구매신청 관리 테이블
|
||||||
|
|
||||||
|
-- 구매신청 그룹 (같이 신청한 항목들의 묶음)
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_requests (
|
||||||
|
request_id SERIAL PRIMARY KEY,
|
||||||
|
request_no VARCHAR(50) UNIQUE, -- PR-20241014-001 형식
|
||||||
|
file_id INTEGER REFERENCES files(id),
|
||||||
|
job_no VARCHAR(50) REFERENCES jobs(job_no),
|
||||||
|
category VARCHAR(50),
|
||||||
|
material_count INTEGER,
|
||||||
|
excel_file_path VARCHAR(500), -- 저장된 엑셀 파일 경로
|
||||||
|
requested_by INTEGER REFERENCES users(user_id),
|
||||||
|
requested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
status VARCHAR(20) DEFAULT 'requested', -- requested, ordered, received
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 구매신청 자재 상세
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_request_items (
|
||||||
|
item_id SERIAL PRIMARY KEY,
|
||||||
|
request_id INTEGER REFERENCES purchase_requests(request_id) ON DELETE CASCADE,
|
||||||
|
material_id INTEGER REFERENCES materials(id),
|
||||||
|
quantity INTEGER,
|
||||||
|
unit VARCHAR(20),
|
||||||
|
user_requirement TEXT,
|
||||||
|
is_ordered BOOLEAN DEFAULT FALSE,
|
||||||
|
is_received BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 인덱스
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_requests_file_id ON purchase_requests(file_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_requests_job_no ON purchase_requests(job_no);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_requests_status ON purchase_requests(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_request_items_request_id ON purchase_request_items(request_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_request_items_material_id ON purchase_request_items(material_id);
|
||||||
|
|
||||||
|
-- 뷰: 구매신청된 자재 ID 목록
|
||||||
|
CREATE OR REPLACE VIEW v_requested_material_ids AS
|
||||||
|
SELECT DISTINCT material_id
|
||||||
|
FROM purchase_request_items;
|
||||||
|
|
||||||
|
COMMENT ON TABLE purchase_requests IS '구매신청 그룹 관리';
|
||||||
|
COMMENT ON TABLE purchase_request_items IS '구매신청 자재 상세';
|
||||||
20
backend/scripts/29_add_revision_status.sql
Normal file
20
backend/scripts/29_add_revision_status.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- 리비전 관리 개선: 자재 상태 추적
|
||||||
|
-- 리비전 업로드 시 삭제된 자재의 상태를 추적
|
||||||
|
|
||||||
|
-- materials 테이블에 revision_status 컬럼 추가
|
||||||
|
ALTER TABLE materials ADD COLUMN IF NOT EXISTS revision_status VARCHAR(20) DEFAULT 'active';
|
||||||
|
-- 가능한 값: 'active', 'inventory', 'deleted_not_purchased', 'changed'
|
||||||
|
|
||||||
|
-- revision_status 설명:
|
||||||
|
-- 'active': 정상 활성 자재 (기본값)
|
||||||
|
-- 'inventory': 재고품 (구매신청 후 리비전에서 삭제됨 - 연노랑색 표시)
|
||||||
|
-- 'deleted_not_purchased': 구매신청 전 삭제됨 (숨김 처리)
|
||||||
|
-- 'changed': 변경된 자재 (추가 구매 필요)
|
||||||
|
|
||||||
|
-- 인덱스 추가 (성능 최적화)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_revision_status ON materials(revision_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_drawing_name ON materials(drawing_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_line_no ON materials(line_no);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN materials.revision_status IS '리비전 자재 상태: active(활성), inventory(재고품), deleted_not_purchased(삭제됨), changed(변경됨)';
|
||||||
|
|
||||||
654
backend/scripts/create_missing_tables.py
Normal file
654
backend/scripts/create_missing_tables.py
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
누락된 테이블 생성 스크립트
|
||||||
|
- support_details
|
||||||
|
- special_material_details
|
||||||
|
- purchase_requests
|
||||||
|
- purchase_request_items
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
# 프로젝트 루트를 Python path에 추가
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
"""데이터베이스 연결"""
|
||||||
|
try:
|
||||||
|
# Docker 환경에서는 서비스명으로 연결
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host="tk-mp-postgres",
|
||||||
|
port="5432",
|
||||||
|
database="tk_mp_bom",
|
||||||
|
user="tkmp_user",
|
||||||
|
password="tkmp_password_2025"
|
||||||
|
)
|
||||||
|
return conn
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ DB 연결 실패: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_admin_user(cursor):
|
||||||
|
"""기본 admin 계정 생성"""
|
||||||
|
try:
|
||||||
|
# admin 계정이 이미 있는지 확인
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM users WHERE username = 'admin';")
|
||||||
|
if cursor.fetchone()[0] > 0:
|
||||||
|
print("✅ admin 계정이 이미 존재합니다.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# bcrypt로 비밀번호 해시 생성
|
||||||
|
password = "admin123"
|
||||||
|
hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||||
|
|
||||||
|
# admin 계정 생성
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO users (
|
||||||
|
username, password, name, email, role, access_level,
|
||||||
|
is_active, status, department, position
|
||||||
|
) VALUES (
|
||||||
|
'admin', %s, 'System Administrator', 'admin@example.com',
|
||||||
|
'admin', 'admin', true, 'active', 'IT', 'Administrator'
|
||||||
|
);
|
||||||
|
""", (hashed_password,))
|
||||||
|
|
||||||
|
print("✅ admin 계정 생성 완료 (username: admin, password: admin123)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ admin 계정 생성 실패: {e}")
|
||||||
|
|
||||||
|
def add_missing_columns(cursor):
|
||||||
|
"""누락된 컬럼들 추가"""
|
||||||
|
try:
|
||||||
|
print("🔧 누락된 컬럼 확인 및 추가 중...")
|
||||||
|
|
||||||
|
# users 테이블에 status 컬럼 확인 및 추가
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'users' AND column_name = 'status';
|
||||||
|
""")
|
||||||
|
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("➕ users 테이블에 status 컬럼 추가 중...")
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'active';
|
||||||
|
""")
|
||||||
|
print("✅ users.status 컬럼 추가 완료")
|
||||||
|
else:
|
||||||
|
print("✅ users.status 컬럼이 이미 존재합니다")
|
||||||
|
|
||||||
|
# files 테이블에 누락된 컬럼들 확인 및 추가
|
||||||
|
files_columns = {
|
||||||
|
'job_no': 'VARCHAR(50)',
|
||||||
|
'bom_name': 'VARCHAR(255)',
|
||||||
|
'description': 'TEXT',
|
||||||
|
'parsed_count': 'INTEGER DEFAULT 0',
|
||||||
|
'classification_completed': 'BOOLEAN DEFAULT FALSE'
|
||||||
|
}
|
||||||
|
|
||||||
|
for column_name, column_type in files_columns.items():
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'files' AND column_name = %s;
|
||||||
|
""", (column_name,))
|
||||||
|
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print(f"➕ files 테이블에 {column_name} 컬럼 추가 중...")
|
||||||
|
cursor.execute(f"""
|
||||||
|
ALTER TABLE files ADD COLUMN {column_name} {column_type};
|
||||||
|
""")
|
||||||
|
print(f"✅ files.{column_name} 컬럼 추가 완료")
|
||||||
|
else:
|
||||||
|
print(f"✅ files.{column_name} 컬럼이 이미 존재합니다")
|
||||||
|
|
||||||
|
# materials 테이블에 누락된 컬럼들 확인 및 추가
|
||||||
|
materials_columns = {
|
||||||
|
'main_nom': 'VARCHAR(50)',
|
||||||
|
'red_nom': 'VARCHAR(50)',
|
||||||
|
'full_material_grade': 'TEXT',
|
||||||
|
'row_number': 'INTEGER',
|
||||||
|
'length': 'NUMERIC(10,3)',
|
||||||
|
'purchase_confirmed': 'BOOLEAN DEFAULT FALSE',
|
||||||
|
'confirmed_quantity': 'NUMERIC(10,3)',
|
||||||
|
'purchase_status': 'VARCHAR(20)',
|
||||||
|
'purchase_confirmed_by': 'VARCHAR(100)',
|
||||||
|
'purchase_confirmed_at': 'TIMESTAMP',
|
||||||
|
'revision_status': 'VARCHAR(20)',
|
||||||
|
'material_hash': 'VARCHAR(64)',
|
||||||
|
'normalized_description': 'TEXT',
|
||||||
|
'drawing_reference': 'VARCHAR(100)',
|
||||||
|
'notes': 'TEXT',
|
||||||
|
'created_at': 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP',
|
||||||
|
'brand': 'VARCHAR(100)',
|
||||||
|
'user_requirement': 'TEXT',
|
||||||
|
'is_active': 'BOOLEAN DEFAULT TRUE',
|
||||||
|
'total_length': 'NUMERIC(10,3)'
|
||||||
|
}
|
||||||
|
|
||||||
|
for column_name, column_type in materials_columns.items():
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'materials' AND column_name = %s;
|
||||||
|
""", (column_name,))
|
||||||
|
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print(f"➕ materials 테이블에 {column_name} 컬럼 추가 중...")
|
||||||
|
cursor.execute(f"""
|
||||||
|
ALTER TABLE materials ADD COLUMN {column_name} {column_type};
|
||||||
|
""")
|
||||||
|
print(f"✅ materials.{column_name} 컬럼 추가 완료")
|
||||||
|
else:
|
||||||
|
print(f"✅ materials.{column_name} 컬럼이 이미 존재합니다")
|
||||||
|
|
||||||
|
# purchase_requests 테이블에 누락된 컬럼들 확인 및 추가
|
||||||
|
purchase_requests_columns = {
|
||||||
|
'file_id': 'INTEGER REFERENCES files(id)'
|
||||||
|
}
|
||||||
|
|
||||||
|
for column_name, column_type in purchase_requests_columns.items():
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'purchase_requests' AND column_name = %s;
|
||||||
|
""", (column_name,))
|
||||||
|
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print(f"➕ purchase_requests 테이블에 {column_name} 컬럼 추가 중...")
|
||||||
|
cursor.execute(f"""
|
||||||
|
ALTER TABLE purchase_requests ADD COLUMN {column_name} {column_type};
|
||||||
|
""")
|
||||||
|
print(f"✅ purchase_requests.{column_name} 컬럼 추가 완료")
|
||||||
|
else:
|
||||||
|
print(f"✅ purchase_requests.{column_name} 컬럼이 이미 존재합니다")
|
||||||
|
|
||||||
|
# material_purchase_tracking 테이블에 누락된 컬럼들 확인 및 추가
|
||||||
|
mpt_columns = {
|
||||||
|
'description': 'TEXT',
|
||||||
|
'purchase_status': 'VARCHAR(20) DEFAULT \'pending\''
|
||||||
|
}
|
||||||
|
|
||||||
|
for column_name, column_type in mpt_columns.items():
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'material_purchase_tracking' AND column_name = %s;
|
||||||
|
""", (column_name,))
|
||||||
|
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print(f"➕ material_purchase_tracking 테이블에 {column_name} 컬럼 추가 중...")
|
||||||
|
cursor.execute(f"""
|
||||||
|
ALTER TABLE material_purchase_tracking ADD COLUMN {column_name} {column_type};
|
||||||
|
""")
|
||||||
|
print(f"✅ material_purchase_tracking.{column_name} 컬럼 추가 완료")
|
||||||
|
else:
|
||||||
|
print(f"✅ material_purchase_tracking.{column_name} 컬럼이 이미 존재합니다")
|
||||||
|
|
||||||
|
# purchase_requests 테이블에 누락된 컬럼들 확인 및 추가
|
||||||
|
purchase_requests_columns = {
|
||||||
|
'file_id': 'INTEGER REFERENCES files(id)',
|
||||||
|
'category': 'VARCHAR(50)',
|
||||||
|
'material_count': 'INTEGER DEFAULT 0',
|
||||||
|
'excel_file_path': 'VARCHAR(500)'
|
||||||
|
}
|
||||||
|
|
||||||
|
for column_name, column_type in purchase_requests_columns.items():
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'purchase_requests' AND column_name = %s;
|
||||||
|
""", (column_name,))
|
||||||
|
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print(f"➕ purchase_requests 테이블에 {column_name} 컬럼 추가 중...")
|
||||||
|
cursor.execute(f"""
|
||||||
|
ALTER TABLE purchase_requests ADD COLUMN {column_name} {column_type};
|
||||||
|
""")
|
||||||
|
print(f"✅ purchase_requests.{column_name} 컬럼 추가 완료")
|
||||||
|
else:
|
||||||
|
print(f"✅ purchase_requests.{column_name} 컬럼이 이미 존재합니다")
|
||||||
|
|
||||||
|
# purchase_request_items 테이블에 누락된 컬럼들 확인 및 추가
|
||||||
|
purchase_request_items_columns = {
|
||||||
|
'user_requirement': 'TEXT',
|
||||||
|
'description': 'TEXT',
|
||||||
|
'category': 'VARCHAR(50)',
|
||||||
|
'subcategory': 'VARCHAR(100)',
|
||||||
|
'material_grade': 'VARCHAR(50)',
|
||||||
|
'size_spec': 'VARCHAR(50)',
|
||||||
|
'drawing_name': 'VARCHAR(100)',
|
||||||
|
'notes': 'TEXT',
|
||||||
|
'is_ordered': 'BOOLEAN DEFAULT FALSE',
|
||||||
|
'is_received': 'BOOLEAN DEFAULT FALSE'
|
||||||
|
}
|
||||||
|
|
||||||
|
for column_name, column_type in purchase_request_items_columns.items():
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'purchase_request_items' AND column_name = %s;
|
||||||
|
""", (column_name,))
|
||||||
|
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print(f"➕ purchase_request_items 테이블에 {column_name} 컬럼 추가 중...")
|
||||||
|
cursor.execute(f"""
|
||||||
|
ALTER TABLE purchase_request_items ADD COLUMN {column_name} {column_type};
|
||||||
|
""")
|
||||||
|
print(f"✅ purchase_request_items.{column_name} 컬럼 추가 완료")
|
||||||
|
else:
|
||||||
|
print(f"✅ purchase_request_items.{column_name} 컬럼이 이미 존재합니다")
|
||||||
|
|
||||||
|
print("✅ 모든 누락된 컬럼 추가 완료!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ 컬럼 추가 실패: {e}")
|
||||||
|
|
||||||
|
def create_missing_tables():
|
||||||
|
"""누락된 테이블들 생성 (처음 설치 시에만)"""
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
if not conn:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# 이미 설치되어 있는지 확인 (핵심 테이블들이 모두 존재하는지 체크)
|
||||||
|
print("🔍 기존 설치 상태 확인 중...")
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COUNT(*) FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name IN ('support_details', 'special_material_details', 'purchase_requests', 'purchase_request_items');
|
||||||
|
""")
|
||||||
|
|
||||||
|
existing_tables = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
if existing_tables == 4:
|
||||||
|
print("✅ 모든 테이블이 이미 존재합니다.")
|
||||||
|
|
||||||
|
# 컬럼 체크는 항상 수행
|
||||||
|
print("🔧 누락된 컬럼 확인 중...")
|
||||||
|
add_missing_columns(cursor)
|
||||||
|
|
||||||
|
# admin 계정 확인
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM users WHERE username = 'admin';")
|
||||||
|
admin_exists = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
if admin_exists == 0:
|
||||||
|
print("👤 admin 계정 생성 중...")
|
||||||
|
create_admin_user(cursor)
|
||||||
|
conn.commit()
|
||||||
|
print("✅ admin 계정 생성 완료")
|
||||||
|
else:
|
||||||
|
print("✅ admin 계정이 이미 존재합니다.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
print(f"🔍 누락된 테이블 확인 및 생성 중... ({existing_tables}/4개 존재)")
|
||||||
|
|
||||||
|
# 1. support_details 테이블
|
||||||
|
print("📋 1. support_details 테이블 확인...")
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'support_details'
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
if not cursor.fetchone()[0]:
|
||||||
|
print("➕ support_details 테이블 생성 중...")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE support_details (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE,
|
||||||
|
file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
|
||||||
|
support_type VARCHAR(50),
|
||||||
|
support_subtype VARCHAR(100),
|
||||||
|
load_rating VARCHAR(50),
|
||||||
|
load_capacity VARCHAR(50),
|
||||||
|
material_standard VARCHAR(100),
|
||||||
|
material_grade VARCHAR(50),
|
||||||
|
pipe_size VARCHAR(20),
|
||||||
|
length_mm NUMERIC(10,2),
|
||||||
|
width_mm NUMERIC(10,2),
|
||||||
|
height_mm NUMERIC(10,2),
|
||||||
|
classification_confidence NUMERIC(3,2),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_support_details_material_id ON support_details(material_id);
|
||||||
|
CREATE INDEX idx_support_details_file_id ON support_details(file_id);
|
||||||
|
""")
|
||||||
|
print("✅ support_details 테이블 생성 완료")
|
||||||
|
else:
|
||||||
|
print("✅ support_details 테이블 이미 존재")
|
||||||
|
|
||||||
|
# 2. special_material_details 테이블
|
||||||
|
print("📋 2. special_material_details 테이블 확인...")
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'special_material_details'
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
if not cursor.fetchone()[0]:
|
||||||
|
print("➕ special_material_details 테이블 생성 중...")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE special_material_details (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE,
|
||||||
|
file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
|
||||||
|
special_type VARCHAR(50),
|
||||||
|
special_subtype VARCHAR(100),
|
||||||
|
material_standard VARCHAR(100),
|
||||||
|
material_grade VARCHAR(50),
|
||||||
|
specifications TEXT,
|
||||||
|
dimensions VARCHAR(100),
|
||||||
|
weight_kg NUMERIC(10,3),
|
||||||
|
classification_confidence NUMERIC(3,2),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_special_material_details_material_id ON special_material_details(material_id);
|
||||||
|
CREATE INDEX idx_special_material_details_file_id ON special_material_details(file_id);
|
||||||
|
""")
|
||||||
|
print("✅ special_material_details 테이블 생성 완료")
|
||||||
|
else:
|
||||||
|
print("✅ special_material_details 테이블 이미 존재")
|
||||||
|
|
||||||
|
# 3. purchase_requests 테이블
|
||||||
|
print("📋 3. purchase_requests 테이블 확인...")
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'purchase_requests'
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
if not cursor.fetchone()[0]:
|
||||||
|
print("➕ purchase_requests 테이블 생성 중...")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE purchase_requests (
|
||||||
|
request_id SERIAL PRIMARY KEY,
|
||||||
|
request_no VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
file_id INTEGER REFERENCES files(id),
|
||||||
|
job_no VARCHAR(50) NOT NULL,
|
||||||
|
category VARCHAR(50),
|
||||||
|
material_count INTEGER DEFAULT 0,
|
||||||
|
excel_file_path VARCHAR(500),
|
||||||
|
project_name VARCHAR(200),
|
||||||
|
requested_by INTEGER REFERENCES users(user_id),
|
||||||
|
requested_by_username VARCHAR(100),
|
||||||
|
request_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
total_items INTEGER DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
approved_by INTEGER REFERENCES users(user_id),
|
||||||
|
approved_by_username VARCHAR(100),
|
||||||
|
approved_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_purchase_requests_job_no ON purchase_requests(job_no);
|
||||||
|
CREATE INDEX idx_purchase_requests_status ON purchase_requests(status);
|
||||||
|
CREATE INDEX idx_purchase_requests_requested_by ON purchase_requests(requested_by);
|
||||||
|
""")
|
||||||
|
print("✅ purchase_requests 테이블 생성 완료")
|
||||||
|
else:
|
||||||
|
print("✅ purchase_requests 테이블 이미 존재")
|
||||||
|
|
||||||
|
# 4. purchase_request_items 테이블
|
||||||
|
print("📋 4. purchase_request_items 테이블 확인...")
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'purchase_request_items'
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
if not cursor.fetchone()[0]:
|
||||||
|
print("➕ purchase_request_items 테이블 생성 중...")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE purchase_request_items (
|
||||||
|
item_id SERIAL PRIMARY KEY,
|
||||||
|
request_id INTEGER REFERENCES purchase_requests(request_id) ON DELETE CASCADE,
|
||||||
|
material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
category VARCHAR(50),
|
||||||
|
subcategory VARCHAR(100),
|
||||||
|
material_grade VARCHAR(50),
|
||||||
|
size_spec VARCHAR(50),
|
||||||
|
quantity NUMERIC(10,3) NOT NULL,
|
||||||
|
unit VARCHAR(10) NOT NULL,
|
||||||
|
drawing_name VARCHAR(100),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_purchase_request_items_request_id ON purchase_request_items(request_id);
|
||||||
|
CREATE INDEX idx_purchase_request_items_material_id ON purchase_request_items(material_id);
|
||||||
|
CREATE INDEX idx_purchase_request_items_category ON purchase_request_items(category);
|
||||||
|
""")
|
||||||
|
print("✅ purchase_request_items 테이블 생성 완료")
|
||||||
|
else:
|
||||||
|
print("✅ purchase_request_items 테이블 이미 존재")
|
||||||
|
|
||||||
|
# 5. revision_sessions 테이블
|
||||||
|
print("📋 5. revision_sessions 테이블 확인...")
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'revision_sessions'
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
if not cursor.fetchone()[0]:
|
||||||
|
print("➕ revision_sessions 테이블 생성 중...")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE revision_sessions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
job_no VARCHAR(50) NOT NULL,
|
||||||
|
current_file_id INTEGER REFERENCES files(id),
|
||||||
|
previous_file_id INTEGER REFERENCES files(id),
|
||||||
|
current_revision VARCHAR(20) NOT NULL,
|
||||||
|
previous_revision VARCHAR(20) NOT NULL,
|
||||||
|
|
||||||
|
status VARCHAR(20) DEFAULT 'processing',
|
||||||
|
total_materials INTEGER DEFAULT 0,
|
||||||
|
processed_materials INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
added_count INTEGER DEFAULT 0,
|
||||||
|
removed_count INTEGER DEFAULT 0,
|
||||||
|
changed_count INTEGER DEFAULT 0,
|
||||||
|
unchanged_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
purchase_cancel_count INTEGER DEFAULT 0,
|
||||||
|
inventory_transfer_count INTEGER DEFAULT 0,
|
||||||
|
additional_purchase_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
created_by VARCHAR(100),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_revision_sessions_job_no ON revision_sessions(job_no);
|
||||||
|
CREATE INDEX idx_revision_sessions_status ON revision_sessions(status);
|
||||||
|
""")
|
||||||
|
print("✅ revision_sessions 테이블 생성 완료")
|
||||||
|
else:
|
||||||
|
print("✅ revision_sessions 테이블 이미 존재")
|
||||||
|
|
||||||
|
# 6. revision_material_changes 테이블
|
||||||
|
print("📋 6. revision_material_changes 테이블 확인...")
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'revision_material_changes'
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
if not cursor.fetchone()[0]:
|
||||||
|
print("➕ revision_material_changes 테이블 생성 중...")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE revision_material_changes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
session_id INTEGER REFERENCES revision_sessions(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
material_id INTEGER REFERENCES materials(id),
|
||||||
|
previous_material_id INTEGER,
|
||||||
|
material_description TEXT NOT NULL,
|
||||||
|
category VARCHAR(50) NOT NULL,
|
||||||
|
|
||||||
|
change_type VARCHAR(20) NOT NULL,
|
||||||
|
previous_quantity NUMERIC(10,3),
|
||||||
|
current_quantity NUMERIC(10,3),
|
||||||
|
quantity_difference NUMERIC(10,3),
|
||||||
|
|
||||||
|
purchase_status VARCHAR(20) NOT NULL,
|
||||||
|
purchase_confirmed_at TIMESTAMP,
|
||||||
|
|
||||||
|
revision_action VARCHAR(30),
|
||||||
|
action_status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
|
||||||
|
processed_by VARCHAR(100),
|
||||||
|
processed_at TIMESTAMP,
|
||||||
|
processing_notes TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_revision_changes_session ON revision_material_changes(session_id);
|
||||||
|
CREATE INDEX idx_revision_changes_action ON revision_material_changes(revision_action);
|
||||||
|
CREATE INDEX idx_revision_changes_status ON revision_material_changes(action_status);
|
||||||
|
""")
|
||||||
|
print("✅ revision_material_changes 테이블 생성 완료")
|
||||||
|
else:
|
||||||
|
print("✅ revision_material_changes 테이블 이미 존재")
|
||||||
|
|
||||||
|
# 7. inventory_transfers 테이블
|
||||||
|
print("📋 7. inventory_transfers 테이블 확인...")
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'inventory_transfers'
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
if not cursor.fetchone()[0]:
|
||||||
|
print("➕ inventory_transfers 테이블 생성 중...")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE inventory_transfers (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
revision_change_id INTEGER REFERENCES revision_material_changes(id),
|
||||||
|
|
||||||
|
material_description TEXT NOT NULL,
|
||||||
|
category VARCHAR(50) NOT NULL,
|
||||||
|
quantity NUMERIC(10,3) NOT NULL,
|
||||||
|
unit VARCHAR(10) NOT NULL,
|
||||||
|
|
||||||
|
inventory_location VARCHAR(100),
|
||||||
|
storage_notes TEXT,
|
||||||
|
|
||||||
|
transferred_by VARCHAR(100) NOT NULL,
|
||||||
|
transferred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
status VARCHAR(20) DEFAULT 'transferred'
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_inventory_transfers_material ON inventory_transfers(material_description);
|
||||||
|
CREATE INDEX idx_inventory_transfers_date ON inventory_transfers(transferred_at);
|
||||||
|
""")
|
||||||
|
print("✅ inventory_transfers 테이블 생성 완료")
|
||||||
|
else:
|
||||||
|
print("✅ inventory_transfers 테이블 이미 존재")
|
||||||
|
|
||||||
|
# 8. revision_action_logs 테이블
|
||||||
|
print("📋 8. revision_action_logs 테이블 확인...")
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'revision_action_logs'
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
if not cursor.fetchone()[0]:
|
||||||
|
print("➕ revision_action_logs 테이블 생성 중...")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE revision_action_logs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
session_id INTEGER REFERENCES revision_sessions(id),
|
||||||
|
revision_change_id INTEGER REFERENCES revision_material_changes(id),
|
||||||
|
|
||||||
|
action_type VARCHAR(30) NOT NULL,
|
||||||
|
action_description TEXT,
|
||||||
|
|
||||||
|
executed_by VARCHAR(100) NOT NULL,
|
||||||
|
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
result VARCHAR(20) NOT NULL,
|
||||||
|
result_message TEXT,
|
||||||
|
result_data JSONB
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_revision_logs_session ON revision_action_logs(session_id);
|
||||||
|
CREATE INDEX idx_revision_logs_type ON revision_action_logs(action_type);
|
||||||
|
CREATE INDEX idx_revision_logs_date ON revision_action_logs(executed_at);
|
||||||
|
""")
|
||||||
|
print("✅ revision_action_logs 테이블 생성 완료")
|
||||||
|
else:
|
||||||
|
print("✅ revision_action_logs 테이블 이미 존재")
|
||||||
|
|
||||||
|
# 변경사항 커밋
|
||||||
|
conn.commit()
|
||||||
|
print("\n🎉 누락된 테이블 생성 완료!")
|
||||||
|
|
||||||
|
# 최종 테이블 목록 확인
|
||||||
|
print("\n📋 현재 테이블 목록:")
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
ORDER BY table_name;
|
||||||
|
""")
|
||||||
|
|
||||||
|
tables = cursor.fetchall()
|
||||||
|
for table in tables:
|
||||||
|
print(f" - {table[0]}")
|
||||||
|
|
||||||
|
print(f"\n총 {len(tables)}개 테이블 존재")
|
||||||
|
|
||||||
|
# users 테이블에 status 컬럼 추가 (필요한 경우)
|
||||||
|
add_missing_columns(cursor)
|
||||||
|
|
||||||
|
# admin 계정 생성
|
||||||
|
create_admin_user(cursor)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 테이블 생성 실패: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("🚀 누락된 테이블 생성 시작...")
|
||||||
|
success = create_missing_tables()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("✅ 모든 작업 완료!")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print("❌ 작업 실패!")
|
||||||
|
sys.exit(1)
|
||||||
25
backend/start.sh
Executable file
25
backend/start.sh
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "🚀 TK-MP Backend 시작 중..."
|
||||||
|
|
||||||
|
# 데이터베이스 연결 대기
|
||||||
|
echo "⏳ 데이터베이스 연결 대기 중..."
|
||||||
|
while ! nc -z tk-mp-postgres 5432; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "✅ 데이터베이스 연결 확인"
|
||||||
|
|
||||||
|
# 자동 마이그레이션 실행 (처음 설치 시에만)
|
||||||
|
echo "🔧 자동 마이그레이션 실행 중..."
|
||||||
|
python scripts/create_missing_tables.py
|
||||||
|
|
||||||
|
migration_result=$?
|
||||||
|
if [ $migration_result -eq 0 ]; then
|
||||||
|
echo "✅ 마이그레이션 완료"
|
||||||
|
else
|
||||||
|
echo "⚠️ 마이그레이션에 문제가 있었지만 서버를 시작합니다..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# FastAPI 서버 시작
|
||||||
|
echo "🌟 FastAPI 서버 시작..."
|
||||||
|
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
53
backend/tests/test_classifier_refactor.py
Normal file
53
backend/tests/test_classifier_refactor.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
|
||||||
|
import pytest
|
||||||
|
from app.services.integrated_classifier import classify_material_integrated
|
||||||
|
from app.services.fitting_classifier import classify_fitting
|
||||||
|
from app.services.classifier_constants import LEVEL1_TYPE_KEYWORDS
|
||||||
|
|
||||||
|
def test_classify_simple_pipe():
|
||||||
|
result = classify_material_integrated("PIPE, A106 Gr.B, 2 INCH")
|
||||||
|
# LEVEL1_TYPE_KEYWORDS["PIPE"] contains "PIPE"
|
||||||
|
assert result["category"] == "PIPE"
|
||||||
|
|
||||||
|
def test_classify_fitting_elbow():
|
||||||
|
result = classify_material_integrated("ELBOW 90DEG, BW")
|
||||||
|
# Should route to FITTING and then call fitting_classifier
|
||||||
|
assert result["category"] == "FITTING"
|
||||||
|
# detail check
|
||||||
|
if "fitting_type" in result:
|
||||||
|
assert result["fitting_type"]["type"] == "ELBOW"
|
||||||
|
|
||||||
|
def test_classify_swagelok_partno():
|
||||||
|
# Regex check in integrated_classifier
|
||||||
|
result = classify_material_integrated("SS-400-1-4 CONNECTOR")
|
||||||
|
# Should be detected by swagelok_pattern as TUBE_FITTING (Level 0)
|
||||||
|
assert result["category"] == "TUBE_FITTING"
|
||||||
|
|
||||||
|
def test_classify_swagelok_keyword():
|
||||||
|
# Keyword check
|
||||||
|
result = classify_material_integrated("SWAGELOK UNION 1/4 INCH")
|
||||||
|
# 'SWAGELOK' is in FITTING list in constants.
|
||||||
|
# So it should be FITTING?
|
||||||
|
# BUT integrated_classifier has logic: if detected_type == FITTING -> call classify_fitting
|
||||||
|
# classify_fitting checks 'SWAGELOK' -> sets category 'INSTRUMENT_FITTING'
|
||||||
|
|
||||||
|
# Let's see what meaningful category it returns.
|
||||||
|
# The return from classify_fitting overrides integrated result if present.
|
||||||
|
assert result["category"] in ["FITTING", "INSTRUMENT_FITTING"]
|
||||||
|
|
||||||
|
def test_classify_u_bolt():
|
||||||
|
# Priority check: U-BOLT is in BOLT keywords but integrated_classifier has early check for SUPPORT
|
||||||
|
result = classify_material_integrated("U-BOLT, 2 INCH")
|
||||||
|
assert result["category"] == "SUPPORT"
|
||||||
|
|
||||||
|
def test_classify_pressure_constants_usage():
|
||||||
|
# fitting_classifier uses imported constants
|
||||||
|
# Test if it recognizes 3000LB (from constants)
|
||||||
|
result = classify_fitting("P_DAT", "COUPLING, 3000LB, SW", "2")
|
||||||
|
assert result["pressure_rating"]["rating"] == "3000LB"
|
||||||
|
assert result["pressure_rating"]["confidence"] > 0.9
|
||||||
|
|
||||||
|
def test_classify_olet_constants_usage():
|
||||||
|
# Detect OLET
|
||||||
|
result = classify_fitting("P_DAT", "WELDOLET, 3000LB", "2", "1")
|
||||||
|
assert result["fitting_type"]["type"] == "OLET"
|
||||||
199
backend/tests/test_revision_comparison.py
Normal file
199
backend/tests/test_revision_comparison.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from app.services.revision_comparator import RevisionComparator
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_db():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def comparator(mock_db):
|
||||||
|
return RevisionComparator(mock_db)
|
||||||
|
|
||||||
|
def test_generate_material_hash(comparator):
|
||||||
|
"""해시 생성 로직 테스트"""
|
||||||
|
# 1. 기본 케이스
|
||||||
|
desc = "PIPE 100A SCH40"
|
||||||
|
size = "100A"
|
||||||
|
mat = "A105"
|
||||||
|
hash1 = comparator._generate_material_hash(desc, size, mat)
|
||||||
|
|
||||||
|
# 2. 공백/대소문자 차이 (정규화 확인)
|
||||||
|
hash2 = comparator._generate_material_hash(" pipe 100a sch40 ", "100A", "a105")
|
||||||
|
assert hash1 == hash2, "공백과 대소문자는 무시되어야 합니다."
|
||||||
|
|
||||||
|
# 3. None 값 처리 (Robustness)
|
||||||
|
hash3 = comparator._generate_material_hash(None, None, None)
|
||||||
|
assert isinstance(hash3, str)
|
||||||
|
assert len(hash3) == 32 # MD5 checking
|
||||||
|
|
||||||
|
# 4. 빈 문자열 처리
|
||||||
|
hash4 = comparator._generate_material_hash("", "", "")
|
||||||
|
assert hash3 == hash4, "None과 빈 문자열은 동일하게 처리되어야 합니다."
|
||||||
|
|
||||||
|
def test_extract_size_from_description(comparator):
|
||||||
|
"""사이즈 추출 로직 테스트"""
|
||||||
|
# 1. inch patterns
|
||||||
|
assert comparator._extract_size_from_description('PIPE 1/2" SCH40') == '1/2"'
|
||||||
|
assert comparator._extract_size_from_description('ELBOW 1.5inch 90D') == '1.5inch'
|
||||||
|
|
||||||
|
# 2. mm patterns
|
||||||
|
assert comparator._extract_size_from_description('Plate 100mm') == '100mm'
|
||||||
|
assert comparator._extract_size_from_description('Bar 50.5 MM') == '50.5 MM'
|
||||||
|
|
||||||
|
# 3. A patterns
|
||||||
|
assert comparator._extract_size_from_description('PIPE 100A') == '100A'
|
||||||
|
assert comparator._extract_size_from_description('FLANGE 50 A') == '50 A'
|
||||||
|
|
||||||
|
# 4. DN patterns
|
||||||
|
assert comparator._extract_size_from_description('VALVE DN100') == 'DN100'
|
||||||
|
|
||||||
|
# 5. Dimensions
|
||||||
|
assert comparator._extract_size_from_description('GASKET 10x20') == '10x20'
|
||||||
|
assert comparator._extract_size_from_description('SHEET 10*20') == '10*20'
|
||||||
|
|
||||||
|
# 6. No match
|
||||||
|
assert comparator._extract_size_from_description('Just Text') == ""
|
||||||
|
assert comparator._extract_size_from_description(None) == ""
|
||||||
|
|
||||||
|
def test_extract_material_from_description(comparator):
|
||||||
|
"""재질 추출 로직 테스트"""
|
||||||
|
# 1. Standard materials
|
||||||
|
assert comparator._extract_material_from_description('PIPE A106 Gr.B') == 'A106 Gr.B' # Should match longer first if implemented correctly
|
||||||
|
assert comparator._extract_material_from_description('FLANGE A105') == 'A105'
|
||||||
|
|
||||||
|
# 2. Stainless Steel
|
||||||
|
assert comparator._extract_material_from_description('PIPE SUS304L') == 'SUS304L'
|
||||||
|
assert comparator._extract_material_from_description('PIPE SS316') == 'SS316'
|
||||||
|
|
||||||
|
# 3. Case Insensitivity
|
||||||
|
assert comparator._extract_material_from_description('pipe sus316l') == 'SUS316L'
|
||||||
|
|
||||||
|
# 4. Partial matches (should prioritize specific)
|
||||||
|
# If "A106" is checked before "A106 Gr.B", it might return "A106".
|
||||||
|
# The implementation list order matters.
|
||||||
|
# In our impl: "A106 Gr.B" is before "A106", so it should work.
|
||||||
|
assert comparator._extract_material_from_description('Material A106 Gr.B Spec') == 'A106 Gr.B'
|
||||||
|
|
||||||
|
def test_compare_materials_logic_flow(comparator):
|
||||||
|
"""비교 로직 통합 테스트"""
|
||||||
|
previous_confirmed = {
|
||||||
|
"revision": "Rev.0",
|
||||||
|
"confirmed_at": "2024-01-01",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"specification": "PIPE 100A A106",
|
||||||
|
"size": "100A",
|
||||||
|
"material": "A106", # Extraction will find 'A106' from 'PIPE 100A A106'
|
||||||
|
"bom_quantity": 10.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Case 1: Identical (Normalized)
|
||||||
|
new_materials_same = [{"description": "pipe 100a", "quantity": 10.0}]
|
||||||
|
# Note: extraction logic needs to find size/mat from description to match hash.
|
||||||
|
# "pipe 100a" -> size="100A", mat="" (A106 not in desc)
|
||||||
|
# The hash will be MD5("pipe 100a|100a|")
|
||||||
|
# Previous hash was MD5("pipe 100a|100a|a106")
|
||||||
|
# They WON'T match if extraction fails to find "A106".
|
||||||
|
|
||||||
|
# Let's provide description that allows extraction to work fully
|
||||||
|
new_materials_full = [{"description": "PIPE 100A A106", "quantity": 10.0}]
|
||||||
|
|
||||||
|
result = comparator.compare_materials(previous_confirmed, new_materials_full)
|
||||||
|
assert result["unchanged_count"] == 1
|
||||||
|
|
||||||
|
# Case 2: Quantity Changed
|
||||||
|
new_materials_qty = [{"description": "PIPE 100A A106", "quantity": 20.0}]
|
||||||
|
result = comparator.compare_materials(previous_confirmed, new_materials_qty)
|
||||||
|
assert result["changed_count"] == 1
|
||||||
|
assert result["changed_materials"][0]["change_type"] == "QUANTITY_CHANGED"
|
||||||
|
|
||||||
|
# Case 3: New Item
|
||||||
|
new_materials_new = [{"description": "NEW ITEM SUS304", "quantity": 1.0}]
|
||||||
|
result = comparator.compare_materials(previous_confirmed, new_materials_new)
|
||||||
|
assert result["new_count"] == 1
|
||||||
|
|
||||||
|
def test_compare_materials_fuzzy_match(comparator):
|
||||||
|
"""Fuzzy Matching 테스트"""
|
||||||
|
previous_confirmed = {
|
||||||
|
"revision": "Rev.0",
|
||||||
|
"confirmed_at": "2024-01-01",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"specification": "PIPE 100A SCH40 A106",
|
||||||
|
"size": "100A",
|
||||||
|
"material": "A106",
|
||||||
|
"bom_quantity": 10.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# 오타가 포함된 유사 자재 (PIPEE -> PIPE, A106 -> A106)
|
||||||
|
# 정규화/해시는 다르지만 텍스트 유사도는 높음
|
||||||
|
new_materials_typo = [{
|
||||||
|
"description": "PIPEE 100A SCH40 A106",
|
||||||
|
"quantity": 10.0
|
||||||
|
}]
|
||||||
|
|
||||||
|
# RapidFuzz가 설치되어 있어야 동작
|
||||||
|
try:
|
||||||
|
import rapidfuzz
|
||||||
|
result = comparator.compare_materials(previous_confirmed, new_materials_typo)
|
||||||
|
|
||||||
|
# 해시는 다르므로 new_count에 포함되거나 유사 자재로 분류됨
|
||||||
|
# 구현에 따라 "new_materials" 리스트에 "change_type": "NEW_BUT_SIMILAR" 로 들어감
|
||||||
|
assert result["new_count"] == 1
|
||||||
|
new_item = result["new_materials"][0]
|
||||||
|
assert new_item["change_type"] == "NEW_BUT_SIMILAR"
|
||||||
|
assert new_item["similarity_score"] > 85
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("rapidfuzz not installed")
|
||||||
|
|
||||||
|
def test_extract_size_word_boundary(comparator):
|
||||||
|
"""Regex Word Boundary 테스트"""
|
||||||
|
# 1. mm boundary check
|
||||||
|
# "100mm" -> ok, "100mm2" -> fail if boundary used
|
||||||
|
assert comparator._extract_size_from_description("100mm") == "100mm"
|
||||||
|
# assert comparator._extract_size_from_description("100mmm") == "" # This depends on implementation strictness
|
||||||
|
|
||||||
|
# 2. inch boundary
|
||||||
|
assert comparator._extract_size_from_description("1/2 inch") == "1/2 inch"
|
||||||
|
|
||||||
|
# 3. DN boundary
|
||||||
|
assert comparator._extract_size_from_description("DN100") == "DN100"
|
||||||
|
# "DN100A" should ideally not match DN100 if we want strictness, or it might match 100A.
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_material_loading(comparator, mock_db):
|
||||||
|
"""DB 기반 동적 자재 로딩 테스트"""
|
||||||
|
# Mocking DB result
|
||||||
|
# fetchall returns list of rows (tuples)
|
||||||
|
mock_db.execute.return_value.fetchall.return_value = [
|
||||||
|
("TITANIUM_GR2",),
|
||||||
|
("INCONEL625",),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 1. Check if DB loaded materials are correctly extracted
|
||||||
|
# Test with a material that is ONLY in the mocked DB response, not in default list
|
||||||
|
description = "PIPE 100A TITANIUM_GR2"
|
||||||
|
material = comparator._extract_material_from_description(description)
|
||||||
|
assert material == "TITANIUM_GR2"
|
||||||
|
|
||||||
|
# Verify DB execute was called
|
||||||
|
assert mock_db.execute.called
|
||||||
|
|
||||||
|
# 2. Test fallback mechanism (simulate DB connection failure)
|
||||||
|
# mock_db.execute.side_effect = Exception("DB Connection Error")
|
||||||
|
# Reset mock to raise exception on next call
|
||||||
|
mock_db.execute.side_effect = Exception("DB Fail")
|
||||||
|
|
||||||
|
# SUS316L is in the default fallback list
|
||||||
|
description_fallback = "PIPE 100A SUS316L"
|
||||||
|
material_fallback = comparator._extract_material_from_description(description_fallback)
|
||||||
|
|
||||||
|
# Should still work using default list
|
||||||
|
assert material_fallback == "SUS316L"
|
||||||
|
|
||||||
620
current_schema.txt
Normal file
620
current_schema.txt
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
table_name | column_name | data_type | is_nullable | column_default
|
||||||
|
-------------------------------+----------------------------+-----------------------------+-------------+--------------------------------------------------------------
|
||||||
|
bolt_details | id | integer | NO | nextval('bolt_details_id_seq'::regclass)
|
||||||
|
bolt_details | material_id | integer | YES |
|
||||||
|
bolt_details | file_id | integer | YES |
|
||||||
|
bolt_details | bolt_type | character varying | YES |
|
||||||
|
bolt_details | thread_type | character varying | YES |
|
||||||
|
bolt_details | diameter | character varying | YES |
|
||||||
|
bolt_details | length | character varying | YES |
|
||||||
|
bolt_details | material_standard | character varying | YES |
|
||||||
|
bolt_details | material_grade | character varying | YES |
|
||||||
|
bolt_details | coating_type | character varying | YES |
|
||||||
|
bolt_details | pressure_rating | character varying | YES |
|
||||||
|
bolt_details | includes_nut | boolean | YES |
|
||||||
|
bolt_details | includes_washer | boolean | YES |
|
||||||
|
bolt_details | nut_type | character varying | YES |
|
||||||
|
bolt_details | washer_type | character varying | YES |
|
||||||
|
bolt_details | classification_confidence | double precision | YES |
|
||||||
|
bolt_details | additional_info | jsonb | YES |
|
||||||
|
bolt_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
bolt_details | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
confirmed_purchase_items | id | integer | NO | nextval('confirmed_purchase_items_id_seq'::regclass)
|
||||||
|
confirmed_purchase_items | confirmation_id | integer | YES |
|
||||||
|
confirmed_purchase_items | item_code | character varying | NO |
|
||||||
|
confirmed_purchase_items | category | character varying | NO |
|
||||||
|
confirmed_purchase_items | specification | text | YES |
|
||||||
|
confirmed_purchase_items | size | character varying | YES |
|
||||||
|
confirmed_purchase_items | material | character varying | YES |
|
||||||
|
confirmed_purchase_items | bom_quantity | numeric | NO | 0
|
||||||
|
confirmed_purchase_items | calculated_qty | numeric | NO | 0
|
||||||
|
confirmed_purchase_items | unit | character varying | NO | 'EA'::character varying
|
||||||
|
confirmed_purchase_items | safety_factor | numeric | NO | 1.0
|
||||||
|
confirmed_purchase_items | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
files | id | integer | NO | nextval('files_id_seq'::regclass)
|
||||||
|
files | project_id | integer | YES |
|
||||||
|
files | filename | character varying | NO |
|
||||||
|
files | original_filename | character varying | NO |
|
||||||
|
files | file_path | character varying | NO |
|
||||||
|
files | revision | character varying | YES | 'Rev.0'::character varying
|
||||||
|
files | upload_date | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
files | uploaded_by | character varying | YES |
|
||||||
|
files | file_type | character varying | YES |
|
||||||
|
files | file_size | integer | YES |
|
||||||
|
files | is_active | boolean | YES | true
|
||||||
|
files | purchase_confirmed | boolean | YES | false
|
||||||
|
files | confirmed_at | timestamp without time zone | YES |
|
||||||
|
files | confirmed_by | character varying | YES |
|
||||||
|
files | job_no | character varying | YES |
|
||||||
|
files | bom_name | character varying | YES |
|
||||||
|
files | description | text | YES |
|
||||||
|
files | parsed_count | integer | YES | 0
|
||||||
|
fitting_details | id | integer | NO | nextval('fitting_details_id_seq'::regclass)
|
||||||
|
fitting_details | material_id | integer | YES |
|
||||||
|
fitting_details | file_id | integer | YES |
|
||||||
|
fitting_details | fitting_type | character varying | YES |
|
||||||
|
fitting_details | fitting_subtype | character varying | YES |
|
||||||
|
fitting_details | connection_method | character varying | YES |
|
||||||
|
fitting_details | connection_code | character varying | YES |
|
||||||
|
fitting_details | pressure_rating | character varying | YES |
|
||||||
|
fitting_details | max_pressure | character varying | YES |
|
||||||
|
fitting_details | manufacturing_method | character varying | YES |
|
||||||
|
fitting_details | material_standard | character varying | YES |
|
||||||
|
fitting_details | material_grade | character varying | YES |
|
||||||
|
fitting_details | material_type | character varying | YES |
|
||||||
|
fitting_details | main_size | character varying | YES |
|
||||||
|
fitting_details | reduced_size | character varying | YES |
|
||||||
|
fitting_details | length_mm | numeric | YES |
|
||||||
|
fitting_details | schedule | character varying | YES |
|
||||||
|
fitting_details | classification_confidence | double precision | YES |
|
||||||
|
fitting_details | additional_info | jsonb | YES |
|
||||||
|
fitting_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
fitting_details | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
flange_details | id | integer | NO | nextval('flange_details_id_seq'::regclass)
|
||||||
|
flange_details | material_id | integer | YES |
|
||||||
|
flange_details | file_id | integer | YES |
|
||||||
|
flange_details | flange_type | character varying | YES |
|
||||||
|
flange_details | facing_type | character varying | YES |
|
||||||
|
flange_details | pressure_rating | character varying | YES |
|
||||||
|
flange_details | material_standard | character varying | YES |
|
||||||
|
flange_details | material_grade | character varying | YES |
|
||||||
|
flange_details | size_inches | character varying | YES |
|
||||||
|
flange_details | bolt_hole_count | integer | YES |
|
||||||
|
flange_details | bolt_hole_size | character varying | YES |
|
||||||
|
flange_details | classification_confidence | double precision | YES |
|
||||||
|
flange_details | additional_info | jsonb | YES |
|
||||||
|
flange_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
flange_details | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
gasket_details | id | integer | NO | nextval('gasket_details_id_seq'::regclass)
|
||||||
|
gasket_details | material_id | integer | YES |
|
||||||
|
gasket_details | file_id | integer | YES |
|
||||||
|
gasket_details | gasket_type | character varying | YES |
|
||||||
|
gasket_details | gasket_subtype | character varying | YES |
|
||||||
|
gasket_details | material_type | character varying | YES |
|
||||||
|
gasket_details | filler_material | character varying | YES |
|
||||||
|
gasket_details | size_inches | character varying | YES |
|
||||||
|
gasket_details | pressure_rating | character varying | YES |
|
||||||
|
gasket_details | thickness | character varying | YES |
|
||||||
|
gasket_details | temperature_range | character varying | YES |
|
||||||
|
gasket_details | fire_safe | boolean | YES |
|
||||||
|
gasket_details | classification_confidence | double precision | YES |
|
||||||
|
gasket_details | additional_info | jsonb | YES |
|
||||||
|
gasket_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
gasket_details | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
instrument_details | id | integer | NO | nextval('instrument_details_id_seq'::regclass)
|
||||||
|
instrument_details | material_id | integer | YES |
|
||||||
|
instrument_details | file_id | integer | YES |
|
||||||
|
instrument_details | instrument_type | character varying | YES |
|
||||||
|
instrument_details | instrument_subtype | character varying | YES |
|
||||||
|
instrument_details | measurement_type | character varying | YES |
|
||||||
|
instrument_details | measurement_range | character varying | YES |
|
||||||
|
instrument_details | accuracy | character varying | YES |
|
||||||
|
instrument_details | connection_type | character varying | YES |
|
||||||
|
instrument_details | connection_size | character varying | YES |
|
||||||
|
instrument_details | body_material | character varying | YES |
|
||||||
|
instrument_details | wetted_parts_material | character varying | YES |
|
||||||
|
instrument_details | electrical_rating | character varying | YES |
|
||||||
|
instrument_details | output_signal | character varying | YES |
|
||||||
|
instrument_details | classification_confidence | double precision | YES |
|
||||||
|
instrument_details | additional_info | jsonb | YES |
|
||||||
|
instrument_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
instrument_details | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
jobs | job_no | character varying | NO |
|
||||||
|
jobs | job_name | character varying | NO |
|
||||||
|
jobs | client_name | character varying | NO |
|
||||||
|
jobs | end_user | character varying | YES |
|
||||||
|
jobs | epc_company | character varying | YES |
|
||||||
|
jobs | project_site | character varying | YES |
|
||||||
|
jobs | contract_date | date | YES |
|
||||||
|
jobs | delivery_date | date | YES |
|
||||||
|
jobs | delivery_terms | character varying | YES |
|
||||||
|
jobs | status | character varying | YES | '진행중'::character varying
|
||||||
|
jobs | delivery_completed_date | date | YES |
|
||||||
|
jobs | project_closed_date | date | YES |
|
||||||
|
jobs | description | text | YES |
|
||||||
|
jobs | created_by | character varying | YES |
|
||||||
|
jobs | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
jobs | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
jobs | is_active | boolean | YES | true
|
||||||
|
jobs | updated_by | character varying | YES |
|
||||||
|
jobs | assigned_to | character varying | YES |
|
||||||
|
jobs | project_type | character varying | NO | '냉동기'::character varying
|
||||||
|
login_logs | log_id | integer | NO | nextval('login_logs_log_id_seq'::regclass)
|
||||||
|
login_logs | user_id | integer | YES |
|
||||||
|
login_logs | login_time | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
login_logs | ip_address | character varying | YES |
|
||||||
|
login_logs | user_agent | text | YES |
|
||||||
|
login_logs | login_status | character varying | YES |
|
||||||
|
login_logs | failure_reason | character varying | YES |
|
||||||
|
login_logs | session_duration | integer | YES |
|
||||||
|
login_logs | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
material_categories | id | integer | NO | nextval('material_categories_id_seq'::regclass)
|
||||||
|
material_categories | standard_id | integer | YES |
|
||||||
|
material_categories | category_code | character varying | NO |
|
||||||
|
material_categories | category_name | character varying | NO |
|
||||||
|
material_categories | description | text | YES |
|
||||||
|
material_categories | is_active | boolean | YES | true
|
||||||
|
material_categories | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
material_categories | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
material_comparison_details | id | integer | NO | nextval('material_comparison_details_id_seq'::regclass)
|
||||||
|
material_comparison_details | comparison_id | integer | NO |
|
||||||
|
material_comparison_details | material_hash | character varying | NO |
|
||||||
|
material_comparison_details | change_type | character varying | NO |
|
||||||
|
material_comparison_details | description | text | NO |
|
||||||
|
material_comparison_details | size_spec | character varying | YES |
|
||||||
|
material_comparison_details | material_grade | character varying | YES |
|
||||||
|
material_comparison_details | previous_quantity | numeric | YES | 0
|
||||||
|
material_comparison_details | current_quantity | numeric | YES | 0
|
||||||
|
material_comparison_details | quantity_diff | numeric | YES | 0
|
||||||
|
material_comparison_details | additional_purchase_needed | numeric | YES | 0
|
||||||
|
material_comparison_details | classified_category | character varying | YES |
|
||||||
|
material_comparison_details | classification_confidence | numeric | YES |
|
||||||
|
material_comparison_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
material_grades | id | integer | NO | nextval('material_grades_id_seq'::regclass)
|
||||||
|
material_grades | specification_id | integer | YES |
|
||||||
|
material_grades | grade_code | character varying | NO |
|
||||||
|
material_grades | grade_name | character varying | YES |
|
||||||
|
material_grades | composition | character varying | YES |
|
||||||
|
material_grades | applications | character varying | YES |
|
||||||
|
material_grades | temp_max | character varying | YES |
|
||||||
|
material_grades | temp_range | character varying | YES |
|
||||||
|
material_grades | yield_strength | character varying | YES |
|
||||||
|
material_grades | tensile_strength | character varying | YES |
|
||||||
|
material_grades | corrosion_resistance | character varying | YES |
|
||||||
|
material_grades | stabilizer | character varying | YES |
|
||||||
|
material_grades | base_grade | character varying | YES |
|
||||||
|
material_grades | is_active | boolean | YES | true
|
||||||
|
material_grades | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
material_grades | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
material_patterns | id | integer | NO | nextval('material_patterns_id_seq'::regclass)
|
||||||
|
material_patterns | specification_id | integer | YES |
|
||||||
|
material_patterns | pattern | text | NO |
|
||||||
|
material_patterns | description | character varying | YES |
|
||||||
|
material_patterns | priority | integer | YES | 1
|
||||||
|
material_patterns | is_active | boolean | YES | true
|
||||||
|
material_patterns | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
material_patterns | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
material_purchase_mapping | id | integer | NO | nextval('material_purchase_mapping_id_seq'::regclass)
|
||||||
|
material_purchase_mapping | material_id | integer | NO |
|
||||||
|
material_purchase_mapping | purchase_item_id | integer | NO |
|
||||||
|
material_purchase_mapping | quantity_ratio | numeric | YES | 1.0
|
||||||
|
material_purchase_mapping | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
material_purchase_tracking | id | integer | NO | nextval('material_purchase_tracking_id_seq'::regclass)
|
||||||
|
material_purchase_tracking | material_hash | character varying | NO |
|
||||||
|
material_purchase_tracking | original_description | text | NO |
|
||||||
|
material_purchase_tracking | size_spec | character varying | YES |
|
||||||
|
material_purchase_tracking | material_grade | character varying | YES |
|
||||||
|
material_purchase_tracking | bom_quantity | numeric | NO |
|
||||||
|
material_purchase_tracking | confirmed_quantity | numeric | YES |
|
||||||
|
material_purchase_tracking | purchase_quantity | numeric | YES |
|
||||||
|
material_purchase_tracking | status | character varying | YES | 'pending'::character varying
|
||||||
|
material_purchase_tracking | confirmed_by | character varying | YES |
|
||||||
|
material_purchase_tracking | confirmed_at | timestamp without time zone | YES |
|
||||||
|
material_purchase_tracking | ordered_by | character varying | YES |
|
||||||
|
material_purchase_tracking | ordered_at | timestamp without time zone | YES |
|
||||||
|
material_purchase_tracking | approved_by | character varying | YES |
|
||||||
|
material_purchase_tracking | approved_at | timestamp without time zone | YES |
|
||||||
|
material_purchase_tracking | job_no | character varying | YES |
|
||||||
|
material_purchase_tracking | revision | character varying | YES |
|
||||||
|
material_purchase_tracking | file_id | integer | YES |
|
||||||
|
material_purchase_tracking | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
material_purchase_tracking | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
material_revisions_comparison | id | integer | NO | nextval('material_revisions_comparison_id_seq'::regclass)
|
||||||
|
material_revisions_comparison | job_no | character varying | NO |
|
||||||
|
material_revisions_comparison | current_revision | character varying | NO |
|
||||||
|
material_revisions_comparison | previous_revision | character varying | NO |
|
||||||
|
material_revisions_comparison | current_file_id | integer | NO |
|
||||||
|
material_revisions_comparison | previous_file_id | integer | NO |
|
||||||
|
material_revisions_comparison | total_current_items | integer | YES | 0
|
||||||
|
material_revisions_comparison | total_previous_items | integer | YES | 0
|
||||||
|
material_revisions_comparison | new_items_count | integer | YES | 0
|
||||||
|
material_revisions_comparison | modified_items_count | integer | YES | 0
|
||||||
|
material_revisions_comparison | removed_items_count | integer | YES | 0
|
||||||
|
material_revisions_comparison | unchanged_items_count | integer | YES | 0
|
||||||
|
material_revisions_comparison | comparison_details | jsonb | YES |
|
||||||
|
material_revisions_comparison | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
material_revisions_comparison | created_by | character varying | YES |
|
||||||
|
material_specifications | id | integer | NO | nextval('material_specifications_id_seq'::regclass)
|
||||||
|
material_specifications | category_id | integer | YES |
|
||||||
|
material_specifications | spec_code | character varying | NO |
|
||||||
|
material_specifications | spec_name | character varying | NO |
|
||||||
|
material_specifications | description | text | YES |
|
||||||
|
material_specifications | material_type | character varying | YES |
|
||||||
|
material_specifications | manufacturing | character varying | YES |
|
||||||
|
material_specifications | pressure_rating | character varying | YES |
|
||||||
|
material_specifications | is_active | boolean | YES | true
|
||||||
|
material_specifications | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
material_specifications | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
material_standards | id | integer | NO | nextval('material_standards_id_seq'::regclass)
|
||||||
|
material_standards | standard_code | character varying | NO |
|
||||||
|
material_standards | standard_name | character varying | NO |
|
||||||
|
material_standards | description | text | YES |
|
||||||
|
material_standards | country | character varying | YES |
|
||||||
|
material_standards | is_active | boolean | YES | true
|
||||||
|
material_standards | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
material_standards | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
material_tubing_mapping | id | integer | NO | nextval('material_tubing_mapping_id_seq'::regclass)
|
||||||
|
material_tubing_mapping | material_id | integer | YES |
|
||||||
|
material_tubing_mapping | tubing_product_id | integer | YES |
|
||||||
|
material_tubing_mapping | confidence_score | numeric | YES |
|
||||||
|
material_tubing_mapping | mapping_method | character varying | YES |
|
||||||
|
material_tubing_mapping | mapped_by | character varying | YES |
|
||||||
|
material_tubing_mapping | mapped_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
material_tubing_mapping | required_length_m | numeric | YES |
|
||||||
|
material_tubing_mapping | calculated_quantity | numeric | YES |
|
||||||
|
material_tubing_mapping | is_verified | boolean | YES | false
|
||||||
|
material_tubing_mapping | verified_by | character varying | YES |
|
||||||
|
material_tubing_mapping | verified_at | timestamp without time zone | YES |
|
||||||
|
material_tubing_mapping | notes | text | YES |
|
||||||
|
material_tubing_mapping | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
materials | id | integer | NO | nextval('materials_id_seq'::regclass)
|
||||||
|
materials | file_id | integer | YES |
|
||||||
|
materials | line_number | integer | YES |
|
||||||
|
materials | original_description | text | NO |
|
||||||
|
materials | classified_category | character varying | YES |
|
||||||
|
materials | classified_subcategory | character varying | YES |
|
||||||
|
materials | material_grade | character varying | YES |
|
||||||
|
materials | schedule | character varying | YES |
|
||||||
|
materials | size_spec | character varying | YES |
|
||||||
|
materials | quantity | numeric | NO |
|
||||||
|
materials | unit | character varying | NO |
|
||||||
|
materials | drawing_name | character varying | YES |
|
||||||
|
materials | area_code | character varying | YES |
|
||||||
|
materials | line_no | character varying | YES |
|
||||||
|
materials | classification_confidence | numeric | YES |
|
||||||
|
materials | classification_details | jsonb | YES |
|
||||||
|
materials | is_verified | boolean | YES | false
|
||||||
|
materials | verified_by | character varying | YES |
|
||||||
|
materials | verified_at | timestamp without time zone | YES |
|
||||||
|
materials | drawing_reference | character varying | YES |
|
||||||
|
materials | notes | text | YES |
|
||||||
|
materials | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
materials | main_nom | character varying | YES |
|
||||||
|
materials | red_nom | character varying | YES |
|
||||||
|
materials | full_material_grade | text | YES |
|
||||||
|
materials | row_number | integer | YES |
|
||||||
|
materials | length | numeric | YES |
|
||||||
|
materials | purchase_confirmed | boolean | YES | false
|
||||||
|
materials | confirmed_quantity | numeric | YES |
|
||||||
|
materials | purchase_status | character varying | YES |
|
||||||
|
materials | purchase_confirmed_by | character varying | YES |
|
||||||
|
materials | purchase_confirmed_at | timestamp without time zone | YES |
|
||||||
|
materials | revision_status | character varying | YES |
|
||||||
|
materials | material_hash | character varying | YES |
|
||||||
|
materials | normalized_description | text | YES |
|
||||||
|
materials | brand | character varying | YES |
|
||||||
|
materials | user_requirement | text | YES |
|
||||||
|
materials | is_active | boolean | YES | true
|
||||||
|
materials | total_length | numeric | YES |
|
||||||
|
permissions | permission_id | integer | NO | nextval('permissions_permission_id_seq'::regclass)
|
||||||
|
permissions | permission_name | character varying | NO |
|
||||||
|
permissions | description | text | YES |
|
||||||
|
permissions | module | character varying | YES |
|
||||||
|
permissions | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
pipe_details | id | integer | NO | nextval('pipe_details_id_seq'::regclass)
|
||||||
|
pipe_details | material_id | integer | YES |
|
||||||
|
pipe_details | file_id | integer | YES |
|
||||||
|
pipe_details | outer_diameter | character varying | YES |
|
||||||
|
pipe_details | schedule | character varying | YES |
|
||||||
|
pipe_details | material_spec | character varying | YES |
|
||||||
|
pipe_details | manufacturing_method | character varying | YES |
|
||||||
|
pipe_details | end_preparation | character varying | YES |
|
||||||
|
pipe_details | length_mm | numeric | YES |
|
||||||
|
pipe_details | classification_confidence | double precision | YES |
|
||||||
|
pipe_details | additional_info | jsonb | YES |
|
||||||
|
pipe_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
pipe_details | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
pipe_end_preparations | id | integer | NO | nextval('pipe_end_preparations_id_seq'::regclass)
|
||||||
|
pipe_end_preparations | material_id | integer | NO |
|
||||||
|
pipe_end_preparations | file_id | integer | NO |
|
||||||
|
pipe_end_preparations | end_preparation_type | character varying | YES | 'PBE'::character varying
|
||||||
|
pipe_end_preparations | end_preparation_code | character varying | YES |
|
||||||
|
pipe_end_preparations | machining_required | boolean | YES | false
|
||||||
|
pipe_end_preparations | cutting_note | text | YES |
|
||||||
|
pipe_end_preparations | original_description | text | NO |
|
||||||
|
pipe_end_preparations | clean_description | text | NO |
|
||||||
|
pipe_end_preparations | confidence | double precision | YES | 0.0
|
||||||
|
pipe_end_preparations | matched_pattern | character varying | YES |
|
||||||
|
pipe_end_preparations | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
pipe_end_preparations | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
projects | id | integer | NO | nextval('projects_id_seq'::regclass)
|
||||||
|
projects | official_project_code | character varying | YES |
|
||||||
|
projects | project_name | character varying | NO |
|
||||||
|
projects | client_name | character varying | YES |
|
||||||
|
projects | design_project_code | character varying | YES |
|
||||||
|
projects | design_project_name | character varying | YES |
|
||||||
|
projects | is_code_matched | boolean | YES | false
|
||||||
|
projects | matched_by | character varying | YES |
|
||||||
|
projects | matched_at | timestamp without time zone | YES |
|
||||||
|
projects | status | character varying | YES | 'active'::character varying
|
||||||
|
projects | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
projects | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
projects | description | text | YES |
|
||||||
|
projects | notes | text | YES |
|
||||||
|
purchase_confirmations | id | integer | NO | nextval('purchase_confirmations_id_seq'::regclass)
|
||||||
|
purchase_confirmations | job_no | character varying | NO |
|
||||||
|
purchase_confirmations | file_id | integer | YES |
|
||||||
|
purchase_confirmations | bom_name | character varying | NO |
|
||||||
|
purchase_confirmations | revision | character varying | NO | 'Rev.0'::character varying
|
||||||
|
purchase_confirmations | confirmed_at | timestamp without time zone | NO |
|
||||||
|
purchase_confirmations | confirmed_by | character varying | NO |
|
||||||
|
purchase_confirmations | is_active | boolean | NO | true
|
||||||
|
purchase_confirmations | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
purchase_confirmations | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
purchase_items | id | integer | NO | nextval('purchase_items_id_seq'::regclass)
|
||||||
|
purchase_items | item_code | character varying | NO |
|
||||||
|
purchase_items | category | character varying | NO |
|
||||||
|
purchase_items | specification | text | NO |
|
||||||
|
purchase_items | material_spec | character varying | YES |
|
||||||
|
purchase_items | size_spec | character varying | YES |
|
||||||
|
purchase_items | unit | character varying | NO |
|
||||||
|
purchase_items | bom_quantity | numeric | NO |
|
||||||
|
purchase_items | safety_factor | numeric | YES | 1.10
|
||||||
|
purchase_items | minimum_order_qty | numeric | YES | 0
|
||||||
|
purchase_items | order_unit_qty | numeric | YES | 1
|
||||||
|
purchase_items | calculated_qty | numeric | YES |
|
||||||
|
purchase_items | cutting_loss | numeric | YES | 0
|
||||||
|
purchase_items | standard_length | numeric | YES |
|
||||||
|
purchase_items | pipes_count | integer | YES |
|
||||||
|
purchase_items | waste_length | numeric | YES |
|
||||||
|
purchase_items | detailed_spec | jsonb | YES |
|
||||||
|
purchase_items | preferred_supplier | character varying | YES |
|
||||||
|
purchase_items | last_unit_price | numeric | YES |
|
||||||
|
purchase_items | currency | character varying | YES | 'KRW'::character varying
|
||||||
|
purchase_items | lead_time_days | integer | YES | 30
|
||||||
|
purchase_items | job_no | character varying | NO |
|
||||||
|
purchase_items | revision | character varying | YES | 'Rev.0'::character varying
|
||||||
|
purchase_items | file_id | integer | YES |
|
||||||
|
purchase_items | is_active | boolean | YES | true
|
||||||
|
purchase_items | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
purchase_items | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
purchase_items | created_by | character varying | YES |
|
||||||
|
purchase_items | updated_by | character varying | YES |
|
||||||
|
purchase_items | approved_by | character varying | YES |
|
||||||
|
purchase_items | approved_at | timestamp without time zone | YES |
|
||||||
|
purchase_request_items | item_id | integer | NO | nextval('purchase_request_items_item_id_seq'::regclass)
|
||||||
|
purchase_request_items | request_id | integer | YES |
|
||||||
|
purchase_request_items | material_id | integer | YES |
|
||||||
|
purchase_request_items | description | text | NO |
|
||||||
|
purchase_request_items | category | character varying | YES |
|
||||||
|
purchase_request_items | subcategory | character varying | YES |
|
||||||
|
purchase_request_items | material_grade | character varying | YES |
|
||||||
|
purchase_request_items | size_spec | character varying | YES |
|
||||||
|
purchase_request_items | quantity | numeric | NO |
|
||||||
|
purchase_request_items | unit | character varying | NO |
|
||||||
|
purchase_request_items | drawing_name | character varying | YES |
|
||||||
|
purchase_request_items | notes | text | YES |
|
||||||
|
purchase_request_items | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
purchase_requests | request_id | integer | NO | nextval('purchase_requests_request_id_seq'::regclass)
|
||||||
|
purchase_requests | request_no | character varying | NO |
|
||||||
|
purchase_requests | job_no | character varying | NO |
|
||||||
|
purchase_requests | project_name | character varying | YES |
|
||||||
|
purchase_requests | requested_by | integer | YES |
|
||||||
|
purchase_requests | requested_by_username | character varying | YES |
|
||||||
|
purchase_requests | request_date | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
purchase_requests | status | character varying | YES | 'pending'::character varying
|
||||||
|
purchase_requests | total_items | integer | YES | 0
|
||||||
|
purchase_requests | notes | text | YES |
|
||||||
|
purchase_requests | approved_by | integer | YES |
|
||||||
|
purchase_requests | approved_by_username | character varying | YES |
|
||||||
|
purchase_requests | approved_at | timestamp without time zone | YES |
|
||||||
|
purchase_requests | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
purchase_requests | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
purchase_requests | file_id | integer | YES |
|
||||||
|
requirement_types | id | integer | NO | nextval('requirement_types_id_seq'::regclass)
|
||||||
|
requirement_types | type_code | character varying | NO |
|
||||||
|
requirement_types | type_name | character varying | NO |
|
||||||
|
requirement_types | category | character varying | NO |
|
||||||
|
requirement_types | description | text | YES |
|
||||||
|
requirement_types | is_active | boolean | YES | true
|
||||||
|
requirement_types | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
role_permissions | role_permission_id | integer | NO | nextval('role_permissions_role_permission_id_seq'::regclass)
|
||||||
|
role_permissions | role | character varying | NO |
|
||||||
|
role_permissions | permission_id | integer | YES |
|
||||||
|
role_permissions | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
special_material_details | id | integer | NO | nextval('special_material_details_id_seq'::regclass)
|
||||||
|
special_material_details | material_id | integer | YES |
|
||||||
|
special_material_details | file_id | integer | YES |
|
||||||
|
special_material_details | special_type | character varying | YES |
|
||||||
|
special_material_details | special_subtype | character varying | YES |
|
||||||
|
special_material_details | material_standard | character varying | YES |
|
||||||
|
special_material_details | material_grade | character varying | YES |
|
||||||
|
special_material_details | specifications | text | YES |
|
||||||
|
special_material_details | dimensions | character varying | YES |
|
||||||
|
special_material_details | weight_kg | numeric | YES |
|
||||||
|
special_material_details | classification_confidence | numeric | YES |
|
||||||
|
special_material_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
special_material_details | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
special_material_grades | id | integer | NO | nextval('special_material_grades_id_seq'::regclass)
|
||||||
|
special_material_grades | material_id | integer | YES |
|
||||||
|
special_material_grades | grade_code | character varying | NO |
|
||||||
|
special_material_grades | composition | character varying | YES |
|
||||||
|
special_material_grades | applications | character varying | YES |
|
||||||
|
special_material_grades | temp_max | character varying | YES |
|
||||||
|
special_material_grades | strength | character varying | YES |
|
||||||
|
special_material_grades | purity | character varying | YES |
|
||||||
|
special_material_grades | corrosion | character varying | YES |
|
||||||
|
special_material_grades | is_active | boolean | YES | true
|
||||||
|
special_material_grades | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
special_material_grades | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
special_material_patterns | id | integer | NO | nextval('special_material_patterns_id_seq'::regclass)
|
||||||
|
special_material_patterns | material_id | integer | YES |
|
||||||
|
special_material_patterns | pattern | text | NO |
|
||||||
|
special_material_patterns | description | character varying | YES |
|
||||||
|
special_material_patterns | priority | integer | YES | 1
|
||||||
|
special_material_patterns | is_active | boolean | YES | true
|
||||||
|
special_material_patterns | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
special_material_patterns | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
special_materials | id | integer | NO | nextval('special_materials_id_seq'::regclass)
|
||||||
|
special_materials | material_type | character varying | NO |
|
||||||
|
special_materials | material_name | character varying | NO |
|
||||||
|
special_materials | description | text | YES |
|
||||||
|
special_materials | composition | character varying | YES |
|
||||||
|
special_materials | applications | text | YES |
|
||||||
|
special_materials | temp_max | character varying | YES |
|
||||||
|
special_materials | manufacturing | character varying | YES |
|
||||||
|
special_materials | is_active | boolean | YES | true
|
||||||
|
special_materials | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
special_materials | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
support_details | id | integer | NO | nextval('support_details_id_seq'::regclass)
|
||||||
|
support_details | material_id | integer | YES |
|
||||||
|
support_details | file_id | integer | YES |
|
||||||
|
support_details | support_type | character varying | YES |
|
||||||
|
support_details | support_subtype | character varying | YES |
|
||||||
|
support_details | load_rating | character varying | YES |
|
||||||
|
support_details | load_capacity | character varying | YES |
|
||||||
|
support_details | material_standard | character varying | YES |
|
||||||
|
support_details | material_grade | character varying | YES |
|
||||||
|
support_details | pipe_size | character varying | YES |
|
||||||
|
support_details | length_mm | numeric | YES |
|
||||||
|
support_details | width_mm | numeric | YES |
|
||||||
|
support_details | height_mm | numeric | YES |
|
||||||
|
support_details | classification_confidence | numeric | YES |
|
||||||
|
support_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
support_details | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
tubing_categories | id | integer | NO | nextval('tubing_categories_id_seq'::regclass)
|
||||||
|
tubing_categories | category_code | character varying | NO |
|
||||||
|
tubing_categories | category_name | character varying | NO |
|
||||||
|
tubing_categories | description | text | YES |
|
||||||
|
tubing_categories | is_active | boolean | YES | true
|
||||||
|
tubing_categories | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
tubing_categories | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
tubing_manufacturers | id | integer | NO | nextval('tubing_manufacturers_id_seq'::regclass)
|
||||||
|
tubing_manufacturers | manufacturer_code | character varying | NO |
|
||||||
|
tubing_manufacturers | manufacturer_name | character varying | NO |
|
||||||
|
tubing_manufacturers | country | character varying | YES |
|
||||||
|
tubing_manufacturers | website | character varying | YES |
|
||||||
|
tubing_manufacturers | contact_info | jsonb | YES |
|
||||||
|
tubing_manufacturers | quality_certs | jsonb | YES |
|
||||||
|
tubing_manufacturers | is_active | boolean | YES | true
|
||||||
|
tubing_manufacturers | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
tubing_manufacturers | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
tubing_products | id | integer | NO | nextval('tubing_products_id_seq'::regclass)
|
||||||
|
tubing_products | specification_id | integer | YES |
|
||||||
|
tubing_products | manufacturer_id | integer | YES |
|
||||||
|
tubing_products | manufacturer_part_number | character varying | NO |
|
||||||
|
tubing_products | manufacturer_product_name | character varying | YES |
|
||||||
|
tubing_products | list_price | numeric | YES |
|
||||||
|
tubing_products | currency | character varying | YES | 'KRW'::character varying
|
||||||
|
tubing_products | lead_time_days | integer | YES |
|
||||||
|
tubing_products | minimum_order_qty | numeric | YES |
|
||||||
|
tubing_products | standard_packaging_qty | numeric | YES |
|
||||||
|
tubing_products | availability_status | character varying | YES |
|
||||||
|
tubing_products | last_price_update | date | YES |
|
||||||
|
tubing_products | datasheet_url | character varying | YES |
|
||||||
|
tubing_products | catalog_page | character varying | YES |
|
||||||
|
tubing_products | notes | text | YES |
|
||||||
|
tubing_products | is_active | boolean | YES | true
|
||||||
|
tubing_products | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
tubing_products | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
tubing_specifications | id | integer | NO | nextval('tubing_specifications_id_seq'::regclass)
|
||||||
|
tubing_specifications | category_id | integer | YES |
|
||||||
|
tubing_specifications | spec_code | character varying | NO |
|
||||||
|
tubing_specifications | spec_name | character varying | NO |
|
||||||
|
tubing_specifications | outer_diameter_mm | numeric | YES |
|
||||||
|
tubing_specifications | wall_thickness_mm | numeric | YES |
|
||||||
|
tubing_specifications | inner_diameter_mm | numeric | YES |
|
||||||
|
tubing_specifications | material_grade | character varying | YES |
|
||||||
|
tubing_specifications | material_standard | character varying | YES |
|
||||||
|
tubing_specifications | max_pressure_bar | numeric | YES |
|
||||||
|
tubing_specifications | max_temperature_c | numeric | YES |
|
||||||
|
tubing_specifications | min_temperature_c | numeric | YES |
|
||||||
|
tubing_specifications | standard_length_m | numeric | YES |
|
||||||
|
tubing_specifications | bend_radius_min_mm | numeric | YES |
|
||||||
|
tubing_specifications | surface_finish | character varying | YES |
|
||||||
|
tubing_specifications | hardness | character varying | YES |
|
||||||
|
tubing_specifications | notes | text | YES |
|
||||||
|
tubing_specifications | is_active | boolean | YES | true
|
||||||
|
tubing_specifications | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
tubing_specifications | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
user_activity_logs | id | integer | NO | nextval('user_activity_logs_id_seq'::regclass)
|
||||||
|
user_activity_logs | user_id | integer | YES |
|
||||||
|
user_activity_logs | username | character varying | NO |
|
||||||
|
user_activity_logs | activity_type | character varying | NO |
|
||||||
|
user_activity_logs | activity_description | text | YES |
|
||||||
|
user_activity_logs | target_id | integer | YES |
|
||||||
|
user_activity_logs | target_type | character varying | YES |
|
||||||
|
user_activity_logs | ip_address | character varying | YES |
|
||||||
|
user_activity_logs | user_agent | text | YES |
|
||||||
|
user_activity_logs | metadata | jsonb | YES |
|
||||||
|
user_activity_logs | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
user_requirements | id | integer | NO | nextval('user_requirements_id_seq'::regclass)
|
||||||
|
user_requirements | file_id | integer | NO |
|
||||||
|
user_requirements | material_id | integer | YES |
|
||||||
|
user_requirements | requirement_type | character varying | NO |
|
||||||
|
user_requirements | requirement_title | character varying | NO |
|
||||||
|
user_requirements | requirement_description | text | YES |
|
||||||
|
user_requirements | requirement_spec | text | YES |
|
||||||
|
user_requirements | status | character varying | YES | 'PENDING'::character varying
|
||||||
|
user_requirements | priority | character varying | YES | 'NORMAL'::character varying
|
||||||
|
user_requirements | assigned_to | character varying | YES |
|
||||||
|
user_requirements | due_date | date | YES |
|
||||||
|
user_requirements | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
user_requirements | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
user_sessions | session_id | integer | NO | nextval('user_sessions_session_id_seq'::regclass)
|
||||||
|
user_sessions | user_id | integer | YES |
|
||||||
|
user_sessions | refresh_token | character varying | NO |
|
||||||
|
user_sessions | expires_at | timestamp without time zone | NO |
|
||||||
|
user_sessions | ip_address | character varying | YES |
|
||||||
|
user_sessions | user_agent | text | YES |
|
||||||
|
user_sessions | is_active | boolean | YES | true
|
||||||
|
user_sessions | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
user_sessions | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
users | user_id | integer | NO | nextval('users_user_id_seq'::regclass)
|
||||||
|
users | username | character varying | NO |
|
||||||
|
users | password | character varying | NO |
|
||||||
|
users | name | character varying | NO |
|
||||||
|
users | email | character varying | YES |
|
||||||
|
users | role | character varying | YES | 'user'::character varying
|
||||||
|
users | access_level | character varying | YES | 'worker'::character varying
|
||||||
|
users | is_active | boolean | YES | true
|
||||||
|
users | failed_login_attempts | integer | YES | 0
|
||||||
|
users | locked_until | timestamp without time zone | YES |
|
||||||
|
users | department | character varying | YES |
|
||||||
|
users | position | character varying | YES |
|
||||||
|
users | phone | character varying | YES |
|
||||||
|
users | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
users | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
users | last_login_at | timestamp without time zone | YES |
|
||||||
|
users | status | character varying | YES | 'active'::character varying
|
||||||
|
valve_details | id | integer | NO | nextval('valve_details_id_seq'::regclass)
|
||||||
|
valve_details | material_id | integer | YES |
|
||||||
|
valve_details | file_id | integer | YES |
|
||||||
|
valve_details | valve_type | character varying | YES |
|
||||||
|
valve_details | valve_subtype | character varying | YES |
|
||||||
|
valve_details | actuator_type | character varying | YES |
|
||||||
|
valve_details | connection_method | character varying | YES |
|
||||||
|
valve_details | pressure_rating | character varying | YES |
|
||||||
|
valve_details | pressure_class | character varying | YES |
|
||||||
|
valve_details | body_material | character varying | YES |
|
||||||
|
valve_details | trim_material | character varying | YES |
|
||||||
|
valve_details | size_inches | character varying | YES |
|
||||||
|
valve_details | fire_safe | boolean | YES |
|
||||||
|
valve_details | low_temp_service | boolean | YES |
|
||||||
|
valve_details | special_features | jsonb | YES |
|
||||||
|
valve_details | classification_confidence | double precision | YES |
|
||||||
|
valve_details | additional_info | jsonb | YES |
|
||||||
|
valve_details | created_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
valve_details | updated_at | timestamp without time zone | YES | CURRENT_TIMESTAMP
|
||||||
|
(616 rows)
|
||||||
|
|
||||||
@@ -26,13 +26,13 @@ services:
|
|||||||
# 개발 환경에서는 모든 포트를 외부에 노출
|
# 개발 환경에서는 모든 포트를 외부에 노출
|
||||||
postgres:
|
postgres:
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "${POSTGRES_PORT:-15432}:5432"
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "${REDIS_PORT:-16379}:6379"
|
||||||
|
|
||||||
pgadmin:
|
pgadmin:
|
||||||
ports:
|
ports:
|
||||||
- "5050:80"
|
- "${PGADMIN_PORT:-15050}:80"
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ services:
|
|||||||
container_name: tk-mp-nginx-proxy
|
container_name: tk-mp-nginx-proxy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "8808:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx-proxy.conf:/etc/nginx/conf.d/default.conf
|
- ./nginx-proxy.conf:/etc/nginx/conf.d/default.conf
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -78,13 +78,13 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
- VITE_API_URL=${VITE_API_URL:-http://localhost:18000}
|
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||||
container_name: tk-mp-frontend
|
container_name: tk-mp-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-13000}:5173"
|
- "${FRONTEND_PORT:-13000}:3000"
|
||||||
environment:
|
environment:
|
||||||
- VITE_API_URL=${VITE_API_URL:-http://localhost:18000}
|
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
348
frontend/PAGES_GUIDE.md
Normal file
348
frontend/PAGES_GUIDE.md
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
# 프론트엔드 페이지 가이드
|
||||||
|
|
||||||
|
이 문서는 TK-MP 프로젝트의 프론트엔드 페이지들의 역할과 기능을 정리한 가이드입니다.
|
||||||
|
|
||||||
|
## 📋 목차
|
||||||
|
|
||||||
|
- [인증 관련 페이지](#인증-관련-페이지)
|
||||||
|
- [대시보드 및 메인 페이지](#대시보드-및-메인-페이지)
|
||||||
|
- [프로젝트 관리 페이지](#프로젝트-관리-페이지)
|
||||||
|
- [BOM 관리 페이지](#bom-관리-페이지)
|
||||||
|
- [구매 관리 페이지](#구매-관리-페이지)
|
||||||
|
- [시스템 관리 페이지](#시스템-관리-페이지)
|
||||||
|
- [컴포넌트 구조](#컴포넌트-구조)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 인증 관련 페이지
|
||||||
|
|
||||||
|
### `LoginPage.jsx`
|
||||||
|
- **역할**: 사용자 로그인 페이지
|
||||||
|
- **기능**:
|
||||||
|
- 사용자 인증 (이메일/비밀번호)
|
||||||
|
- 로그인 상태 관리
|
||||||
|
- 인증 실패 시 에러 메시지 표시
|
||||||
|
- **라우팅**: `/login`
|
||||||
|
- **접근 권한**: 모든 사용자 (비인증)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대시보드 및 메인 페이지
|
||||||
|
|
||||||
|
### `DashboardPage.jsx`
|
||||||
|
- **역할**: 메인 대시보드 페이지
|
||||||
|
- **기능**:
|
||||||
|
- 프로젝트 선택 드롭다운
|
||||||
|
- **새로운 3개 BOM 카드**: 📤 BOM Upload, 📊 Revision Management, 📋 BOM Management
|
||||||
|
- 구매신청 관리 카드
|
||||||
|
- 관리자 전용 기능 (사용자 관리, 로그 관리)
|
||||||
|
- 프로젝트 생성/편집/삭제/비활성화
|
||||||
|
- **라우팅**: `/dashboard`
|
||||||
|
- **접근 권한**: 인증된 사용자
|
||||||
|
- **디자인**: 데본씽크 스타일, 글래스모피즘 효과
|
||||||
|
- **업데이트**: BOM 기능을 3개 전용 페이지로 분리
|
||||||
|
|
||||||
|
### `MainPage.jsx`
|
||||||
|
- **역할**: 초기 랜딩 페이지
|
||||||
|
- **기능**: 기본 페이지 구조 및 네비게이션
|
||||||
|
- **라우팅**: `/`
|
||||||
|
- **접근 권한**: 인증된 사용자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 프로젝트 관리 페이지
|
||||||
|
|
||||||
|
### `ProjectsPage.jsx`
|
||||||
|
- **역할**: 프로젝트 목록 및 관리
|
||||||
|
- **기능**:
|
||||||
|
- 프로젝트 목록 조회
|
||||||
|
- 프로젝트 생성/수정/삭제
|
||||||
|
- 프로젝트 상태 관리
|
||||||
|
- **라우팅**: `/projects`
|
||||||
|
- **접근 권한**: 관리자
|
||||||
|
|
||||||
|
### `InactiveProjectsPage.jsx`
|
||||||
|
- **역할**: 비활성화된 프로젝트 관리
|
||||||
|
- **기능**:
|
||||||
|
- 비활성 프로젝트 목록 조회
|
||||||
|
- 프로젝트 활성화/삭제
|
||||||
|
- 전체 선택/해제 기능
|
||||||
|
- **라우팅**: `/inactive-projects`
|
||||||
|
- **접근 권한**: 관리자
|
||||||
|
|
||||||
|
### `JobRegistrationPage.jsx`
|
||||||
|
- **역할**: 새로운 작업(Job) 등록
|
||||||
|
- **기능**:
|
||||||
|
- 작업 정보 입력 및 등록
|
||||||
|
- 프로젝트 연결
|
||||||
|
- **라우팅**: `/job-registration`
|
||||||
|
- **접근 권한**: 관리자
|
||||||
|
|
||||||
|
### `JobSelectionPage.jsx`
|
||||||
|
- **역할**: 작업 선택 페이지
|
||||||
|
- **기능**:
|
||||||
|
- 등록된 작업 목록 조회
|
||||||
|
- 작업 선택 및 이동
|
||||||
|
- **라우팅**: `/job-selection`
|
||||||
|
- **접근 권한**: 인증된 사용자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BOM 관리 페이지
|
||||||
|
|
||||||
|
### `BOMUploadPage.jsx` ⭐ 신규
|
||||||
|
- **역할**: BOM 파일 업로드 전용 페이지
|
||||||
|
- **기능**:
|
||||||
|
- 드래그 앤 드롭 파일 업로드
|
||||||
|
- 파일 검증 (형식: .xlsx, .xls, .csv / 최대 50MB)
|
||||||
|
- 실시간 업로드 진행률 표시
|
||||||
|
- 자동 BOM 이름 설정
|
||||||
|
- 업로드 완료 후 BOM 관리 페이지로 자동 이동
|
||||||
|
- **라우팅**: `/bom-upload`
|
||||||
|
- **접근 권한**: 인증된 사용자
|
||||||
|
- **디자인**: 모던 UI, 글래스모피즘 효과
|
||||||
|
|
||||||
|
### `BOMRevisionPage.jsx` ⭐ 신규
|
||||||
|
- **역할**: BOM 리비전 관리 전용 페이지
|
||||||
|
- **현재 상태**: 기본 구조 완성, 고급 기능 개발 예정
|
||||||
|
- **기능**:
|
||||||
|
- BOM 파일 목록 표시
|
||||||
|
- 리비전 히스토리 개요
|
||||||
|
- 개발 예정 기능 안내 (타임라인, 비교, 롤백)
|
||||||
|
- **라우팅**: `/bom-revision`
|
||||||
|
- **접근 권한**: 인증된 사용자
|
||||||
|
- **향후 계획**: 📊 리비전 타임라인, 🔍 변경사항 비교, ⏪ 롤백 시스템
|
||||||
|
|
||||||
|
### `BOMManagementPage.jsx`
|
||||||
|
- **역할**: BOM(Bill of Materials) 자재 관리 페이지
|
||||||
|
- **기능**:
|
||||||
|
- 카테고리별 자재 조회 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT, SPECIAL, UNCLASSIFIED)
|
||||||
|
- 자재 선택 및 구매신청 (엑셀 내보내기)
|
||||||
|
- 구매신청된 자재 비활성화 표시
|
||||||
|
- 사용자 요구사항 입력 및 저장 (Brand, Additional Request)
|
||||||
|
- 카테고리별 전용 컴포넌트 구조
|
||||||
|
- **라우팅**: `/bom-management`
|
||||||
|
- **접근 권한**: 인증된 사용자
|
||||||
|
- **특징**: 카테고리별 컴포넌트로 완전 분리된 구조
|
||||||
|
|
||||||
|
### `NewMaterialsPage.jsx` (레거시)
|
||||||
|
- **역할**: 기존 자재 관리 페이지 (현재 백업용)
|
||||||
|
- **상태**: 사용 중단, `BOMManagementPage`로 대체됨
|
||||||
|
- **기능**: 자재 분류, 편집, 내보내기 (기존 로직 보존)
|
||||||
|
|
||||||
|
### `BOMStatusPage.jsx`
|
||||||
|
- **역할**: BOM 상태 조회 페이지
|
||||||
|
- **기능**:
|
||||||
|
- BOM 파일 상태 확인
|
||||||
|
- 처리 진행률 표시
|
||||||
|
- **라우팅**: `/bom-status`
|
||||||
|
- **접근 권한**: 인증된 사용자
|
||||||
|
|
||||||
|
### `_deprecated/BOMWorkspacePage.jsx` (사용 중단)
|
||||||
|
- **역할**: 기존 BOM 작업 공간 (사용 중단)
|
||||||
|
- **상태**: `BOMUploadPage`와 `BOMRevisionPage`로 분리됨
|
||||||
|
- **이유**: 업로드와 리비전 관리 기능을 별도 페이지로 분리하여 사용성 개선
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구매 관리 페이지
|
||||||
|
|
||||||
|
### `PurchaseRequestPage.jsx`
|
||||||
|
- **역할**: 구매신청 관리 페이지
|
||||||
|
- **기능**:
|
||||||
|
- 구매신청 목록 조회
|
||||||
|
- 구매신청 제목 편집 (인라인 편집)
|
||||||
|
- 원본 파일 정보 표시
|
||||||
|
- 엑셀 파일 다운로드
|
||||||
|
- 구매신청 자재 상세 조회
|
||||||
|
- **라우팅**: `/purchase-requests`
|
||||||
|
- **접근 권한**: 인증된 사용자
|
||||||
|
- **특징**: BOM 페이지와 연동된 구매 워크플로우
|
||||||
|
|
||||||
|
### `PurchaseBatchPage.jsx`
|
||||||
|
- **역할**: 구매 배치 처리 페이지
|
||||||
|
- **기능**:
|
||||||
|
- 대량 구매 처리
|
||||||
|
- 배치 작업 관리
|
||||||
|
- **라우팅**: `/purchase-batch`
|
||||||
|
- **접근 권한**: 관리자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시스템 관리 페이지
|
||||||
|
|
||||||
|
### `UserManagementPage.jsx`
|
||||||
|
- **역할**: 사용자 관리 페이지
|
||||||
|
- **기능**:
|
||||||
|
- 사용자 목록 조회
|
||||||
|
- 사용자 생성/수정/삭제
|
||||||
|
- 권한 관리
|
||||||
|
- 사용자 상태 관리
|
||||||
|
- **라우팅**: `/user-management`
|
||||||
|
- **접근 권한**: 관리자
|
||||||
|
|
||||||
|
### `SystemSettingsPage.jsx`
|
||||||
|
- **역할**: 시스템 설정 페이지
|
||||||
|
- **기능**:
|
||||||
|
- 시스템 전반 설정 관리
|
||||||
|
- 환경 변수 설정
|
||||||
|
- **라우팅**: `/system-settings`
|
||||||
|
- **접근 권한**: 관리자
|
||||||
|
|
||||||
|
### `SystemSetupPage.jsx`
|
||||||
|
- **역할**: 시스템 초기 설정 페이지
|
||||||
|
- **기능**:
|
||||||
|
- 시스템 초기 구성
|
||||||
|
- 기본 데이터 설정
|
||||||
|
- **라우팅**: `/system-setup`
|
||||||
|
- **접근 권한**: 관리자
|
||||||
|
|
||||||
|
### `SystemLogsPage.jsx`
|
||||||
|
- **역할**: 시스템 로그 조회 페이지
|
||||||
|
- **기능**:
|
||||||
|
- 시스템 로그 조회
|
||||||
|
- 로그 필터링 및 검색
|
||||||
|
- **라우팅**: `/system-logs`
|
||||||
|
- **접근 권한**: 관리자
|
||||||
|
|
||||||
|
### `LogMonitoringPage.jsx`
|
||||||
|
- **역할**: 로그 모니터링 페이지
|
||||||
|
- **기능**:
|
||||||
|
- 실시간 로그 모니터링
|
||||||
|
- 로그 분석 및 알림
|
||||||
|
- **라우팅**: `/log-monitoring`
|
||||||
|
- **접근 권한**: 관리자
|
||||||
|
|
||||||
|
### `AccountSettingsPage.jsx`
|
||||||
|
- **역할**: 개인 계정 설정 페이지
|
||||||
|
- **기능**:
|
||||||
|
- 개인 정보 수정
|
||||||
|
- 비밀번호 변경
|
||||||
|
- 계정 설정 관리
|
||||||
|
- **라우팅**: `/account-settings`
|
||||||
|
- **접근 권한**: 인증된 사용자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 워크스페이스 페이지
|
||||||
|
|
||||||
|
### `ProjectWorkspacePage.jsx`
|
||||||
|
- **역할**: 프로젝트 작업 공간
|
||||||
|
- **기능**:
|
||||||
|
- 프로젝트별 작업 환경
|
||||||
|
- 파일 관리 및 협업 도구
|
||||||
|
- **라우팅**: `/project-workspace`
|
||||||
|
- **접근 권한**: 인증된 사용자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 컴포넌트 구조
|
||||||
|
|
||||||
|
### `components/common/`
|
||||||
|
- **ErrorBoundary.jsx**: 에러 경계 컴포넌트
|
||||||
|
- **UserMenu.jsx**: 사용자 드롭다운 메뉴
|
||||||
|
|
||||||
|
### `components/bom/`
|
||||||
|
- **shared/**: 공통 BOM 컴포넌트
|
||||||
|
- `FilterableHeader.jsx`: 필터링 가능한 테이블 헤더
|
||||||
|
- `MaterialTable.jsx`: 자재 테이블 공통 컴포넌트
|
||||||
|
- **materials/**: 카테고리별 자재 뷰 컴포넌트
|
||||||
|
- `PipeMaterialsView.jsx`: 파이프 자재 관리
|
||||||
|
- `FittingMaterialsView.jsx`: 피팅 자재 관리
|
||||||
|
- `FlangeMaterialsView.jsx`: 플랜지 자재 관리
|
||||||
|
- `ValveMaterialsView.jsx`: 밸브 자재 관리
|
||||||
|
- `GasketMaterialsView.jsx`: 가스켓 자재 관리
|
||||||
|
- `BoltMaterialsView.jsx`: 볼트 자재 관리
|
||||||
|
- `SupportMaterialsView.jsx`: 서포트 자재 관리
|
||||||
|
- `SpecialMaterialsView.jsx`: 특수 제작 자재 관리
|
||||||
|
|
||||||
|
#### SPECIAL 카테고리 상세 기능
|
||||||
|
`SpecialMaterialsView.jsx`는 특수 제작이 필요한 자재들을 관리하는 컴포넌트입니다:
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- **자동 타입 분류**: FLANGE, OIL PUMP, COMPRESSOR, VALVE, FITTING, PIPE 등 큰 범주 자동 인식
|
||||||
|
- **정보 파싱**: 자재 설명을 도면, 항목1-4로 체계적 분리
|
||||||
|
- **테이블 구조**: `Type | Drawing | Item 1 | Item 2 | Item 3 | Item 4 | Additional Request | Purchase Quantity`
|
||||||
|
- **엑셀 내보내기**: P열 납기일 규칙 준수, 관리항목 자동 채움
|
||||||
|
- **저장 기능**: 추가요청사항 저장/편집 (다른 카테고리와 동일)
|
||||||
|
|
||||||
|
**처리 예시:**
|
||||||
|
- `SAE SPECIAL FF, OIL PUMP, ASTM A105` → Type: OIL PUMP, Item1: SAE SPECIAL FF, Item2: OIL PUMP, Item3: ASTM A105
|
||||||
|
- `FLG SPECIAL FF, COMPRESSOR(N11), ASTM A105` → Type: FLANGE, Item1: FLG SPECIAL FF, Item2: COMPRESSOR(N11), Item3: ASTM A105
|
||||||
|
|
||||||
|
**분류 조건:**
|
||||||
|
- `SPECIAL` 키워드 포함 (단, `SPECIFICATION` 제외)
|
||||||
|
- 한글 `스페셜` 또는 `SPL` 키워드 포함
|
||||||
|
|
||||||
|
### 기타 컴포넌트
|
||||||
|
- **NavigationMenu.jsx**: 사이드바 네비게이션
|
||||||
|
- **NavigationBar.jsx**: 상단 네비게이션 바
|
||||||
|
- **FileUpload.jsx**: 파일 업로드 컴포넌트
|
||||||
|
- **ProtectedRoute.jsx**: 권한 기반 라우트 보호
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 페이지 추가 시 규칙
|
||||||
|
|
||||||
|
1. **새 페이지 생성 시 이 문서 업데이트 필수**
|
||||||
|
2. **페이지 역할과 기능을 명확히 문서화**
|
||||||
|
3. **라우팅 경로와 접근 권한 명시**
|
||||||
|
4. **관련 컴포넌트와의 연관성 설명**
|
||||||
|
5. **디자인 시스템 준수 (데본씽크 스타일, 글래스모피즘)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 디자인 시스템
|
||||||
|
|
||||||
|
### 색상 팔레트
|
||||||
|
- **Primary**: 블루 그라데이션 (#3b82f6 → #1d4ed8)
|
||||||
|
- **Background**: 글래스 효과 (backdrop-filter: blur)
|
||||||
|
- **Cards**: 20px 둥근 모서리, 그림자 효과
|
||||||
|
|
||||||
|
### BOM 카테고리 색상
|
||||||
|
- **PIPE**: #3b82f6 (파란색)
|
||||||
|
- **FITTING**: #10b981 (초록색)
|
||||||
|
- **FLANGE**: #f59e0b (주황색)
|
||||||
|
- **VALVE**: #ef4444 (빨간색)
|
||||||
|
- **GASKET**: #8b5cf6 (보라색)
|
||||||
|
- **BOLT**: #6b7280 (회색)
|
||||||
|
- **SUPPORT**: #f97316 (주황색)
|
||||||
|
- **SPECIAL**: #ec4899 (핑크색)
|
||||||
|
|
||||||
|
### 반응형 디자인
|
||||||
|
- **Desktop**: 3-4열 그리드
|
||||||
|
- **Tablet**: 2열 그리드
|
||||||
|
- **Mobile**: 1열 그리드
|
||||||
|
|
||||||
|
### 타이포그래피
|
||||||
|
- **Font Family**: Apple 시스템 폰트
|
||||||
|
- **Weight**: 다양한 weight 활용 (400, 500, 600, 700)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*마지막 업데이트: 2024-10-17*
|
||||||
|
*다음 페이지 추가 시 반드시 이 문서를 업데이트하세요.*
|
||||||
|
|
||||||
|
## 최근 업데이트 내역
|
||||||
|
|
||||||
|
### 2024-10-17: BOM 페이지 구조 개편 ⭐ 주요 업데이트
|
||||||
|
- **새로운 페이지 추가**:
|
||||||
|
- `BOMUploadPage.jsx`: 전용 업로드 페이지 (드래그 앤 드롭, 파일 검증)
|
||||||
|
- `BOMRevisionPage.jsx`: 리비전 관리 페이지 (기본 구조, 향후 고급 기능 예정)
|
||||||
|
- **기존 페이지 정리**:
|
||||||
|
- `BOMWorkspacePage.jsx` → `_deprecated/` 폴더로 이동 (사용 중단)
|
||||||
|
- 업로드와 리비전 기능을 별도 페이지로 분리하여 사용성 개선
|
||||||
|
- **대시보드 개편**:
|
||||||
|
- BOM 관리를 3개 카드로 분리: 📤 Upload, 📊 Revision, 📋 Management
|
||||||
|
- 각 기능별 전용 페이지로 명확한 역할 분담
|
||||||
|
- **라우팅 업데이트**:
|
||||||
|
- `/bom-upload`: 새 파일 업로드
|
||||||
|
- `/bom-revision`: 리비전 관리
|
||||||
|
- `/bom-management`: 자재 관리
|
||||||
|
|
||||||
|
### 2024-10-17: SPECIAL 카테고리 추가
|
||||||
|
- `SpecialMaterialsView.jsx` 컴포넌트 추가
|
||||||
|
- 특수 제작 자재 관리 기능 구현
|
||||||
|
- 자동 타입 분류 및 정보 파싱 시스템
|
||||||
|
- 엑셀 내보내기 규칙 적용 (P열 납기일, 관리항목 자동 채움)
|
||||||
|
- BOM 카테고리 색상 팔레트에 SPECIAL (#ec4899) 추가
|
||||||
@@ -50,40 +50,95 @@ uvicorn app.main:app --reload
|
|||||||
frontend/
|
frontend/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── Dashboard.jsx # 대시보드
|
│ │ ├── common/ # 공통 컴포넌트
|
||||||
│ │ ├── FileUpload.jsx # 파일 업로드
|
│ │ ├── bom/ # BOM 관련 컴포넌트
|
||||||
│ │ ├── MaterialList.jsx # 자재 목록
|
│ │ │ ├── materials/ # 카테고리별 자재 뷰
|
||||||
│ │ └── ProjectManager.jsx # 프로젝트 관리
|
│ │ │ └── shared/ # BOM 공통 컴포넌트
|
||||||
|
│ │ └── ... # 기타 컴포넌트
|
||||||
|
│ ├── pages/ # 페이지 컴포넌트
|
||||||
|
│ │ ├── DashboardPage.jsx # 메인 대시보드
|
||||||
|
│ │ ├── BOMManagementPage.jsx # BOM 관리
|
||||||
|
│ │ ├── PurchaseRequestPage.jsx # 구매신청 관리
|
||||||
|
│ │ └── ... # 기타 페이지들
|
||||||
│ ├── App.jsx # 메인 앱
|
│ ├── App.jsx # 메인 앱
|
||||||
│ ├── main.jsx # 엔트리 포인트
|
│ ├── main.jsx # 엔트리 포인트
|
||||||
│ └── index.css # 전역 스타일
|
│ └── index.css # 전역 스타일
|
||||||
|
├── PAGES_GUIDE.md # 📋 페이지 역할 가이드
|
||||||
├── package.json
|
├── package.json
|
||||||
└── vite.config.js
|
└── vite.config.js
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🎯 주요 컴포넌트
|
## 📋 페이지 가이드
|
||||||
|
|
||||||
### Dashboard
|
**모든 페이지의 역할과 기능은 [`PAGES_GUIDE.md`](./PAGES_GUIDE.md)에 상세히 문서화되어 있습니다.**
|
||||||
- 프로젝트 통계 및 현황 표시
|
|
||||||
- 최근 활동 목록
|
|
||||||
- 실시간 데이터 업데이트
|
|
||||||
|
|
||||||
### FileUpload
|
### 🔄 페이지 개발 규칙
|
||||||
- 드래그&드롭 인터페이스
|
|
||||||
- Excel 파일 검증
|
|
||||||
- 업로드 진행률 표시
|
|
||||||
- 배치 파일 처리
|
|
||||||
|
|
||||||
### MaterialList
|
1. **새 페이지 생성 시 `PAGES_GUIDE.md` 업데이트 필수**
|
||||||
- 페이지네이션이 있는 데이터 그리드
|
2. **페이지 역할, 기능, 라우팅, 접근 권한을 명확히 문서화**
|
||||||
- 실시간 검색 및 필터링
|
3. **관련 컴포넌트와의 연관성 설명**
|
||||||
- CSV 내보내기
|
4. **디자인 시스템 준수 (데본씽크 스타일, 글래스모피즘)**
|
||||||
- 정렬 및 컬럼 관리
|
|
||||||
|
|
||||||
### ProjectManager
|
### ⚠️ **Docker 배포 시 주의사항**
|
||||||
- 프로젝트 CRUD 작업
|
|
||||||
- 카드 형태의 프로젝트 표시
|
**프론트엔드 변경사항이 반영되지 않을 때:**
|
||||||
- 모달 기반 편집
|
|
||||||
|
```bash
|
||||||
|
# 1. 프론트엔드 컨테이너 완전 재빌드 (캐시 문제 해결)
|
||||||
|
docker-compose stop frontend
|
||||||
|
docker-compose rm -f frontend
|
||||||
|
docker-compose build --no-cache frontend
|
||||||
|
docker-compose up -d frontend
|
||||||
|
|
||||||
|
# 2. 배포 후 index 파일 버전 확인
|
||||||
|
docker exec tk-mp-frontend find /usr/share/nginx/html -name "index-*.js"
|
||||||
|
|
||||||
|
# 3. 로컬 빌드 버전과 비교
|
||||||
|
ls -la frontend/dist/assets/index-*.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**주의:** Docker 컨테이너는 이전 빌드를 캐시할 수 있어 최신 변경사항이 반영되지 않을 수 있습니다.
|
||||||
|
변경사항이 보이지 않으면 반드시 `--no-cache` 옵션으로 재빌드하세요.
|
||||||
|
|
||||||
|
### 🚀 **빠른 배포 명령어**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 프론트엔드 빠른 재배포 (한 줄 명령어)
|
||||||
|
docker-compose stop frontend && docker-compose rm -f frontend && docker-compose build --no-cache frontend && docker-compose up -d frontend
|
||||||
|
|
||||||
|
# 전체 시스템 재시작
|
||||||
|
docker-compose restart
|
||||||
|
|
||||||
|
# 특정 서비스만 재시작
|
||||||
|
docker-compose restart backend
|
||||||
|
docker-compose restart frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 주요 페이지
|
||||||
|
|
||||||
|
### DashboardPage
|
||||||
|
- 프로젝트 선택 드롭다운
|
||||||
|
- 프로젝트별 기능 카드 (BOM 관리, 구매신청 관리)
|
||||||
|
- 관리자 전용 기능 (사용자 관리, 로그 관리)
|
||||||
|
- 데본씽크 스타일 디자인
|
||||||
|
|
||||||
|
### BOMManagementPage
|
||||||
|
- 카테고리별 자재 관리 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT)
|
||||||
|
- 구매신청된 자재 비활성화 표시
|
||||||
|
- 엑셀 내보내기 및 서버 저장
|
||||||
|
- 사용자 요구사항 입력
|
||||||
|
|
||||||
|
### PurchaseRequestPage
|
||||||
|
- 구매신청 목록 조회 및 관리
|
||||||
|
- 구매신청 제목 인라인 편집
|
||||||
|
- 원본 파일 정보 표시
|
||||||
|
- 엑셀 파일 다운로드
|
||||||
|
|
||||||
|
### 카테고리별 자재 뷰 컴포넌트
|
||||||
|
- 각 자재 카테고리별 전용 뷰 컴포넌트
|
||||||
|
- 통일된 테이블 형태 UI
|
||||||
|
- 정렬, 필터링, 전체 선택 기능
|
||||||
|
- 구매신청된 자재 비활성화 처리
|
||||||
|
|
||||||
## 📱 반응형 디자인
|
## 📱 반응형 디자인
|
||||||
|
|
||||||
|
|||||||
311
frontend/package-lock.json
generated
311
frontend/package-lock.json
generated
@@ -34,20 +34,6 @@
|
|||||||
"vite": "^4.5.0"
|
"vite": "^4.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ampproject/remapping": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@jridgewell/gen-mapping": "^0.3.5",
|
|
||||||
"@jridgewell/trace-mapping": "^0.3.24"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||||
@@ -63,9 +49,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/compat-data": {
|
"node_modules/@babel/compat-data": {
|
||||||
"version": "7.28.0",
|
"version": "7.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz",
|
||||||
"integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
|
"integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -73,22 +59,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/core": {
|
"node_modules/@babel/core": {
|
||||||
"version": "7.28.0",
|
"version": "7.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
|
||||||
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
|
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.2.0",
|
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.0",
|
"@babel/generator": "^7.28.3",
|
||||||
"@babel/helper-compilation-targets": "^7.27.2",
|
"@babel/helper-compilation-targets": "^7.27.2",
|
||||||
"@babel/helper-module-transforms": "^7.27.3",
|
"@babel/helper-module-transforms": "^7.28.3",
|
||||||
"@babel/helpers": "^7.27.6",
|
"@babel/helpers": "^7.28.4",
|
||||||
"@babel/parser": "^7.28.0",
|
"@babel/parser": "^7.28.4",
|
||||||
"@babel/template": "^7.27.2",
|
"@babel/template": "^7.27.2",
|
||||||
"@babel/traverse": "^7.28.0",
|
"@babel/traverse": "^7.28.4",
|
||||||
"@babel/types": "^7.28.0",
|
"@babel/types": "^7.28.4",
|
||||||
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
"convert-source-map": "^2.0.0",
|
"convert-source-map": "^2.0.0",
|
||||||
"debug": "^4.1.0",
|
"debug": "^4.1.0",
|
||||||
"gensync": "^1.0.0-beta.2",
|
"gensync": "^1.0.0-beta.2",
|
||||||
@@ -111,13 +97,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator": {
|
"node_modules/@babel/generator": {
|
||||||
"version": "7.28.0",
|
"version": "7.28.3",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
|
||||||
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
|
"integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.28.0",
|
"@babel/parser": "^7.28.3",
|
||||||
"@babel/types": "^7.28.0",
|
"@babel/types": "^7.28.2",
|
||||||
"@jridgewell/gen-mapping": "^0.3.12",
|
"@jridgewell/gen-mapping": "^0.3.12",
|
||||||
"@jridgewell/trace-mapping": "^0.3.28",
|
"@jridgewell/trace-mapping": "^0.3.28",
|
||||||
"jsesc": "^3.0.2"
|
"jsesc": "^3.0.2"
|
||||||
@@ -166,15 +152,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-transforms": {
|
"node_modules/@babel/helper-module-transforms": {
|
||||||
"version": "7.27.3",
|
"version": "7.28.3",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
|
||||||
"integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==",
|
"integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-module-imports": "^7.27.1",
|
"@babel/helper-module-imports": "^7.27.1",
|
||||||
"@babel/helper-validator-identifier": "^7.27.1",
|
"@babel/helper-validator-identifier": "^7.27.1",
|
||||||
"@babel/traverse": "^7.27.3"
|
"@babel/traverse": "^7.28.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -222,26 +208,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helpers": {
|
"node_modules/@babel/helpers": {
|
||||||
"version": "7.27.6",
|
"version": "7.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
|
||||||
"integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==",
|
"integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/template": "^7.27.2",
|
"@babel/template": "^7.27.2",
|
||||||
"@babel/types": "^7.27.6"
|
"@babel/types": "^7.28.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.28.0",
|
"version": "7.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
|
||||||
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
|
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.28.0"
|
"@babel/types": "^7.28.4"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
@@ -283,9 +269,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/runtime": {
|
"node_modules/@babel/runtime": {
|
||||||
"version": "7.27.6",
|
"version": "7.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||||
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
|
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -306,17 +292,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/traverse": {
|
"node_modules/@babel/traverse": {
|
||||||
"version": "7.28.0",
|
"version": "7.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz",
|
||||||
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
|
"integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.0",
|
"@babel/generator": "^7.28.3",
|
||||||
"@babel/helper-globals": "^7.28.0",
|
"@babel/helper-globals": "^7.28.0",
|
||||||
"@babel/parser": "^7.28.0",
|
"@babel/parser": "^7.28.4",
|
||||||
"@babel/template": "^7.27.2",
|
"@babel/template": "^7.27.2",
|
||||||
"@babel/types": "^7.28.0",
|
"@babel/types": "^7.28.4",
|
||||||
"debug": "^4.3.1"
|
"debug": "^4.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -324,9 +310,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.28.1",
|
"version": "7.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
|
||||||
"integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==",
|
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.27.1",
|
"@babel/helper-string-parser": "^7.27.1",
|
||||||
@@ -375,9 +361,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@emotion/is-prop-valid": {
|
"node_modules/@emotion/is-prop-valid": {
|
||||||
"version": "1.3.1",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
|
||||||
"integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==",
|
"integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/memoize": "^0.9.0"
|
"@emotion/memoize": "^0.9.0"
|
||||||
@@ -857,9 +843,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.7.0",
|
"version": "4.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
|
||||||
"integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
|
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -958,15 +944,26 @@
|
|||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.12",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
"integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
|
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
"@jridgewell/trace-mapping": "^0.3.24"
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@jridgewell/remapping": {
|
||||||
|
"version": "2.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||||
|
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/resolve-uri": {
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
@@ -977,15 +974,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
"version": "0.3.29",
|
"version": "0.3.31",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
"integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/resolve-uri": "^3.1.0",
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
@@ -1079,7 +1076,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/material/node_modules/@mui/private-theming": {
|
"node_modules/@mui/private-theming": {
|
||||||
"version": "5.17.1",
|
"version": "5.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz",
|
||||||
"integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==",
|
"integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==",
|
||||||
@@ -1106,7 +1103,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/material/node_modules/@mui/styled-engine": {
|
"node_modules/@mui/styled-engine": {
|
||||||
"version": "5.18.0",
|
"version": "5.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz",
|
||||||
"integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==",
|
"integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==",
|
||||||
@@ -1139,7 +1136,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/material/node_modules/@mui/system": {
|
"node_modules/@mui/system": {
|
||||||
"version": "5.18.0",
|
"version": "5.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz",
|
||||||
"integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==",
|
"integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==",
|
||||||
@@ -1179,7 +1176,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/material/node_modules/@mui/types": {
|
"node_modules/@mui/types": {
|
||||||
"version": "7.2.24",
|
"version": "7.2.24",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz",
|
||||||
"integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==",
|
"integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==",
|
||||||
@@ -1193,7 +1190,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/material/node_modules/@mui/utils": {
|
"node_modules/@mui/utils": {
|
||||||
"version": "5.17.1",
|
"version": "5.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz",
|
||||||
"integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==",
|
"integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==",
|
||||||
@@ -1281,9 +1278,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.19",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
"integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==",
|
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -1323,13 +1320,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/babel__traverse": {
|
"node_modules/@types/babel__traverse": {
|
||||||
"version": "7.20.7",
|
"version": "7.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
|
||||||
"integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==",
|
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.20.7"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/parse-json": {
|
"node_modules/@types/parse-json": {
|
||||||
@@ -1345,9 +1342,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.23",
|
"version": "18.3.26",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
|
||||||
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
|
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
@@ -1381,16 +1378,16 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.6.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
"integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==",
|
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "^7.27.4",
|
"@babel/core": "^7.28.0",
|
||||||
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
||||||
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
||||||
"@rolldown/pluginutils": "1.0.0-beta.19",
|
"@rolldown/pluginutils": "1.0.0-beta.27",
|
||||||
"@types/babel__core": "^7.20.5",
|
"@types/babel__core": "^7.20.5",
|
||||||
"react-refresh": "^0.17.0"
|
"react-refresh": "^0.17.0"
|
||||||
},
|
},
|
||||||
@@ -1398,7 +1395,7 @@
|
|||||||
"node": "^14.18.0 || >=16.0.0"
|
"node": "^14.18.0 || >=16.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
@@ -1663,13 +1660,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.10.0",
|
"version": "1.12.2",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||||
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
|
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.6",
|
||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.4",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1695,6 +1692,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/baseline-browser-mapping": {
|
||||||
|
"version": "2.8.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz",
|
||||||
|
"integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
@@ -1707,9 +1714,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.25.1",
|
"version": "4.26.3",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
|
||||||
"integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==",
|
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1727,9 +1734,10 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001726",
|
"baseline-browser-mapping": "^2.8.9",
|
||||||
"electron-to-chromium": "^1.5.173",
|
"caniuse-lite": "^1.0.30001746",
|
||||||
"node-releases": "^2.0.19",
|
"electron-to-chromium": "^1.5.227",
|
||||||
|
"node-releases": "^2.0.21",
|
||||||
"update-browserslist-db": "^1.1.3"
|
"update-browserslist-db": "^1.1.3"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -1798,9 +1806,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001727",
|
"version": "1.0.30001750",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz",
|
||||||
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
|
"integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1849,9 +1857,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/chart.js": {
|
"node_modules/chart.js": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kurkle/color": "^0.3.0"
|
"@kurkle/color": "^0.3.0"
|
||||||
@@ -1939,15 +1947,6 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cosmiconfig/node_modules/yaml": {
|
|
||||||
"version": "1.10.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
|
||||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/crc-32": {
|
"node_modules/crc-32": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
@@ -2036,9 +2035,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -2142,16 +2141,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.182",
|
"version": "1.5.237",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.182.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
|
||||||
"integrity": "sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==",
|
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/error-ex": {
|
"node_modules/error-ex": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||||
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
|
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-arrayish": "^0.2.1"
|
"is-arrayish": "^0.2.1"
|
||||||
@@ -2494,9 +2493,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-react-refresh": {
|
"node_modules/eslint-plugin-react-refresh": {
|
||||||
"version": "0.4.20",
|
"version": "0.4.24",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz",
|
||||||
"integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==",
|
"integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -2736,9 +2735,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.9",
|
"version": "1.15.11",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -2772,9 +2771,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||||
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
|
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
@@ -2858,6 +2857,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/generator-function": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gensync": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
@@ -3353,14 +3362,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-generator-function": {
|
"node_modules/is-generator-function": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||||
"integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==",
|
"integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.3",
|
"call-bound": "^1.0.4",
|
||||||
"get-proto": "^1.0.0",
|
"generator-function": "^2.0.0",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
"has-tostringtag": "^1.0.2",
|
"has-tostringtag": "^1.0.2",
|
||||||
"safe-regex-test": "^1.1.0"
|
"safe-regex-test": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -3852,9 +3862,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.19",
|
"version": "2.0.23",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
|
||||||
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
|
"integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -4280,9 +4290,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "19.1.0",
|
"version": "19.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
|
||||||
"integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==",
|
"integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
@@ -5308,6 +5318,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "1.10.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||||
|
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -71,6 +71,17 @@ body {
|
|||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 접근 거부 페이지 */
|
/* 접근 거부 페이지 */
|
||||||
.access-denied-container {
|
.access-denied-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,7 @@ import { logApiError } from './utils/errorLogger';
|
|||||||
|
|
||||||
// 환경변수에서 API URL을 읽음 (Vite 기준)
|
// 환경변수에서 API URL을 읽음 (Vite 기준)
|
||||||
// 프로덕션에서는 nginx 프록시를 통해 /api 경로 사용
|
// 프로덕션에서는 nginx 프록시를 통해 /api 경로 사용
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL ||
|
const API_BASE_URL = '/api';
|
||||||
(import.meta.env.DEV ? 'http://localhost:18000' : '/api');
|
|
||||||
|
|
||||||
console.log('API Base URL:', API_BASE_URL);
|
console.log('API Base URL:', API_BASE_URL);
|
||||||
console.log('Environment:', import.meta.env.MODE);
|
console.log('Environment:', import.meta.env.MODE);
|
||||||
|
|||||||
@@ -1,268 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import errorLogger from '../utils/errorLogger';
|
|
||||||
|
|
||||||
class ErrorBoundary extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
hasError: false,
|
|
||||||
error: null,
|
|
||||||
errorInfo: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error) {
|
|
||||||
// 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트합니다.
|
|
||||||
return { hasError: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error, errorInfo) {
|
|
||||||
// 오류 정보를 상태에 저장
|
|
||||||
this.setState({
|
|
||||||
error: error,
|
|
||||||
errorInfo: errorInfo
|
|
||||||
});
|
|
||||||
|
|
||||||
// 오류 로깅
|
|
||||||
errorLogger.logError({
|
|
||||||
type: 'react_error_boundary',
|
|
||||||
message: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
componentStack: errorInfo.componentStack,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
url: window.location.href,
|
|
||||||
props: this.props.errorContext || {}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleReload = () => {
|
|
||||||
window.location.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleGoHome = () => {
|
|
||||||
window.location.href = '/';
|
|
||||||
};
|
|
||||||
|
|
||||||
handleReportError = () => {
|
|
||||||
const errorReport = {
|
|
||||||
error: this.state.error?.message,
|
|
||||||
stack: this.state.error?.stack,
|
|
||||||
componentStack: this.state.errorInfo?.componentStack,
|
|
||||||
url: window.location.href,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
userAgent: navigator.userAgent
|
|
||||||
};
|
|
||||||
|
|
||||||
// 오류 보고서를 클립보드에 복사
|
|
||||||
navigator.clipboard.writeText(JSON.stringify(errorReport, null, 2))
|
|
||||||
.then(() => {
|
|
||||||
alert('오류 정보가 클립보드에 복사되었습니다.');
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// 클립보드 복사 실패 시 텍스트 영역에 표시
|
|
||||||
const textarea = document.createElement('textarea');
|
|
||||||
textarea.value = JSON.stringify(errorReport, null, 2);
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
textarea.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
alert('오류 정보가 클립보드에 복사되었습니다.');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
minHeight: '100vh',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: '#f8f9fa',
|
|
||||||
padding: '20px'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
maxWidth: '600px',
|
|
||||||
width: '100%',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
|
||||||
padding: '40px',
|
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '48px',
|
|
||||||
marginBottom: '20px'
|
|
||||||
}}>
|
|
||||||
😵
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 style={{
|
|
||||||
fontSize: '24px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#dc3545',
|
|
||||||
marginBottom: '16px'
|
|
||||||
}}>
|
|
||||||
앗! 문제가 발생했습니다
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p style={{
|
|
||||||
fontSize: '16px',
|
|
||||||
color: '#6c757d',
|
|
||||||
marginBottom: '30px',
|
|
||||||
lineHeight: '1.5'
|
|
||||||
}}>
|
|
||||||
예상치 못한 오류가 발생했습니다. <br />
|
|
||||||
이 문제는 자동으로 개발팀에 보고되었습니다.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '12px',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
marginBottom: '30px'
|
|
||||||
}}>
|
|
||||||
<button
|
|
||||||
onClick={this.handleReload}
|
|
||||||
style={{
|
|
||||||
padding: '12px 24px',
|
|
||||||
backgroundColor: '#007bff',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: '500',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'background-color 0.2s'
|
|
||||||
}}
|
|
||||||
onMouseOver={(e) => e.target.style.backgroundColor = '#0056b3'}
|
|
||||||
onMouseOut={(e) => e.target.style.backgroundColor = '#007bff'}
|
|
||||||
>
|
|
||||||
🔄 페이지 새로고침
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={this.handleGoHome}
|
|
||||||
style={{
|
|
||||||
padding: '12px 24px',
|
|
||||||
backgroundColor: '#28a745',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: '500',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'background-color 0.2s'
|
|
||||||
}}
|
|
||||||
onMouseOver={(e) => e.target.style.backgroundColor = '#1e7e34'}
|
|
||||||
onMouseOut={(e) => e.target.style.backgroundColor = '#28a745'}
|
|
||||||
>
|
|
||||||
🏠 홈으로 이동
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={this.handleReportError}
|
|
||||||
style={{
|
|
||||||
padding: '12px 24px',
|
|
||||||
backgroundColor: '#6c757d',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: '500',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'background-color 0.2s'
|
|
||||||
}}
|
|
||||||
onMouseOver={(e) => e.target.style.backgroundColor = '#545b62'}
|
|
||||||
onMouseOut={(e) => e.target.style.backgroundColor = '#6c757d'}
|
|
||||||
>
|
|
||||||
📋 오류 정보 복사
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 개발 환경에서만 상세 오류 정보 표시 */}
|
|
||||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
|
||||||
<details style={{
|
|
||||||
textAlign: 'left',
|
|
||||||
backgroundColor: '#f8f9fa',
|
|
||||||
padding: '16px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
marginTop: '20px',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontFamily: 'monospace'
|
|
||||||
}}>
|
|
||||||
<summary style={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontWeight: '600',
|
|
||||||
marginBottom: '8px',
|
|
||||||
color: '#495057'
|
|
||||||
}}>
|
|
||||||
개발자 정보 (클릭하여 펼치기)
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<strong>오류 메시지:</strong>
|
|
||||||
<pre style={{
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
margin: '4px 0',
|
|
||||||
color: '#dc3545'
|
|
||||||
}}>
|
|
||||||
{this.state.error.message}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<strong>스택 트레이스:</strong>
|
|
||||||
<pre style={{
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
margin: '4px 0',
|
|
||||||
fontSize: '11px',
|
|
||||||
color: '#6c757d'
|
|
||||||
}}>
|
|
||||||
{this.state.error.stack}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{this.state.errorInfo?.componentStack && (
|
|
||||||
<div>
|
|
||||||
<strong>컴포넌트 스택:</strong>
|
|
||||||
<pre style={{
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
margin: '4px 0',
|
|
||||||
fontSize: '11px',
|
|
||||||
color: '#6c757d'
|
|
||||||
}}>
|
|
||||||
{this.state.errorInfo.componentStack}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
marginTop: '30px',
|
|
||||||
padding: '16px',
|
|
||||||
backgroundColor: '#e3f2fd',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '14px',
|
|
||||||
color: '#1565c0'
|
|
||||||
}}>
|
|
||||||
💡 <strong>도움말:</strong> 문제가 계속 발생하면 페이지를 새로고침하거나
|
|
||||||
브라우저 캐시를 삭제해보세요.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ErrorBoundary;
|
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
animation: dropdownSlide 0.3s ease-out;
|
animation: dropdownSlide 0.3s ease-out;
|
||||||
z-index: 1000;
|
z-index: 1050;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes dropdownSlide {
|
@keyframes dropdownSlide {
|
||||||
|
|||||||
3
frontend/src/components/bom/index.js
Normal file
3
frontend/src/components/bom/index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// BOM Components
|
||||||
|
export * from './materials';
|
||||||
|
export * from './shared';
|
||||||
742
frontend/src/components/bom/materials/BoltMaterialsView.jsx
Normal file
742
frontend/src/components/bom/materials/BoltMaterialsView.jsx
Normal file
@@ -0,0 +1,742 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||||
|
import api from '../../../api';
|
||||||
|
import { FilterableHeader } from '../shared';
|
||||||
|
|
||||||
|
const BoltMaterialsView = ({
|
||||||
|
materials,
|
||||||
|
selectedMaterials,
|
||||||
|
setSelectedMaterials,
|
||||||
|
userRequirements,
|
||||||
|
setUserRequirements,
|
||||||
|
purchasedMaterials,
|
||||||
|
onPurchasedMaterialsUpdate,
|
||||||
|
updateMaterial, // 자재 업데이트 함수
|
||||||
|
jobNo,
|
||||||
|
fileId,
|
||||||
|
user
|
||||||
|
}) => {
|
||||||
|
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||||
|
const [columnFilters, setColumnFilters] = useState({});
|
||||||
|
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||||
|
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||||
|
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||||
|
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadSavedData = () => {
|
||||||
|
const savedRequestsData = {};
|
||||||
|
|
||||||
|
materials.forEach(material => {
|
||||||
|
if (material.user_requirement && material.user_requirement.trim()) {
|
||||||
|
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSavedRequests(savedRequestsData);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (materials && materials.length > 0) {
|
||||||
|
loadSavedData();
|
||||||
|
} else {
|
||||||
|
setSavedRequests({});
|
||||||
|
}
|
||||||
|
}, [materials]);
|
||||||
|
|
||||||
|
// 추가요구사항 저장 함수
|
||||||
|
const handleSaveRequest = async (materialId, request) => {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
try {
|
||||||
|
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||||
|
user_requirement: request.trim()
|
||||||
|
});
|
||||||
|
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||||
|
|
||||||
|
if (updateMaterial) {
|
||||||
|
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('추가요구사항 저장 실패:', error);
|
||||||
|
alert('추가요구사항 저장에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 추가요구사항 편집 시작
|
||||||
|
const handleEditRequest = (materialId, currentRequest) => {
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 볼트 추가요구사항 추출 함수
|
||||||
|
const extractBoltAdditionalRequirements = (description) => {
|
||||||
|
const additionalReqs = [];
|
||||||
|
|
||||||
|
// 표면처리 패턴 확인
|
||||||
|
const surfacePatterns = {
|
||||||
|
'ELEC.GALV': 'ELEC.GALV',
|
||||||
|
'ELEC GALV': 'ELEC.GALV',
|
||||||
|
'GALVANIZED': 'GALVANIZED',
|
||||||
|
'GALV': 'GALV',
|
||||||
|
'HOT DIP GALV': 'HDG',
|
||||||
|
'HDG': 'HDG',
|
||||||
|
'ZINC PLATED': 'ZINC PLATED',
|
||||||
|
'ZINC': 'ZINC',
|
||||||
|
'PLAIN': 'PLAIN'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [pattern, treatment] of Object.entries(surfacePatterns)) {
|
||||||
|
if (description.includes(pattern)) {
|
||||||
|
additionalReqs.push(treatment);
|
||||||
|
break; // 첫 번째 매치만 사용
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return additionalReqs.join(', ') || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseBoltInfo = (material) => {
|
||||||
|
const qty = Math.round(material.quantity || 0);
|
||||||
|
|
||||||
|
// 플랜지당 볼트 세트 수 추출 (예: (8), (4))
|
||||||
|
const boltDetails = material.bolt_details || {};
|
||||||
|
let boltsPerFlange = boltDetails.dimensions?.bolts_per_flange || 1;
|
||||||
|
|
||||||
|
// 백엔드에서 정보가 없으면 원본 설명에서 직접 추출
|
||||||
|
if (boltsPerFlange === 1) {
|
||||||
|
const description = material.original_description || '';
|
||||||
|
const flangePattern = description.match(/\((\d+)\)/);
|
||||||
|
if (flangePattern) {
|
||||||
|
boltsPerFlange = parseInt(flangePattern[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실제 필요한 볼트 수 = 플랜지 수 × 플랜지당 볼트 세트 수
|
||||||
|
const totalBoltsNeeded = qty * boltsPerFlange;
|
||||||
|
const safetyQty = Math.ceil(totalBoltsNeeded * 1.05); // 5% 여유율
|
||||||
|
const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수
|
||||||
|
|
||||||
|
// 길이 정보 (bolt_details 우선, 없으면 원본 설명에서 추출)
|
||||||
|
let boltLength = '-';
|
||||||
|
if (boltDetails.length && boltDetails.length !== '-') {
|
||||||
|
boltLength = boltDetails.length;
|
||||||
|
} else {
|
||||||
|
// 원본 설명에서 길이 추출
|
||||||
|
const description = material.original_description || '';
|
||||||
|
const lengthPatterns = [
|
||||||
|
/(\d+(?:\.\d+)?)\s*LG/i, // 75 LG, 90.0000 LG
|
||||||
|
/(\d+(?:\.\d+)?)\s*mm/i, // 50mm
|
||||||
|
/(\d+(?:\.\d+)?)\s*MM/i, // 50MM
|
||||||
|
/LG[,\s]*(\d+(?:\.\d+)?)/i // LG, 75 형태
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of lengthPatterns) {
|
||||||
|
const match = description.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
let lengthValue = match[1];
|
||||||
|
// 소수점 제거 (145.0000 → 145)
|
||||||
|
if (lengthValue.includes('.') && lengthValue.endsWith('.0000')) {
|
||||||
|
lengthValue = lengthValue.split('.')[0];
|
||||||
|
} else if (lengthValue.includes('.') && /\.0+$/.test(lengthValue)) {
|
||||||
|
lengthValue = lengthValue.split('.')[0];
|
||||||
|
}
|
||||||
|
boltLength = `${lengthValue}mm`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 재질 정보 (bolt_details 우선, 없으면 기본 필드 사용)
|
||||||
|
let boltGrade = '-';
|
||||||
|
if (boltDetails.material_standard && boltDetails.material_grade) {
|
||||||
|
// bolt_details에서 완전한 재질 정보 구성
|
||||||
|
if (boltDetails.material_grade !== 'UNKNOWN' && boltDetails.material_grade !== 'UNCLASSIFIED' && boltDetails.material_grade !== boltDetails.material_standard) {
|
||||||
|
boltGrade = `${boltDetails.material_standard} ${boltDetails.material_grade}`;
|
||||||
|
} else {
|
||||||
|
boltGrade = boltDetails.material_standard;
|
||||||
|
}
|
||||||
|
} else if (material.full_material_grade && material.full_material_grade !== '-') {
|
||||||
|
boltGrade = material.full_material_grade;
|
||||||
|
} else if (material.material_grade && material.material_grade !== '-') {
|
||||||
|
boltGrade = material.material_grade;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 볼트 타입 (PSV_BOLT, LT_BOLT 등)
|
||||||
|
let boltSubtype = 'BOLT_GENERAL';
|
||||||
|
if (boltDetails.bolt_type && boltDetails.bolt_type !== 'UNKNOWN' && boltDetails.bolt_type !== 'UNCLASSIFIED') {
|
||||||
|
boltSubtype = boltDetails.bolt_type;
|
||||||
|
} else {
|
||||||
|
// 원본 설명에서 특수 볼트 타입 추출
|
||||||
|
const description = material.original_description || '';
|
||||||
|
const upperDesc = description.toUpperCase();
|
||||||
|
if (upperDesc.includes('PSV')) {
|
||||||
|
boltSubtype = 'PSV_BOLT';
|
||||||
|
} else if (upperDesc.includes('LT')) {
|
||||||
|
boltSubtype = 'LT_BOLT';
|
||||||
|
} else if (upperDesc.includes('CK')) {
|
||||||
|
boltSubtype = 'CK_BOLT';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 압력 등급 추출 (150LB 등)
|
||||||
|
let boltPressure = '-';
|
||||||
|
const description = material.original_description || '';
|
||||||
|
const pressureMatch = description.match(/(\d+)\s*LB/i);
|
||||||
|
if (pressureMatch) {
|
||||||
|
boltPressure = `${pressureMatch[1]}LB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Requirements 추출 (ELEC.GALV 등)
|
||||||
|
const userRequirements = extractBoltAdditionalRequirements(material.original_description || '');
|
||||||
|
|
||||||
|
// Purchase Quantity (Quantity + Unit 통합) - 플랜지당 볼트 세트 수 정보 포함
|
||||||
|
const purchaseQuantity = boltsPerFlange > 1
|
||||||
|
? `${purchaseQty} SETS (${boltsPerFlange}/flange)`
|
||||||
|
: `${purchaseQty} SETS`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'BOLT',
|
||||||
|
subtype: boltSubtype,
|
||||||
|
size: material.size_spec || material.main_nom || '-',
|
||||||
|
pressure: boltPressure, // 압력 등급 (150LB 등)
|
||||||
|
schedule: boltLength, // 길이 정보
|
||||||
|
grade: boltGrade,
|
||||||
|
userRequirements: userRequirements, // User Requirements (ELEC.GALV 등)
|
||||||
|
additionalReq: '-', // 추가요구사항 (사용자 입력)
|
||||||
|
purchaseQuantity: purchaseQuantity // 구매수량 (통합)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 정렬 처리
|
||||||
|
const handleSort = (key) => {
|
||||||
|
let direction = 'asc';
|
||||||
|
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||||
|
direction = 'desc';
|
||||||
|
}
|
||||||
|
setSortConfig({ key, direction });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터링된 및 정렬된 자재 목록
|
||||||
|
const getFilteredAndSortedMaterials = () => {
|
||||||
|
let filtered = materials.filter(material => {
|
||||||
|
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||||
|
if (!filterValue) return true;
|
||||||
|
const info = parseBoltInfo(material);
|
||||||
|
const value = info[key]?.toString().toLowerCase() || '';
|
||||||
|
return value.includes(filterValue.toLowerCase());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sortConfig && sortConfig.key) {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const aInfo = parseBoltInfo(a);
|
||||||
|
const bInfo = parseBoltInfo(b);
|
||||||
|
|
||||||
|
if (!aInfo || !bInfo) return 0;
|
||||||
|
|
||||||
|
const aValue = aInfo[sortConfig.key];
|
||||||
|
const bValue = bInfo[sortConfig.key];
|
||||||
|
|
||||||
|
// 값이 없는 경우 처리
|
||||||
|
if (aValue === undefined && bValue === undefined) return 0;
|
||||||
|
if (aValue === undefined) return 1;
|
||||||
|
if (bValue === undefined) return -1;
|
||||||
|
|
||||||
|
// 숫자인 경우 숫자로 비교
|
||||||
|
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||||
|
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문자열로 비교
|
||||||
|
const aStr = String(aValue).toLowerCase();
|
||||||
|
const bStr = String(bValue).toLowerCase();
|
||||||
|
|
||||||
|
if (sortConfig.direction === 'asc') {
|
||||||
|
return aStr.localeCompare(bStr);
|
||||||
|
} else {
|
||||||
|
return bStr.localeCompare(aStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전체 선택/해제 (구매신청된 자재 제외)
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||||
|
|
||||||
|
if (selectedMaterials.size === selectableMaterials.length) {
|
||||||
|
setSelectedMaterials(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개별 선택 (구매신청된 자재는 선택 불가)
|
||||||
|
const handleMaterialSelect = (materialId) => {
|
||||||
|
if (purchasedMaterials.has(materialId)) {
|
||||||
|
return; // 구매신청된 자재는 선택 불가
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSelected = new Set(selectedMaterials);
|
||||||
|
if (newSelected.has(materialId)) {
|
||||||
|
newSelected.delete(materialId);
|
||||||
|
} else {
|
||||||
|
newSelected.add(materialId);
|
||||||
|
}
|
||||||
|
setSelectedMaterials(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엑셀 내보내기
|
||||||
|
const handleExportToExcel = async () => {
|
||||||
|
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||||
|
if (selectedMaterialsData.length === 0) {
|
||||||
|
alert('내보낼 자재를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||||
|
const excelFileName = `BOLT_Materials_${timestamp}.xlsx`;
|
||||||
|
|
||||||
|
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||||
|
...material,
|
||||||
|
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔄 볼트 엑셀 내보내기 시작 - 새로운 방식');
|
||||||
|
|
||||||
|
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||||
|
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||||
|
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'BOLT',
|
||||||
|
filename: excelFileName,
|
||||||
|
uploadDate: new Date().toLocaleDateString()
|
||||||
|
});
|
||||||
|
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||||
|
|
||||||
|
// 2. 구매신청 생성
|
||||||
|
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||||
|
const response = await api.post('/purchase-request/create', {
|
||||||
|
file_id: fileId,
|
||||||
|
job_no: jobNo,
|
||||||
|
category: 'BOLT',
|
||||||
|
material_ids: allMaterialIds,
|
||||||
|
materials_data: dataWithRequirements.map(m => ({
|
||||||
|
material_id: m.id,
|
||||||
|
description: m.original_description,
|
||||||
|
category: m.classified_category,
|
||||||
|
size: m.size_inch || m.size_spec,
|
||||||
|
schedule: m.schedule,
|
||||||
|
material_grade: m.material_grade || m.full_material_grade,
|
||||||
|
quantity: m.quantity,
|
||||||
|
unit: m.unit,
|
||||||
|
user_requirement: userRequirements[m.id] || ''
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||||
|
|
||||||
|
// 3. 엑셀 파일을 서버에 업로드
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('excel_file', excelBlob, excelFileName);
|
||||||
|
formData.append('request_id', response.data.request_id);
|
||||||
|
formData.append('category', 'BOLT');
|
||||||
|
|
||||||
|
console.log('📤 엑셀 파일 서버 업로드 중...');
|
||||||
|
await api.post('/purchase-request/upload-excel', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
});
|
||||||
|
console.log('✅ 엑셀 파일 서버 업로드 완료');
|
||||||
|
|
||||||
|
// 4. 구매된 자재 목록 업데이트 (비활성화)
|
||||||
|
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||||
|
console.log('✅ 구매된 자재 목록 업데이트 완료');
|
||||||
|
|
||||||
|
// 5. 클라이언트에 파일 다운로드
|
||||||
|
const url = window.URL.createObjectURL(excelBlob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = excelFileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data?.message || '구매신청 생성 실패');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||||
|
// 실패 시에도 클라이언트 다운로드는 진행
|
||||||
|
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'BOLT',
|
||||||
|
filename: excelFileName,
|
||||||
|
uploadDate: new Date().toLocaleDateString()
|
||||||
|
});
|
||||||
|
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택 해제
|
||||||
|
setSelectedMaterials(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '32px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0f172a',
|
||||||
|
margin: '0 0 8px 0'
|
||||||
|
}}>
|
||||||
|
Bolt Materials
|
||||||
|
</h3>
|
||||||
|
<p style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#64748b',
|
||||||
|
margin: 0
|
||||||
|
}}>
|
||||||
|
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
color: '#6b7280',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleExportToExcel}
|
||||||
|
disabled={selectedMaterials.size === 0}
|
||||||
|
style={{
|
||||||
|
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #6b7280 0%, #4b5563 100%)' : '#e5e7eb',
|
||||||
|
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Export to Excel ({selectedMaterials.size})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '600px',
|
||||||
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||||
|
}}>
|
||||||
|
<div style={{ minWidth: '1500px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 200px 200px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(() => {
|
||||||
|
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||||
|
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
|
||||||
|
})()}
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="subtype"
|
||||||
|
filterKey="subtype"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="size"
|
||||||
|
filterKey="size"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Size
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="pressure"
|
||||||
|
filterKey="pressure"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Pressure
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="schedule"
|
||||||
|
filterKey="schedule"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Length
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="grade"
|
||||||
|
filterKey="grade"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Material Grade
|
||||||
|
</FilterableHeader>
|
||||||
|
<div>User Requirements</div>
|
||||||
|
<div>Additional Request</div>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="purchaseQuantity"
|
||||||
|
filterKey="purchaseQuantity"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Purchase Quantity
|
||||||
|
</FilterableHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행들 */}
|
||||||
|
{filteredMaterials.map((material, index) => {
|
||||||
|
const info = parseBoltInfo(material);
|
||||||
|
const isSelected = selectedMaterials.has(material.id);
|
||||||
|
const isPurchased = purchasedMaterials.has(material.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={material.id}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 200px 200px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||||
|
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||||
|
transition: 'background 0.15s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = '#f8fafc';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = 'white';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => handleMaterialSelect(material.id)}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||||
|
{info.subtype}
|
||||||
|
{isPurchased && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
background: '#fbbf24',
|
||||||
|
color: '#92400e',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}>
|
||||||
|
PURCHASED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.size}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.pressure}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.schedule}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.grade}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#1f2937',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
{info.userRequirements}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||||
|
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||||
|
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: '#f9fafb',
|
||||||
|
color: '#374151'
|
||||||
|
}}>
|
||||||
|
{savedRequests[material.id]}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userRequirements[material.id] || ''}
|
||||||
|
onChange={(e) => setUserRequirements({
|
||||||
|
...userRequirements,
|
||||||
|
[material.id]: e.target.value
|
||||||
|
})}
|
||||||
|
placeholder="Enter additional request..."
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||||
|
disabled={isPurchased || savingRequest[material.id]}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{savingRequest[material.id] ? '...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.purchaseQuantity}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredMaterials.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '60px 20px',
|
||||||
|
color: '#64748b'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||||
|
No Bolt Materials Found
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px' }}>
|
||||||
|
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||||
|
? 'Try adjusting your filters'
|
||||||
|
: 'No bolt materials available in this BOM'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BoltMaterialsView;
|
||||||
898
frontend/src/components/bom/materials/FittingMaterialsView.jsx
Normal file
898
frontend/src/components/bom/materials/FittingMaterialsView.jsx
Normal file
@@ -0,0 +1,898 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||||
|
import api from '../../../api';
|
||||||
|
import { FilterableHeader, MaterialTable } from '../shared';
|
||||||
|
|
||||||
|
const FittingMaterialsView = ({
|
||||||
|
materials,
|
||||||
|
selectedMaterials,
|
||||||
|
setSelectedMaterials,
|
||||||
|
userRequirements,
|
||||||
|
setUserRequirements,
|
||||||
|
purchasedMaterials,
|
||||||
|
onPurchasedMaterialsUpdate,
|
||||||
|
updateMaterial, // 자재 업데이트 함수
|
||||||
|
fileId,
|
||||||
|
jobNo,
|
||||||
|
user,
|
||||||
|
onNavigate
|
||||||
|
}) => {
|
||||||
|
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||||
|
const [columnFilters, setColumnFilters] = useState({});
|
||||||
|
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||||
|
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||||
|
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||||
|
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadSavedData = () => {
|
||||||
|
const savedRequestsData = {};
|
||||||
|
|
||||||
|
materials.forEach(material => {
|
||||||
|
if (material.user_requirement && material.user_requirement.trim()) {
|
||||||
|
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSavedRequests(savedRequestsData);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (materials && materials.length > 0) {
|
||||||
|
loadSavedData();
|
||||||
|
} else {
|
||||||
|
setSavedRequests({});
|
||||||
|
}
|
||||||
|
}, [materials]);
|
||||||
|
|
||||||
|
// 추가요구사항 저장 함수
|
||||||
|
const handleSaveRequest = async (materialId, request) => {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
try {
|
||||||
|
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||||
|
user_requirement: request.trim()
|
||||||
|
});
|
||||||
|
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||||
|
|
||||||
|
if (updateMaterial) {
|
||||||
|
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('추가요구사항 저장 실패:', error);
|
||||||
|
alert('추가요구사항 저장에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 추가요구사항 편집 시작
|
||||||
|
const handleEditRequest = (materialId, currentRequest) => {
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 니플 끝단 정보 추출 (기존 로직 복원)
|
||||||
|
const extractNippleEndInfo = (description) => {
|
||||||
|
const descUpper = description.toUpperCase();
|
||||||
|
|
||||||
|
// 니플 끝단 패턴들 (기존 NewMaterialsPage와 동일)
|
||||||
|
const endPatterns = {
|
||||||
|
'PBE': 'PBE', // Plain Both End
|
||||||
|
'BBE': 'BBE', // Bevel Both End
|
||||||
|
'POE': 'POE', // Plain One End
|
||||||
|
'BOE': 'BOE', // Bevel One End
|
||||||
|
'TOE': 'TOE', // Thread One End
|
||||||
|
'SW X NPT': 'SW×NPT', // Socket Weld x NPT
|
||||||
|
'SW X SW': 'SW×SW', // Socket Weld x Socket Weld
|
||||||
|
'NPT X NPT': 'NPT×NPT', // NPT x NPT
|
||||||
|
'BOTH END THREADED': 'B.E.T',
|
||||||
|
'B.E.T': 'B.E.T',
|
||||||
|
'ONE END THREADED': 'O.E.T',
|
||||||
|
'O.E.T': 'O.E.T',
|
||||||
|
'THREADED': 'THD'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [pattern, display] of Object.entries(endPatterns)) {
|
||||||
|
if (descUpper.includes(pattern)) {
|
||||||
|
return display;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 피팅 정보 파싱 (기존 상세 로직 복원)
|
||||||
|
const parseFittingInfo = (material) => {
|
||||||
|
const fittingDetails = material.fitting_details || {};
|
||||||
|
const classificationDetails = material.classification_details || {};
|
||||||
|
|
||||||
|
// 개선된 분류기 결과 우선 사용
|
||||||
|
const fittingTypeInfo = classificationDetails.fitting_type || {};
|
||||||
|
const scheduleInfo = classificationDetails.schedule_info || {};
|
||||||
|
|
||||||
|
// 기존 필드와 새 필드 통합
|
||||||
|
const fittingType = fittingTypeInfo.type || fittingDetails.fitting_type || '';
|
||||||
|
const fittingSubtype = fittingTypeInfo.subtype || fittingDetails.fitting_subtype || '';
|
||||||
|
const mainSchedule = scheduleInfo.main_schedule || fittingDetails.schedule || '';
|
||||||
|
const redSchedule = scheduleInfo.red_schedule || '';
|
||||||
|
const hasDifferentSchedules = scheduleInfo.has_different_schedules || false;
|
||||||
|
|
||||||
|
const description = material.original_description || '';
|
||||||
|
|
||||||
|
// 피팅 타입별 상세 표시
|
||||||
|
let displayType = '';
|
||||||
|
|
||||||
|
// 개선된 분류기 결과 우선 표시
|
||||||
|
if (fittingType === 'TEE' && fittingSubtype === 'REDUCING') {
|
||||||
|
displayType = 'TEE REDUCING';
|
||||||
|
} else if (fittingType === 'REDUCER' && fittingSubtype === 'CONCENTRIC') {
|
||||||
|
displayType = 'REDUCER CONC';
|
||||||
|
} else if (fittingType === 'REDUCER' && fittingSubtype === 'ECCENTRIC') {
|
||||||
|
displayType = 'REDUCER ECC';
|
||||||
|
} else if (description.toUpperCase().includes('TEE RED')) {
|
||||||
|
displayType = 'TEE REDUCING';
|
||||||
|
} else if (description.toUpperCase().includes('RED CONC')) {
|
||||||
|
displayType = 'REDUCER CONC';
|
||||||
|
} else if (description.toUpperCase().includes('RED ECC')) {
|
||||||
|
displayType = 'REDUCER ECC';
|
||||||
|
} else if (description.toUpperCase().includes('CAP')) {
|
||||||
|
if (description.includes('NPT(F)')) {
|
||||||
|
displayType = 'CAP NPT(F)';
|
||||||
|
} else if (description.includes('SW')) {
|
||||||
|
displayType = 'CAP SW';
|
||||||
|
} else if (description.includes('BW')) {
|
||||||
|
displayType = 'CAP BW';
|
||||||
|
} else {
|
||||||
|
displayType = 'CAP';
|
||||||
|
}
|
||||||
|
} else if (description.toUpperCase().includes('PLUG')) {
|
||||||
|
if (description.toUpperCase().includes('HEX')) {
|
||||||
|
if (description.includes('NPT(M)')) {
|
||||||
|
displayType = 'HEX PLUG NPT(M)';
|
||||||
|
} else {
|
||||||
|
displayType = 'HEX PLUG';
|
||||||
|
}
|
||||||
|
} else if (description.includes('NPT(M)')) {
|
||||||
|
displayType = 'PLUG NPT(M)';
|
||||||
|
} else if (description.includes('NPT')) {
|
||||||
|
displayType = 'PLUG NPT';
|
||||||
|
} else {
|
||||||
|
displayType = 'PLUG';
|
||||||
|
}
|
||||||
|
} else if (fittingType === 'NIPPLE') {
|
||||||
|
const length = fittingDetails.length_mm || fittingDetails.avg_length_mm;
|
||||||
|
const endInfo = extractNippleEndInfo(description);
|
||||||
|
|
||||||
|
let nippleType = 'NIPPLE';
|
||||||
|
if (length) nippleType += ` ${length}mm`;
|
||||||
|
if (endInfo) nippleType += ` ${endInfo}`;
|
||||||
|
|
||||||
|
displayType = nippleType;
|
||||||
|
} else if (fittingType === 'ELBOW') {
|
||||||
|
let elbowDetails = [];
|
||||||
|
|
||||||
|
// 각도 정보 추출
|
||||||
|
if (fittingSubtype.includes('90DEG') || description.includes('90') || description.includes('90°')) {
|
||||||
|
elbowDetails.push('90°');
|
||||||
|
} else if (fittingSubtype.includes('45DEG') || description.includes('45') || description.includes('45°')) {
|
||||||
|
elbowDetails.push('45°');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 반경 정보 추출 (Long Radius / Short Radius)
|
||||||
|
if (fittingSubtype.includes('LONG_RADIUS') || description.toUpperCase().includes('LR') || description.toUpperCase().includes('LONG RADIUS')) {
|
||||||
|
elbowDetails.push('LR');
|
||||||
|
} else if (fittingSubtype.includes('SHORT_RADIUS') || description.toUpperCase().includes('SR') || description.toUpperCase().includes('SHORT RADIUS')) {
|
||||||
|
elbowDetails.push('SR');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연결 방식
|
||||||
|
if (description.includes('SW')) {
|
||||||
|
elbowDetails.push('SW');
|
||||||
|
} else if (description.includes('BW')) {
|
||||||
|
elbowDetails.push('BW');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본값 설정 (각도가 없으면 90도로 가정)
|
||||||
|
if (!elbowDetails.some(detail => detail.includes('°'))) {
|
||||||
|
elbowDetails.unshift('90°');
|
||||||
|
}
|
||||||
|
|
||||||
|
displayType = `ELBOW ${elbowDetails.join(' ')}`.trim();
|
||||||
|
} else if (fittingType === 'TEE') {
|
||||||
|
// TEE 타입과 연결 방식 상세 표시
|
||||||
|
let teeDetails = [];
|
||||||
|
|
||||||
|
// 등경/축소 타입
|
||||||
|
if (fittingSubtype === 'EQUAL' || description.toUpperCase().includes('TEE EQ')) {
|
||||||
|
teeDetails.push('EQ');
|
||||||
|
} else if (fittingSubtype === 'REDUCING' || description.toUpperCase().includes('TEE RED')) {
|
||||||
|
teeDetails.push('RED');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연결 방식
|
||||||
|
if (description.includes('SW')) {
|
||||||
|
teeDetails.push('SW');
|
||||||
|
} else if (description.includes('BW')) {
|
||||||
|
teeDetails.push('BW');
|
||||||
|
}
|
||||||
|
|
||||||
|
displayType = `TEE ${teeDetails.join(' ')}`.trim();
|
||||||
|
} else if (fittingType === 'REDUCER') {
|
||||||
|
const reducerType = fittingSubtype === 'CONCENTRIC' ? 'CONC' : fittingSubtype === 'ECCENTRIC' ? 'ECC' : '';
|
||||||
|
const sizes = fittingDetails.reduced_size ? `${material.size_spec}×${fittingDetails.reduced_size}` : material.size_spec;
|
||||||
|
displayType = `RED ${reducerType} ${sizes}`.trim();
|
||||||
|
} else if (fittingType === 'SWAGE') {
|
||||||
|
const swageType = fittingSubtype || '';
|
||||||
|
displayType = `SWAGE ${swageType}`.trim();
|
||||||
|
} else if (fittingType === 'OLET') {
|
||||||
|
const oletSubtype = fittingSubtype || '';
|
||||||
|
let oletDisplayName = '';
|
||||||
|
|
||||||
|
// 백엔드 분류기 결과 우선 사용
|
||||||
|
switch (oletSubtype) {
|
||||||
|
case 'SOCKOLET':
|
||||||
|
oletDisplayName = 'SOCK-O-LET';
|
||||||
|
break;
|
||||||
|
case 'WELDOLET':
|
||||||
|
oletDisplayName = 'WELD-O-LET';
|
||||||
|
break;
|
||||||
|
case 'ELLOLET':
|
||||||
|
oletDisplayName = 'ELL-O-LET';
|
||||||
|
break;
|
||||||
|
case 'THREADOLET':
|
||||||
|
oletDisplayName = 'THREAD-O-LET';
|
||||||
|
break;
|
||||||
|
case 'ELBOLET':
|
||||||
|
oletDisplayName = 'ELB-O-LET';
|
||||||
|
break;
|
||||||
|
case 'NIPOLET':
|
||||||
|
oletDisplayName = 'NIP-O-LET';
|
||||||
|
break;
|
||||||
|
case 'COUPOLET':
|
||||||
|
oletDisplayName = 'COUP-O-LET';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// 백엔드 분류가 없으면 description에서 직접 추출
|
||||||
|
const upperDesc = description.toUpperCase();
|
||||||
|
if (upperDesc.includes('SOCK-O-LET') || upperDesc.includes('SOCKOLET')) {
|
||||||
|
oletDisplayName = 'SOCK-O-LET';
|
||||||
|
} else if (upperDesc.includes('WELD-O-LET') || upperDesc.includes('WELDOLET')) {
|
||||||
|
oletDisplayName = 'WELD-O-LET';
|
||||||
|
} else if (upperDesc.includes('ELL-O-LET') || upperDesc.includes('ELLOLET')) {
|
||||||
|
oletDisplayName = 'ELL-O-LET';
|
||||||
|
} else if (upperDesc.includes('THREAD-O-LET') || upperDesc.includes('THREADOLET')) {
|
||||||
|
oletDisplayName = 'THREAD-O-LET';
|
||||||
|
} else if (upperDesc.includes('ELB-O-LET') || upperDesc.includes('ELBOLET')) {
|
||||||
|
oletDisplayName = 'ELB-O-LET';
|
||||||
|
} else if (upperDesc.includes('NIP-O-LET') || upperDesc.includes('NIPOLET')) {
|
||||||
|
oletDisplayName = 'NIP-O-LET';
|
||||||
|
} else if (upperDesc.includes('COUP-O-LET') || upperDesc.includes('COUPOLET')) {
|
||||||
|
oletDisplayName = 'COUP-O-LET';
|
||||||
|
} else {
|
||||||
|
oletDisplayName = 'OLET';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
displayType = oletDisplayName;
|
||||||
|
} else if (!displayType) {
|
||||||
|
displayType = fittingType || 'FITTING';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 압력 등급과 스케줄 추출 (기존 NewMaterialsPage 로직)
|
||||||
|
let pressure = '-';
|
||||||
|
let schedule = '-';
|
||||||
|
|
||||||
|
// 압력 등급 찾기 (3000LB, 6000LB 등) - 소켓웰드 피팅에 특히 중요
|
||||||
|
const pressureMatch = description.match(/(\d+)LB/i);
|
||||||
|
if (pressureMatch) {
|
||||||
|
pressure = `${pressureMatch[1]}LB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 소켓웰드 피팅의 경우 압력 등급이 더 중요함
|
||||||
|
if (description.includes('SW') && !pressureMatch) {
|
||||||
|
// SW 피팅인데 압력이 명시되지 않은 경우 기본값 설정
|
||||||
|
if (description.includes('3000') || description.includes('3K')) {
|
||||||
|
pressure = '3000LB';
|
||||||
|
} else if (description.includes('6000') || description.includes('6K')) {
|
||||||
|
pressure = '6000LB';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스케줄 표시 (분리 스케줄 지원) - 개선된 로직
|
||||||
|
// 레듀싱 자재인지 확인
|
||||||
|
const isReducingFitting = displayType.includes('REDUCING') || displayType.includes('RED') ||
|
||||||
|
description.toUpperCase().includes('RED') ||
|
||||||
|
description.toUpperCase().includes('REDUCING');
|
||||||
|
|
||||||
|
if (hasDifferentSchedules && mainSchedule && redSchedule) {
|
||||||
|
schedule = `${mainSchedule}×${redSchedule}`;
|
||||||
|
} else if (isReducingFitting && mainSchedule && mainSchedule !== 'UNKNOWN' && mainSchedule !== 'UNCLASSIFIED') {
|
||||||
|
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
|
||||||
|
schedule = `${mainSchedule}×${mainSchedule}`;
|
||||||
|
} else if (mainSchedule && mainSchedule !== 'UNKNOWN' && mainSchedule !== 'UNCLASSIFIED') {
|
||||||
|
schedule = mainSchedule;
|
||||||
|
} else {
|
||||||
|
// Description에서 스케줄 추출 - 더 강력한 패턴 매칭
|
||||||
|
const schedulePatterns = [
|
||||||
|
/SCH\s*(\d+S?)/i, // SCH 40, SCH 80S
|
||||||
|
/SCHEDULE\s*(\d+S?)/i, // SCHEDULE 40
|
||||||
|
/스케줄\s*(\d+S?)/i, // 스케줄 40
|
||||||
|
/(\d+S?)\s*SCH/i, // 40 SCH (역순)
|
||||||
|
/SCH\.?\s*(\d+S?)/i, // SCH.40
|
||||||
|
/SCH\s*(\d+S?)\s*[xX×]\s*SCH\s*(\d+S?)/i // SCH 40 x SCH 80
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of schedulePatterns) {
|
||||||
|
const match = description.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
if (match.length > 2) {
|
||||||
|
// 분리 스케줄 패턴 (SCH 40 x SCH 80)
|
||||||
|
schedule = `SCH ${match[1]}×SCH ${match[2]}`;
|
||||||
|
} else {
|
||||||
|
const scheduleNum = match[1];
|
||||||
|
if (isReducingFitting) {
|
||||||
|
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
|
||||||
|
schedule = `SCH ${scheduleNum}×SCH ${scheduleNum}`;
|
||||||
|
} else {
|
||||||
|
schedule = `SCH ${scheduleNum}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 여전히 찾지 못했다면 더 넓은 패턴 시도
|
||||||
|
if (schedule === '-') {
|
||||||
|
const broadPatterns = [
|
||||||
|
/\b(\d+)\s*LB/i, // 압력 등급에서 유추
|
||||||
|
/\b(40|80|120|160)\b/i, // 일반적인 스케줄 숫자
|
||||||
|
/\b(10|20|30|40|60|80|100|120|140|160)\b/i // 모든 표준 스케줄
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of broadPatterns) {
|
||||||
|
const match = description.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
const num = match[1];
|
||||||
|
// 압력 등급이 아닌 경우만 스케줄로 간주
|
||||||
|
if (!description.includes(`${num}LB`)) {
|
||||||
|
if (isReducingFitting) {
|
||||||
|
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
|
||||||
|
schedule = `SCH ${num}×SCH ${num}`;
|
||||||
|
} else {
|
||||||
|
schedule = `SCH ${num}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'FITTING',
|
||||||
|
subtype: displayType,
|
||||||
|
size: material.size_spec || '-',
|
||||||
|
pressure: pressure,
|
||||||
|
schedule: schedule,
|
||||||
|
grade: material.full_material_grade || material.material_grade || '-',
|
||||||
|
quantity: Math.round(material.quantity || 0),
|
||||||
|
unit: '개',
|
||||||
|
isFitting: true
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 정렬 처리
|
||||||
|
const handleSort = (key) => {
|
||||||
|
let direction = 'asc';
|
||||||
|
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||||
|
direction = 'desc';
|
||||||
|
}
|
||||||
|
setSortConfig({ key, direction });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터링된 및 정렬된 자재 목록
|
||||||
|
const getFilteredAndSortedMaterials = () => {
|
||||||
|
let filtered = materials.filter(material => {
|
||||||
|
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||||
|
if (!filterValue) return true;
|
||||||
|
const info = parseFittingInfo(material);
|
||||||
|
const value = info[key]?.toString().toLowerCase() || '';
|
||||||
|
return value.includes(filterValue.toLowerCase());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sortConfig.key) {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const aInfo = parseFittingInfo(a);
|
||||||
|
const bInfo = parseFittingInfo(b);
|
||||||
|
const aValue = aInfo[sortConfig.key] || '';
|
||||||
|
const bValue = bInfo[sortConfig.key] || '';
|
||||||
|
|
||||||
|
if (sortConfig.direction === 'asc') {
|
||||||
|
return aValue > bValue ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
return aValue < bValue ? 1 : -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전체 선택/해제 (구매신청된 자재 제외)
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||||
|
|
||||||
|
if (selectedMaterials.size === selectableMaterials.length) {
|
||||||
|
setSelectedMaterials(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개별 선택 (구매신청된 자재는 선택 불가)
|
||||||
|
const handleMaterialSelect = (materialId) => {
|
||||||
|
if (purchasedMaterials.has(materialId)) {
|
||||||
|
return; // 구매신청된 자재는 선택 불가
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSelected = new Set(selectedMaterials);
|
||||||
|
if (newSelected.has(materialId)) {
|
||||||
|
newSelected.delete(materialId);
|
||||||
|
} else {
|
||||||
|
newSelected.add(materialId);
|
||||||
|
}
|
||||||
|
setSelectedMaterials(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엑셀 내보내기
|
||||||
|
const handleExportToExcel = async () => {
|
||||||
|
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||||
|
if (selectedMaterialsData.length === 0) {
|
||||||
|
alert('내보낼 자재를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||||
|
const excelFileName = `FITTING_Materials_${timestamp}.xlsx`;
|
||||||
|
|
||||||
|
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||||
|
...material,
|
||||||
|
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔄 피팅 엑셀 내보내기 시작 - 새로운 방식');
|
||||||
|
|
||||||
|
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||||
|
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||||
|
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'FITTING',
|
||||||
|
filename: excelFileName,
|
||||||
|
uploadDate: new Date().toLocaleDateString()
|
||||||
|
});
|
||||||
|
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||||
|
|
||||||
|
// 2. 구매신청 생성
|
||||||
|
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||||
|
const response = await api.post('/purchase-request/create', {
|
||||||
|
file_id: fileId,
|
||||||
|
job_no: jobNo,
|
||||||
|
category: 'FITTING',
|
||||||
|
material_ids: allMaterialIds,
|
||||||
|
materials_data: dataWithRequirements.map(m => ({
|
||||||
|
material_id: m.id,
|
||||||
|
description: m.original_description,
|
||||||
|
category: m.classified_category,
|
||||||
|
size: m.size_inch || m.size_spec,
|
||||||
|
schedule: m.schedule,
|
||||||
|
material_grade: m.material_grade || m.full_material_grade,
|
||||||
|
quantity: m.quantity,
|
||||||
|
unit: m.unit,
|
||||||
|
user_requirement: userRequirements[m.id] || ''
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||||
|
|
||||||
|
// 3. 생성된 엑셀 파일을 서버에 업로드
|
||||||
|
console.log('📤 서버에 엑셀 파일 업로드 중...');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('excel_file', excelBlob, excelFileName);
|
||||||
|
formData.append('request_id', response.data.request_id);
|
||||||
|
formData.append('category', 'FITTING');
|
||||||
|
|
||||||
|
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||||
|
|
||||||
|
if (onPurchasedMaterialsUpdate) {
|
||||||
|
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 클라이언트 다운로드
|
||||||
|
const url = window.URL.createObjectURL(excelBlob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = excelFileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||||
|
// 실패 시에도 클라이언트 다운로드는 진행
|
||||||
|
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'FITTING',
|
||||||
|
filename: excelFileName,
|
||||||
|
uploadDate: new Date().toLocaleDateString()
|
||||||
|
});
|
||||||
|
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택 해제
|
||||||
|
setSelectedMaterials(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
|
||||||
|
// 필터 헤더 컴포넌트
|
||||||
|
const FilterableHeader = ({ sortKey, filterKey, children }) => (
|
||||||
|
<div className="filterable-header" style={{ position: 'relative' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span
|
||||||
|
onClick={() => handleSort(sortKey)}
|
||||||
|
style={{ cursor: 'pointer', flex: 1 }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{sortConfig.key === sortKey && (
|
||||||
|
<span style={{ marginLeft: '4px' }}>
|
||||||
|
{sortConfig.direction === 'asc' ? '↑' : '↓'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '2px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#6b7280'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔍
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showFilterDropdown === filterKey && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
background: 'white',
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '8px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||||
|
zIndex: 1000,
|
||||||
|
minWidth: '150px'
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={`Filter ${children}...`}
|
||||||
|
value={columnFilters[filterKey] || ''}
|
||||||
|
onChange={(e) => setColumnFilters({
|
||||||
|
...columnFilters,
|
||||||
|
[filterKey]: e.target.value
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '4px 8px',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '32px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0f172a',
|
||||||
|
margin: '0 0 8px 0'
|
||||||
|
}}>
|
||||||
|
Fitting Materials
|
||||||
|
</h3>
|
||||||
|
<p style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#64748b',
|
||||||
|
margin: 0
|
||||||
|
}}>
|
||||||
|
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
color: '#6b7280',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleExportToExcel}
|
||||||
|
disabled={selectedMaterials.size === 0}
|
||||||
|
style={{
|
||||||
|
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #10b981 0%, #059669 100%)' : '#e5e7eb',
|
||||||
|
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Export to Excel ({selectedMaterials.size})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '600px',
|
||||||
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||||
|
}}>
|
||||||
|
<div style={{ minWidth: '1380px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 120px 250px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(() => {
|
||||||
|
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||||
|
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
|
||||||
|
})()}
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>Type</div>
|
||||||
|
<div>Size</div>
|
||||||
|
<div>Pressure</div>
|
||||||
|
<div>Schedule</div>
|
||||||
|
<div>Material Grade</div>
|
||||||
|
<div>User Requirements</div>
|
||||||
|
<div>Purchase Quantity</div>
|
||||||
|
<div>Additional Request</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행들 */}
|
||||||
|
{filteredMaterials.map((material, index) => {
|
||||||
|
const info = parseFittingInfo(material);
|
||||||
|
const isSelected = selectedMaterials.has(material.id);
|
||||||
|
const isPurchased = purchasedMaterials.has(material.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={material.id}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 120px 250px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||||
|
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = '#f8fafc';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = 'white';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => handleMaterialSelect(material.id)}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||||
|
{info.subtype}
|
||||||
|
{isPurchased && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
background: '#fbbf24',
|
||||||
|
color: '#92400e',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}>
|
||||||
|
PURCHASED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||||
|
{info.size}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||||
|
{info.pressure}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||||
|
{info.schedule}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||||
|
{info.grade}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#1f2937',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}}>
|
||||||
|
{material.user_requirements?.join(', ') || '-'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
|
||||||
|
{info.quantity} {info.unit}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||||
|
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||||
|
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: '#f9fafb',
|
||||||
|
color: '#374151'
|
||||||
|
}}>
|
||||||
|
{savedRequests[material.id]}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userRequirements[material.id] || ''}
|
||||||
|
onChange={(e) => setUserRequirements({
|
||||||
|
...userRequirements,
|
||||||
|
[material.id]: e.target.value
|
||||||
|
})}
|
||||||
|
placeholder="Enter additional request..."
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||||
|
disabled={isPurchased || savingRequest[material.id]}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{savingRequest[material.id] ? '...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredMaterials.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '60px 20px',
|
||||||
|
color: '#64748b'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||||
|
No Fitting Materials Found
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px' }}>
|
||||||
|
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||||
|
? 'Try adjusting your filters'
|
||||||
|
: 'No fitting materials available in this BOM'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FittingMaterialsView;
|
||||||
722
frontend/src/components/bom/materials/FlangeMaterialsView.jsx
Normal file
722
frontend/src/components/bom/materials/FlangeMaterialsView.jsx
Normal file
@@ -0,0 +1,722 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||||
|
import api from '../../../api';
|
||||||
|
import { FilterableHeader, MaterialTable } from '../shared';
|
||||||
|
|
||||||
|
const FlangeMaterialsView = ({
|
||||||
|
materials,
|
||||||
|
selectedMaterials,
|
||||||
|
setSelectedMaterials,
|
||||||
|
userRequirements,
|
||||||
|
setUserRequirements,
|
||||||
|
purchasedMaterials,
|
||||||
|
onPurchasedMaterialsUpdate,
|
||||||
|
updateMaterial, // 자재 업데이트 함수
|
||||||
|
fileId,
|
||||||
|
jobNo,
|
||||||
|
user,
|
||||||
|
onNavigate
|
||||||
|
}) => {
|
||||||
|
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||||
|
const [columnFilters, setColumnFilters] = useState({});
|
||||||
|
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||||
|
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||||
|
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||||
|
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadSavedData = () => {
|
||||||
|
const savedRequestsData = {};
|
||||||
|
|
||||||
|
materials.forEach(material => {
|
||||||
|
if (material.user_requirement && material.user_requirement.trim()) {
|
||||||
|
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSavedRequests(savedRequestsData);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (materials && materials.length > 0) {
|
||||||
|
loadSavedData();
|
||||||
|
} else {
|
||||||
|
setSavedRequests({});
|
||||||
|
}
|
||||||
|
}, [materials]);
|
||||||
|
|
||||||
|
// 추가요구사항 저장 함수
|
||||||
|
const handleSaveRequest = async (materialId, request) => {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
try {
|
||||||
|
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||||
|
user_requirement: request.trim()
|
||||||
|
});
|
||||||
|
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||||
|
|
||||||
|
if (updateMaterial) {
|
||||||
|
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('추가요구사항 저장 실패:', error);
|
||||||
|
alert('추가요구사항 저장에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 추가요구사항 편집 시작
|
||||||
|
const handleEditRequest = (materialId, currentRequest) => {
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 플랜지 정보 파싱
|
||||||
|
const parseFlangeInfo = (material) => {
|
||||||
|
const description = material.original_description || '';
|
||||||
|
const flangeDetails = material.flange_details || {};
|
||||||
|
|
||||||
|
const flangeTypeMap = {
|
||||||
|
'WN': 'WELD NECK FLANGE',
|
||||||
|
'WELD_NECK': 'WELD NECK FLANGE',
|
||||||
|
'SO': 'SLIP ON FLANGE',
|
||||||
|
'SLIP_ON': 'SLIP ON FLANGE',
|
||||||
|
'SW': 'SOCKET WELD FLANGE',
|
||||||
|
'SOCKET_WELD': 'SOCKET WELD FLANGE',
|
||||||
|
'THREADED': 'THREADED FLANGE',
|
||||||
|
'THD': 'THREADED FLANGE',
|
||||||
|
'BLIND': 'BLIND FLANGE',
|
||||||
|
'LAP_JOINT': 'LAP JOINT FLANGE',
|
||||||
|
'LJ': 'LAP JOINT FLANGE',
|
||||||
|
'REDUCING': 'REDUCING FLANGE',
|
||||||
|
'ORIFICE': 'ORIFICE FLANGE',
|
||||||
|
'SPECTACLE': 'SPECTACLE BLIND',
|
||||||
|
'SPECTACLE_BLIND': 'SPECTACLE BLIND',
|
||||||
|
'PADDLE': 'PADDLE BLIND',
|
||||||
|
'PADDLE_BLIND': 'PADDLE BLIND',
|
||||||
|
'SPACER': 'SPACER',
|
||||||
|
'SWIVEL': 'SWIVEL FLANGE',
|
||||||
|
'DRIP_RING': 'DRIP RING',
|
||||||
|
'NOZZLE': 'NOZZLE FLANGE'
|
||||||
|
};
|
||||||
|
|
||||||
|
const facingTypeMap = {
|
||||||
|
'RF': 'RAISED FACE',
|
||||||
|
'RAISED_FACE': 'RAISED FACE',
|
||||||
|
'FF': 'FLAT FACE',
|
||||||
|
'FLAT_FACE': 'FLAT FACE',
|
||||||
|
'RTJ': 'RING TYPE JOINT',
|
||||||
|
'RING_TYPE_JOINT': 'RING TYPE JOINT'
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawFlangeType = flangeDetails.flange_type || '';
|
||||||
|
const rawFacingType = flangeDetails.facing_type || '';
|
||||||
|
|
||||||
|
|
||||||
|
// rawFlangeType에서 facing 정보 분리 (예: "WN RF" -> "WN")
|
||||||
|
let cleanFlangeType = rawFlangeType;
|
||||||
|
let extractedFacing = rawFacingType;
|
||||||
|
|
||||||
|
// facing 정보가 flange_type에 포함된 경우 분리
|
||||||
|
if (rawFlangeType.includes(' RF')) {
|
||||||
|
cleanFlangeType = rawFlangeType.replace(' RF', '').trim();
|
||||||
|
if (!extractedFacing || extractedFacing === '-') extractedFacing = 'RAISED_FACE';
|
||||||
|
} else if (rawFlangeType.includes(' FF')) {
|
||||||
|
cleanFlangeType = rawFlangeType.replace(' FF', '').trim();
|
||||||
|
if (!extractedFacing || extractedFacing === '-') extractedFacing = 'FLAT_FACE';
|
||||||
|
} else if (rawFlangeType.includes(' RTJ')) {
|
||||||
|
cleanFlangeType = rawFlangeType.replace(' RTJ', '').trim();
|
||||||
|
if (!extractedFacing || extractedFacing === '-') extractedFacing = 'RING_TYPE_JOINT';
|
||||||
|
}
|
||||||
|
|
||||||
|
let displayType = flangeTypeMap[cleanFlangeType] || '-';
|
||||||
|
let facingType = facingTypeMap[extractedFacing] || '-';
|
||||||
|
|
||||||
|
// Description에서 추출
|
||||||
|
if (displayType === '-') {
|
||||||
|
const desc = description.toUpperCase();
|
||||||
|
if (desc.includes('ORIFICE')) {
|
||||||
|
displayType = 'ORIFICE FLANGE';
|
||||||
|
} else if (desc.includes('SPECTACLE')) {
|
||||||
|
displayType = 'SPECTACLE BLIND';
|
||||||
|
} else if (desc.includes('PADDLE')) {
|
||||||
|
displayType = 'PADDLE BLIND';
|
||||||
|
} else if (desc.includes('SPACER')) {
|
||||||
|
displayType = 'SPACER';
|
||||||
|
} else if (desc.includes('REDUCING') || desc.includes('RED')) {
|
||||||
|
displayType = 'REDUCING FLANGE';
|
||||||
|
} else if (desc.includes('BLIND')) {
|
||||||
|
displayType = 'BLIND FLANGE';
|
||||||
|
} else if (desc.includes('WN RF') || desc.includes('WN-RF')) {
|
||||||
|
displayType = 'WELD NECK FLANGE';
|
||||||
|
if (facingType === '-') facingType = 'RAISED FACE';
|
||||||
|
} else if (desc.includes('WN FF') || desc.includes('WN-FF')) {
|
||||||
|
displayType = 'WELD NECK FLANGE';
|
||||||
|
if (facingType === '-') facingType = 'FLAT FACE';
|
||||||
|
} else if (desc.includes('WN RTJ') || desc.includes('WN-RTJ')) {
|
||||||
|
displayType = 'WELD NECK FLANGE';
|
||||||
|
if (facingType === '-') facingType = 'RING TYPE JOINT';
|
||||||
|
} else if (desc.includes('WN')) {
|
||||||
|
displayType = 'WELD NECK FLANGE';
|
||||||
|
} else if (desc.includes('SO RF') || desc.includes('SO-RF')) {
|
||||||
|
displayType = 'SLIP ON FLANGE';
|
||||||
|
if (facingType === '-') facingType = 'RAISED FACE';
|
||||||
|
} else if (desc.includes('SO FF') || desc.includes('SO-FF')) {
|
||||||
|
displayType = 'SLIP ON FLANGE';
|
||||||
|
if (facingType === '-') facingType = 'FLAT FACE';
|
||||||
|
} else if (desc.includes('SO')) {
|
||||||
|
displayType = 'SLIP ON FLANGE';
|
||||||
|
} else if (desc.includes('SW')) {
|
||||||
|
displayType = 'SOCKET WELD FLANGE';
|
||||||
|
} else {
|
||||||
|
displayType = 'FLANGE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (facingType === '-') {
|
||||||
|
const desc = description.toUpperCase();
|
||||||
|
if (desc.includes('RF')) {
|
||||||
|
facingType = 'RAISED FACE';
|
||||||
|
} else if (desc.includes('FF')) {
|
||||||
|
facingType = 'FLAT FACE';
|
||||||
|
} else if (desc.includes('RTJ')) {
|
||||||
|
facingType = 'RING TYPE JOINT';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원본 설명에서 스케줄 추출
|
||||||
|
let schedule = '-';
|
||||||
|
const upperDesc = description.toUpperCase();
|
||||||
|
|
||||||
|
// SCH 40, SCH 80 등의 패턴 찾기
|
||||||
|
if (upperDesc.includes('SCH')) {
|
||||||
|
const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
|
||||||
|
if (schMatch && schMatch[1]) {
|
||||||
|
schedule = `SCH ${schMatch[1]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 압력 등급 추출
|
||||||
|
let pressure = '-';
|
||||||
|
const pressureMatch = description.match(/(\d+)LB/i);
|
||||||
|
if (pressureMatch) {
|
||||||
|
pressure = `${pressureMatch[1]}LB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'FLANGE',
|
||||||
|
subtype: displayType, // 풀네임 플랜지 타입
|
||||||
|
facing: facingType, // 새로 추가: 끝단처리 정보
|
||||||
|
size: material.size_spec || '-',
|
||||||
|
pressure: flangeDetails.pressure_rating || pressure,
|
||||||
|
schedule: schedule,
|
||||||
|
grade: material.full_material_grade || material.material_grade || '-',
|
||||||
|
quantity: Math.round(material.quantity || 0),
|
||||||
|
unit: '개',
|
||||||
|
isFlange: true // 플랜지 구분용 플래그
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 정렬 처리
|
||||||
|
const handleSort = (key) => {
|
||||||
|
let direction = 'asc';
|
||||||
|
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||||
|
direction = 'desc';
|
||||||
|
}
|
||||||
|
setSortConfig({ key, direction });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터링된 및 정렬된 자재 목록
|
||||||
|
const getFilteredAndSortedMaterials = () => {
|
||||||
|
let filtered = materials.filter(material => {
|
||||||
|
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||||
|
if (!filterValue) return true;
|
||||||
|
const info = parseFlangeInfo(material);
|
||||||
|
const value = info[key]?.toString().toLowerCase() || '';
|
||||||
|
return value.includes(filterValue.toLowerCase());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sortConfig.key) {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const aInfo = parseFlangeInfo(a);
|
||||||
|
const bInfo = parseFlangeInfo(b);
|
||||||
|
const aValue = aInfo[sortConfig.key] || '';
|
||||||
|
const bValue = bInfo[sortConfig.key] || '';
|
||||||
|
|
||||||
|
if (sortConfig.direction === 'asc') {
|
||||||
|
return aValue > bValue ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
return aValue < bValue ? 1 : -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전체 선택/해제
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
if (selectedMaterials.size === filteredMaterials.length) {
|
||||||
|
setSelectedMaterials(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedMaterials(new Set(filteredMaterials.map(m => m.id)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개별 선택
|
||||||
|
const handleMaterialSelect = (materialId) => {
|
||||||
|
const newSelected = new Set(selectedMaterials);
|
||||||
|
if (newSelected.has(materialId)) {
|
||||||
|
newSelected.delete(materialId);
|
||||||
|
} else {
|
||||||
|
newSelected.add(materialId);
|
||||||
|
}
|
||||||
|
setSelectedMaterials(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엑셀 내보내기
|
||||||
|
const handleExportToExcel = async () => {
|
||||||
|
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||||
|
if (selectedMaterialsData.length === 0) {
|
||||||
|
alert('내보낼 자재를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||||
|
const excelFileName = `FLANGE_Materials_${timestamp}.xlsx`;
|
||||||
|
|
||||||
|
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||||
|
...material,
|
||||||
|
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔄 엑셀 내보내기 시작 - 새로운 방식');
|
||||||
|
|
||||||
|
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||||
|
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||||
|
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'FLANGE',
|
||||||
|
filename: excelFileName,
|
||||||
|
uploadDate: new Date().toLocaleDateString()
|
||||||
|
});
|
||||||
|
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||||
|
|
||||||
|
// 2. 구매신청 생성
|
||||||
|
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||||
|
const response = await api.post('/purchase-request/create', {
|
||||||
|
file_id: fileId,
|
||||||
|
job_no: jobNo,
|
||||||
|
category: 'FLANGE',
|
||||||
|
material_ids: allMaterialIds,
|
||||||
|
materials_data: dataWithRequirements.map(m => ({
|
||||||
|
material_id: m.id,
|
||||||
|
description: m.original_description,
|
||||||
|
category: m.classified_category,
|
||||||
|
size: m.size_inch || m.size_spec,
|
||||||
|
schedule: m.schedule,
|
||||||
|
material_grade: m.material_grade || m.full_material_grade,
|
||||||
|
quantity: m.quantity,
|
||||||
|
unit: m.unit,
|
||||||
|
user_requirement: userRequirements[m.id] || ''
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||||
|
|
||||||
|
// 3. 생성된 엑셀 파일을 서버에 업로드
|
||||||
|
console.log('📤 서버에 엑셀 파일 업로드 중...');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('excel_file', excelBlob, excelFileName);
|
||||||
|
formData.append('request_id', response.data.request_id);
|
||||||
|
formData.append('category', 'FLANGE');
|
||||||
|
|
||||||
|
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||||
|
|
||||||
|
if (onPurchasedMaterialsUpdate) {
|
||||||
|
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 클라이언트 다운로드
|
||||||
|
const url = window.URL.createObjectURL(excelBlob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = excelFileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||||
|
// 실패 시에도 클라이언트 다운로드는 진행
|
||||||
|
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'FLANGE',
|
||||||
|
filename: excelFileName,
|
||||||
|
uploadDate: new Date().toLocaleDateString()
|
||||||
|
});
|
||||||
|
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedMaterials(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
|
||||||
|
// 필터 헤더 컴포넌트
|
||||||
|
const FilterableHeader = ({ sortKey, filterKey, children }) => (
|
||||||
|
<div className="filterable-header" style={{ position: 'relative' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span
|
||||||
|
onClick={() => handleSort(sortKey)}
|
||||||
|
style={{ cursor: 'pointer', flex: 1 }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{sortConfig.key === sortKey && (
|
||||||
|
<span style={{ marginLeft: '4px' }}>
|
||||||
|
{sortConfig.direction === 'asc' ? '↑' : '↓'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '2px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#6b7280'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔍
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showFilterDropdown === filterKey && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
background: 'white',
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '8px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||||
|
zIndex: 1000,
|
||||||
|
minWidth: '150px'
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={`Filter ${children}...`}
|
||||||
|
value={columnFilters[filterKey] || ''}
|
||||||
|
onChange={(e) => setColumnFilters({
|
||||||
|
...columnFilters,
|
||||||
|
[filterKey]: e.target.value
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '4px 8px',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '32px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0f172a',
|
||||||
|
margin: '0 0 8px 0'
|
||||||
|
}}>
|
||||||
|
Flange Materials
|
||||||
|
</h3>
|
||||||
|
<p style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#64748b',
|
||||||
|
margin: 0
|
||||||
|
}}>
|
||||||
|
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
color: '#6b7280',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleExportToExcel}
|
||||||
|
disabled={selectedMaterials.size === 0}
|
||||||
|
style={{
|
||||||
|
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' : '#e5e7eb',
|
||||||
|
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Export to Excel ({selectedMaterials.size})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '600px',
|
||||||
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||||
|
}}>
|
||||||
|
<div style={{ minWidth: '1400px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 250px 150px 120px 120px 100px 150px 180px 250px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(() => {
|
||||||
|
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||||
|
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
|
||||||
|
})()}
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FilterableHeader sortKey="subtype" filterKey="subtype">Type</FilterableHeader>
|
||||||
|
<FilterableHeader sortKey="facing" filterKey="facing">Facing</FilterableHeader>
|
||||||
|
<FilterableHeader sortKey="size" filterKey="size">Size</FilterableHeader>
|
||||||
|
<FilterableHeader sortKey="pressure" filterKey="pressure">Pressure</FilterableHeader>
|
||||||
|
<FilterableHeader sortKey="schedule" filterKey="schedule">Schedule</FilterableHeader>
|
||||||
|
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader>
|
||||||
|
<div>Purchase Quantity</div>
|
||||||
|
<div>Additional Request</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행들 */}
|
||||||
|
<div>
|
||||||
|
{filteredMaterials.map((material, index) => {
|
||||||
|
const info = parseFlangeInfo(material);
|
||||||
|
const isSelected = selectedMaterials.has(material.id);
|
||||||
|
const isPurchased = purchasedMaterials.has(material.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={material.id}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 250px 150px 120px 120px 100px 150px 180px 250px',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||||
|
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||||
|
transition: 'background 0.15s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = '#f8fafc';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = 'white';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => handleMaterialSelect(material.id)}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500', textAlign: 'center' }}>
|
||||||
|
{info.subtype}
|
||||||
|
{isPurchased && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
background: '#fbbf24',
|
||||||
|
color: '#92400e',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}>
|
||||||
|
PURCHASED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.facing}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.size}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.pressure}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.schedule}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.grade}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'center' }}>
|
||||||
|
{info.quantity} {info.unit}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||||
|
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||||
|
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: '#f9fafb',
|
||||||
|
color: '#374151'
|
||||||
|
}}>
|
||||||
|
{savedRequests[material.id]}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userRequirements[material.id] || ''}
|
||||||
|
onChange={(e) => setUserRequirements({
|
||||||
|
...userRequirements,
|
||||||
|
[material.id]: e.target.value
|
||||||
|
})}
|
||||||
|
placeholder="Enter additional request..."
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||||
|
disabled={isPurchased || savingRequest[material.id]}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{savingRequest[material.id] ? '...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredMaterials.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '60px 20px',
|
||||||
|
color: '#64748b'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||||
|
No Flange Materials Found
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px' }}>
|
||||||
|
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||||
|
? 'Try adjusting your filters'
|
||||||
|
: 'No flange materials available in this BOM'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FlangeMaterialsView;
|
||||||
693
frontend/src/components/bom/materials/GasketMaterialsView.jsx
Normal file
693
frontend/src/components/bom/materials/GasketMaterialsView.jsx
Normal file
@@ -0,0 +1,693 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||||
|
import api from '../../../api';
|
||||||
|
import { FilterableHeader } from '../shared';
|
||||||
|
|
||||||
|
const GasketMaterialsView = ({
|
||||||
|
materials,
|
||||||
|
selectedMaterials,
|
||||||
|
setSelectedMaterials,
|
||||||
|
userRequirements,
|
||||||
|
setUserRequirements,
|
||||||
|
purchasedMaterials,
|
||||||
|
onPurchasedMaterialsUpdate,
|
||||||
|
updateMaterial, // 자재 업데이트 함수
|
||||||
|
fileId,
|
||||||
|
jobNo,
|
||||||
|
user
|
||||||
|
}) => {
|
||||||
|
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||||
|
const [columnFilters, setColumnFilters] = useState({});
|
||||||
|
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||||
|
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||||
|
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||||
|
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadSavedData = () => {
|
||||||
|
const savedRequestsData = {};
|
||||||
|
|
||||||
|
materials.forEach(material => {
|
||||||
|
if (material.user_requirement && material.user_requirement.trim()) {
|
||||||
|
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSavedRequests(savedRequestsData);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (materials && materials.length > 0) {
|
||||||
|
loadSavedData();
|
||||||
|
} else {
|
||||||
|
setSavedRequests({});
|
||||||
|
}
|
||||||
|
}, [materials]);
|
||||||
|
|
||||||
|
// 추가요구사항 저장 함수
|
||||||
|
const handleSaveRequest = async (materialId, request) => {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
try {
|
||||||
|
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||||
|
user_requirement: request.trim()
|
||||||
|
});
|
||||||
|
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||||
|
|
||||||
|
if (updateMaterial) {
|
||||||
|
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('추가요구사항 저장 실패:', error);
|
||||||
|
alert('추가요구사항 저장에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 추가요구사항 편집 시작
|
||||||
|
const handleEditRequest = (materialId, currentRequest) => {
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseGasketInfo = (material) => {
|
||||||
|
const qty = Math.round(material.quantity || 0);
|
||||||
|
const purchaseQty = Math.ceil(qty * 1.05 / 5) * 5; // 5% 여유율 + 5의 배수
|
||||||
|
|
||||||
|
const description = material.original_description || '';
|
||||||
|
|
||||||
|
// 가스켓 타입 풀네임 매핑
|
||||||
|
const gasketTypeMap = {
|
||||||
|
'SWG': 'SPIRAL WOUND GASKET',
|
||||||
|
'RTJ': 'RING TYPE JOINT',
|
||||||
|
'FF': 'FULL FACE GASKET',
|
||||||
|
'RF': 'RAISED FACE GASKET',
|
||||||
|
'SHEET': 'SHEET GASKET',
|
||||||
|
'O-RING': 'O-RING GASKET'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 타입 추출 및 풀네임 변환
|
||||||
|
let gasketType = '-';
|
||||||
|
const typeMatch = description.match(/\b(SWG|RTJ|FF|RF|SHEET|O-RING)\b/i);
|
||||||
|
if (typeMatch) {
|
||||||
|
const shortType = typeMatch[1].toUpperCase();
|
||||||
|
gasketType = gasketTypeMap[shortType] || shortType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 크기 정보 추출 (예: 1 1/2")
|
||||||
|
let size = material.size_spec || material.size_inch || '-';
|
||||||
|
if (size === '-') {
|
||||||
|
const sizeMatch = description.match(/(\d+(?:\s+\d+\/\d+)?(?:\.\d+)?)\s*"/);
|
||||||
|
if (sizeMatch) {
|
||||||
|
size = sizeMatch[1] + '"';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 압력등급 추출
|
||||||
|
let pressure = '-';
|
||||||
|
const pressureMatch = description.match(/(\d+LB)/i);
|
||||||
|
if (pressureMatch) {
|
||||||
|
pressure = pressureMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 구조 정보 추출 (H/F/I/O)
|
||||||
|
let structure = '-';
|
||||||
|
if (description.includes('H/F/I/O')) {
|
||||||
|
structure = 'H/F/I/O';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 재질 상세 정보 추출 (SS304/GRAPHITE/SS304/SS304)
|
||||||
|
let material_detail = '-';
|
||||||
|
const materialMatch = description.match(/H\/F\/I\/O\s+([^,]+)/);
|
||||||
|
if (materialMatch) {
|
||||||
|
material_detail = materialMatch[1].trim();
|
||||||
|
// 두께 정보 제거
|
||||||
|
material_detail = material_detail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 두께 정보 추출
|
||||||
|
let thickness = '-';
|
||||||
|
const thicknessMatch = description.match(/(\d+(?:\.\d+)?)\s*mm/i);
|
||||||
|
if (thicknessMatch) {
|
||||||
|
thickness = thicknessMatch[1] + 'mm';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: gasketType, // 풀네임으로 표시 (SPIRAL WOUND GASKET)
|
||||||
|
size: size,
|
||||||
|
pressure: pressure,
|
||||||
|
structure: structure, // H/F/I/O
|
||||||
|
material: material_detail, // SS304/GRAPHITE/SS304/SS304
|
||||||
|
thickness: thickness,
|
||||||
|
userRequirements: material.user_requirements?.join(', ') || '-',
|
||||||
|
purchaseQuantity: purchaseQty,
|
||||||
|
isGasket: true
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 정렬 처리
|
||||||
|
const handleSort = (key) => {
|
||||||
|
let direction = 'asc';
|
||||||
|
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||||
|
direction = 'desc';
|
||||||
|
}
|
||||||
|
setSortConfig({ key, direction });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터링된 및 정렬된 자재 목록
|
||||||
|
const getFilteredAndSortedMaterials = () => {
|
||||||
|
let filtered = materials.filter(material => {
|
||||||
|
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||||
|
if (!filterValue) return true;
|
||||||
|
const info = parseGasketInfo(material);
|
||||||
|
const value = info[key]?.toString().toLowerCase() || '';
|
||||||
|
return value.includes(filterValue.toLowerCase());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sortConfig && sortConfig.key) {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const aInfo = parseGasketInfo(a);
|
||||||
|
const bInfo = parseGasketInfo(b);
|
||||||
|
|
||||||
|
if (!aInfo || !bInfo) return 0;
|
||||||
|
|
||||||
|
const aValue = aInfo[sortConfig.key];
|
||||||
|
const bValue = bInfo[sortConfig.key];
|
||||||
|
|
||||||
|
// 값이 없는 경우 처리
|
||||||
|
if (aValue === undefined && bValue === undefined) return 0;
|
||||||
|
if (aValue === undefined) return 1;
|
||||||
|
if (bValue === undefined) return -1;
|
||||||
|
|
||||||
|
// 숫자인 경우 숫자로 비교
|
||||||
|
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||||
|
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문자열로 비교
|
||||||
|
const aStr = String(aValue).toLowerCase();
|
||||||
|
const bStr = String(bValue).toLowerCase();
|
||||||
|
|
||||||
|
if (sortConfig.direction === 'asc') {
|
||||||
|
return aStr.localeCompare(bStr);
|
||||||
|
} else {
|
||||||
|
return bStr.localeCompare(aStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전체 선택/해제 (구매신청된 자재 제외)
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||||
|
|
||||||
|
if (selectedMaterials.size === selectableMaterials.length) {
|
||||||
|
setSelectedMaterials(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개별 선택 (구매신청된 자재는 선택 불가)
|
||||||
|
const handleMaterialSelect = (materialId) => {
|
||||||
|
if (purchasedMaterials.has(materialId)) {
|
||||||
|
return; // 구매신청된 자재는 선택 불가
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSelected = new Set(selectedMaterials);
|
||||||
|
if (newSelected.has(materialId)) {
|
||||||
|
newSelected.delete(materialId);
|
||||||
|
} else {
|
||||||
|
newSelected.add(materialId);
|
||||||
|
}
|
||||||
|
setSelectedMaterials(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엑셀 내보내기
|
||||||
|
const handleExportToExcel = async () => {
|
||||||
|
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||||
|
if (selectedMaterialsData.length === 0) {
|
||||||
|
alert('내보낼 자재를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||||
|
const excelFileName = `GASKET_Materials_${timestamp}.xlsx`;
|
||||||
|
|
||||||
|
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||||
|
...material,
|
||||||
|
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔄 가스켓 엑셀 내보내기 시작 - 새로운 방식');
|
||||||
|
|
||||||
|
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||||
|
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||||
|
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'GASKET',
|
||||||
|
filename: excelFileName,
|
||||||
|
uploadDate: new Date().toLocaleDateString()
|
||||||
|
});
|
||||||
|
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||||
|
|
||||||
|
// 2. 구매신청 생성
|
||||||
|
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||||
|
const response = await api.post('/purchase-request/create', {
|
||||||
|
file_id: fileId,
|
||||||
|
job_no: jobNo,
|
||||||
|
category: 'GASKET',
|
||||||
|
material_ids: allMaterialIds,
|
||||||
|
materials_data: dataWithRequirements.map(m => ({
|
||||||
|
material_id: m.id,
|
||||||
|
description: m.original_description,
|
||||||
|
category: m.classified_category,
|
||||||
|
size: m.size_inch || m.size_spec,
|
||||||
|
schedule: m.schedule,
|
||||||
|
material_grade: m.material_grade || m.full_material_grade,
|
||||||
|
quantity: m.quantity,
|
||||||
|
unit: m.unit,
|
||||||
|
user_requirement: userRequirements[m.id] || ''
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||||
|
|
||||||
|
// 3. 생성된 엑셀 파일을 서버에 업로드
|
||||||
|
console.log('📤 서버에 엑셀 파일 업로드 중...');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('excel_file', excelBlob, excelFileName);
|
||||||
|
formData.append('request_id', response.data.request_id);
|
||||||
|
formData.append('category', 'GASKET');
|
||||||
|
|
||||||
|
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||||
|
|
||||||
|
if (onPurchasedMaterialsUpdate) {
|
||||||
|
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 클라이언트 다운로드
|
||||||
|
const url = window.URL.createObjectURL(excelBlob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = excelFileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||||
|
// 실패 시에도 클라이언트 다운로드는 진행
|
||||||
|
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'GASKET',
|
||||||
|
filename: excelFileName,
|
||||||
|
uploadDate: new Date().toLocaleDateString()
|
||||||
|
});
|
||||||
|
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '32px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0f172a',
|
||||||
|
margin: '0 0 8px 0'
|
||||||
|
}}>
|
||||||
|
Gasket Materials
|
||||||
|
</h3>
|
||||||
|
<p style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#64748b',
|
||||||
|
margin: 0
|
||||||
|
}}>
|
||||||
|
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
color: '#6b7280',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleExportToExcel}
|
||||||
|
disabled={selectedMaterials.size === 0}
|
||||||
|
style={{
|
||||||
|
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)' : '#e5e7eb',
|
||||||
|
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Export to Excel ({selectedMaterials.size})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '600px',
|
||||||
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||||
|
}}>
|
||||||
|
<div style={{ minWidth: '1400px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 250px 120px 100px 120px 200px 100px 180px 200px 120px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(() => {
|
||||||
|
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||||
|
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
|
||||||
|
})()}
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="type"
|
||||||
|
filterKey="type"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="size"
|
||||||
|
filterKey="size"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Size
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="pressure"
|
||||||
|
filterKey="pressure"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Pressure
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="structure"
|
||||||
|
filterKey="structure"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Structure
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="material"
|
||||||
|
filterKey="material"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Material
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="thickness"
|
||||||
|
filterKey="thickness"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Thickness
|
||||||
|
</FilterableHeader>
|
||||||
|
<div>User Requirements</div>
|
||||||
|
<div>Additional Request</div>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="purchaseQuantity"
|
||||||
|
filterKey="purchaseQuantity"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Purchase Quantity
|
||||||
|
</FilterableHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행들 */}
|
||||||
|
{filteredMaterials.map((material, index) => {
|
||||||
|
const info = parseGasketInfo(material);
|
||||||
|
const isSelected = selectedMaterials.has(material.id);
|
||||||
|
const isPurchased = purchasedMaterials.has(material.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={material.id}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 250px 120px 100px 120px 200px 100px 180px 200px 120px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||||
|
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||||
|
transition: 'background 0.15s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = '#f8fafc';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = 'white';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => handleMaterialSelect(material.id)}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500', textAlign: 'center' }}>
|
||||||
|
{info.type}
|
||||||
|
{isPurchased && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
background: '#fbbf24',
|
||||||
|
color: '#92400e',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}>
|
||||||
|
PURCHASED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.size}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.pressure}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.structure}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.material}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.thickness}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#1f2937',
|
||||||
|
textAlign: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}}>
|
||||||
|
{info.userRequirements}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||||
|
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||||
|
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: '#f9fafb',
|
||||||
|
color: '#374151'
|
||||||
|
}}>
|
||||||
|
{savedRequests[material.id]}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userRequirements[material.id] || ''}
|
||||||
|
onChange={(e) => setUserRequirements({
|
||||||
|
...userRequirements,
|
||||||
|
[material.id]: e.target.value
|
||||||
|
})}
|
||||||
|
placeholder="Enter additional request..."
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||||
|
disabled={isPurchased || savingRequest[material.id]}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{savingRequest[material.id] ? '...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center', fontWeight: '500' }}>
|
||||||
|
{info.purchaseQuantity.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredMaterials.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '60px 20px',
|
||||||
|
color: '#64748b'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||||
|
No Gasket Materials Found
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px' }}>
|
||||||
|
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||||
|
? 'Try adjusting your filters'
|
||||||
|
: 'No gasket materials available in this BOM'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GasketMaterialsView;
|
||||||
792
frontend/src/components/bom/materials/PipeMaterialsView.jsx
Normal file
792
frontend/src/components/bom/materials/PipeMaterialsView.jsx
Normal file
@@ -0,0 +1,792 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||||
|
import api from '../../../api';
|
||||||
|
import { FilterableHeader, MaterialTable } from '../shared';
|
||||||
|
|
||||||
|
const PipeMaterialsView = ({
|
||||||
|
materials,
|
||||||
|
selectedMaterials,
|
||||||
|
setSelectedMaterials,
|
||||||
|
userRequirements,
|
||||||
|
setUserRequirements,
|
||||||
|
purchasedMaterials,
|
||||||
|
onPurchasedMaterialsUpdate,
|
||||||
|
updateMaterial, // 자재 업데이트 함수
|
||||||
|
fileId,
|
||||||
|
jobNo,
|
||||||
|
user,
|
||||||
|
onNavigate
|
||||||
|
}) => {
|
||||||
|
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||||
|
const [columnFilters, setColumnFilters] = useState({});
|
||||||
|
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||||
|
const [brandInputs, setBrandInputs] = useState({}); // 브랜드 입력 상태
|
||||||
|
const [savingBrand, setSavingBrand] = useState({}); // 브랜드 저장 중 상태
|
||||||
|
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||||
|
const [savedBrands, setSavedBrands] = useState({}); // 저장된 브랜드 상태
|
||||||
|
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||||
|
const [editingBrand, setEditingBrand] = useState({}); // 브랜드 편집 모드
|
||||||
|
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadSavedData = () => {
|
||||||
|
const savedBrandsData = {};
|
||||||
|
const savedRequestsData = {};
|
||||||
|
|
||||||
|
materials.forEach(material => {
|
||||||
|
// 백엔드에서 가져온 데이터가 있으면 저장된 상태로 설정
|
||||||
|
if (material.brand && material.brand.trim()) {
|
||||||
|
savedBrandsData[material.id] = material.brand.trim();
|
||||||
|
}
|
||||||
|
if (material.user_requirement && material.user_requirement.trim()) {
|
||||||
|
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSavedBrands(savedBrandsData);
|
||||||
|
setSavedRequests(savedRequestsData);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (materials && materials.length > 0) {
|
||||||
|
loadSavedData();
|
||||||
|
} else {
|
||||||
|
setSavedBrands({});
|
||||||
|
setSavedRequests({});
|
||||||
|
}
|
||||||
|
}, [materials]);
|
||||||
|
|
||||||
|
// 브랜드 저장 함수
|
||||||
|
const handleSaveBrand = async (materialId, brand) => {
|
||||||
|
if (!brand.trim()) return;
|
||||||
|
|
||||||
|
setSavingBrand(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
try {
|
||||||
|
await api.patch(`/materials/${materialId}/brand`, { brand: brand.trim() });
|
||||||
|
setSavedBrands(prev => ({ ...prev, [materialId]: brand.trim() }));
|
||||||
|
setEditingBrand(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
setBrandInputs(prev => ({ ...prev, [materialId]: '' }));
|
||||||
|
|
||||||
|
if (updateMaterial) {
|
||||||
|
updateMaterial(materialId, { brand: brand.trim() });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('브랜드 저장 실패:', error);
|
||||||
|
alert('브랜드 저장에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSavingBrand(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 브랜드 편집 시작
|
||||||
|
const handleEditBrand = (materialId, currentBrand) => {
|
||||||
|
setEditingBrand(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
setBrandInputs(prev => ({ ...prev, [materialId]: currentBrand || '' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 추가요구사항 저장 함수
|
||||||
|
const handleSaveRequest = async (materialId, request) => {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
try {
|
||||||
|
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||||
|
user_requirement: request.trim()
|
||||||
|
});
|
||||||
|
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||||
|
|
||||||
|
if (updateMaterial) {
|
||||||
|
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('추가요구사항 저장 실패:', error);
|
||||||
|
alert('추가요구사항 저장에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 추가요구사항 편집 시작
|
||||||
|
const handleEditRequest = (materialId, currentRequest) => {
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파이프 구매 수량 계산 (백엔드 그룹화 데이터 활용)
|
||||||
|
const calculatePipePurchase = (material) => {
|
||||||
|
const pipeDetails = material.pipe_details || {};
|
||||||
|
|
||||||
|
// 백엔드에서 이미 그룹화된 데이터 사용
|
||||||
|
let pipeCount = 1; // 기본값
|
||||||
|
let totalBomLengthMm = 0;
|
||||||
|
|
||||||
|
if (pipeDetails.pipe_count && pipeDetails.total_length_mm) {
|
||||||
|
// 백엔드에서 그룹화된 데이터 사용
|
||||||
|
pipeCount = pipeDetails.pipe_count; // 실제 단관 개수
|
||||||
|
totalBomLengthMm = pipeDetails.total_length_mm; // 이미 합산된 총 길이
|
||||||
|
} else {
|
||||||
|
// 개별 파이프 데이터인 경우
|
||||||
|
pipeCount = material.quantity || 1;
|
||||||
|
|
||||||
|
// 길이 정보 우선순위: length_mm > length > pipe_details.length_mm
|
||||||
|
let singlePipeLengthMm = 0;
|
||||||
|
if (material.length_mm) {
|
||||||
|
singlePipeLengthMm = material.length_mm;
|
||||||
|
} else if (material.length) {
|
||||||
|
singlePipeLengthMm = material.length * 1000; // m를 mm로 변환
|
||||||
|
} else if (pipeDetails.length_mm) {
|
||||||
|
singlePipeLengthMm = pipeDetails.length_mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalBomLengthMm = singlePipeLengthMm * pipeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 여유분 포함 계산: 각 단관당 2mm 여유분 추가
|
||||||
|
const allowancePerPipe = 2; // mm
|
||||||
|
const totalAllowanceMm = allowancePerPipe * pipeCount;
|
||||||
|
const totalLengthWithAllowance = totalBomLengthMm + totalAllowanceMm; // mm
|
||||||
|
|
||||||
|
// 6,000mm(6m) 표준 길이로 필요한 본수 계산 (올림)
|
||||||
|
const standardLengthMm = 6000; // mm
|
||||||
|
const requiredStandardPipes = Math.ceil(totalLengthWithAllowance / standardLengthMm);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pipeCount, // 단관 개수
|
||||||
|
totalBomLengthMm, // 총 BOM 길이 (mm)
|
||||||
|
totalLengthWithAllowance, // 여유분 포함 총 길이 (mm)
|
||||||
|
totalLengthM: totalLengthWithAllowance / 1000, // 총 길이 (m)
|
||||||
|
requiredStandardPipes, // 필요한 표준 파이프 본수
|
||||||
|
standardLengthMm,
|
||||||
|
allowancePerPipe,
|
||||||
|
totalAllowanceMm,
|
||||||
|
// 디버깅용 정보
|
||||||
|
isGrouped: !!(pipeDetails.pipe_count && pipeDetails.total_length_mm)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파이프 정보 파싱 (개선된 로직)
|
||||||
|
const parsePipeInfo = (material) => {
|
||||||
|
const calc = calculatePipePurchase(material);
|
||||||
|
const pipeDetails = material.pipe_details || {};
|
||||||
|
|
||||||
|
// User 요구사항 추출 (분류기에서 제공된 정보)
|
||||||
|
const userRequirements = material.user_requirements || [];
|
||||||
|
const userReqText = userRequirements.length > 0 ? userRequirements.join(', ') : '-';
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Type 컬럼 제거 (모두 PIPE로 동일)
|
||||||
|
type: pipeDetails.manufacturing_method || 'SMLS', // Subtype을 Type으로 변경
|
||||||
|
size: material.size_spec || '-',
|
||||||
|
schedule: pipeDetails.schedule || material.schedule || '-',
|
||||||
|
grade: material.full_material_grade || material.material_grade || '-',
|
||||||
|
userRequirements: userReqText, // User 요구사항
|
||||||
|
length: calc.totalLengthWithAllowance, // 여유분 포함 총 길이 (mm)
|
||||||
|
quantity: calc.pipeCount, // 단관 개수
|
||||||
|
unit: `${calc.requiredStandardPipes}본`, // 6m 표준 파이프 필요 본수
|
||||||
|
details: calc,
|
||||||
|
isPipe: true
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// 정렬 처리
|
||||||
|
const handleSort = (key) => {
|
||||||
|
let direction = 'asc';
|
||||||
|
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||||
|
direction = 'desc';
|
||||||
|
}
|
||||||
|
setSortConfig({ key, direction });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터링된 및 정렬된 자재 목록
|
||||||
|
const getFilteredAndSortedMaterials = () => {
|
||||||
|
let filtered = materials.filter(material => {
|
||||||
|
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||||
|
if (!filterValue) return true;
|
||||||
|
const info = parsePipeInfo(material);
|
||||||
|
const value = info[key]?.toString().toLowerCase() || '';
|
||||||
|
return value.includes(filterValue.toLowerCase());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sortConfig.key) {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const aInfo = parsePipeInfo(a);
|
||||||
|
const bInfo = parsePipeInfo(b);
|
||||||
|
const aValue = aInfo[sortConfig.key] || '';
|
||||||
|
const bValue = bInfo[sortConfig.key] || '';
|
||||||
|
|
||||||
|
if (sortConfig.direction === 'asc') {
|
||||||
|
return aValue > bValue ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
return aValue < bValue ? 1 : -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전체 선택/해제 (구매된 자재 제외)
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||||
|
|
||||||
|
if (selectedMaterials.size === selectableMaterials.length) {
|
||||||
|
setSelectedMaterials(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개별 선택 (구매된 자재는 선택 불가)
|
||||||
|
const handleMaterialSelect = (materialId) => {
|
||||||
|
if (purchasedMaterials.has(materialId)) {
|
||||||
|
return; // 구매된 자재는 선택 불가
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSelected = new Set(selectedMaterials);
|
||||||
|
if (newSelected.has(materialId)) {
|
||||||
|
newSelected.delete(materialId);
|
||||||
|
} else {
|
||||||
|
newSelected.add(materialId);
|
||||||
|
}
|
||||||
|
setSelectedMaterials(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엑셀 내보내기
|
||||||
|
const handleExportToExcel = async () => {
|
||||||
|
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||||
|
if (selectedMaterialsData.length === 0) {
|
||||||
|
alert('내보낼 자재를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||||
|
const excelFileName = `PIPE_Materials_${timestamp}.xlsx`;
|
||||||
|
|
||||||
|
// 사용자 요구사항 포함
|
||||||
|
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||||
|
...material,
|
||||||
|
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔄 파이프 엑셀 내보내기 시작 - 새로운 방식');
|
||||||
|
|
||||||
|
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||||
|
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||||
|
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'PIPE',
|
||||||
|
filename: excelFileName,
|
||||||
|
uploadDate: new Date().toLocaleDateString()
|
||||||
|
});
|
||||||
|
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||||
|
|
||||||
|
// 2. 구매신청 생성
|
||||||
|
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||||
|
const response = await api.post('/purchase-request/create', {
|
||||||
|
file_id: fileId,
|
||||||
|
job_no: jobNo,
|
||||||
|
category: 'PIPE',
|
||||||
|
material_ids: allMaterialIds,
|
||||||
|
materials_data: dataWithRequirements.map(m => ({
|
||||||
|
material_id: m.id,
|
||||||
|
description: m.original_description,
|
||||||
|
category: m.classified_category,
|
||||||
|
size: m.size_inch || m.size_spec,
|
||||||
|
schedule: m.schedule,
|
||||||
|
material_grade: m.material_grade || m.full_material_grade,
|
||||||
|
quantity: m.quantity,
|
||||||
|
unit: m.unit,
|
||||||
|
user_requirement: userRequirements[m.id] || ''
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||||
|
|
||||||
|
// 3. 생성된 엑셀 파일을 서버에 업로드
|
||||||
|
console.log('📤 서버에 엑셀 파일 업로드 중...');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('excel_file', excelBlob, excelFileName);
|
||||||
|
formData.append('request_id', response.data.request_id);
|
||||||
|
formData.append('category', 'PIPE');
|
||||||
|
|
||||||
|
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||||
|
|
||||||
|
if (onPurchasedMaterialsUpdate) {
|
||||||
|
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 클라이언트 다운로드
|
||||||
|
const url = window.URL.createObjectURL(excelBlob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = excelFileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||||
|
// 실패 시에도 클라이언트 다운로드는 진행
|
||||||
|
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'PIPE',
|
||||||
|
filename: excelFileName,
|
||||||
|
uploadDate: new Date().toLocaleDateString()
|
||||||
|
});
|
||||||
|
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택 해제
|
||||||
|
setSelectedMaterials(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
|
||||||
|
// 필터 헤더 컴포넌트
|
||||||
|
const FilterableHeader = ({ sortKey, filterKey, children }) => (
|
||||||
|
<div className="filterable-header" style={{ position: 'relative' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span
|
||||||
|
onClick={() => handleSort(sortKey)}
|
||||||
|
style={{ cursor: 'pointer', flex: 1 }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{sortConfig.key === sortKey && (
|
||||||
|
<span style={{ marginLeft: '4px' }}>
|
||||||
|
{sortConfig.direction === 'asc' ? '↑' : '↓'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '2px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#6b7280'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔍
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showFilterDropdown === filterKey && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
background: 'white',
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '8px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||||
|
zIndex: 1000,
|
||||||
|
minWidth: '150px'
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={`Filter ${children}...`}
|
||||||
|
value={columnFilters[filterKey] || ''}
|
||||||
|
onChange={(e) => setColumnFilters({
|
||||||
|
...columnFilters,
|
||||||
|
[filterKey]: e.target.value
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '4px 8px',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '32px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0f172a',
|
||||||
|
margin: '0 0 8px 0'
|
||||||
|
}}>
|
||||||
|
Pipe Materials
|
||||||
|
</h3>
|
||||||
|
<p style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#64748b',
|
||||||
|
margin: 0
|
||||||
|
}}>
|
||||||
|
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
color: '#6b7280',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleExportToExcel}
|
||||||
|
disabled={selectedMaterials.size === 0}
|
||||||
|
style={{
|
||||||
|
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)' : '#e5e7eb',
|
||||||
|
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Export to Excel ({selectedMaterials.size})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'auto',
|
||||||
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||||
|
maxHeight: '600px'
|
||||||
|
}}>
|
||||||
|
{/* 테이블 내용 - 헤더와 본문이 함께 스크롤 */}
|
||||||
|
<div style={{
|
||||||
|
minWidth: '1200px'
|
||||||
|
}}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 120px 120px 120px 150px 200px 120px 100px 120px 300px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(() => {
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||||
|
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
|
||||||
|
})()}
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="type"
|
||||||
|
filterKey="type"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="size"
|
||||||
|
filterKey="size"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Size
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="schedule"
|
||||||
|
filterKey="schedule"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Schedule
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="grade"
|
||||||
|
filterKey="grade"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Material Grade
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="userRequirements"
|
||||||
|
filterKey="userRequirements"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
User Requirements
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="length"
|
||||||
|
filterKey="length"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Length (MM)
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="quantity"
|
||||||
|
filterKey="quantity"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Quantity (EA)
|
||||||
|
</FilterableHeader>
|
||||||
|
<div>Purchase Unit</div>
|
||||||
|
<div>Additional Request</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행들 */}
|
||||||
|
<div>
|
||||||
|
{filteredMaterials.map((material, index) => {
|
||||||
|
const info = parsePipeInfo(material);
|
||||||
|
const isSelected = selectedMaterials.has(material.id);
|
||||||
|
const isPurchased = purchasedMaterials.has(material.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={material.id}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 120px 120px 120px 150px 200px 120px 100px 120px 300px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||||
|
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = '#f8fafc';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = 'white';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => handleMaterialSelect(material.id)}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||||
|
{info.type}
|
||||||
|
{isPurchased && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
background: '#fbbf24',
|
||||||
|
color: '#92400e',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}>
|
||||||
|
PURCHASED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||||
|
{info.size}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||||
|
{info.schedule}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||||
|
{info.grade}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#1f2937',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}}>
|
||||||
|
{info.userRequirements}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||||
|
{Math.round(info.length).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
|
||||||
|
{info.quantity}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>
|
||||||
|
{info.unit}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||||
|
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||||
|
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: '#f9fafb',
|
||||||
|
color: '#374151'
|
||||||
|
}}>
|
||||||
|
{savedRequests[material.id]}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userRequirements[material.id] || ''}
|
||||||
|
onChange={(e) => setUserRequirements({
|
||||||
|
...userRequirements,
|
||||||
|
[material.id]: e.target.value
|
||||||
|
})}
|
||||||
|
placeholder="Enter additional request..."
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||||
|
disabled={isPurchased || savingRequest[material.id]}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{savingRequest[material.id] ? '...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredMaterials.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '60px 20px',
|
||||||
|
color: '#64748b'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||||
|
No Pipe Materials Found
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px' }}>
|
||||||
|
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||||
|
? 'Try adjusting your filters'
|
||||||
|
: 'No pipe materials available in this BOM'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PipeMaterialsView;
|
||||||
628
frontend/src/components/bom/materials/SpecialMaterialsView.jsx
Normal file
628
frontend/src/components/bom/materials/SpecialMaterialsView.jsx
Normal file
@@ -0,0 +1,628 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||||
|
import api from '../../../api';
|
||||||
|
import { FilterableHeader } from '../shared';
|
||||||
|
|
||||||
|
const SpecialMaterialsView = ({
|
||||||
|
materials,
|
||||||
|
selectedMaterials,
|
||||||
|
setSelectedMaterials,
|
||||||
|
userRequirements,
|
||||||
|
setUserRequirements,
|
||||||
|
purchasedMaterials,
|
||||||
|
onPurchasedMaterialsUpdate,
|
||||||
|
updateMaterial, // 자재 업데이트 함수
|
||||||
|
jobNo,
|
||||||
|
fileId,
|
||||||
|
user,
|
||||||
|
onNavigate
|
||||||
|
}) => {
|
||||||
|
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||||
|
const [columnFilters, setColumnFilters] = useState({});
|
||||||
|
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||||
|
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||||
|
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||||
|
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadSavedData = () => {
|
||||||
|
const savedRequestsData = {};
|
||||||
|
|
||||||
|
materials.forEach(material => {
|
||||||
|
if (material.user_requirement && material.user_requirement.trim()) {
|
||||||
|
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSavedRequests(savedRequestsData);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (materials && materials.length > 0) {
|
||||||
|
loadSavedData();
|
||||||
|
} else {
|
||||||
|
setSavedRequests({});
|
||||||
|
}
|
||||||
|
}, [materials]);
|
||||||
|
|
||||||
|
// 추가요구사항 저장 함수
|
||||||
|
const handleSaveRequest = async (materialId, request) => {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
try {
|
||||||
|
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||||
|
user_requirement: request.trim()
|
||||||
|
});
|
||||||
|
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||||
|
|
||||||
|
if (updateMaterial) {
|
||||||
|
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('추가요구사항 저장 실패:', error);
|
||||||
|
alert('추가요구사항 저장에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 추가요구사항 편집 시작
|
||||||
|
const handleEditRequest = (materialId, currentRequest) => {
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// SPECIAL 자재 정보 파싱
|
||||||
|
const parseSpecialInfo = (material) => {
|
||||||
|
const description = material.original_description || '';
|
||||||
|
const qty = Math.round(material.quantity || 0);
|
||||||
|
|
||||||
|
// Type 추출 (큰 범주: 우선순위 기반 분류)
|
||||||
|
let type = 'SPECIAL';
|
||||||
|
const descUpper = description.toUpperCase();
|
||||||
|
|
||||||
|
// 우선순위 1: 주요 장비 타입 (OIL PUMP, COMPRESSOR 등이 FLG보다 우선)
|
||||||
|
if (descUpper.includes('OIL PUMP') || (descUpper.includes('PUMP') && !descUpper.includes('FITTING'))) {
|
||||||
|
type = 'OIL PUMP';
|
||||||
|
} else if (descUpper.includes('COMPRESSOR')) {
|
||||||
|
type = 'COMPRESSOR';
|
||||||
|
} else if (descUpper.includes('VALVE') && !descUpper.includes('FLG')) {
|
||||||
|
type = 'VALVE';
|
||||||
|
}
|
||||||
|
// 우선순위 2: 구조물/부품 타입
|
||||||
|
else if (descUpper.includes('FLG') || descUpper.includes('FLANGE')) {
|
||||||
|
// FLG가 있어도 주요 장비가 함께 있으면 장비 타입 우선
|
||||||
|
if (descUpper.includes('OIL PUMP') || descUpper.includes('COMPRESSOR')) {
|
||||||
|
if (descUpper.includes('OIL PUMP')) {
|
||||||
|
type = 'OIL PUMP';
|
||||||
|
} else if (descUpper.includes('COMPRESSOR')) {
|
||||||
|
type = 'COMPRESSOR';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
type = 'FLANGE';
|
||||||
|
}
|
||||||
|
} else if (descUpper.includes('FITTING')) {
|
||||||
|
type = 'FITTING';
|
||||||
|
} else if (descUpper.includes('PIPE')) {
|
||||||
|
type = 'PIPE';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 도면 정보 (drawing_name 또는 line_no에서 추출)
|
||||||
|
const drawing = material.drawing_name || material.line_no || '-';
|
||||||
|
|
||||||
|
// 설명을 항목별로 분리 (콤마, 세미콜론, 파이프 등으로 구분)
|
||||||
|
const parts = description
|
||||||
|
.split(/[,;|\/]/)
|
||||||
|
.map(part => part.trim())
|
||||||
|
.filter(part => part.length > 0);
|
||||||
|
|
||||||
|
// 최대 4개 항목으로 제한
|
||||||
|
const detail1 = parts[0] || '-';
|
||||||
|
const detail2 = parts[1] || '-';
|
||||||
|
const detail3 = parts[2] || '-';
|
||||||
|
const detail4 = parts[3] || '-';
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
drawing,
|
||||||
|
detail1,
|
||||||
|
detail2,
|
||||||
|
detail3,
|
||||||
|
detail4,
|
||||||
|
quantity: qty,
|
||||||
|
originalQuantity: qty,
|
||||||
|
purchaseQuantity: qty,
|
||||||
|
isSpecial: true
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 정렬 처리
|
||||||
|
const handleSort = (key) => {
|
||||||
|
let direction = 'asc';
|
||||||
|
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||||
|
direction = 'desc';
|
||||||
|
}
|
||||||
|
setSortConfig({ key, direction });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터링 및 정렬된 자료
|
||||||
|
const getFilteredAndSortedMaterials = () => {
|
||||||
|
let filtered = materials.filter(material => {
|
||||||
|
const info = parseSpecialInfo(material);
|
||||||
|
|
||||||
|
// 컬럼 필터 적용
|
||||||
|
for (const [key, filterValue] of Object.entries(columnFilters)) {
|
||||||
|
if (filterValue && filterValue.trim()) {
|
||||||
|
const materialValue = String(info[key] || '').toLowerCase();
|
||||||
|
const filter = filterValue.toLowerCase();
|
||||||
|
if (!materialValue.includes(filter)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 정렬 적용
|
||||||
|
if (sortConfig.key) {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const aInfo = parseSpecialInfo(a);
|
||||||
|
const bInfo = parseSpecialInfo(b);
|
||||||
|
|
||||||
|
const aValue = aInfo[sortConfig.key];
|
||||||
|
const bValue = bInfo[sortConfig.key];
|
||||||
|
|
||||||
|
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||||
|
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
|
||||||
|
// 전체 선택/해제
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
const selectableMaterials = filteredMaterials.filter(material =>
|
||||||
|
!purchasedMaterials.has(material.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const allSelected = selectableMaterials.every(material =>
|
||||||
|
selectedMaterials.has(material.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
// 전체 해제
|
||||||
|
const newSelected = new Set(selectedMaterials);
|
||||||
|
selectableMaterials.forEach(material => {
|
||||||
|
newSelected.delete(material.id);
|
||||||
|
});
|
||||||
|
setSelectedMaterials(newSelected);
|
||||||
|
} else {
|
||||||
|
// 전체 선택
|
||||||
|
const newSelected = new Set(selectedMaterials);
|
||||||
|
selectableMaterials.forEach(material => {
|
||||||
|
newSelected.add(material.id);
|
||||||
|
});
|
||||||
|
setSelectedMaterials(newSelected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개별 선택/해제
|
||||||
|
const handleMaterialSelect = (materialId) => {
|
||||||
|
const newSelected = new Set(selectedMaterials);
|
||||||
|
if (newSelected.has(materialId)) {
|
||||||
|
newSelected.delete(materialId);
|
||||||
|
} else {
|
||||||
|
newSelected.add(materialId);
|
||||||
|
}
|
||||||
|
setSelectedMaterials(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엑셀 내보내기
|
||||||
|
const handleExportToExcel = async () => {
|
||||||
|
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||||
|
if (selectedMaterialsData.length === 0) {
|
||||||
|
alert('내보낼 자재를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||||
|
const excelFileName = `SPECIAL_Materials_${timestamp}.xlsx`;
|
||||||
|
|
||||||
|
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||||
|
...material,
|
||||||
|
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔄 스페셜 엑셀 내보내기 시작 - 새로운 방식');
|
||||||
|
|
||||||
|
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||||
|
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||||
|
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'SPECIAL',
|
||||||
|
filename: excelFileName,
|
||||||
|
user: user?.username || 'unknown'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 구매신청 생성
|
||||||
|
console.log('📝 구매신청 생성 중...');
|
||||||
|
const purchaseResponse = await api.post('/purchase-request/create', {
|
||||||
|
materials_data: dataWithRequirements,
|
||||||
|
file_id: fileId,
|
||||||
|
job_no: jobNo
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ 구매신청 생성 완료:', purchaseResponse.data);
|
||||||
|
|
||||||
|
// 3. 엑셀 파일을 서버에 업로드
|
||||||
|
console.log('📤 엑셀 파일 서버 업로드 중...');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('excel_file', excelBlob, excelFileName);
|
||||||
|
formData.append('request_id', purchaseResponse.data.request_id);
|
||||||
|
formData.append('filename', excelFileName);
|
||||||
|
|
||||||
|
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||||
|
|
||||||
|
// 4. 구매신청된 자재들을 비활성화
|
||||||
|
const purchasedIds = selectedMaterialsData.map(m => m.id);
|
||||||
|
onPurchasedMaterialsUpdate(purchasedIds);
|
||||||
|
|
||||||
|
// 5. 선택 해제
|
||||||
|
setSelectedMaterials(new Set());
|
||||||
|
|
||||||
|
// 6. 클라이언트에서도 다운로드
|
||||||
|
const url = window.URL.createObjectURL(excelBlob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = excelFileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
console.log('✅ 스페셜 엑셀 내보내기 완료');
|
||||||
|
alert(`스페셜 자재 엑셀 파일이 생성되었습니다.\n파일명: ${excelFileName}`);
|
||||||
|
|
||||||
|
// 구매신청 관리 페이지로 이동
|
||||||
|
if (onNavigate) {
|
||||||
|
onNavigate('purchase-requests');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 스페셜 엑셀 내보내기 실패:', error);
|
||||||
|
alert('엑셀 내보내기 중 오류가 발생했습니다: ' + (error.response?.data?.detail || error.message));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const allSelected = filteredMaterials.length > 0 &&
|
||||||
|
filteredMaterials.filter(material => !purchasedMaterials.has(material.id))
|
||||||
|
.every(material => selectedMaterials.has(material.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '24px',
|
||||||
|
padding: '20px',
|
||||||
|
background: 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
color: 'white'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '24px', fontWeight: '700' }}>
|
||||||
|
Special Items
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: '8px 0 0 0', opacity: 0.9 }}>
|
||||||
|
특수 제작 품목 관리 ({filteredMaterials.length}개)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleExportToExcel}
|
||||||
|
disabled={selectedMaterials.size === 0}
|
||||||
|
style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
background: selectedMaterials.size > 0 ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.1)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
backdropFilter: 'blur(10px)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
구매신청 ({selectedMaterials.size})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '600px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
minWidth: '1400px'
|
||||||
|
}}>
|
||||||
|
<div style={{ minWidth: '1400px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 150px 200px 200px 200px 200px 200px 250px 150px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderBottom: '2px solid #e2e8f0',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#1f2937',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="type"
|
||||||
|
filterKey="type"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="drawing"
|
||||||
|
filterKey="drawing"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Drawing
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="detail1"
|
||||||
|
filterKey="detail1"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Detail 1
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="detail2"
|
||||||
|
filterKey="detail2"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Detail 2
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="detail3"
|
||||||
|
filterKey="detail3"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Detail 3
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="detail4"
|
||||||
|
filterKey="detail4"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Detail 4
|
||||||
|
</FilterableHeader>
|
||||||
|
<div>Additional Request</div>
|
||||||
|
<div>Purchase Quantity</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행들 */}
|
||||||
|
<div>
|
||||||
|
{filteredMaterials.map((material, index) => {
|
||||||
|
const info = parseSpecialInfo(material);
|
||||||
|
const isSelected = selectedMaterials.has(material.id);
|
||||||
|
const isPurchased = purchasedMaterials.has(material.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={material.id}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 150px 200px 200px 200px 200px 200px 250px 150px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||||
|
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = '#f8fafc';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = 'white';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => handleMaterialSelect(material.id)}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||||
|
{info.type}
|
||||||
|
{isPurchased && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
background: '#fbbf24',
|
||||||
|
color: '#92400e',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
|
PURCHASED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.drawing}</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail1}</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail2}</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail3}</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.detail4}</div>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||||
|
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||||
|
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: '#f9fafb',
|
||||||
|
color: '#374151'
|
||||||
|
}}>
|
||||||
|
{savedRequests[material.id]}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userRequirements[material.id] || ''}
|
||||||
|
onChange={(e) => setUserRequirements({
|
||||||
|
...userRequirements,
|
||||||
|
[material.id]: e.target.value
|
||||||
|
})}
|
||||||
|
placeholder="Enter additional request..."
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||||
|
disabled={isPurchased || savingRequest[material.id]}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{savingRequest[material.id] ? '...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.purchaseQuantity}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredMaterials.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '60px 20px',
|
||||||
|
color: '#6b7280',
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
marginTop: '20px'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📋</div>
|
||||||
|
<h3 style={{ margin: '0 0 8px 0', color: '#374151' }}>스페셜 자재가 없습니다</h3>
|
||||||
|
<p style={{ margin: 0 }}>특수 제작이 필요한 자재가 발견되면 여기에 표시됩니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpecialMaterialsView;
|
||||||
687
frontend/src/components/bom/materials/SupportMaterialsView.jsx
Normal file
687
frontend/src/components/bom/materials/SupportMaterialsView.jsx
Normal file
@@ -0,0 +1,687 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||||
|
import api from '../../../api';
|
||||||
|
import { FilterableHeader } from '../shared';
|
||||||
|
|
||||||
|
const SupportMaterialsView = ({
|
||||||
|
materials,
|
||||||
|
selectedMaterials,
|
||||||
|
setSelectedMaterials,
|
||||||
|
userRequirements,
|
||||||
|
setUserRequirements,
|
||||||
|
purchasedMaterials,
|
||||||
|
onPurchasedMaterialsUpdate,
|
||||||
|
updateMaterial, // 자재 업데이트 함수
|
||||||
|
jobNo,
|
||||||
|
fileId,
|
||||||
|
user
|
||||||
|
}) => {
|
||||||
|
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||||
|
const [columnFilters, setColumnFilters] = useState({});
|
||||||
|
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||||
|
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||||
|
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||||
|
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadSavedData = () => {
|
||||||
|
const savedRequestsData = {};
|
||||||
|
|
||||||
|
materials.forEach(material => {
|
||||||
|
if (material.user_requirement && material.user_requirement.trim()) {
|
||||||
|
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSavedRequests(savedRequestsData);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (materials && materials.length > 0) {
|
||||||
|
loadSavedData();
|
||||||
|
} else {
|
||||||
|
setSavedRequests({});
|
||||||
|
}
|
||||||
|
}, [materials]);
|
||||||
|
|
||||||
|
// 추가요구사항 저장 함수
|
||||||
|
const handleSaveRequest = async (materialId, request) => {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
try {
|
||||||
|
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||||
|
user_requirement: request.trim()
|
||||||
|
});
|
||||||
|
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||||
|
|
||||||
|
if (updateMaterial) {
|
||||||
|
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('추가요구사항 저장 실패:', error);
|
||||||
|
alert('추가요구사항 저장에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 추가요구사항 편집 시작
|
||||||
|
const handleEditRequest = (materialId, currentRequest) => {
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSupportInfo = (material) => {
|
||||||
|
const desc = material.original_description || '';
|
||||||
|
const descUpper = desc.toUpperCase();
|
||||||
|
|
||||||
|
// 서포트 타입 분류 (백엔드 분류기와 동일한 로직)
|
||||||
|
let supportType = 'U-BOLT'; // 기본값
|
||||||
|
|
||||||
|
if (descUpper.includes('URETHANE') || descUpper.includes('BLOCK SHOE') || descUpper.includes('우레탄')) {
|
||||||
|
supportType = 'URETHANE BLOCK SHOE';
|
||||||
|
} else if (descUpper.includes('CLAMP') || descUpper.includes('클램프')) {
|
||||||
|
// 클램프 타입 상세 분류 (CL-1, CL-2, CL-3 등)
|
||||||
|
const clampMatch = desc.match(/CL[-\s]*(\d+)/i);
|
||||||
|
if (clampMatch) {
|
||||||
|
supportType = `CLAMP CL-${clampMatch[1]}`;
|
||||||
|
} else {
|
||||||
|
supportType = 'CLAMP CL-1'; // 기본값
|
||||||
|
}
|
||||||
|
} else if (descUpper.includes('HANGER') || descUpper.includes('행거')) {
|
||||||
|
supportType = 'HANGER';
|
||||||
|
} else if (descUpper.includes('SPRING') || descUpper.includes('스프링')) {
|
||||||
|
supportType = 'SPRING HANGER';
|
||||||
|
} else if (descUpper.includes('GUIDE') || descUpper.includes('가이드')) {
|
||||||
|
supportType = 'GUIDE';
|
||||||
|
} else if (descUpper.includes('ANCHOR') || descUpper.includes('앵커')) {
|
||||||
|
supportType = 'ANCHOR';
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Requirements 추출 (분류기에서 제공된 것 우선)
|
||||||
|
const userRequirements = material.user_requirements || [];
|
||||||
|
|
||||||
|
// 구매 수량 계산 (서포트는 취합된 숫자 그대로)
|
||||||
|
const qty = Math.round(material.quantity || 0);
|
||||||
|
const purchaseQty = qty;
|
||||||
|
|
||||||
|
// Material Grade 처리 - 우레탄 블럭슈의 경우 두께 정보 포함
|
||||||
|
let materialGrade = material.full_material_grade || material.material_grade || '-';
|
||||||
|
|
||||||
|
if (supportType === 'URETHANE BLOCK SHOE') {
|
||||||
|
// 두께 정보 추출 (40t, 27t 등 - 여기서 t는 thickness를 의미)
|
||||||
|
const thicknessMatch = desc.match(/(\d+)\s*[tT]/);
|
||||||
|
if (thicknessMatch) {
|
||||||
|
const thickness = `${thicknessMatch[1]}t`;
|
||||||
|
if (materialGrade === '-' || !materialGrade) {
|
||||||
|
materialGrade = thickness;
|
||||||
|
} else if (!materialGrade.includes(thickness)) {
|
||||||
|
materialGrade = `${materialGrade} ${thickness}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: supportType,
|
||||||
|
size: material.main_nom || material.size_inch || material.size_spec || '-',
|
||||||
|
grade: materialGrade,
|
||||||
|
userRequirements: userRequirements.join(', ') || '-',
|
||||||
|
additionalReq: '-',
|
||||||
|
purchaseQuantity: `${purchaseQty} EA`,
|
||||||
|
originalQuantity: qty,
|
||||||
|
isSupport: true
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 동일한 서포트 항목 합산
|
||||||
|
const consolidateSupportMaterials = (materials) => {
|
||||||
|
const consolidated = {};
|
||||||
|
|
||||||
|
materials.forEach(material => {
|
||||||
|
const info = parseSupportInfo(material);
|
||||||
|
const key = `${info.type}|${info.size}|${info.grade}`;
|
||||||
|
|
||||||
|
if (!consolidated[key]) {
|
||||||
|
consolidated[key] = {
|
||||||
|
...material,
|
||||||
|
// Material Grade 정보를 parsedInfo에서 가져와서 설정
|
||||||
|
material_grade: info.grade,
|
||||||
|
full_material_grade: info.grade,
|
||||||
|
consolidatedQuantity: info.originalQuantity,
|
||||||
|
consolidatedIds: [material.id],
|
||||||
|
parsedInfo: info
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
consolidated[key].consolidatedQuantity += info.originalQuantity;
|
||||||
|
consolidated[key].consolidatedIds.push(material.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 합산된 수량으로 구매 수량 재계산 (서포트는 취합된 숫자 그대로)
|
||||||
|
return Object.values(consolidated).map(item => {
|
||||||
|
const purchaseQty = item.consolidatedQuantity;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
parsedInfo: {
|
||||||
|
...item.parsedInfo,
|
||||||
|
originalQuantity: item.consolidatedQuantity,
|
||||||
|
purchaseQuantity: `${purchaseQty} EA`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 정렬 처리
|
||||||
|
const handleSort = (key) => {
|
||||||
|
let direction = 'asc';
|
||||||
|
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||||
|
direction = 'desc';
|
||||||
|
}
|
||||||
|
setSortConfig({ key, direction });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터링된 및 정렬된 자재 목록
|
||||||
|
const getFilteredAndSortedMaterials = () => {
|
||||||
|
// 먼저 합산 처리
|
||||||
|
let consolidated = consolidateSupportMaterials(materials);
|
||||||
|
|
||||||
|
// 필터링
|
||||||
|
let filtered = consolidated.filter(material => {
|
||||||
|
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||||
|
if (!filterValue) return true;
|
||||||
|
const info = material.parsedInfo;
|
||||||
|
const value = info[key]?.toString().toLowerCase() || '';
|
||||||
|
return value.includes(filterValue.toLowerCase());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
if (sortConfig && sortConfig.key) {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const aInfo = a.parsedInfo;
|
||||||
|
const bInfo = b.parsedInfo;
|
||||||
|
|
||||||
|
if (!aInfo || !bInfo) return 0;
|
||||||
|
|
||||||
|
const aValue = aInfo[sortConfig.key];
|
||||||
|
const bValue = bInfo[sortConfig.key];
|
||||||
|
|
||||||
|
// 값이 없는 경우 처리
|
||||||
|
if (aValue === undefined && bValue === undefined) return 0;
|
||||||
|
if (aValue === undefined) return 1;
|
||||||
|
if (bValue === undefined) return -1;
|
||||||
|
|
||||||
|
// 숫자인 경우 숫자로 비교
|
||||||
|
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||||
|
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문자열로 비교
|
||||||
|
const aStr = String(aValue).toLowerCase();
|
||||||
|
const bStr = String(bValue).toLowerCase();
|
||||||
|
|
||||||
|
if (sortConfig.direction === 'asc') {
|
||||||
|
return aStr.localeCompare(bStr);
|
||||||
|
} else {
|
||||||
|
return bStr.localeCompare(aStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전체 선택/해제 (구매신청된 자재 제외)
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
const selectableMaterials = filteredMaterials.filter(material =>
|
||||||
|
!material.consolidatedIds.some(id => purchasedMaterials.has(id))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedMaterials.size === selectableMaterials.flatMap(m => m.consolidatedIds).length) {
|
||||||
|
setSelectedMaterials(new Set());
|
||||||
|
} else {
|
||||||
|
const allIds = selectableMaterials.flatMap(m => m.consolidatedIds);
|
||||||
|
setSelectedMaterials(new Set(allIds));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개별 선택 (구매신청된 자재는 선택 불가)
|
||||||
|
const handleMaterialSelect = (consolidatedMaterial) => {
|
||||||
|
const hasAnyPurchased = consolidatedMaterial.consolidatedIds.some(id => purchasedMaterials.has(id));
|
||||||
|
if (hasAnyPurchased) {
|
||||||
|
return; // 구매신청된 자재가 포함된 경우 선택 불가
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSelected = new Set(selectedMaterials);
|
||||||
|
const allSelected = consolidatedMaterial.consolidatedIds.every(id => newSelected.has(id));
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
// 모두 선택된 경우 모두 해제
|
||||||
|
consolidatedMaterial.consolidatedIds.forEach(id => newSelected.delete(id));
|
||||||
|
} else {
|
||||||
|
// 일부 또는 전체 미선택인 경우 모두 선택
|
||||||
|
consolidatedMaterial.consolidatedIds.forEach(id => newSelected.add(id));
|
||||||
|
}
|
||||||
|
setSelectedMaterials(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엑셀 내보내기
|
||||||
|
const handleExportToExcel = async () => {
|
||||||
|
// 선택된 합산 자료 가져오기
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
const selectedConsolidatedMaterials = filteredMaterials.filter(consolidatedMaterial =>
|
||||||
|
consolidatedMaterial.consolidatedIds.some(id => selectedMaterials.has(id))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedConsolidatedMaterials.length === 0) {
|
||||||
|
alert('내보낼 자재를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||||
|
const excelFileName = `SUPPORT_Materials_${timestamp}.xlsx`;
|
||||||
|
|
||||||
|
// 합산된 자료를 엑셀 형태로 변환
|
||||||
|
const dataWithRequirements = selectedConsolidatedMaterials.map(consolidatedMaterial => ({
|
||||||
|
...consolidatedMaterial,
|
||||||
|
// 합산된 수량으로 덮어쓰기
|
||||||
|
quantity: consolidatedMaterial.consolidatedQuantity,
|
||||||
|
// 사용자 요구사항은 대표 ID 기준 (저장된 데이터 우선)
|
||||||
|
user_requirement: savedRequests[consolidatedMaterial.id] || userRequirements[consolidatedMaterial.id] || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔄 서포트 엑셀 내보내기 시작 - 새로운 방식');
|
||||||
|
|
||||||
|
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||||
|
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||||
|
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'SUPPORT',
|
||||||
|
filename: excelFileName,
|
||||||
|
uploadDate: new Date().toLocaleDateString()
|
||||||
|
});
|
||||||
|
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||||
|
|
||||||
|
// 2. 구매신청 생성
|
||||||
|
const allMaterialIds = selectedConsolidatedMaterials.flatMap(cm => cm.consolidatedIds);
|
||||||
|
const response = await api.post('/purchase-request/create', {
|
||||||
|
file_id: fileId,
|
||||||
|
job_no: jobNo,
|
||||||
|
category: 'SUPPORT',
|
||||||
|
material_ids: allMaterialIds,
|
||||||
|
materials_data: dataWithRequirements.map(m => ({
|
||||||
|
material_id: m.id,
|
||||||
|
description: m.original_description,
|
||||||
|
category: m.classified_category,
|
||||||
|
size: m.size_inch || m.size_spec,
|
||||||
|
schedule: m.schedule,
|
||||||
|
material_grade: m.material_grade || m.full_material_grade,
|
||||||
|
quantity: m.quantity, // 이미 합산된 수량
|
||||||
|
unit: m.unit,
|
||||||
|
user_requirement: userRequirements[m.id] || ''
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||||
|
|
||||||
|
// 3. 엑셀 파일을 서버에 업로드
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('excel_file', excelBlob, excelFileName);
|
||||||
|
formData.append('request_id', response.data.request_id);
|
||||||
|
formData.append('category', 'SUPPORT');
|
||||||
|
|
||||||
|
console.log('📤 엑셀 파일 서버 업로드 중...');
|
||||||
|
await api.post('/purchase-request/upload-excel', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
});
|
||||||
|
console.log('✅ 엑셀 파일 서버 업로드 완료');
|
||||||
|
|
||||||
|
// 4. 구매된 자재 목록 업데이트 (비활성화)
|
||||||
|
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||||
|
console.log('✅ 구매된 자재 목록 업데이트 완료');
|
||||||
|
|
||||||
|
// 5. 클라이언트에 파일 다운로드
|
||||||
|
const url = window.URL.createObjectURL(excelBlob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = excelFileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data?.message || '구매신청 생성 실패');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||||
|
// 실패 시에도 클라이언트 다운로드는 진행
|
||||||
|
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'SUPPORT',
|
||||||
|
filename: excelFileName,
|
||||||
|
uploadDate: new Date().toLocaleDateString()
|
||||||
|
});
|
||||||
|
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택 해제
|
||||||
|
setSelectedMaterials(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '32px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0f172a',
|
||||||
|
margin: '0 0 8px 0'
|
||||||
|
}}>
|
||||||
|
Support Materials
|
||||||
|
</h3>
|
||||||
|
<p style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#64748b',
|
||||||
|
margin: 0
|
||||||
|
}}>
|
||||||
|
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
color: '#6b7280',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleExportToExcel}
|
||||||
|
disabled={selectedMaterials.size === 0}
|
||||||
|
style={{
|
||||||
|
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)' : '#e5e7eb',
|
||||||
|
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Export to Excel ({selectedMaterials.size})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'auto',
|
||||||
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||||
|
maxHeight: '600px'
|
||||||
|
}}>
|
||||||
|
<div style={{ minWidth: '1200px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 200px 120px 150px 180px 200px 200px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(() => {
|
||||||
|
const selectableMaterials = filteredMaterials.filter(material =>
|
||||||
|
!material.consolidatedIds.some(id => purchasedMaterials.has(id))
|
||||||
|
);
|
||||||
|
return selectedMaterials.size === selectableMaterials.flatMap(m => m.consolidatedIds).length && selectableMaterials.length > 0;
|
||||||
|
})()}
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="type"
|
||||||
|
filterKey="type"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="size"
|
||||||
|
filterKey="size"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Size
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="grade"
|
||||||
|
filterKey="grade"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Material Grade
|
||||||
|
</FilterableHeader>
|
||||||
|
<div>User Requirements</div>
|
||||||
|
<div>Additional Request</div>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="purchaseQuantity"
|
||||||
|
filterKey="purchaseQuantity"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Purchase Quantity
|
||||||
|
</FilterableHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행들 */}
|
||||||
|
{filteredMaterials.map((consolidatedMaterial, index) => {
|
||||||
|
const info = consolidatedMaterial.parsedInfo;
|
||||||
|
const hasAnyPurchased = consolidatedMaterial.consolidatedIds.some(id => purchasedMaterials.has(id));
|
||||||
|
const allSelected = consolidatedMaterial.consolidatedIds.every(id => selectedMaterials.has(id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`consolidated-${index}`}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 200px 120px 150px 180px 200px 200px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||||
|
background: allSelected ? '#eff6ff' : (hasAnyPurchased ? '#fef3c7' : 'white'),
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!allSelected && !hasAnyPurchased) {
|
||||||
|
e.target.style.background = '#f8fafc';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!allSelected && !hasAnyPurchased) {
|
||||||
|
e.target.style.background = 'white';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={() => handleMaterialSelect(consolidatedMaterial)}
|
||||||
|
disabled={hasAnyPurchased}
|
||||||
|
style={{
|
||||||
|
cursor: hasAnyPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: hasAnyPurchased ? 0.5 : 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||||
|
{info.type}
|
||||||
|
{hasAnyPurchased && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
background: '#fbbf24',
|
||||||
|
color: '#92400e',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}>
|
||||||
|
PURCHASED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.size}</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.grade}</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.userRequirements}</div>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||||
|
{!editingRequest[consolidatedMaterial.id] && savedRequests[consolidatedMaterial.id] ? (
|
||||||
|
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: '#f9fafb',
|
||||||
|
color: '#374151'
|
||||||
|
}}>
|
||||||
|
{savedRequests[consolidatedMaterial.id]}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditRequest(consolidatedMaterial.id, savedRequests[consolidatedMaterial.id])}
|
||||||
|
disabled={hasAnyPurchased}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: hasAnyPurchased ? '#d1d5db' : '#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: hasAnyPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: hasAnyPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userRequirements[consolidatedMaterial.id] || ''}
|
||||||
|
onChange={(e) => setUserRequirements({
|
||||||
|
...userRequirements,
|
||||||
|
[consolidatedMaterial.id]: e.target.value
|
||||||
|
})}
|
||||||
|
placeholder="Enter additional request..."
|
||||||
|
disabled={hasAnyPurchased}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
opacity: hasAnyPurchased ? 0.5 : 1,
|
||||||
|
cursor: hasAnyPurchased ? 'not-allowed' : 'text'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveRequest(consolidatedMaterial.id, userRequirements[consolidatedMaterial.id] || '')}
|
||||||
|
disabled={hasAnyPurchased || savingRequest[consolidatedMaterial.id]}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: hasAnyPurchased ? '#d1d5db' : '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: hasAnyPurchased || savingRequest[consolidatedMaterial.id] ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: hasAnyPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{savingRequest[consolidatedMaterial.id] ? '...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.purchaseQuantity}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredMaterials.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '60px 20px',
|
||||||
|
color: '#64748b'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||||
|
No Support Materials Found
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px' }}>
|
||||||
|
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||||
|
? 'Try adjusting your filters'
|
||||||
|
: 'No support materials available in this BOM'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SupportMaterialsView;
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||||
|
import api from '../../../api';
|
||||||
|
import { FilterableHeader } from '../shared';
|
||||||
|
|
||||||
|
const UnclassifiedMaterialsView = ({
|
||||||
|
materials,
|
||||||
|
selectedMaterials,
|
||||||
|
setSelectedMaterials,
|
||||||
|
userRequirements,
|
||||||
|
setUserRequirements,
|
||||||
|
purchasedMaterials,
|
||||||
|
onPurchasedMaterialsUpdate,
|
||||||
|
updateMaterial, // 자재 업데이트 함수
|
||||||
|
jobNo,
|
||||||
|
fileId,
|
||||||
|
user,
|
||||||
|
onNavigate
|
||||||
|
}) => {
|
||||||
|
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||||
|
const [columnFilters, setColumnFilters] = useState({});
|
||||||
|
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||||
|
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||||
|
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||||
|
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadSavedData = () => {
|
||||||
|
const savedRequestsData = {};
|
||||||
|
|
||||||
|
materials.forEach(material => {
|
||||||
|
if (material.user_requirement && material.user_requirement.trim()) {
|
||||||
|
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSavedRequests(savedRequestsData);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (materials && materials.length > 0) {
|
||||||
|
loadSavedData();
|
||||||
|
} else {
|
||||||
|
setSavedRequests({});
|
||||||
|
}
|
||||||
|
}, [materials]);
|
||||||
|
|
||||||
|
// 추가요구사항 저장 함수
|
||||||
|
const handleSaveRequest = async (materialId, request) => {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
try {
|
||||||
|
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||||
|
user_requirement: request.trim()
|
||||||
|
});
|
||||||
|
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
|
||||||
|
|
||||||
|
if (updateMaterial) {
|
||||||
|
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('추가요구사항 저장 실패:', error);
|
||||||
|
alert('추가요구사항 저장에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 추가요구사항 편집 시작
|
||||||
|
const handleEditRequest = (materialId, currentRequest) => {
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 미분류 자재 정보 파싱 (원본 그대로 표시)
|
||||||
|
const parseUnclassifiedInfo = (material) => {
|
||||||
|
const description = material.original_description || material.description || '';
|
||||||
|
const qty = Math.round(material.quantity || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: description || '-',
|
||||||
|
size: material.main_nom || material.size_spec || '-',
|
||||||
|
drawing: material.drawing_name || material.line_no || '-',
|
||||||
|
lineNo: material.line_no || '-',
|
||||||
|
quantity: qty,
|
||||||
|
originalQuantity: qty,
|
||||||
|
purchaseQuantity: qty,
|
||||||
|
isUnclassified: true
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 정렬 처리
|
||||||
|
const handleSort = (key) => {
|
||||||
|
let direction = 'asc';
|
||||||
|
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||||
|
direction = 'desc';
|
||||||
|
}
|
||||||
|
setSortConfig({ key, direction });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터링 및 정렬된 자료
|
||||||
|
const getFilteredAndSortedMaterials = () => {
|
||||||
|
let filtered = materials.filter(material => {
|
||||||
|
const info = parseUnclassifiedInfo(material);
|
||||||
|
|
||||||
|
// 컬럼 필터 적용
|
||||||
|
for (const [key, filterValue] of Object.entries(columnFilters)) {
|
||||||
|
if (filterValue && filterValue.trim()) {
|
||||||
|
const materialValue = String(info[key] || '').toLowerCase();
|
||||||
|
const filter = filterValue.toLowerCase();
|
||||||
|
if (!materialValue.includes(filter)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 정렬 적용
|
||||||
|
if (sortConfig.key) {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const aInfo = parseUnclassifiedInfo(a);
|
||||||
|
const bInfo = parseUnclassifiedInfo(b);
|
||||||
|
|
||||||
|
const aValue = aInfo[sortConfig.key];
|
||||||
|
const bValue = bInfo[sortConfig.key];
|
||||||
|
|
||||||
|
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||||
|
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
|
||||||
|
// 전체 선택/해제
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
const selectableMaterials = filteredMaterials.filter(material =>
|
||||||
|
!purchasedMaterials.has(material.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const allSelected = selectableMaterials.every(material =>
|
||||||
|
selectedMaterials.has(material.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
// 전체 해제
|
||||||
|
const newSelected = new Set(selectedMaterials);
|
||||||
|
selectableMaterials.forEach(material => {
|
||||||
|
newSelected.delete(material.id);
|
||||||
|
});
|
||||||
|
setSelectedMaterials(newSelected);
|
||||||
|
} else {
|
||||||
|
// 전체 선택
|
||||||
|
const newSelected = new Set(selectedMaterials);
|
||||||
|
selectableMaterials.forEach(material => {
|
||||||
|
newSelected.add(material.id);
|
||||||
|
});
|
||||||
|
setSelectedMaterials(newSelected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개별 선택/해제
|
||||||
|
const handleMaterialSelect = (materialId) => {
|
||||||
|
const newSelected = new Set(selectedMaterials);
|
||||||
|
if (newSelected.has(materialId)) {
|
||||||
|
newSelected.delete(materialId);
|
||||||
|
} else {
|
||||||
|
newSelected.add(materialId);
|
||||||
|
}
|
||||||
|
setSelectedMaterials(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엑셀 내보내기
|
||||||
|
const handleExportToExcel = async () => {
|
||||||
|
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||||
|
if (selectedMaterialsData.length === 0) {
|
||||||
|
alert('내보낼 자재를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||||
|
const excelFileName = `UNCLASSIFIED_Materials_${timestamp}.xlsx`;
|
||||||
|
|
||||||
|
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||||
|
...material,
|
||||||
|
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔄 미분류 엑셀 내보내기 시작 - 새로운 방식');
|
||||||
|
|
||||||
|
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||||
|
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||||
|
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'UNCLASSIFIED',
|
||||||
|
filename: excelFileName,
|
||||||
|
user: user?.username || 'unknown'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 구매신청 생성
|
||||||
|
console.log('📝 구매신청 생성 중...');
|
||||||
|
const purchaseResponse = await api.post('/purchase-request/create', {
|
||||||
|
materials_data: dataWithRequirements,
|
||||||
|
file_id: fileId,
|
||||||
|
job_no: jobNo
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ 구매신청 생성 완료:', purchaseResponse.data);
|
||||||
|
|
||||||
|
// 3. 엑셀 파일을 서버에 업로드
|
||||||
|
console.log('📤 엑셀 파일 서버 업로드 중...');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('excel_file', excelBlob, excelFileName);
|
||||||
|
formData.append('request_id', purchaseResponse.data.request_id);
|
||||||
|
formData.append('filename', excelFileName);
|
||||||
|
|
||||||
|
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||||
|
|
||||||
|
// 4. 구매신청된 자재들을 비활성화
|
||||||
|
const purchasedIds = selectedMaterialsData.map(m => m.id);
|
||||||
|
onPurchasedMaterialsUpdate(purchasedIds);
|
||||||
|
|
||||||
|
// 5. 선택 해제
|
||||||
|
setSelectedMaterials(new Set());
|
||||||
|
|
||||||
|
// 6. 클라이언트에서도 다운로드
|
||||||
|
const url = window.URL.createObjectURL(excelBlob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = excelFileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
console.log('✅ 미분류 엑셀 내보내기 완료');
|
||||||
|
alert(`미분류 자재 엑셀 파일이 생성되었습니다.\n파일명: ${excelFileName}`);
|
||||||
|
|
||||||
|
// 구매신청 관리 페이지로 이동
|
||||||
|
if (onNavigate) {
|
||||||
|
onNavigate('purchase-requests');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 미분류 엑셀 내보내기 실패:', error);
|
||||||
|
alert('엑셀 내보내기 중 오류가 발생했습니다: ' + (error.response?.data?.detail || error.message));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const allSelected = filteredMaterials.length > 0 &&
|
||||||
|
filteredMaterials.filter(material => !purchasedMaterials.has(material.id))
|
||||||
|
.every(material => selectedMaterials.has(material.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '24px',
|
||||||
|
padding: '20px',
|
||||||
|
background: 'linear-gradient(135deg, #64748b 0%, #475569 100%)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
color: 'white'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '24px', fontWeight: '700' }}>
|
||||||
|
Unclassified Materials
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: '8px 0 0 0', opacity: 0.9 }}>
|
||||||
|
분류되지 않은 자재 관리 ({filteredMaterials.length}개)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleExportToExcel}
|
||||||
|
disabled={selectedMaterials.size === 0}
|
||||||
|
style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
background: selectedMaterials.size > 0 ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.1)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
backdropFilter: 'blur(10px)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
구매신청 ({selectedMaterials.size})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '600px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
minWidth: '1200px'
|
||||||
|
}}>
|
||||||
|
<div style={{ minWidth: '1200px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 400px 150px 200px 150px 250px 150px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderBottom: '2px solid #e2e8f0',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#1f2937',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="description"
|
||||||
|
filterKey="description"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="size"
|
||||||
|
filterKey="size"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Size
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="drawing"
|
||||||
|
filterKey="drawing"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Drawing
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="lineNo"
|
||||||
|
filterKey="lineNo"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Line No
|
||||||
|
</FilterableHeader>
|
||||||
|
<div>Additional Request</div>
|
||||||
|
<div>Purchase Quantity</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행들 */}
|
||||||
|
<div>
|
||||||
|
{filteredMaterials.map((material, index) => {
|
||||||
|
const info = parseUnclassifiedInfo(material);
|
||||||
|
const isSelected = selectedMaterials.has(material.id);
|
||||||
|
const isPurchased = purchasedMaterials.has(material.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={material.id}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 400px 150px 200px 150px 250px 150px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||||
|
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = '#f8fafc';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = 'white';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => handleMaterialSelect(material.id)}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#1f2937',
|
||||||
|
textAlign: 'left',
|
||||||
|
paddingLeft: '8px',
|
||||||
|
wordBreak: 'break-word'
|
||||||
|
}}>
|
||||||
|
{info.description}
|
||||||
|
{isPurchased && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
background: '#fbbf24',
|
||||||
|
color: '#92400e',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
|
PURCHASED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.size}</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.drawing}</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.lineNo}</div>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||||
|
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||||
|
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: '#f9fafb',
|
||||||
|
color: '#374151'
|
||||||
|
}}>
|
||||||
|
{savedRequests[material.id]}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userRequirements[material.id] || ''}
|
||||||
|
onChange={(e) => setUserRequirements({
|
||||||
|
...userRequirements,
|
||||||
|
[material.id]: e.target.value
|
||||||
|
})}
|
||||||
|
placeholder="Enter additional request..."
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||||
|
disabled={isPurchased || savingRequest[material.id]}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{savingRequest[material.id] ? '...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||||
|
{info.purchaseQuantity}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredMaterials.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '60px 20px',
|
||||||
|
color: '#6b7280',
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
marginTop: '20px'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: '16px' }}>❓</div>
|
||||||
|
<h3 style={{ margin: '0 0 8px 0', color: '#374151' }}>미분류 자재가 없습니다</h3>
|
||||||
|
<p style={{ margin: 0 }}>분류되지 않은 자재가 발견되면 여기에 표시됩니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnclassifiedMaterialsView;
|
||||||
840
frontend/src/components/bom/materials/ValveMaterialsView.jsx
Normal file
840
frontend/src/components/bom/materials/ValveMaterialsView.jsx
Normal file
@@ -0,0 +1,840 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
|
||||||
|
import api from '../../../api';
|
||||||
|
import { FilterableHeader } from '../shared';
|
||||||
|
|
||||||
|
const ValveMaterialsView = ({
|
||||||
|
materials,
|
||||||
|
selectedMaterials,
|
||||||
|
setSelectedMaterials,
|
||||||
|
userRequirements,
|
||||||
|
setUserRequirements,
|
||||||
|
purchasedMaterials,
|
||||||
|
onPurchasedMaterialsUpdate,
|
||||||
|
updateMaterial, // 자재 업데이트 함수
|
||||||
|
fileId,
|
||||||
|
jobNo,
|
||||||
|
user
|
||||||
|
}) => {
|
||||||
|
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||||
|
const [columnFilters, setColumnFilters] = useState({});
|
||||||
|
const [showFilterDropdown, setShowFilterDropdown] = useState(null);
|
||||||
|
const [brandInputs, setBrandInputs] = useState({}); // 브랜드 입력 상태
|
||||||
|
const [savingBrand, setSavingBrand] = useState({}); // 브랜드 저장 중 상태
|
||||||
|
const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
|
||||||
|
const [savedBrands, setSavedBrands] = useState({}); // 저장된 브랜드 상태
|
||||||
|
const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
|
||||||
|
const [editingBrand, setEditingBrand] = useState({}); // 브랜드 편집 모드
|
||||||
|
const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 시 저장된 데이터 로드
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadSavedData = () => {
|
||||||
|
const savedBrandsData = {};
|
||||||
|
const savedRequestsData = {};
|
||||||
|
|
||||||
|
console.log('🔍 ValveMaterialsView useEffect 트리거됨:', materials.length, '개 자재');
|
||||||
|
console.log('🔍 현재 materials 배열:', materials.map(m => ({id: m.id, brand: m.brand, user_requirement: m.user_requirement})));
|
||||||
|
|
||||||
|
materials.forEach(material => {
|
||||||
|
// 백엔드에서 가져온 데이터가 있으면 저장된 상태로 설정
|
||||||
|
if (material.brand && material.brand.trim()) {
|
||||||
|
savedBrandsData[material.id] = material.brand.trim();
|
||||||
|
console.log('✅ 브랜드 로드됨:', material.id, '→', material.brand);
|
||||||
|
}
|
||||||
|
if (material.user_requirement && material.user_requirement.trim()) {
|
||||||
|
savedRequestsData[material.id] = material.user_requirement.trim();
|
||||||
|
console.log('✅ 요구사항 로드됨:', material.id, '→', material.user_requirement);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('💾 최종 저장된 브랜드:', savedBrandsData);
|
||||||
|
console.log('💾 최종 저장된 요구사항:', savedRequestsData);
|
||||||
|
|
||||||
|
// 상태 업데이트를 즉시 반영하기 위해 setTimeout 사용
|
||||||
|
setSavedBrands(savedBrandsData);
|
||||||
|
setSavedRequests(savedRequestsData);
|
||||||
|
|
||||||
|
// 상태 업데이트 후 강제 리렌더링 확인
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('🔄 상태 업데이트 후 확인 - savedBrands:', savedBrandsData);
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('🔄 ValveMaterialsView useEffect 실행 - materials 길이:', materials?.length || 0);
|
||||||
|
|
||||||
|
if (materials && materials.length > 0) {
|
||||||
|
loadSavedData();
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ materials가 비어있거나 undefined');
|
||||||
|
// 빈 상태로 초기화
|
||||||
|
setSavedBrands({});
|
||||||
|
setSavedRequests({});
|
||||||
|
}
|
||||||
|
}, [materials]);
|
||||||
|
|
||||||
|
const parseValveInfo = (material) => {
|
||||||
|
const valveDetails = material.valve_details || {};
|
||||||
|
const description = material.original_description || '';
|
||||||
|
const descUpper = description.toUpperCase();
|
||||||
|
|
||||||
|
// 1. 벨브 타입 파싱 (한글명으로 표시)
|
||||||
|
let valveType = '';
|
||||||
|
if (descUpper.includes('SIGHT GLASS') || descUpper.includes('사이트글라스')) {
|
||||||
|
valveType = 'SIGHT GLASS';
|
||||||
|
} else if (descUpper.includes('STRAINER') || descUpper.includes('스트레이너')) {
|
||||||
|
valveType = 'STRAINER';
|
||||||
|
} else if (descUpper.includes('GATE') || descUpper.includes('게이트')) {
|
||||||
|
valveType = 'GATE VALVE';
|
||||||
|
} else if (descUpper.includes('BALL') || descUpper.includes('볼')) {
|
||||||
|
valveType = 'BALL VALVE';
|
||||||
|
} else if (descUpper.includes('CHECK') || descUpper.includes('체크')) {
|
||||||
|
valveType = 'CHECK VALVE';
|
||||||
|
} else if (descUpper.includes('GLOBE') || descUpper.includes('글로브')) {
|
||||||
|
valveType = 'GLOBE VALVE';
|
||||||
|
} else if (descUpper.includes('BUTTERFLY') || descUpper.includes('버터플라이')) {
|
||||||
|
valveType = 'BUTTERFLY VALVE';
|
||||||
|
} else if (descUpper.includes('NEEDLE') || descUpper.includes('니들')) {
|
||||||
|
valveType = 'NEEDLE VALVE';
|
||||||
|
} else if (descUpper.includes('RELIEF') || descUpper.includes('릴리프')) {
|
||||||
|
valveType = 'RELIEF VALVE';
|
||||||
|
} else {
|
||||||
|
valveType = 'VALVE';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 사이즈 정보
|
||||||
|
const size = material.main_nom || material.size_inch || material.size_spec || '-';
|
||||||
|
|
||||||
|
// 3. 압력 등급
|
||||||
|
const pressure = material.pressure_rating ||
|
||||||
|
(descUpper.match(/(\d+)\s*LB/) ? descUpper.match(/(\d+)\s*LB/)[0] : '-');
|
||||||
|
|
||||||
|
// 4. 브랜드 (사용자 입력 가능)
|
||||||
|
const brand = '-'; // 기본값, 사용자가 입력할 수 있도록
|
||||||
|
|
||||||
|
// 5. 추가 정보 추출 (3-WAY, DOUL PLATE, DOUBLE DISC 등)
|
||||||
|
let additionalInfo = '';
|
||||||
|
const additionalPatterns = [
|
||||||
|
'3-WAY', '3WAY', 'THREE WAY',
|
||||||
|
'DOUL PLATE', 'DOUBLE PLATE', 'DUAL PLATE',
|
||||||
|
'DOUBLE DISC', 'DUAL DISC',
|
||||||
|
'SWING', 'LIFT', 'TILTING',
|
||||||
|
'WAFER', 'LUG', 'FLANGED',
|
||||||
|
'FULL BORE', 'REDUCED BORE',
|
||||||
|
'FIRE SAFE', 'ANTI STATIC'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of additionalPatterns) {
|
||||||
|
if (descUpper.includes(pattern)) {
|
||||||
|
if (additionalInfo) {
|
||||||
|
additionalInfo += ', ';
|
||||||
|
}
|
||||||
|
additionalInfo += pattern;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!additionalInfo) {
|
||||||
|
additionalInfo = '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 연결 방식 (투입구/Connection Type)
|
||||||
|
let connectionType = '';
|
||||||
|
if (descUpper.includes('SW X THRD') || descUpper.includes('SW×THRD')) {
|
||||||
|
connectionType = 'SW×THRD';
|
||||||
|
} else if (descUpper.includes('FLG') || descUpper.includes('FLANGE')) {
|
||||||
|
connectionType = 'FLG';
|
||||||
|
} else if (descUpper.includes('SW') || descUpper.includes('SOCKET')) {
|
||||||
|
connectionType = 'SW';
|
||||||
|
} else if (descUpper.includes('THRD') || descUpper.includes('THREAD')) {
|
||||||
|
connectionType = 'THRD';
|
||||||
|
} else if (descUpper.includes('BW') || descUpper.includes('BUTT WELD')) {
|
||||||
|
connectionType = 'BW';
|
||||||
|
} else {
|
||||||
|
connectionType = '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 구매 수량 계산 (기본 수량 그대로)
|
||||||
|
const qty = Math.round(material.quantity || 0);
|
||||||
|
const purchaseQuantity = `${qty} EA`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: valveType, // 벨브 종류만 (GATE VALVE, BALL VALVE 등)
|
||||||
|
size: size,
|
||||||
|
pressure: pressure,
|
||||||
|
brand: brand, // 브랜드 (사용자 입력 가능)
|
||||||
|
additionalInfo: additionalInfo, // 추가 정보 (3-WAY, DOUL PLATE 등)
|
||||||
|
connection: connectionType, // 투입구/연결방식 (SW, FLG 등)
|
||||||
|
additionalRequest: '-', // 추가 요구사항 (기존 User Requirement)
|
||||||
|
purchaseQuantity: purchaseQuantity,
|
||||||
|
originalQuantity: qty,
|
||||||
|
isValve: true
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 정렬 처리
|
||||||
|
const handleSort = (key) => {
|
||||||
|
let direction = 'asc';
|
||||||
|
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||||
|
direction = 'desc';
|
||||||
|
}
|
||||||
|
setSortConfig({ key, direction });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터링된 및 정렬된 자재 목록
|
||||||
|
const getFilteredAndSortedMaterials = () => {
|
||||||
|
let filtered = materials.filter(material => {
|
||||||
|
return Object.entries(columnFilters).every(([key, filterValue]) => {
|
||||||
|
if (!filterValue) return true;
|
||||||
|
const info = parseValveInfo(material);
|
||||||
|
const value = info[key]?.toString().toLowerCase() || '';
|
||||||
|
return value.includes(filterValue.toLowerCase());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sortConfig && sortConfig.key) {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const aInfo = parseValveInfo(a);
|
||||||
|
const bInfo = parseValveInfo(b);
|
||||||
|
|
||||||
|
if (!aInfo || !bInfo) return 0;
|
||||||
|
|
||||||
|
const aValue = aInfo[sortConfig.key];
|
||||||
|
const bValue = bInfo[sortConfig.key];
|
||||||
|
|
||||||
|
// 값이 없는 경우 처리
|
||||||
|
if (aValue === undefined && bValue === undefined) return 0;
|
||||||
|
if (aValue === undefined) return 1;
|
||||||
|
if (bValue === undefined) return -1;
|
||||||
|
|
||||||
|
// 숫자인 경우 숫자로 비교
|
||||||
|
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||||
|
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문자열로 비교
|
||||||
|
const aStr = String(aValue).toLowerCase();
|
||||||
|
const bStr = String(bValue).toLowerCase();
|
||||||
|
|
||||||
|
if (sortConfig.direction === 'asc') {
|
||||||
|
return aStr.localeCompare(bStr);
|
||||||
|
} else {
|
||||||
|
return bStr.localeCompare(aStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전체 선택/해제 (구매신청된 자재 제외)
|
||||||
|
// 브랜드 저장 함수
|
||||||
|
const handleSaveBrand = async (materialId, brand) => {
|
||||||
|
if (!brand.trim()) return;
|
||||||
|
|
||||||
|
setSavingBrand(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
try {
|
||||||
|
await api.patch(`/materials/${materialId}/brand`, { brand: brand.trim() });
|
||||||
|
// 성공 시 저장된 상태로 전환
|
||||||
|
setSavedBrands(prev => ({ ...prev, [materialId]: brand.trim() }));
|
||||||
|
setEditingBrand(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
setBrandInputs(prev => ({ ...prev, [materialId]: '' })); // 입력 필드 초기화
|
||||||
|
|
||||||
|
// materials 배열도 업데이트 (카테고리 변경 시 데이터 유지를 위해)
|
||||||
|
if (updateMaterial) {
|
||||||
|
updateMaterial(materialId, { brand: brand.trim() });
|
||||||
|
console.log('✅ materials 배열 업데이트 완료:', materialId, '→', brand.trim());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('브랜드 저장 실패:', error);
|
||||||
|
alert('브랜드 저장에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSavingBrand(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 브랜드 편집 시작
|
||||||
|
const handleEditBrand = (materialId, currentBrand) => {
|
||||||
|
setEditingBrand(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
setBrandInputs(prev => ({ ...prev, [materialId]: currentBrand || '' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 추가요구사항 저장 함수
|
||||||
|
const handleSaveRequest = async (materialId, request) => {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
try {
|
||||||
|
await api.patch(`/materials/${materialId}/user-requirement`, {
|
||||||
|
user_requirement: request.trim()
|
||||||
|
});
|
||||||
|
// 성공 시 저장된 상태로 전환
|
||||||
|
setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: '' })); // 입력 필드 초기화
|
||||||
|
|
||||||
|
// materials 배열도 업데이트 (카테고리 변경 시 데이터 유지를 위해)
|
||||||
|
if (updateMaterial) {
|
||||||
|
updateMaterial(materialId, { user_requirement: request.trim() });
|
||||||
|
console.log('✅ materials 배열 업데이트 완료:', materialId, '→', request.trim());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('추가요구사항 저장 실패:', error);
|
||||||
|
alert('추가요구사항 저장에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSavingRequest(prev => ({ ...prev, [materialId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 추가요구사항 편집 시작
|
||||||
|
const handleEditRequest = (materialId, currentRequest) => {
|
||||||
|
setEditingRequest(prev => ({ ...prev, [materialId]: true }));
|
||||||
|
setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||||
|
|
||||||
|
if (selectedMaterials.size === selectableMaterials.length) {
|
||||||
|
setSelectedMaterials(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개별 선택 (구매신청된 자재는 선택 불가)
|
||||||
|
const handleMaterialSelect = (materialId) => {
|
||||||
|
if (purchasedMaterials.has(materialId)) {
|
||||||
|
return; // 구매신청된 자재는 선택 불가
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSelected = new Set(selectedMaterials);
|
||||||
|
if (newSelected.has(materialId)) {
|
||||||
|
newSelected.delete(materialId);
|
||||||
|
} else {
|
||||||
|
newSelected.add(materialId);
|
||||||
|
}
|
||||||
|
setSelectedMaterials(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엑셀 내보내기
|
||||||
|
const handleExportToExcel = async () => {
|
||||||
|
const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
|
||||||
|
if (selectedMaterialsData.length === 0) {
|
||||||
|
alert('내보낼 자재를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||||
|
const excelFileName = `VALVE_Materials_${timestamp}.xlsx`;
|
||||||
|
|
||||||
|
const dataWithRequirements = selectedMaterialsData.map(material => ({
|
||||||
|
...material,
|
||||||
|
user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔄 밸브 엑셀 내보내기 시작 - 새로운 방식');
|
||||||
|
|
||||||
|
// 1. 먼저 클라이언트에서 엑셀 파일 생성
|
||||||
|
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
|
||||||
|
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'VALVE',
|
||||||
|
filename: excelFileName,
|
||||||
|
uploadDate: new Date().toLocaleDateString()
|
||||||
|
});
|
||||||
|
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
|
||||||
|
|
||||||
|
// 2. 구매신청 생성
|
||||||
|
const allMaterialIds = selectedMaterialsData.map(m => m.id);
|
||||||
|
const response = await api.post('/purchase-request/create', {
|
||||||
|
file_id: fileId,
|
||||||
|
job_no: jobNo,
|
||||||
|
category: 'VALVE',
|
||||||
|
material_ids: allMaterialIds,
|
||||||
|
materials_data: dataWithRequirements.map(m => ({
|
||||||
|
material_id: m.id,
|
||||||
|
description: m.original_description,
|
||||||
|
category: m.classified_category,
|
||||||
|
size: m.size_inch || m.size_spec,
|
||||||
|
schedule: m.schedule,
|
||||||
|
material_grade: m.material_grade || m.full_material_grade,
|
||||||
|
quantity: m.quantity,
|
||||||
|
unit: m.unit,
|
||||||
|
user_requirement: userRequirements[m.id] || ''
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
|
||||||
|
|
||||||
|
// 3. 생성된 엑셀 파일을 서버에 업로드
|
||||||
|
console.log('📤 서버에 엑셀 파일 업로드 중...');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('excel_file', excelBlob, excelFileName);
|
||||||
|
formData.append('request_id', response.data.request_id);
|
||||||
|
formData.append('category', 'VALVE');
|
||||||
|
|
||||||
|
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
|
||||||
|
|
||||||
|
if (onPurchasedMaterialsUpdate) {
|
||||||
|
onPurchasedMaterialsUpdate(allMaterialIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 클라이언트 다운로드
|
||||||
|
const url = window.URL.createObjectURL(excelBlob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = excelFileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('엑셀 저장 또는 구매신청 실패:', error);
|
||||||
|
// 실패 시에도 클라이언트 다운로드는 진행
|
||||||
|
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
|
||||||
|
category: 'VALVE',
|
||||||
|
filename: excelFileName,
|
||||||
|
uploadDate: new Date().toLocaleDateString()
|
||||||
|
});
|
||||||
|
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredMaterials = getFilteredAndSortedMaterials();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '32px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0f172a',
|
||||||
|
margin: '0 0 8px 0'
|
||||||
|
}}>
|
||||||
|
Valve Materials
|
||||||
|
</h3>
|
||||||
|
<p style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#64748b',
|
||||||
|
margin: 0
|
||||||
|
}}>
|
||||||
|
{filteredMaterials.length} items • {selectedMaterials.size} selected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
color: '#6b7280',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedMaterials.size === filteredMaterials.length ? 'Deselect All' : 'Select All'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleExportToExcel}
|
||||||
|
disabled={selectedMaterials.size === 0}
|
||||||
|
style={{
|
||||||
|
background: selectedMaterials.size > 0 ? 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' : '#e5e7eb',
|
||||||
|
color: selectedMaterials.size > 0 ? 'white' : '#9ca3af',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
cursor: selectedMaterials.size > 0 ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Export to Excel ({selectedMaterials.size})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'auto',
|
||||||
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||||
|
maxHeight: '600px'
|
||||||
|
}}>
|
||||||
|
<div style={{ minWidth: '1600px' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 200px 120px 120px 150px 180px 180px 150px 200px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(() => {
|
||||||
|
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
|
||||||
|
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
|
||||||
|
})()}
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="type"
|
||||||
|
filterKey="type"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="size"
|
||||||
|
filterKey="size"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Size
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="pressure"
|
||||||
|
filterKey="pressure"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Pressure
|
||||||
|
</FilterableHeader>
|
||||||
|
<div>Brand</div>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="additionalInfo"
|
||||||
|
filterKey="additionalInfo"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Additional Info
|
||||||
|
</FilterableHeader>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="connection"
|
||||||
|
filterKey="connection"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Connection
|
||||||
|
</FilterableHeader>
|
||||||
|
<div>Additional Request</div>
|
||||||
|
<FilterableHeader
|
||||||
|
sortKey="purchaseQuantity"
|
||||||
|
filterKey="purchaseQuantity"
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSort={handleSort}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
onFilterChange={setColumnFilters}
|
||||||
|
showFilterDropdown={showFilterDropdown}
|
||||||
|
setShowFilterDropdown={setShowFilterDropdown}
|
||||||
|
>
|
||||||
|
Purchase Quantity
|
||||||
|
</FilterableHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행들 */}
|
||||||
|
{filteredMaterials.map((material, index) => {
|
||||||
|
const info = parseValveInfo(material);
|
||||||
|
const isSelected = selectedMaterials.has(material.id);
|
||||||
|
const isPurchased = purchasedMaterials.has(material.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={material.id}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '50px 200px 120px 120px 150px 180px 180px 150px 200px',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||||
|
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = '#f8fafc';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected && !isPurchased) {
|
||||||
|
e.target.style.background = 'white';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => handleMaterialSelect(material.id)}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||||
|
{info.type}
|
||||||
|
{isPurchased && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
background: '#fbbf24',
|
||||||
|
color: '#92400e',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}>
|
||||||
|
PURCHASED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.size}</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.pressure}</div>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||||
|
{(() => {
|
||||||
|
// 디버깅: 렌더링 시점의 상태 확인
|
||||||
|
const hasEditingBrand = !!editingBrand[material.id];
|
||||||
|
const hasSavedBrand = !!savedBrands[material.id];
|
||||||
|
const shouldShowSaved = !hasEditingBrand && hasSavedBrand;
|
||||||
|
|
||||||
|
if (material.id === 11789) { // 테스트 자재만 로그
|
||||||
|
console.log(`🎨 UI 렌더링 - ID ${material.id}:`, {
|
||||||
|
editingBrand: hasEditingBrand,
|
||||||
|
savedBrandExists: hasSavedBrand,
|
||||||
|
savedBrandValue: savedBrands[material.id],
|
||||||
|
shouldShowSaved: shouldShowSaved,
|
||||||
|
allSavedBrands: Object.keys(savedBrands),
|
||||||
|
renderingMode: shouldShowSaved ? 'SAVED_VIEW' : 'INPUT_VIEW'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 명시적으로 boolean 반환
|
||||||
|
return shouldShowSaved ? true : false;
|
||||||
|
})() ? (
|
||||||
|
// 저장된 상태 - 브랜드 표시 + 수정 버튼
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: '#f9fafb',
|
||||||
|
color: '#374151'
|
||||||
|
}}>
|
||||||
|
{savedBrands[material.id]}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditBrand(material.id, savedBrands[material.id])}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={brandInputs[material.id] || ''}
|
||||||
|
onChange={(e) => setBrandInputs({
|
||||||
|
...brandInputs,
|
||||||
|
[material.id]: e.target.value
|
||||||
|
})}
|
||||||
|
placeholder="Enter brand..."
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
textAlign: 'center',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveBrand(material.id, brandInputs[material.id] || '')}
|
||||||
|
disabled={isPurchased || savingBrand[material.id] || !brandInputs[material.id]?.trim()}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#3b82f6',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased || savingBrand[material.id] ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased || !brandInputs[material.id]?.trim() ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{savingBrand[material.id] ? '...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.additionalInfo}</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.connection}</div>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||||
|
{!editingRequest[material.id] && savedRequests[material.id] ? (
|
||||||
|
// 저장된 상태 - 요구사항 표시 + 수정 버튼
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
background: '#f9fafb',
|
||||||
|
color: '#374151'
|
||||||
|
}}>
|
||||||
|
{savedRequests[material.id]}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditRequest(material.id, savedRequests[material.id])}
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 편집 상태 - 입력 필드 + 저장 버튼
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userRequirements[material.id] || ''}
|
||||||
|
onChange={(e) => setUserRequirements({
|
||||||
|
...userRequirements,
|
||||||
|
[material.id]: e.target.value
|
||||||
|
})}
|
||||||
|
placeholder="Enter additional request..."
|
||||||
|
disabled={isPurchased}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
cursor: isPurchased ? 'not-allowed' : 'text'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveRequest(material.id, userRequirements[material.id] || '')}
|
||||||
|
disabled={isPurchased || savingRequest[material.id]}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isPurchased ? '#d1d5db' : '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: isPurchased || savingRequest[material.id] ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isPurchased ? 0.5 : 1,
|
||||||
|
minWidth: '40px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{savingRequest[material.id] ? '...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.purchaseQuantity}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredMaterials.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '60px 20px',
|
||||||
|
color: '#64748b'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||||
|
No Valve Materials Found
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px' }}>
|
||||||
|
{Object.keys(columnFilters).some(key => columnFilters[key])
|
||||||
|
? 'Try adjusting your filters'
|
||||||
|
: 'No valve materials available in this BOM'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ValveMaterialsView;
|
||||||
10
frontend/src/components/bom/materials/index.js
Normal file
10
frontend/src/components/bom/materials/index.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// BOM Materials Components
|
||||||
|
export { default as PipeMaterialsView } from './PipeMaterialsView';
|
||||||
|
export { default as FittingMaterialsView } from './FittingMaterialsView';
|
||||||
|
export { default as FlangeMaterialsView } from './FlangeMaterialsView';
|
||||||
|
export { default as ValveMaterialsView } from './ValveMaterialsView';
|
||||||
|
export { default as GasketMaterialsView } from './GasketMaterialsView';
|
||||||
|
export { default as BoltMaterialsView } from './BoltMaterialsView';
|
||||||
|
export { default as SupportMaterialsView } from './SupportMaterialsView';
|
||||||
|
export { default as SpecialMaterialsView } from './SpecialMaterialsView';
|
||||||
|
export { default as UnclassifiedMaterialsView } from './UnclassifiedMaterialsView';
|
||||||
78
frontend/src/components/bom/shared/FilterableHeader.jsx
Normal file
78
frontend/src/components/bom/shared/FilterableHeader.jsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const FilterableHeader = ({
|
||||||
|
sortKey,
|
||||||
|
filterKey,
|
||||||
|
children,
|
||||||
|
sortConfig,
|
||||||
|
onSort,
|
||||||
|
columnFilters,
|
||||||
|
onFilterChange,
|
||||||
|
showFilterDropdown,
|
||||||
|
setShowFilterDropdown
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="filterable-header" style={{ position: 'relative' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span
|
||||||
|
onClick={() => onSort(sortKey)}
|
||||||
|
style={{ cursor: 'pointer', flex: 1 }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{sortConfig && sortConfig.key === sortKey && (
|
||||||
|
<span style={{ marginLeft: '4px' }}>
|
||||||
|
{sortConfig.direction === 'asc' ? '↑' : '↓'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilterDropdown(showFilterDropdown === filterKey ? null : filterKey)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '2px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#6b7280'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔍
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showFilterDropdown === filterKey && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
background: 'white',
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '8px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||||
|
zIndex: 1000,
|
||||||
|
minWidth: '150px'
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={`Filter ${children}...`}
|
||||||
|
value={columnFilters[filterKey] || ''}
|
||||||
|
onChange={(e) => onFilterChange({
|
||||||
|
...columnFilters,
|
||||||
|
[filterKey]: e.target.value
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '4px 8px',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilterableHeader;
|
||||||
161
frontend/src/components/bom/shared/MaterialTable.jsx
Normal file
161
frontend/src/components/bom/shared/MaterialTable.jsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const MaterialTable = ({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
style = {}
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`material-table ${className}`}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||||
|
...style
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MaterialTableHeader = ({
|
||||||
|
children,
|
||||||
|
gridColumns,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`material-table-header ${className}`}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: gridColumns,
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MaterialTableBody = ({
|
||||||
|
children,
|
||||||
|
maxHeight = '600px',
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`material-table-body ${className}`}
|
||||||
|
style={{
|
||||||
|
maxHeight,
|
||||||
|
overflowY: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MaterialTableRow = ({
|
||||||
|
children,
|
||||||
|
gridColumns,
|
||||||
|
isSelected = false,
|
||||||
|
isPurchased = false,
|
||||||
|
isLast = false,
|
||||||
|
onClick,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`material-table-row ${className}`}
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: gridColumns,
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: !isLast ? '1px solid #f1f5f9' : 'none',
|
||||||
|
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
cursor: onClick ? 'pointer' : 'default'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected && !isPurchased && !onClick) {
|
||||||
|
e.target.style.background = '#f8fafc';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected && !isPurchased && !onClick) {
|
||||||
|
e.target.style.background = 'white';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MaterialTableCell = ({
|
||||||
|
children,
|
||||||
|
align = 'left',
|
||||||
|
fontWeight = 'normal',
|
||||||
|
color = '#1f2937',
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`material-table-cell ${className}`}
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color,
|
||||||
|
fontWeight,
|
||||||
|
textAlign: align
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MaterialTableEmpty = ({
|
||||||
|
icon = '📦',
|
||||||
|
title = 'No Materials Found',
|
||||||
|
message = 'No materials available',
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`material-table-empty ${className}`}
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '60px 20px',
|
||||||
|
color: '#64748b'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: '16px' }}>{icon}</div>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px' }}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 복합 컴포넌트로 export
|
||||||
|
MaterialTable.Header = MaterialTableHeader;
|
||||||
|
MaterialTable.Body = MaterialTableBody;
|
||||||
|
MaterialTable.Row = MaterialTableRow;
|
||||||
|
MaterialTable.Cell = MaterialTableCell;
|
||||||
|
MaterialTable.Empty = MaterialTableEmpty;
|
||||||
|
|
||||||
|
export default MaterialTable;
|
||||||
3
frontend/src/components/bom/shared/index.js
Normal file
3
frontend/src/components/bom/shared/index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// BOM Shared Components
|
||||||
|
export { default as FilterableHeader } from './FilterableHeader';
|
||||||
|
export { default as MaterialTable } from './MaterialTable';
|
||||||
536
frontend/src/components/bom/tabs/BOMFilesTab.jsx
Normal file
536
frontend/src/components/bom/tabs/BOMFilesTab.jsx
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import api from '../../../api';
|
||||||
|
|
||||||
|
const BOMFilesTab = ({
|
||||||
|
selectedProject,
|
||||||
|
user,
|
||||||
|
bomFiles,
|
||||||
|
setBomFiles,
|
||||||
|
selectedBOM,
|
||||||
|
onBOMSelect,
|
||||||
|
refreshTrigger
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [revisionDialog, setRevisionDialog] = useState({ open: false, file: null });
|
||||||
|
const [groupedFiles, setGroupedFiles] = useState({});
|
||||||
|
|
||||||
|
// BOM 파일 목록 로드 함수
|
||||||
|
const loadBOMFiles = async () => {
|
||||||
|
if (!selectedProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const projectCode = selectedProject.official_project_code || selectedProject.job_no;
|
||||||
|
const encodedProjectCode = encodeURIComponent(projectCode);
|
||||||
|
const response = await api.get(`/files/project/${encodedProjectCode}`);
|
||||||
|
const files = response.data || [];
|
||||||
|
|
||||||
|
setBomFiles(files);
|
||||||
|
|
||||||
|
// BOM 이름별로 그룹화
|
||||||
|
const groups = groupFilesByBOM(files);
|
||||||
|
setGroupedFiles(groups);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('BOM 파일 로드 실패:', err);
|
||||||
|
setError('BOM 파일을 불러오는데 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// BOM 파일 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
loadBOMFiles();
|
||||||
|
}, [selectedProject, refreshTrigger, setBomFiles]);
|
||||||
|
|
||||||
|
// 파일을 BOM 이름별로 그룹화
|
||||||
|
const groupFilesByBOM = (fileList) => {
|
||||||
|
const groups = {};
|
||||||
|
|
||||||
|
fileList.forEach(file => {
|
||||||
|
const bomName = file.bom_name || file.original_filename;
|
||||||
|
if (!groups[bomName]) {
|
||||||
|
groups[bomName] = [];
|
||||||
|
}
|
||||||
|
groups[bomName].push(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 각 그룹 내에서 리비전 번호로 정렬
|
||||||
|
Object.keys(groups).forEach(bomName => {
|
||||||
|
groups[bomName].sort((a, b) => {
|
||||||
|
const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
|
||||||
|
const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
|
||||||
|
return revB - revA; // 최신 리비전이 위로
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
};
|
||||||
|
|
||||||
|
// BOM 선택 처리
|
||||||
|
const handleBOMClick = (bomFile) => {
|
||||||
|
if (onBOMSelect) {
|
||||||
|
onBOMSelect(bomFile);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 삭제
|
||||||
|
const handleDeleteFile = async (fileId, bomName) => {
|
||||||
|
if (!window.confirm(`이 파일을 삭제하시겠습니까?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/files/delete/${fileId}`);
|
||||||
|
|
||||||
|
// 파일 목록 새로고침
|
||||||
|
const projectCode = selectedProject.official_project_code || selectedProject.job_no;
|
||||||
|
const encodedProjectCode = encodeURIComponent(projectCode);
|
||||||
|
const response = await api.get(`/files/project/${encodedProjectCode}`);
|
||||||
|
const files = response.data || [];
|
||||||
|
setBomFiles(files);
|
||||||
|
setGroupedFiles(groupFilesByBOM(files));
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('파일 삭제 실패:', err);
|
||||||
|
setError('파일 삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 리비전 업로드
|
||||||
|
const handleRevisionUpload = (parentFile) => {
|
||||||
|
setRevisionDialog({
|
||||||
|
open: true,
|
||||||
|
file: parentFile
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 리비전 업로드 성공 핸들러
|
||||||
|
const handleRevisionUploadSuccess = () => {
|
||||||
|
setRevisionDialog({ open: false, file: null });
|
||||||
|
// BOM 파일 목록 새로고침
|
||||||
|
loadBOMFiles();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 업로드 처리
|
||||||
|
const handleFileUpload = async (file) => {
|
||||||
|
if (!file || !revisionDialog.file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('job_no', selectedProject.job_no);
|
||||||
|
formData.append('parent_file_id', revisionDialog.file.id);
|
||||||
|
formData.append('bom_name', revisionDialog.file.bom_name || revisionDialog.file.original_filename);
|
||||||
|
|
||||||
|
const response = await api.post('/files/upload', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
alert(`리비전 업로드 성공! ${response.data.revision}`);
|
||||||
|
handleRevisionUploadSuccess();
|
||||||
|
} else {
|
||||||
|
alert(response.data.message || '리비전 업로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('리비전 업로드 실패:', error);
|
||||||
|
alert('리비전 업로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 날짜 포맷팅
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return 'N/A';
|
||||||
|
try {
|
||||||
|
return new Date(dateString).toLocaleDateString('ko-KR');
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '60px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#6b7280'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '24px', marginBottom: '16px' }}>⏳</div>
|
||||||
|
<div>Loading BOM files...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '40px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#fee2e2',
|
||||||
|
border: '1px solid #fecaca',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '16px',
|
||||||
|
color: '#dc2626'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '20px', marginBottom: '8px' }}>⚠️</div>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bomFiles.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '60px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#6b7280'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📄</div>
|
||||||
|
<h3 style={{ fontSize: '20px', fontWeight: '600', marginBottom: '8px' }}>
|
||||||
|
No BOM Files Found
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '16px', margin: 0 }}>
|
||||||
|
Upload your first BOM file using the Upload tab
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '40px' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '32px'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0f172a',
|
||||||
|
margin: '0 0 8px 0'
|
||||||
|
}}>
|
||||||
|
BOM Files & Revisions
|
||||||
|
</h2>
|
||||||
|
<p style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#64748b',
|
||||||
|
margin: 0
|
||||||
|
}}>
|
||||||
|
Select a BOM file to manage its materials
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
|
||||||
|
padding: '12px 20px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1d4ed8'
|
||||||
|
}}>
|
||||||
|
{Object.keys(groupedFiles).length} BOM Groups • {bomFiles.length} Total Files
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BOM 파일 그룹 목록 */}
|
||||||
|
<div style={{ display: 'grid', gap: '24px' }}>
|
||||||
|
{Object.entries(groupedFiles).map(([bomName, files]) => {
|
||||||
|
const latestFile = files[0]; // 최신 리비전
|
||||||
|
const isSelected = selectedBOM?.id === latestFile.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={bomName} style={{
|
||||||
|
background: isSelected ? '#eff6ff' : 'white',
|
||||||
|
border: isSelected ? '2px solid #3b82f6' : '1px solid #e5e7eb',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '24px',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
onClick={() => handleBOMClick(latestFile)}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: isSelected ? '#1d4ed8' : '#374151',
|
||||||
|
margin: '0 0 8px 0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px'
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '24px' }}>📋</span>
|
||||||
|
{bomName}
|
||||||
|
{isSelected && (
|
||||||
|
<span style={{
|
||||||
|
background: '#3b82f6',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '12px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}>
|
||||||
|
SELECTED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||||
|
gap: '12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#6b7280'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontWeight: '500' }}>Latest:</span> {latestFile.revision || 'Rev.0'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontWeight: '500' }}>Revisions:</span> {Math.max(0, files.length - 1)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontWeight: '500' }}>Updated:</span> {formatDate(latestFile.upload_date)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontWeight: '500' }}>Size:</span> {latestFile.file_size ? `${Math.round(latestFile.file_size / 1024)} KB` : 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginLeft: '16px' }}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRevisionUpload(latestFile);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: 'white',
|
||||||
|
color: '#f59e0b',
|
||||||
|
border: '1px solid #f59e0b',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📝 New Revision
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteFile(latestFile.id, bomName);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: '#fee2e2',
|
||||||
|
color: '#dc2626',
|
||||||
|
border: '1px solid #fecaca',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🗑️ Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 리비전 히스토리 */}
|
||||||
|
{files.length > 1 && (
|
||||||
|
<div style={{
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px',
|
||||||
|
marginTop: '16px'
|
||||||
|
}}>
|
||||||
|
<h4 style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
margin: '0 0 8px 0'
|
||||||
|
}}>
|
||||||
|
Revision History
|
||||||
|
</h4>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
|
{files.map((file, index) => (
|
||||||
|
<div key={file.id} style={{
|
||||||
|
background: index === 0 ? '#dbeafe' : 'white',
|
||||||
|
color: index === 0 ? '#1d4ed8' : '#6b7280',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
border: '1px solid #e5e7eb'
|
||||||
|
}}>
|
||||||
|
{file.revision || 'Rev.0'}
|
||||||
|
{index === 0 && ' (Latest)'}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 선택 안내 */}
|
||||||
|
{!isSelected && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '12px',
|
||||||
|
background: 'rgba(59, 130, 246, 0.05)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#3b82f6',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}>
|
||||||
|
Click to select this BOM for material management
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 향후 기능 안내 */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: '40px',
|
||||||
|
padding: '24px',
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid #e2e8f0'
|
||||||
|
}}>
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
🚧 Coming Soon: Advanced Revision Features
|
||||||
|
</h3>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||||
|
gap: '16px'
|
||||||
|
}}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '24px', marginBottom: '8px' }}>📊</div>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
|
||||||
|
Visual Timeline
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#6b7280' }}>
|
||||||
|
Interactive revision history
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '24px', marginBottom: '8px' }}>🔍</div>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
|
||||||
|
Diff Comparison
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#6b7280' }}>
|
||||||
|
Compare changes between revisions
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '24px', marginBottom: '8px' }}>⏪</div>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
|
||||||
|
Rollback System
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#6b7280' }}>
|
||||||
|
Restore previous versions
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 리비전 업로드 다이얼로그 */}
|
||||||
|
{revisionDialog.open && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '24px',
|
||||||
|
maxWidth: '500px',
|
||||||
|
width: '90%',
|
||||||
|
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
|
||||||
|
}}>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600' }}>
|
||||||
|
📝 리비전 업로드: {revisionDialog.file?.original_filename || 'BOM 파일'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px', fontSize: '14px', color: '#6b7280' }}>
|
||||||
|
새로운 리비전 파일을 선택해주세요.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.xlsx,.xls"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileUpload(file);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
marginBottom: '16px',
|
||||||
|
padding: '8px',
|
||||||
|
border: '2px dashed #d1d5db',
|
||||||
|
borderRadius: '8px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setRevisionDialog({ open: false, file: null })}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: '#f3f4f6',
|
||||||
|
color: '#374151',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BOMFilesTab;
|
||||||
105
frontend/src/components/bom/tabs/BOMMaterialsTab.jsx
Normal file
105
frontend/src/components/bom/tabs/BOMMaterialsTab.jsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import BOMManagementPage from '../../../pages/BOMManagementPage';
|
||||||
|
|
||||||
|
const BOMMaterialsTab = ({
|
||||||
|
selectedProject,
|
||||||
|
user,
|
||||||
|
selectedBOM,
|
||||||
|
onNavigate
|
||||||
|
}) => {
|
||||||
|
// BOMManagementPage에 필요한 props 구성
|
||||||
|
const bomManagementProps = {
|
||||||
|
onNavigate,
|
||||||
|
user,
|
||||||
|
selectedProject,
|
||||||
|
fileId: selectedBOM?.id,
|
||||||
|
jobNo: selectedBOM?.job_no || selectedProject?.official_project_code || selectedProject?.job_no,
|
||||||
|
bomName: selectedBOM?.bom_name || selectedBOM?.original_filename,
|
||||||
|
revision: selectedBOM?.revision || 'Rev.0',
|
||||||
|
filename: selectedBOM?.original_filename
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
minHeight: '600px'
|
||||||
|
}}>
|
||||||
|
{/* 헤더 정보 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '24px 40px',
|
||||||
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
background: '#f8fafc'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0f172a',
|
||||||
|
margin: '0 0 8px 0'
|
||||||
|
}}>
|
||||||
|
Material Management
|
||||||
|
</h2>
|
||||||
|
<p style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#64748b',
|
||||||
|
margin: 0
|
||||||
|
}}>
|
||||||
|
BOM: {selectedBOM?.bom_name || selectedBOM?.original_filename} • {selectedBOM?.revision || 'Rev.0'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||||
|
gap: '12px',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: '600', color: '#1d4ed8' }}>
|
||||||
|
{selectedBOM?.id || 'N/A'}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#1d4ed8', fontWeight: '500' }}>
|
||||||
|
File ID
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: '600', color: '#059669' }}>
|
||||||
|
{selectedBOM?.revision || 'Rev.0'}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#059669', fontWeight: '500' }}>
|
||||||
|
Revision
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BOM 관리 페이지 임베드 */}
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
// BOMManagementPage의 기본 패딩을 제거하기 위한 스타일 오버라이드
|
||||||
|
'& > div': {
|
||||||
|
padding: '0 !important',
|
||||||
|
background: 'transparent !important',
|
||||||
|
minHeight: 'auto !important'
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<BOMManagementPage {...bomManagementProps} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BOMMaterialsTab;
|
||||||
494
frontend/src/components/bom/tabs/BOMUploadTab.jsx
Normal file
494
frontend/src/components/bom/tabs/BOMUploadTab.jsx
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
|
import api from '../../../api';
|
||||||
|
|
||||||
|
const BOMUploadTab = ({
|
||||||
|
selectedProject,
|
||||||
|
user,
|
||||||
|
onUploadSuccess,
|
||||||
|
onNavigate
|
||||||
|
}) => {
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState([]);
|
||||||
|
const [bomName, setBomName] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState('');
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
// 파일 검증
|
||||||
|
const validateFile = (file) => {
|
||||||
|
const allowedTypes = [
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'text/csv'
|
||||||
|
];
|
||||||
|
|
||||||
|
const maxSize = 50 * 1024 * 1024; // 50MB
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type) && !file.name.match(/\.(xlsx|xls|csv)$/i)) {
|
||||||
|
return '지원되지 않는 파일 형식입니다. Excel 또는 CSV 파일만 업로드 가능합니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return '파일 크기가 너무 큽니다. 50MB 이하의 파일만 업로드 가능합니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 선택 처리
|
||||||
|
const handleFileSelect = useCallback((files) => {
|
||||||
|
const fileList = Array.from(files);
|
||||||
|
const validFiles = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
fileList.forEach(file => {
|
||||||
|
const error = validateFile(file);
|
||||||
|
if (error) {
|
||||||
|
errors.push(`${file.name}: ${error}`);
|
||||||
|
} else {
|
||||||
|
validFiles.push(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
setError(errors.join('\n'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFiles(validFiles);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// 첫 번째 파일명을 기본 BOM 이름으로 설정 (확장자 제거)
|
||||||
|
if (validFiles.length > 0 && !bomName) {
|
||||||
|
const fileName = validFiles[0].name;
|
||||||
|
const nameWithoutExt = fileName.replace(/\.[^/.]+$/, '');
|
||||||
|
setBomName(nameWithoutExt);
|
||||||
|
}
|
||||||
|
}, [bomName]);
|
||||||
|
|
||||||
|
// 드래그 앤 드롭 처리
|
||||||
|
const handleDragOver = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
handleFileSelect(e.dataTransfer.files);
|
||||||
|
}, [handleFileSelect]);
|
||||||
|
|
||||||
|
// 파일 선택 버튼 클릭
|
||||||
|
const handleFileButtonClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 업로드
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (selectedFiles.length === 0) {
|
||||||
|
setError('업로드할 파일을 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bomName.trim()) {
|
||||||
|
setError('BOM 이름을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedProject) {
|
||||||
|
setError('프로젝트를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUploading(true);
|
||||||
|
setUploadProgress(0);
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
|
||||||
|
let uploadedFile = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < selectedFiles.length; i++) {
|
||||||
|
const file = selectedFiles[i];
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('job_no', selectedProject.official_project_code || selectedProject.job_no);
|
||||||
|
formData.append('bom_name', bomName.trim());
|
||||||
|
formData.append('revision', 'Rev.0'); // 새 업로드는 항상 Rev.0
|
||||||
|
|
||||||
|
const response = await api.post('/files/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
const progress = Math.round(
|
||||||
|
((i * 100) + (progressEvent.loaded * 100) / progressEvent.total) / selectedFiles.length
|
||||||
|
);
|
||||||
|
setUploadProgress(progress);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.data?.success) {
|
||||||
|
throw new Error(response.data?.message || '업로드 실패');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첫 번째 파일의 정보를 저장
|
||||||
|
if (i === 0) {
|
||||||
|
uploadedFile = {
|
||||||
|
id: response.data.file_id,
|
||||||
|
bom_name: bomName.trim(),
|
||||||
|
revision: 'Rev.0',
|
||||||
|
job_no: selectedProject.official_project_code || selectedProject.job_no,
|
||||||
|
original_filename: file.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccess(`${selectedFiles.length}개 파일이 성공적으로 업로드되었습니다!`);
|
||||||
|
|
||||||
|
// 업로드 성공 즉시 콜백 호출 (파일 목록 새로고침)
|
||||||
|
if (onUploadSuccess) {
|
||||||
|
onUploadSuccess(uploadedFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 초기화
|
||||||
|
setSelectedFiles([]);
|
||||||
|
setBomName('');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('업로드 실패:', err);
|
||||||
|
setError(`업로드 실패: ${err.response?.data?.detail || err.message}`);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
setUploadProgress(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 제거
|
||||||
|
const removeFile = (index) => {
|
||||||
|
const newFiles = selectedFiles.filter((_, i) => i !== index);
|
||||||
|
setSelectedFiles(newFiles);
|
||||||
|
|
||||||
|
if (newFiles.length === 0) {
|
||||||
|
setBomName('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 크기 포맷팅
|
||||||
|
const formatFileSize = (bytes) => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '40px' }}>
|
||||||
|
{/* BOM 이름 입력 */}
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
marginBottom: '8px'
|
||||||
|
}}>
|
||||||
|
BOM Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={bomName}
|
||||||
|
onChange={(e) => setBomName(e.target.value)}
|
||||||
|
placeholder="Enter BOM name..."
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '16px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||||
|
onBlur={(e) => e.target.style.borderColor = '#e5e7eb'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 파일 드롭 영역 */}
|
||||||
|
<div
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
style={{
|
||||||
|
border: `3px dashed ${dragOver ? '#3b82f6' : '#d1d5db'}`,
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '60px 40px',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: dragOver ? '#eff6ff' : '#f9fafb',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: '24px'
|
||||||
|
}}
|
||||||
|
onClick={handleFileButtonClick}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
|
||||||
|
{dragOver ? '📁' : '📄'}
|
||||||
|
</div>
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
margin: '0 0 8px 0'
|
||||||
|
}}>
|
||||||
|
{dragOver ? 'Drop files here' : 'Upload BOM Files'}
|
||||||
|
</h3>
|
||||||
|
<p style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#6b7280',
|
||||||
|
margin: '0 0 16px 0'
|
||||||
|
}}>
|
||||||
|
Drag and drop your Excel or CSV files here, or click to browse
|
||||||
|
</p>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: 'rgba(59, 130, 246, 0.1)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#3b82f6'
|
||||||
|
}}>
|
||||||
|
<span>📋</span>
|
||||||
|
Supported: .xlsx, .xls, .csv (Max 50MB)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 숨겨진 파일 입력 */}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".xlsx,.xls,.csv"
|
||||||
|
onChange={(e) => handleFileSelect(e.target.files)}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 선택된 파일 목록 */}
|
||||||
|
{selectedFiles.length > 0 && (
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<h4 style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
Selected Files ({selectedFiles.length})
|
||||||
|
</h4>
|
||||||
|
<div style={{
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px'
|
||||||
|
}}>
|
||||||
|
{selectedFiles.map((file, index) => (
|
||||||
|
<div key={index} style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
marginBottom: index < selectedFiles.length - 1 ? '8px' : '0',
|
||||||
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<span style={{ fontSize: '20px' }}>📄</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: '500', color: '#374151' }}>
|
||||||
|
{file.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#6b7280' }}>
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeFile(index)}
|
||||||
|
style={{
|
||||||
|
background: '#fee2e2',
|
||||||
|
color: '#dc2626',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 업로드 진행률 */}
|
||||||
|
{uploading && (
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '8px'
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '14px', fontWeight: '500', color: '#374151' }}>
|
||||||
|
Uploading...
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '14px', fontWeight: '500', color: '#3b82f6' }}>
|
||||||
|
{uploadProgress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '8px',
|
||||||
|
background: '#e5e7eb',
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: `${uploadProgress}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: 'linear-gradient(90deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||||
|
transition: 'width 0.3s ease'
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
background: '#fee2e2',
|
||||||
|
border: '1px solid #fecaca',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
marginBottom: '24px'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span style={{ fontSize: '16px' }}>⚠️</span>
|
||||||
|
<div style={{ fontSize: '14px', color: '#dc2626', whiteSpace: 'pre-line' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 성공 메시지 */}
|
||||||
|
{success && (
|
||||||
|
<div style={{
|
||||||
|
background: '#dcfce7',
|
||||||
|
border: '1px solid #bbf7d0',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
marginBottom: '24px'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span style={{ fontSize: '16px' }}>✅</span>
|
||||||
|
<div style={{ fontSize: '14px', color: '#059669' }}>
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 업로드 버튼 */}
|
||||||
|
<div style={{ display: 'flex', gap: '16px', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={uploading || selectedFiles.length === 0 || !bomName.trim()}
|
||||||
|
style={{
|
||||||
|
padding: '12px 32px',
|
||||||
|
background: (uploading || selectedFiles.length === 0 || !bomName.trim())
|
||||||
|
? '#d1d5db'
|
||||||
|
: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '12px',
|
||||||
|
cursor: (uploading || selectedFiles.length === 0 || !bomName.trim())
|
||||||
|
? 'not-allowed'
|
||||||
|
: 'pointer',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '600',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploading ? 'Uploading...' : 'Upload BOM'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 가이드 정보 */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: '40px',
|
||||||
|
padding: '24px',
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid #e2e8f0'
|
||||||
|
}}>
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
📋 Upload Guidelines
|
||||||
|
</h3>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||||
|
gap: '20px'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ fontSize: '14px', fontWeight: '600', color: '#059669', marginBottom: '8px' }}>
|
||||||
|
✅ Supported Formats
|
||||||
|
</h4>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '16px', color: '#6b7280', fontSize: '14px' }}>
|
||||||
|
<li>Excel files (.xlsx, .xls)</li>
|
||||||
|
<li>CSV files (.csv)</li>
|
||||||
|
<li>Maximum file size: 50MB</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ fontSize: '14px', fontWeight: '600', color: '#3b82f6', marginBottom: '8px' }}>
|
||||||
|
📊 Required Columns
|
||||||
|
</h4>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '16px', color: '#6b7280', fontSize: '14px' }}>
|
||||||
|
<li>Description (자재명/품명)</li>
|
||||||
|
<li>Quantity (수량)</li>
|
||||||
|
<li>Size information (사이즈)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ fontSize: '14px', fontWeight: '600', color: '#f59e0b', marginBottom: '8px' }}>
|
||||||
|
⚡ Auto Processing
|
||||||
|
</h4>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '16px', color: '#6b7280', fontSize: '14px' }}>
|
||||||
|
<li>Automatic material classification</li>
|
||||||
|
<li>WELD GAP items excluded</li>
|
||||||
|
<li>Ready for material management</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BOMUploadTab;
|
||||||
163
frontend/src/components/common/ErrorBoundary.jsx
Normal file
163
frontend/src/components/common/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
class ErrorBoundary extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null, errorInfo: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error) {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error, errorInfo) {
|
||||||
|
this.setState({
|
||||||
|
error: error,
|
||||||
|
errorInfo: errorInfo
|
||||||
|
});
|
||||||
|
|
||||||
|
// 에러 로깅
|
||||||
|
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||||
|
|
||||||
|
// 에러 컨텍스트 정보 로깅
|
||||||
|
if (this.props.errorContext) {
|
||||||
|
console.error('Error context:', this.props.errorContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
|
||||||
|
padding: '40px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
borderRadius: '20px',
|
||||||
|
padding: '40px',
|
||||||
|
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: '600px'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '64px', marginBottom: '24px' }}>⚠️</div>
|
||||||
|
|
||||||
|
<h2 style={{
|
||||||
|
fontSize: '28px',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#dc2626',
|
||||||
|
margin: '0 0 16px 0',
|
||||||
|
letterSpacing: '-0.025em'
|
||||||
|
}}>
|
||||||
|
Something went wrong
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#64748b',
|
||||||
|
marginBottom: '32px',
|
||||||
|
lineHeight: '1.6'
|
||||||
|
}}>
|
||||||
|
An unexpected error occurred. Please try refreshing the page or contact support if the problem persists.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '16px', justifyContent: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '12px 24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.target.style.transform = 'translateY(-2px)';
|
||||||
|
e.target.style.boxShadow = '0 8px 25px 0 rgba(59, 130, 246, 0.5)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.target.style.transform = 'translateY(0)';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh Page
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => this.setState({ hasError: false, error: null, errorInfo: null })}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
color: '#6b7280',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '12px 24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.target.style.background = '#f9fafb';
|
||||||
|
e.target.style.borderColor = '#9ca3af';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.target.style.background = 'white';
|
||||||
|
e.target.style.borderColor = '#d1d5db';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 개발 환경에서만 에러 상세 정보 표시 */}
|
||||||
|
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||||
|
<details style={{
|
||||||
|
marginTop: '32px',
|
||||||
|
textAlign: 'left',
|
||||||
|
background: '#f8fafc',
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #e2e8f0'
|
||||||
|
}}>
|
||||||
|
<summary style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
marginBottom: '8px'
|
||||||
|
}}>
|
||||||
|
Error Details (Development)
|
||||||
|
</summary>
|
||||||
|
<pre style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#dc2626',
|
||||||
|
overflow: 'auto',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word'
|
||||||
|
}}>
|
||||||
|
{this.state.error && this.state.error.toString()}
|
||||||
|
<br />
|
||||||
|
{this.state.errorInfo.componentStack}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
219
frontend/src/components/common/UserMenu.jsx
Normal file
219
frontend/src/components/common/UserMenu.jsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const UserMenu = ({ user, onNavigate, onLogout }) => {
|
||||||
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
background: '#f8f9fa',
|
||||||
|
border: '1px solid #e9ecef',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#495057',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.target.style.background = '#e9ecef';
|
||||||
|
e.target.style.borderColor = '#dee2e6';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.target.style.background = '#f8f9fa';
|
||||||
|
e.target.style.borderColor = '#e9ecef';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
|
{(user?.name || user?.username || 'U').charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'left' }}>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||||
|
{user?.name || user?.username}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||||
|
{user?.role === 'system' ? '시스템 관리자' :
|
||||||
|
user?.role === 'admin' ? '관리자' : '사용자'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#6c757d',
|
||||||
|
transform: showUserMenu ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||||
|
transition: 'transform 0.2s ease'
|
||||||
|
}}>
|
||||||
|
▼
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 드롭다운 메뉴 */}
|
||||||
|
{showUserMenu && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
right: 0,
|
||||||
|
marginTop: '8px',
|
||||||
|
background: 'white',
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||||
|
zIndex: 1050,
|
||||||
|
minWidth: '200px'
|
||||||
|
}}>
|
||||||
|
<div style={{ padding: '8px 0' }}>
|
||||||
|
<div style={{ padding: '8px 16px', borderBottom: '1px solid #f1f3f4' }}>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||||
|
{user?.name || user?.username}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||||
|
{user?.email || '이메일 없음'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onNavigate('account-settings');
|
||||||
|
setShowUserMenu(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
':hover': { background: '#f8f9fa' }
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
|
||||||
|
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||||||
|
>
|
||||||
|
⚙️ 계정 설정
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onNavigate('user-management');
|
||||||
|
setShowUserMenu(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
|
||||||
|
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||||||
|
>
|
||||||
|
👥 사용자 관리
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onNavigate('system-settings');
|
||||||
|
setShowUserMenu(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
|
||||||
|
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||||||
|
>
|
||||||
|
🔧 시스템 설정
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onNavigate('system-logs');
|
||||||
|
setShowUserMenu(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
|
||||||
|
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||||||
|
>
|
||||||
|
📊 시스템 로그
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid #f1f3f4', marginTop: '4px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onLogout();
|
||||||
|
setShowUserMenu(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#dc3545',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
|
||||||
|
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||||||
|
>
|
||||||
|
🚪 로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserMenu;
|
||||||
3
frontend/src/components/common/index.js
Normal file
3
frontend/src/components/common/index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Common Components
|
||||||
|
export { default as UserMenu } from './UserMenu';
|
||||||
|
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||||
541
frontend/src/components/revision/RevisionManagementPanel.jsx
Normal file
541
frontend/src/components/revision/RevisionManagementPanel.jsx
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
/**
|
||||||
|
* 리비전 관리 패널 컴포넌트
|
||||||
|
* BOM 관리 페이지에 통합되어 리비전 기능을 제공
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRevisionManagement } from '../../hooks/useRevisionManagement';
|
||||||
|
|
||||||
|
const RevisionManagementPanel = ({
|
||||||
|
jobNo,
|
||||||
|
currentFileId,
|
||||||
|
previousFileId,
|
||||||
|
onRevisionComplete,
|
||||||
|
onRevisionCancel
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
currentSession,
|
||||||
|
sessionStatus,
|
||||||
|
createRevisionSession,
|
||||||
|
getSessionStatus,
|
||||||
|
compareCategory,
|
||||||
|
getSessionChanges,
|
||||||
|
processRevisionAction,
|
||||||
|
completeSession,
|
||||||
|
cancelSession,
|
||||||
|
getRevisionSummary,
|
||||||
|
getSupportedCategories,
|
||||||
|
clearError
|
||||||
|
} = useRevisionManagement();
|
||||||
|
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||||
|
const [categoryChanges, setCategoryChanges] = useState({});
|
||||||
|
const [revisionSummary, setRevisionSummary] = useState(null);
|
||||||
|
const [processingActions, setProcessingActions] = useState(new Set());
|
||||||
|
|
||||||
|
// 컴포넌트 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
initializeRevisionPanel();
|
||||||
|
}, [currentFileId, previousFileId]);
|
||||||
|
|
||||||
|
// 세션 상태 모니터링
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentSession?.session_id) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
refreshSessionStatus();
|
||||||
|
}, 5000); // 5초마다 상태 갱신
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [currentSession]);
|
||||||
|
|
||||||
|
const initializeRevisionPanel = async () => {
|
||||||
|
try {
|
||||||
|
// 지원 카테고리 로드
|
||||||
|
const categoriesResult = await getSupportedCategories();
|
||||||
|
if (categoriesResult.success) {
|
||||||
|
setCategories(categoriesResult.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리비전 세션 생성
|
||||||
|
if (currentFileId && previousFileId) {
|
||||||
|
const sessionResult = await createRevisionSession(jobNo, currentFileId, previousFileId);
|
||||||
|
if (sessionResult.success) {
|
||||||
|
console.log('✅ 리비전 세션 생성 완료');
|
||||||
|
await refreshSessionStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('리비전 패널 초기화 실패:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshSessionStatus = async () => {
|
||||||
|
if (currentSession?.session_id) {
|
||||||
|
try {
|
||||||
|
await getSessionStatus(currentSession.session_id);
|
||||||
|
await loadRevisionSummary();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('세션 상태 갱신 실패:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRevisionSummary = async () => {
|
||||||
|
if (currentSession?.session_id) {
|
||||||
|
try {
|
||||||
|
const summaryResult = await getRevisionSummary(currentSession.session_id);
|
||||||
|
if (summaryResult.success) {
|
||||||
|
setRevisionSummary(summaryResult.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('리비전 요약 로드 실패:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategoryCompare = async (category) => {
|
||||||
|
if (!currentSession?.session_id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await compareCategory(currentSession.session_id, category);
|
||||||
|
if (result.success) {
|
||||||
|
// 변경사항 로드
|
||||||
|
const changesResult = await getSessionChanges(currentSession.session_id, category);
|
||||||
|
if (changesResult.success) {
|
||||||
|
setCategoryChanges(prev => ({
|
||||||
|
...prev,
|
||||||
|
[category]: changesResult.data.changes
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshSessionStatus();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`카테고리 ${category} 비교 실패:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActionProcess = async (changeId, action, notes = '') => {
|
||||||
|
setProcessingActions(prev => new Set(prev).add(changeId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await processRevisionAction(changeId, action, notes);
|
||||||
|
if (result.success) {
|
||||||
|
// 해당 카테고리 변경사항 새로고침
|
||||||
|
if (selectedCategory) {
|
||||||
|
const changesResult = await getSessionChanges(currentSession.session_id, selectedCategory);
|
||||||
|
if (changesResult.success) {
|
||||||
|
setCategoryChanges(prev => ({
|
||||||
|
...prev,
|
||||||
|
[selectedCategory]: changesResult.data.changes
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshSessionStatus();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('액션 처리 실패:', error);
|
||||||
|
} finally {
|
||||||
|
setProcessingActions(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(changeId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompleteRevision = async () => {
|
||||||
|
if (!currentSession?.session_id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await completeSession(currentSession.session_id);
|
||||||
|
if (result.success) {
|
||||||
|
onRevisionComplete?.(result.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('리비전 완료 실패:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelRevision = async (reason = '') => {
|
||||||
|
if (!currentSession?.session_id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await cancelSession(currentSession.session_id, reason);
|
||||||
|
if (result.success) {
|
||||||
|
onRevisionCancel?.(result.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('리비전 취소 실패:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionColor = (action) => {
|
||||||
|
const colors = {
|
||||||
|
'new_material': '#10b981',
|
||||||
|
'additional_purchase': '#f59e0b',
|
||||||
|
'inventory_transfer': '#8b5cf6',
|
||||||
|
'purchase_cancel': '#ef4444',
|
||||||
|
'quantity_update': '#3b82f6',
|
||||||
|
'maintain': '#6b7280'
|
||||||
|
};
|
||||||
|
return colors[action] || '#6b7280';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionLabel = (action) => {
|
||||||
|
const labels = {
|
||||||
|
'new_material': '신규 자재',
|
||||||
|
'additional_purchase': '추가 구매',
|
||||||
|
'inventory_transfer': '재고 이관',
|
||||||
|
'purchase_cancel': '구매 취소',
|
||||||
|
'quantity_update': '수량 업데이트',
|
||||||
|
'maintain': '유지'
|
||||||
|
};
|
||||||
|
return labels[action] || action;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!currentSession) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '2px dashed #cbd5e1'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '18px', color: '#64748b', marginBottom: '8px' }}>
|
||||||
|
🔄 리비전 세션 초기화 중...
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#94a3b8' }}>
|
||||||
|
자재 비교를 위한 세션을 준비하고 있습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.1)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
color: 'white',
|
||||||
|
padding: '20px 24px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '20px', fontWeight: '600' }}>
|
||||||
|
📊 리비전 관리
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '4px 0 0 0', fontSize: '14px', opacity: 0.9 }}>
|
||||||
|
Job: {jobNo} | 세션 ID: {currentSession.session_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleCompleteRevision}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
background: '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: loading ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✅ 완료
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCancelRevision('사용자 요청')}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
background: '#ef4444',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: loading ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
❌ 취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
background: '#fef2f2',
|
||||||
|
border: '1px solid #fecaca',
|
||||||
|
color: '#dc2626',
|
||||||
|
padding: '12px 24px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}>
|
||||||
|
<span>⚠️ {error}</span>
|
||||||
|
<button
|
||||||
|
onClick={clearError}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#dc2626',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '16px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 진행 상황 */}
|
||||||
|
{sessionStatus && (
|
||||||
|
<div style={{ padding: '20px 24px', borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||||
|
gap: '16px',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: '700', color: '#10b981' }}>
|
||||||
|
{sessionStatus.session_info.added_count || 0}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#64748b' }}>추가</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: '700', color: '#ef4444' }}>
|
||||||
|
{sessionStatus.session_info.removed_count || 0}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#64748b' }}>제거</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: '700', color: '#f59e0b' }}>
|
||||||
|
{sessionStatus.session_info.changed_count || 0}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#64748b' }}>변경</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: '700', color: '#6b7280' }}>
|
||||||
|
{sessionStatus.session_info.unchanged_count || 0}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#64748b' }}>유지</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 진행률 바 */}
|
||||||
|
<div style={{
|
||||||
|
background: '#f1f5f9',
|
||||||
|
borderRadius: '8px',
|
||||||
|
height: '8px',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 100%)',
|
||||||
|
height: '100%',
|
||||||
|
width: `${sessionStatus.progress_percentage || 0}%`,
|
||||||
|
transition: 'width 0.3s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#64748b',
|
||||||
|
marginTop: '4px'
|
||||||
|
}}>
|
||||||
|
진행률: {Math.round(sessionStatus.progress_percentage || 0)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 카테고리 탭 */}
|
||||||
|
<div style={{ padding: '20px 24px' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '20px'
|
||||||
|
}}>
|
||||||
|
{categories.map(category => {
|
||||||
|
const hasChanges = revisionSummary?.category_summaries?.[category.key];
|
||||||
|
const isActive = selectedCategory === category.key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={category.key}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCategory(category.key);
|
||||||
|
if (!categoryChanges[category.key]) {
|
||||||
|
handleCategoryCompare(category.key);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: isActive
|
||||||
|
? 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)'
|
||||||
|
: hasChanges
|
||||||
|
? 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)'
|
||||||
|
: 'white',
|
||||||
|
color: isActive ? 'white' : hasChanges ? '#92400e' : '#64748b',
|
||||||
|
border: isActive ? 'none' : '1px solid #e2e8f0',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
{hasChanges && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-4px',
|
||||||
|
right: '-4px',
|
||||||
|
background: '#ef4444',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
fontSize: '10px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
{hasChanges.total_changes}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선택된 카테고리의 변경사항 */}
|
||||||
|
{selectedCategory && categoryChanges[selectedCategory] && (
|
||||||
|
<div style={{
|
||||||
|
background: '#f8fafc',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
maxHeight: '400px',
|
||||||
|
overflowY: 'auto'
|
||||||
|
}}>
|
||||||
|
<h4 style={{
|
||||||
|
margin: '0 0 16px 0',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1e293b'
|
||||||
|
}}>
|
||||||
|
{categories.find(c => c.key === selectedCategory)?.name} 변경사항
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{categoryChanges[selectedCategory].map((change, index) => (
|
||||||
|
<div
|
||||||
|
key={change.id || index}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#1e293b',
|
||||||
|
marginBottom: '4px'
|
||||||
|
}}>
|
||||||
|
{change.material_description}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#64748b',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px'
|
||||||
|
}}>
|
||||||
|
<span>이전: {change.previous_quantity || 0}</span>
|
||||||
|
<span>현재: {change.current_quantity || 0}</span>
|
||||||
|
<span>차이: {change.quantity_difference > 0 ? '+' : ''}{change.quantity_difference}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: getActionColor(change.revision_action),
|
||||||
|
color: 'white',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getActionLabel(change.revision_action)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{change.action_status === 'pending' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleActionProcess(change.id, change.revision_action)}
|
||||||
|
disabled={processingActions.has(change.id)}
|
||||||
|
style={{
|
||||||
|
background: '#3b82f6',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: '11px',
|
||||||
|
cursor: processingActions.has(change.id) ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: processingActions.has(change.id) ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{processingActions.has(change.id) ? '처리중...' : '처리'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{change.action_status === 'completed' && (
|
||||||
|
<span style={{
|
||||||
|
color: '#10b981',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}>
|
||||||
|
✅ 완료
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RevisionManagementPanel;
|
||||||
318
frontend/src/hooks/useRevisionManagement.js
Normal file
318
frontend/src/hooks/useRevisionManagement.js
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
/**
|
||||||
|
* 리비전 관리 훅
|
||||||
|
* - 리비전 세션 생성, 관리, 완료
|
||||||
|
* - 자재 비교 및 변경사항 처리
|
||||||
|
* - 리비전 히스토리 조회
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
export const useRevisionManagement = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [currentSession, setCurrentSession] = useState(null);
|
||||||
|
const [sessionStatus, setSessionStatus] = useState(null);
|
||||||
|
const [revisionHistory, setRevisionHistory] = useState([]);
|
||||||
|
|
||||||
|
// 에러 처리 헬퍼
|
||||||
|
const handleError = useCallback((error, defaultMessage) => {
|
||||||
|
console.error(defaultMessage, error);
|
||||||
|
const errorMessage = error.response?.data?.detail || error.message || defaultMessage;
|
||||||
|
setError(errorMessage);
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 리비전 세션 생성
|
||||||
|
const createRevisionSession = useCallback(async (jobNo, currentFileId, previousFileId) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.post('/revision-management/sessions', {
|
||||||
|
job_no: jobNo,
|
||||||
|
current_file_id: currentFileId,
|
||||||
|
previous_file_id: previousFileId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
setCurrentSession(response.data.data);
|
||||||
|
console.log('✅ 리비전 세션 생성 완료:', response.data.data);
|
||||||
|
return { success: true, data: response.data.data };
|
||||||
|
} else {
|
||||||
|
return handleError(new Error(response.data.message), '리비전 세션 생성 실패');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, '리비전 세션 생성 중 오류 발생');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [handleError]);
|
||||||
|
|
||||||
|
// 세션 상태 조회
|
||||||
|
const getSessionStatus = useCallback(async (sessionId) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/revision-management/sessions/${sessionId}`);
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
setSessionStatus(response.data.data);
|
||||||
|
console.log('✅ 세션 상태 조회 완료:', response.data.data);
|
||||||
|
return { success: true, data: response.data.data };
|
||||||
|
} else {
|
||||||
|
return handleError(new Error(response.data.message), '세션 상태 조회 실패');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, '세션 상태 조회 중 오류 발생');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [handleError]);
|
||||||
|
|
||||||
|
// 카테고리별 자재 비교
|
||||||
|
const compareCategory = useCallback(async (sessionId, category) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.post(`/revision-management/sessions/${sessionId}/compare/${category}`);
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
console.log(`✅ 카테고리 ${category} 비교 완료:`, response.data.data);
|
||||||
|
return { success: true, data: response.data.data };
|
||||||
|
} else {
|
||||||
|
return handleError(new Error(response.data.message), `카테고리 ${category} 비교 실패`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, `카테고리 ${category} 비교 중 오류 발생`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [handleError]);
|
||||||
|
|
||||||
|
// 세션 변경사항 조회
|
||||||
|
const getSessionChanges = useCallback(async (sessionId, category = null) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = category ? { category } : {};
|
||||||
|
const response = await api.get(`/revision-management/sessions/${sessionId}/changes`, { params });
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
console.log('✅ 세션 변경사항 조회 완료:', response.data.data);
|
||||||
|
return { success: true, data: response.data.data };
|
||||||
|
} else {
|
||||||
|
return handleError(new Error(response.data.message), '세션 변경사항 조회 실패');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, '세션 변경사항 조회 중 오류 발생');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [handleError]);
|
||||||
|
|
||||||
|
// 리비전 액션 처리
|
||||||
|
const processRevisionAction = useCallback(async (changeId, action, notes = null) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.post(`/revision-management/changes/${changeId}/process`, {
|
||||||
|
action,
|
||||||
|
notes
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
console.log('✅ 리비전 액션 처리 완료:', response.data.data);
|
||||||
|
return { success: true, data: response.data.data };
|
||||||
|
} else {
|
||||||
|
return handleError(new Error(response.data.message), '리비전 액션 처리 실패');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, '리비전 액션 처리 중 오류 발생');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [handleError]);
|
||||||
|
|
||||||
|
// 세션 완료
|
||||||
|
const completeSession = useCallback(async (sessionId) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.post(`/revision-management/sessions/${sessionId}/complete`);
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
setCurrentSession(null);
|
||||||
|
setSessionStatus(null);
|
||||||
|
console.log('✅ 리비전 세션 완료:', response.data.data);
|
||||||
|
return { success: true, data: response.data.data };
|
||||||
|
} else {
|
||||||
|
return handleError(new Error(response.data.message), '리비전 세션 완료 실패');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, '리비전 세션 완료 중 오류 발생');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [handleError]);
|
||||||
|
|
||||||
|
// 세션 취소
|
||||||
|
const cancelSession = useCallback(async (sessionId, reason = null) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = reason ? { reason } : {};
|
||||||
|
const response = await api.post(`/revision-management/sessions/${sessionId}/cancel`, null, { params });
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
setCurrentSession(null);
|
||||||
|
setSessionStatus(null);
|
||||||
|
console.log('✅ 리비전 세션 취소:', response.data.data);
|
||||||
|
return { success: true, data: response.data.data };
|
||||||
|
} else {
|
||||||
|
return handleError(new Error(response.data.message), '리비전 세션 취소 실패');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, '리비전 세션 취소 중 오류 발생');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [handleError]);
|
||||||
|
|
||||||
|
// 리비전 히스토리 조회
|
||||||
|
const getRevisionHistory = useCallback(async (jobNo) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/revision-management/history/${jobNo}`);
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
setRevisionHistory(response.data.data.history);
|
||||||
|
console.log('✅ 리비전 히스토리 조회 완료:', response.data.data);
|
||||||
|
return { success: true, data: response.data.data };
|
||||||
|
} else {
|
||||||
|
return handleError(new Error(response.data.message), '리비전 히스토리 조회 실패');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, '리비전 히스토리 조회 중 오류 발생');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [handleError]);
|
||||||
|
|
||||||
|
// 리비전 요약 조회
|
||||||
|
const getRevisionSummary = useCallback(async (sessionId) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/revision-management/sessions/${sessionId}/summary`);
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
console.log('✅ 리비전 요약 조회 완료:', response.data.data);
|
||||||
|
return { success: true, data: response.data.data };
|
||||||
|
} else {
|
||||||
|
return handleError(new Error(response.data.message), '리비전 요약 조회 실패');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, '리비전 요약 조회 중 오류 발생');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [handleError]);
|
||||||
|
|
||||||
|
// 지원 카테고리 조회
|
||||||
|
const getSupportedCategories = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/revision-management/categories');
|
||||||
|
if (response.data.success) {
|
||||||
|
return { success: true, data: response.data.data.categories };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('지원 카테고리 조회 실패:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 카테고리 반환
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{ key: "PIPE", name: "배관", description: "파이프 및 배관 자재" },
|
||||||
|
{ key: "FITTING", name: "피팅", description: "배관 연결 부품" },
|
||||||
|
{ key: "FLANGE", name: "플랜지", description: "플랜지 및 연결 부품" },
|
||||||
|
{ key: "VALVE", name: "밸브", description: "각종 밸브류" },
|
||||||
|
{ key: "GASKET", name: "가스켓", description: "씰링 부품" },
|
||||||
|
{ key: "BOLT", name: "볼트", description: "체결 부품" },
|
||||||
|
{ key: "SUPPORT", name: "서포트", description: "지지대 및 구조물" },
|
||||||
|
{ key: "SPECIAL", name: "특수자재", description: "특수 목적 자재" },
|
||||||
|
{ key: "UNCLASSIFIED", name: "미분류", description: "분류되지 않은 자재" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 리비전 액션 목록 조회
|
||||||
|
const getRevisionActions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/revision-management/actions');
|
||||||
|
if (response.data.success) {
|
||||||
|
return { success: true, data: response.data.data.actions };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('리비전 액션 조회 실패:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 액션 반환
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{ key: "new_material", name: "신규 자재", description: "새로 추가된 자재" },
|
||||||
|
{ key: "additional_purchase", name: "추가 구매", description: "구매된 자재의 수량 증가" },
|
||||||
|
{ key: "inventory_transfer", name: "재고 이관", description: "구매된 자재의 수량 감소 또는 제거" },
|
||||||
|
{ key: "purchase_cancel", name: "구매 취소", description: "미구매 자재의 제거" },
|
||||||
|
{ key: "quantity_update", name: "수량 업데이트", description: "미구매 자재의 수량 변경" },
|
||||||
|
{ key: "maintain", name: "유지", description: "변경사항 없음" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 상태 초기화
|
||||||
|
const resetState = useCallback(() => {
|
||||||
|
setCurrentSession(null);
|
||||||
|
setSessionStatus(null);
|
||||||
|
setRevisionHistory([]);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 상태
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
currentSession,
|
||||||
|
sessionStatus,
|
||||||
|
revisionHistory,
|
||||||
|
|
||||||
|
// 액션
|
||||||
|
createRevisionSession,
|
||||||
|
getSessionStatus,
|
||||||
|
compareCategory,
|
||||||
|
getSessionChanges,
|
||||||
|
processRevisionAction,
|
||||||
|
completeSession,
|
||||||
|
cancelSession,
|
||||||
|
getRevisionHistory,
|
||||||
|
getRevisionSummary,
|
||||||
|
getSupportedCategories,
|
||||||
|
getRevisionActions,
|
||||||
|
resetState,
|
||||||
|
|
||||||
|
// 유틸리티
|
||||||
|
clearError: () => setError(null)
|
||||||
|
};
|
||||||
|
};
|
||||||
184
frontend/src/pages/BOMManagementPage.css
Normal file
184
frontend/src/pages/BOMManagementPage.css
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
/* BOM Management Page Styles */
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-management-page {
|
||||||
|
padding: 40px;
|
||||||
|
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-header-card {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 32px;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-stat-card {
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-stat-number {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-stat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-category-tabs {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 24px 32px;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-category-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-category-button {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-align: center;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-category-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-category-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-category-count {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-content-card {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-loading-spinner {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border: 4px solid #e2e8f0;
|
||||||
|
border-top: 4px solid #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-loading-text {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-error {
|
||||||
|
padding: 60px;
|
||||||
|
text-align: center;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-error-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-error-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-error-message {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 반응형 디자인 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.bom-management-page {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-header-card {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-category-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-category-button {
|
||||||
|
padding: 12px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.bom-stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bom-category-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user