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, 일반 예외)
|
||||
|
||||
### 📊 구현된 페이지들
|
||||
|
||||
#### **📋 기존 페이지들**
|
||||
- MainPage: 메인 대시보드
|
||||
- JobSelectionPage: 프로젝트 선택
|
||||
- JobRegistrationPage: 프로젝트 등록
|
||||
@@ -1264,6 +1266,179 @@ const purchaseQuantity = Math.ceil(bomQuantity / 5) * 5;
|
||||
- PurchaseConfirmationPage: 구매 확인
|
||||
- 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 배포 가이드 ⭐
|
||||
|
||||
@@ -11,6 +11,7 @@ RUN apt-get update && apt-get install -y \
|
||||
libpq-dev \
|
||||
libmagic1 \
|
||||
libmagic-dev \
|
||||
netcat-openbsd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# requirements.txt 복사 및 의존성 설치
|
||||
@@ -27,4 +28,4 @@ EXPOSE 8000
|
||||
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")
|
||||
async def get_all_users(
|
||||
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}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
@@ -391,10 +609,11 @@ async def delete_user(
|
||||
try:
|
||||
# 토큰 검증 및 권한 확인
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
if payload['role'] != 'system':
|
||||
# admin role도 사용자 삭제 가능하도록 수정
|
||||
if payload['role'] not in ['system', 'admin']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="사용자 삭제는 시스템 관리자만 가능합니다"
|
||||
detail="사용자 삭제는 관리자만 가능합니다"
|
||||
)
|
||||
|
||||
# 자기 자신 삭제 방지
|
||||
@@ -404,7 +623,30 @@ async def delete_user(
|
||||
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 = user_repo.find_by_id(user_id)
|
||||
|
||||
@@ -414,15 +656,39 @@ async def delete_user(
|
||||
detail="해당 사용자를 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
user_repo.delete_user(user)
|
||||
|
||||
logger.info(f"User deleted by admin: {user.username} (deleted by: {payload['username']})")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': '사용자가 삭제되었습니다',
|
||||
'deleted_user_id': user_id
|
||||
}
|
||||
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 soft-deleted (has BOM data): {result.username} (deleted by: {payload['username']})")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'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
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -62,14 +62,38 @@ class AuthService:
|
||||
message="아이디 또는 비밀번호가 올바르지 않습니다"
|
||||
)
|
||||
|
||||
# 계정 활성화 상태 확인
|
||||
if not user.is_active:
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_disabled')
|
||||
logger.warning(f"Login failed - account disabled: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
message="비활성화된 계정입니다. 관리자에게 문의하세요"
|
||||
)
|
||||
# 계정 상태 확인 (새로운 status 체계)
|
||||
if hasattr(user, 'status'):
|
||||
if user.status == 'pending':
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'pending_account')
|
||||
logger.warning(f"Login failed - pending account: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
message="계정 승인 대기 중입니다. 관리자 승인 후 이용 가능합니다"
|
||||
)
|
||||
elif user.status == 'suspended':
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'suspended_account')
|
||||
logger.warning(f"Login failed - suspended account: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
message="계정이 정지되었습니다. 관리자에게 문의하세요"
|
||||
)
|
||||
elif user.status == 'deleted':
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'deleted_account')
|
||||
logger.warning(f"Login failed - deleted account: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
message="삭제된 계정입니다"
|
||||
)
|
||||
else:
|
||||
# 하위 호환성: status 필드가 없으면 is_active 사용
|
||||
if not user.is_active:
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_disabled')
|
||||
logger.warning(f"Login failed - account disabled: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
message="비활성화된 계정입니다. 관리자에게 문의하세요"
|
||||
)
|
||||
|
||||
# 계정 잠금 상태 확인
|
||||
if user.is_locked():
|
||||
|
||||
@@ -32,7 +32,8 @@ class User(Base):
|
||||
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)
|
||||
locked_until = Column(DateTime, nullable=True)
|
||||
|
||||
@@ -302,9 +303,15 @@ class UserRepository:
|
||||
raise
|
||||
|
||||
def get_all_users(self, skip: int = 0, limit: int = 100) -> List[User]:
|
||||
"""모든 사용자 조회"""
|
||||
"""활성 사용자만 조회 (status='active')"""
|
||||
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:
|
||||
logger.error(f"Failed to get all users: {str(e)}")
|
||||
return []
|
||||
|
||||
@@ -83,7 +83,8 @@ async def signup_request(
|
||||
'position': signup_data.position,
|
||||
'phone': signup_data.phone,
|
||||
'role': 'user',
|
||||
'is_active': False # 비활성 상태로 승인 대기 표시
|
||||
'is_active': False, # 하위 호환성
|
||||
'status': 'pending' # 새로운 status 체계: 승인 대기
|
||||
})
|
||||
|
||||
# 가입 사유 저장 (notes 컬럼 활용)
|
||||
@@ -130,13 +131,13 @@ async def get_signup_requests(
|
||||
detail="관리자만 접근 가능합니다"
|
||||
)
|
||||
|
||||
# 승인 대기 중인 사용자 조회 (is_active=False인 사용자)
|
||||
# 승인 대기 중인 사용자 조회 (status='pending'인 사용자)
|
||||
query = text("""
|
||||
SELECT
|
||||
user_id as id, username, name, email, department, position,
|
||||
phone, notes, created_at
|
||||
user_id, username, name, email, department, position,
|
||||
phone, created_at, role, is_active, status
|
||||
FROM users
|
||||
WHERE is_active = FALSE
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
|
||||
@@ -145,15 +146,18 @@ async def get_signup_requests(
|
||||
pending_users = []
|
||||
for row in results:
|
||||
pending_users.append({
|
||||
"id": row.id,
|
||||
"user_id": row.user_id,
|
||||
"id": row.user_id, # 호환성을 위해 둘 다 제공
|
||||
"username": row.username,
|
||||
"name": row.name,
|
||||
"email": row.email,
|
||||
"department": row.department,
|
||||
"position": row.position,
|
||||
"phone": row.phone,
|
||||
"reason": row.notes,
|
||||
"requested_at": row.created_at.isoformat() if row.created_at else None
|
||||
"role": row.role,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"requested_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"is_active": row.is_active
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -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}")
|
||||
async def approve_signup(
|
||||
user_id: int,
|
||||
@@ -201,9 +238,10 @@ async def approve_signup(
|
||||
update_query = text("""
|
||||
UPDATE users
|
||||
SET is_active = TRUE,
|
||||
status = 'active',
|
||||
access_level = :access_level,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = :user_id AND is_active = FALSE
|
||||
WHERE user_id = :user_id AND status = 'pending'
|
||||
RETURNING user_id as id, username, name
|
||||
""")
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class SecuritySettings(BaseSettings):
|
||||
"""보안 설정"""
|
||||
cors_origins: List[str] = Field(default=[], description="CORS 허용 도메인")
|
||||
cors_methods: List[str] = Field(
|
||||
default=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
default=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
description="CORS 허용 메서드"
|
||||
)
|
||||
cors_headers: List[str] = Field(
|
||||
@@ -147,6 +147,7 @@ class Settings(BaseSettings):
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
extra = "ignore"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@@ -9,8 +9,16 @@ DATABASE_URL = os.getenv(
|
||||
"postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom"
|
||||
)
|
||||
|
||||
# SQLAlchemy 엔진 생성
|
||||
engine = create_engine(DATABASE_URL)
|
||||
# SQLAlchemy 엔진 생성 (UTF-8 인코딩 설정)
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={
|
||||
"client_encoding": "utf8",
|
||||
"options": "-c client_encoding=utf8 -c timezone=UTC"
|
||||
},
|
||||
pool_pre_ping=True,
|
||||
echo=False
|
||||
)
|
||||
|
||||
# 세션 팩토리 생성
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
@@ -91,12 +91,49 @@ try:
|
||||
except ImportError:
|
||||
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:
|
||||
from .routers import tubing
|
||||
app.include_router(tubing.router, prefix="/tubing", tags=["tubing"])
|
||||
except ImportError:
|
||||
logger.warning("tubing 라우터를 찾을 수 없습니다")
|
||||
|
||||
# 구매 추적 라우터
|
||||
try:
|
||||
from .routers import purchase_tracking
|
||||
app.include_router(purchase_tracking.router)
|
||||
except ImportError:
|
||||
logger.warning("purchase_tracking 라우터를 찾을 수 없습니다")
|
||||
|
||||
# 엑셀 내보내기 관리 라우터
|
||||
try:
|
||||
from .routers import export_manager
|
||||
app.include_router(export_manager.router)
|
||||
except ImportError:
|
||||
logger.warning("export_manager 라우터를 찾을 수 없습니다")
|
||||
|
||||
# 구매신청 관리 라우터
|
||||
try:
|
||||
from .routers import purchase_request
|
||||
app.include_router(purchase_request.router)
|
||||
logger.info("purchase_request 라우터 등록 완료")
|
||||
except ImportError as e:
|
||||
logger.warning(f"purchase_request 라우터를 찾을 수 없습니다: {e}")
|
||||
|
||||
# 자재 관리 라우터
|
||||
try:
|
||||
from .routers import materials
|
||||
app.include_router(materials.router)
|
||||
logger.info("materials 라우터 등록 완료")
|
||||
except ImportError as e:
|
||||
logger.warning(f"materials 라우터를 찾을 수 없습니다: {e}")
|
||||
|
||||
# 파일 관리 API 라우터 등록 (비활성화 - files 라우터와 충돌 방지)
|
||||
# try:
|
||||
# from .api import file_management
|
||||
@@ -204,6 +241,14 @@ async def root():
|
||||
# print(f"Jobs 조회 에러: {str(e)}")
|
||||
# return {"error": f"Jobs 조회 실패: {str(e)}"}
|
||||
|
||||
# 리비전 관리 라우터
|
||||
try:
|
||||
from .routers import revision_management
|
||||
app.include_router(revision_management.router)
|
||||
logger.info("revision_management 라우터 등록 완료")
|
||||
except ImportError as e:
|
||||
logger.warning(f"revision_management 라우터를 찾을 수 없습니다: {e}")
|
||||
|
||||
# 파일 업로드는 /files/upload 엔드포인트를 사용하세요 (routers/files.py)
|
||||
|
||||
# parse_file과 classify_material_item 함수는 routers/files.py로 이동되었습니다
|
||||
|
||||
@@ -70,6 +70,25 @@ class Material(Base):
|
||||
drawing_reference = Column(String(100))
|
||||
notes = Column(Text)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# 추가 필드들
|
||||
main_nom = Column(String(50))
|
||||
red_nom = Column(String(50))
|
||||
purchase_confirmed = Column(Boolean, default=False)
|
||||
purchase_confirmed_at = Column(DateTime)
|
||||
purchase_status = Column(String(20), default='not_purchased')
|
||||
purchase_confirmed_by = Column(String(100))
|
||||
confirmed_quantity = Column(Numeric(10, 3))
|
||||
revision_status = Column(String(20), default='active')
|
||||
material_hash = Column(String(100))
|
||||
normalized_description = Column(Text)
|
||||
full_material_grade = Column(String(100))
|
||||
row_number = Column(Integer)
|
||||
length = Column(Numeric(10, 3))
|
||||
brand = Column(String(100))
|
||||
user_requirement = Column(Text)
|
||||
total_length = Column(Numeric(10, 3))
|
||||
|
||||
# 관계 설정
|
||||
file = relationship("File", back_populates="materials")
|
||||
|
||||
@@ -526,6 +526,7 @@ async def get_projects(
|
||||
projects.append({
|
||||
"id": row.id,
|
||||
"official_project_code": row.official_project_code,
|
||||
"job_no": row.official_project_code, # job_no 필드 추가 (프론트엔드 호환성)
|
||||
"project_name": row.project_name,
|
||||
"job_name": row.project_name, # 호환성을 위해 추가
|
||||
"client_name": row.client_name,
|
||||
|
||||
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),
|
||||
"length": dimensions_result.get('length', ''),
|
||||
"diameter": dimensions_result.get('diameter', ''),
|
||||
"dimension_description": dimensions_result.get('dimension_description', '')
|
||||
"dimension_description": dimensions_result.get('dimension_description', ''),
|
||||
"bolts_per_flange": dimensions_result.get('bolts_per_flange', 1)
|
||||
},
|
||||
|
||||
"grade_strength": {
|
||||
@@ -966,12 +967,19 @@ def extract_bolt_dimensions(main_nom: str, description: str) -> Dict:
|
||||
except:
|
||||
nominal_size_fraction = actual_bolt_size
|
||||
|
||||
# 플랜지당 볼트 세트 수 추출 (예: (8), (4))
|
||||
bolts_per_flange = 1 # 기본값
|
||||
flange_bolt_pattern = re.search(r'\((\d+)\)', description)
|
||||
if flange_bolt_pattern:
|
||||
bolts_per_flange = int(flange_bolt_pattern.group(1))
|
||||
|
||||
dimensions = {
|
||||
"nominal_size": actual_bolt_size, # 실제 볼트 사이즈
|
||||
"nominal_size_fraction": nominal_size_fraction, # 분수 변환값
|
||||
"length": "",
|
||||
"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',\s*(\d+(?:\.\d+)?)\s*LG', # ", 145.0000 LG" 형태 (PSV, LT 볼트용)
|
||||
r',\s*(\d+(?:\.\d+)?)\s+(?:CK|PSV|LT)', # ", 140 CK" 형태 (PSV 볼트용)
|
||||
r'PSV\s+(\d+(?:\.\d+)?)', # PSV 140 형태 (PSV 볼트 전용)
|
||||
r'(\d+(?:\.\d+)?)\s+PSV', # 140 PSV 형태 (PSV 볼트 전용)
|
||||
r'(\d+(?:\.\d+)?)\s*MM(?:\s|,|$)', # 75MM 형태 (단독)
|
||||
r'(\d+(?:\.\d+)?)\s*mm(?:\s|,|$)' # 75mm 형태 (단독)
|
||||
]
|
||||
|
||||
157
backend/app/services/classifier_constants.py
Normal file
157
backend/app/services/classifier_constants.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
자재 분류 시스템용 상수 및 키워드 정의
|
||||
중복 로직 제거 및 유지보수성 향상을 위해 중앙 집중화됨
|
||||
"""
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
# ==============================================================================
|
||||
# 1. 압력 등급 (Pressure Ratings)
|
||||
# ==============================================================================
|
||||
|
||||
# 단순 키워드 목록 (Integrated Classifier용)
|
||||
LEVEL3_PRESSURE_KEYWORDS = [
|
||||
"150LB", "300LB", "600LB", "900LB", "1500LB",
|
||||
"2500LB", "3000LB", "6000LB", "9000LB"
|
||||
]
|
||||
|
||||
# 상세 스펙 및 메타데이터 (Fitting Classifier용)
|
||||
PRESSURE_RATINGS_SPECS = {
|
||||
"150LB": {"max_pressure": "285 PSI", "common_use": "저압 일반용"},
|
||||
"300LB": {"max_pressure": "740 PSI", "common_use": "중압용"},
|
||||
"600LB": {"max_pressure": "1480 PSI", "common_use": "고압용"},
|
||||
"900LB": {"max_pressure": "2220 PSI", "common_use": "고압용"},
|
||||
"1500LB": {"max_pressure": "3705 PSI", "common_use": "고압용"},
|
||||
"2500LB": {"max_pressure": "6170 PSI", "common_use": "초고압용"},
|
||||
"3000LB": {"max_pressure": "7400 PSI", "common_use": "소구경 고압용"},
|
||||
"6000LB": {"max_pressure": "14800 PSI", "common_use": "소구경 초고압용"},
|
||||
"9000LB": {"max_pressure": "22200 PSI", "common_use": "소구경 극고압용"}
|
||||
}
|
||||
|
||||
# 정규식 패턴 (Fitting Classifier용)
|
||||
PRESSURE_PATTERNS = [
|
||||
r"(\d+)LB",
|
||||
r"CLASS\s*(\d+)",
|
||||
r"CL\s*(\d+)",
|
||||
r"(\d+)#",
|
||||
r"(\d+)\s*LB"
|
||||
]
|
||||
|
||||
# ==============================================================================
|
||||
# 2. OLET 키워드 (OLET Keywords)
|
||||
# ==============================================================================
|
||||
# Fitting Classifier와 Integrated Classifier에서 공통 사용
|
||||
OLET_KEYWORDS = [
|
||||
# Full Names
|
||||
"SOCK-O-LET", "WELD-O-LET", "ELL-O-LET", "THREAD-O-LET", "ELB-O-LET",
|
||||
"NIP-O-LET", "COUP-O-LET",
|
||||
# Variations
|
||||
"SOCKOLET", "WELDOLET", "ELLOLET", "THREADOLET", "ELBOLET", "NIPOLET", "COUPOLET",
|
||||
"OLET", "올렛", "O-LET", "SOCKLET", "SOCKET-O-LET", "WELD O-LET", "ELL O-LET",
|
||||
"THREADED-O-LET", "ELBOW-O-LET", "NIPPLE-O-LET", "COUPLING-O-LET",
|
||||
# Abbreviations (Caution: specific context needed sometimes)
|
||||
"SOL", "WOL", "EOL", "TOL", "NOL", "COL"
|
||||
]
|
||||
|
||||
# ==============================================================================
|
||||
# 3. 연결 방식 (Connection Methods)
|
||||
# ==============================================================================
|
||||
LEVEL3_CONNECTION_KEYWORDS = {
|
||||
"SW": ["SW", "SOCKET WELD", "소켓웰드", "SOCKET-WELD", "_SW_"],
|
||||
"THD": ["THD", "THREADED", "NPT", "나사", "THRD", "TR", "_TR", "_THD"],
|
||||
"BW": ["BW", "BUTT WELD", "맞대기용접", "BUTT-WELD", "_BW"],
|
||||
"FL": ["FL", "FLANGED", "플랜지", "FLG", "_FL_"]
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 4. 재질 키워드 (Material Keywords)
|
||||
# ==============================================================================
|
||||
LEVEL4_MATERIAL_KEYWORDS = {
|
||||
"PIPE": ["A106", "A333", "A312", "A53"],
|
||||
"FITTING": ["A234", "A403", "A420"],
|
||||
"FLANGE": ["A182", "A350"],
|
||||
"VALVE": ["A216", "A217", "A351", "A352"],
|
||||
"BOLT": ["A193", "A194", "A320", "A325", "A490"]
|
||||
}
|
||||
|
||||
GENERIC_MATERIALS = {
|
||||
"A105": ["VALVE", "FLANGE", "FITTING"],
|
||||
"316": ["VALVE", "FLANGE", "FITTING", "PIPE", "BOLT"],
|
||||
"304": ["VALVE", "FLANGE", "FITTING", "PIPE", "BOLT"]
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 5. 메인 분류 키워드 (Level 1 Type Keywords)
|
||||
# ==============================================================================
|
||||
LEVEL1_TYPE_KEYWORDS = {
|
||||
"BOLT": [
|
||||
"FLANGE BOLT", "U-BOLT", "U BOLT", "BOLT", "STUD", "NUT", "SCREW",
|
||||
"WASHER", "볼트", "너트", "스터드", "나사", "와셔", "유볼트"
|
||||
],
|
||||
"VALVE": [
|
||||
"VALVE", "GATE", "BALL", "GLOBE", "CHECK", "BUTTERFLY", "NEEDLE",
|
||||
"RELIEF", "SIGHT GLASS", "STRAINER", "밸브", "게이트", "볼", "글로브",
|
||||
"체크", "버터플라이", "니들", "릴리프", "사이트글라스", "스트레이너"
|
||||
],
|
||||
"FLANGE": [
|
||||
"FLG", "FLANGE", "플랜지", "프랜지", "ORIFICE", "SPECTACLE", "PADDLE",
|
||||
"SPACER", "BLIND", "REDUCING FLANGE", "RED FLANGE"
|
||||
],
|
||||
"PIPE": [
|
||||
"PIPE", "TUBE", "파이프", "배관", "SMLS", "SEAMLESS"
|
||||
],
|
||||
"FITTING": [
|
||||
# Standard Fittings
|
||||
"ELBOW", "ELL", "TEE", "REDUCER", "CAP", "COUPLING", "NIPPLE", "SWAGE", "PLUG",
|
||||
"엘보", "티", "리듀서", "캡", "니플", "커플링", "플러그", "CONC", "ECC",
|
||||
# Instrument Fittings
|
||||
"SWAGELOK", "DK-LOK", "HY-LOK", "SUPERLOK", "TUBE FITTING", "COMPRESSION",
|
||||
"UNION", "FERRULE", "NUT & FERRULE", "MALE CONNECTOR", "FEMALE CONNECTOR",
|
||||
"TUBE ADAPTER", "PORT CONNECTOR", "CONNECTOR"
|
||||
] + OLET_KEYWORDS, # OLET Keywords 병합
|
||||
"GASKET": [
|
||||
"GASKET", "GASK", "가스켓", "SWG", "SPIRAL"
|
||||
],
|
||||
"INSTRUMENT": [
|
||||
"GAUGE", "TRANSMITTER", "SENSOR", "THERMOMETER", "계기", "게이지", "트랜스미터", "센서"
|
||||
],
|
||||
"SUPPORT": [
|
||||
"URETHANE BLOCK", "URETHANE", "BLOCK SHOE", "CLAMP", "SUPPORT", "HANGER",
|
||||
"SPRING", "우레탄", "블록", "클램프", "서포트", "행거", "스프링", "PIPE CLAMP"
|
||||
],
|
||||
"PLATE": [
|
||||
"PLATE", "PL", "CHECKER PLATE", "판재", "철판"
|
||||
],
|
||||
"STRUCTURAL": [
|
||||
"H-BEAM", "BEAM", "ANGLE", "CHANNEL", "H-SECTION", "I-BEAM", "형강", "앵글", "채널"
|
||||
]
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 6. 서브타입 키워드 (Level 2 Subtype Keywords)
|
||||
# ==============================================================================
|
||||
LEVEL2_SUBTYPE_KEYWORDS = {
|
||||
"VALVE": {
|
||||
"GATE": ["GATE VALVE", "GATE", "게이트 밸브"],
|
||||
"BALL": ["BALL VALVE", "BALL", "볼 밸브"],
|
||||
"GLOBE": ["GLOBE VALVE", "GLOBE", "글로브 밸브"],
|
||||
"CHECK": ["CHECK VALVE", "CHECK", "체크 밸브", "역지 밸브"]
|
||||
},
|
||||
"FLANGE": {
|
||||
"WELD_NECK": ["WELD NECK", "WN", "웰드넥"],
|
||||
"SLIP_ON": ["SLIP ON", "SO", "슬립온"],
|
||||
"BLIND": ["BLIND", "BL", "막음", "차단"],
|
||||
"SOCKET_WELD": ["SOCKET WELD", "소켓웰드"]
|
||||
},
|
||||
"BOLT": {
|
||||
"HEX_BOLT": ["HEX BOLT", "HEXAGON", "육각 볼트"],
|
||||
"STUD_BOLT": ["STUD BOLT", "STUD", "스터드 볼트"],
|
||||
"U_BOLT": ["U-BOLT", "U BOLT", "유볼트"]
|
||||
},
|
||||
"SUPPORT": {
|
||||
"URETHANE_BLOCK": ["URETHANE BLOCK", "BLOCK SHOE", "우레탄 블록"],
|
||||
"CLAMP": ["CLAMP", "클램프"],
|
||||
"HANGER": ["HANGER", "SUPPORT", "행거", "서포트"],
|
||||
"SPRING": ["SPRING", "스프링"]
|
||||
}
|
||||
}
|
||||
300
backend/app/services/excel_parser.py
Normal file
300
backend/app/services/excel_parser.py
Normal file
@@ -0,0 +1,300 @@
|
||||
|
||||
import pandas as pd
|
||||
import re
|
||||
from typing import List, Dict, Optional
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# 허용된 확장자
|
||||
ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"}
|
||||
|
||||
class BOMParser:
|
||||
"""BOM 파일 파싱을 담당하는 클래스"""
|
||||
|
||||
@staticmethod
|
||||
def validate_extension(filename: str) -> bool:
|
||||
return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_filename(original_filename: str) -> str:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
stem = Path(original_filename).stem
|
||||
suffix = Path(original_filename).suffix
|
||||
return f"{stem}_{timestamp}_{unique_id}{suffix}"
|
||||
|
||||
@staticmethod
|
||||
def detect_format(df: pd.DataFrame) -> str:
|
||||
"""
|
||||
엑셀 헤더를 분석하여 양식을 감지합니다.
|
||||
|
||||
Returns:
|
||||
'INVENTOR': 인벤터 추출 양식 (NO., NAME, Q'ty, LENGTH & THICKNESS...)
|
||||
'STANDARD': 기존 표준/퍼지 매핑 엑셀 (DWG_NAME, LINE_NUM, MAIN_NOM...)
|
||||
"""
|
||||
columns = [str(c).strip().upper() for c in df.columns]
|
||||
|
||||
# 인벤터 양식 특징 (오타 포함)
|
||||
INVENTOR_KEYWORDS = ['LENGTH & THICKNESS', 'DESCIPTION', "Q'TY"]
|
||||
|
||||
for keyword in INVENTOR_KEYWORDS:
|
||||
if any(keyword in col for col in columns):
|
||||
return 'INVENTOR'
|
||||
|
||||
# 표준 양식 특징
|
||||
STANDARD_KEYWORDS = ['MAIN_NOM', 'DWG_NAME', 'LINE_NUM']
|
||||
for keyword in STANDARD_KEYWORDS:
|
||||
if any(keyword in col for col in columns):
|
||||
return 'STANDARD'
|
||||
|
||||
return 'STANDARD' # 기본값
|
||||
|
||||
@classmethod
|
||||
def parse_file(cls, file_path: str) -> List[Dict]:
|
||||
"""파일 경로를 받아 적절한 파서를 통해 데이터를 추출합니다."""
|
||||
file_extension = Path(file_path).suffix.lower()
|
||||
|
||||
try:
|
||||
if file_extension == ".csv":
|
||||
df = pd.read_csv(file_path, encoding='utf-8')
|
||||
elif file_extension in [".xlsx", ".xls"]:
|
||||
# xlrd 엔진 명시 (xls 지원)
|
||||
if file_extension == ".xls":
|
||||
df = pd.read_excel(file_path, sheet_name=0, engine='xlrd')
|
||||
else:
|
||||
df = pd.read_excel(file_path, sheet_name=0, engine='openpyxl')
|
||||
else:
|
||||
raise ValueError("지원하지 않는 파일 형식")
|
||||
|
||||
# 데이터프레임 전처리 (빈 행 제거 등)
|
||||
df = df.dropna(how='all')
|
||||
|
||||
# 양식 감지
|
||||
format_type = cls.detect_format(df)
|
||||
print(f"📋 감지된 BOM 양식: {format_type}")
|
||||
|
||||
if format_type == 'INVENTOR':
|
||||
return cls._parse_inventor_bom(df)
|
||||
else:
|
||||
return cls._parse_standard_bom(df)
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"파일 파싱 실패: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def _parse_standard_bom(df: pd.DataFrame) -> List[Dict]:
|
||||
"""기존의 퍼지 매핑 방식 파서 (표준 양식)"""
|
||||
# 컬럼명 전처리
|
||||
df.columns = df.columns.str.strip().str.upper()
|
||||
|
||||
# 대소문자 구분 없는 매핑을 위해 컬럼명 대문자화 사용
|
||||
column_mapping = {
|
||||
'description': ['DESCRIPTION', 'ITEM', 'MATERIAL', '품명', '자재명', 'SPECIFICATION'],
|
||||
'quantity': ['QTY', 'QUANTITY', 'EA', '수량', 'AMOUNT'],
|
||||
'main_size': ['MAIN_NOM', 'NOMINAL_DIAMETER', 'ND', '주배관', 'SIZE'],
|
||||
'red_size': ['RED_NOM', 'REDUCED_DIAMETER', '축소배관'],
|
||||
'length': ['LENGTH', 'LEN', '길이'],
|
||||
'weight': ['WEIGHT', 'WT', '중량'],
|
||||
'dwg_name': ['DWG_NAME', 'DRAWING', '도면명', '도면번호'],
|
||||
'line_num': ['LINE_NUM', 'LINE_NUMBER', '라인번호']
|
||||
}
|
||||
|
||||
mapped_columns = {}
|
||||
for standard_col, possible_names in column_mapping.items():
|
||||
for possible_name in possible_names:
|
||||
# 대문자로 비교
|
||||
possible_upper = possible_name.upper()
|
||||
if possible_upper in df.columns:
|
||||
mapped_columns[standard_col] = possible_upper
|
||||
break
|
||||
|
||||
print(f"📋 [Standard] 컬럼 매핑 결과: {mapped_columns}")
|
||||
|
||||
materials = []
|
||||
for index, row in df.iterrows():
|
||||
description = str(row.get(mapped_columns.get('description', ''), ''))
|
||||
|
||||
# 제외 항목 처리
|
||||
description_upper = description.upper()
|
||||
if ('WELD GAP' in description_upper or 'WELDING GAP' in description_upper or
|
||||
'웰드갭' in description_upper or '용접갭' in description_upper):
|
||||
continue
|
||||
|
||||
# 수량 처리
|
||||
quantity_raw = row.get(mapped_columns.get('quantity', ''), 0)
|
||||
try:
|
||||
quantity = float(quantity_raw) if pd.notna(quantity_raw) else 0
|
||||
except:
|
||||
quantity = 0
|
||||
|
||||
# 재질 등급 추출 (ASTM)
|
||||
material_grade = ""
|
||||
if "ASTM" in description_upper:
|
||||
astm_match = re.search(r'ASTM\s+(A\d{3,4}[A-Z]*(?:\s+(?:GR\s+)?[A-Z0-9]+)?)', description_upper)
|
||||
if astm_match:
|
||||
material_grade = astm_match.group(0).strip()
|
||||
|
||||
# 사이즈 처리
|
||||
main_size = str(row.get(mapped_columns.get('main_size', ''), ''))
|
||||
red_size = str(row.get(mapped_columns.get('red_size', ''), ''))
|
||||
|
||||
main_nom = main_size if main_size != 'nan' and main_size != '' else None
|
||||
red_nom = red_size if red_size != 'nan' and red_size != '' else None
|
||||
|
||||
if main_size != 'nan' and red_size != 'nan' and red_size != '':
|
||||
size_spec = f"{main_size} x {red_size}"
|
||||
elif main_size != 'nan' and main_size != '':
|
||||
size_spec = main_size
|
||||
else:
|
||||
size_spec = ""
|
||||
|
||||
# 길이 처리
|
||||
length_raw = row.get(mapped_columns.get('length', ''), '')
|
||||
length_value = None
|
||||
if pd.notna(length_raw) and str(length_raw).strip() != '':
|
||||
try:
|
||||
length_value = float(str(length_raw).strip())
|
||||
except:
|
||||
length_value = None
|
||||
|
||||
# 도면/라인 번호
|
||||
dwg_name = row.get(mapped_columns.get('dwg_name', ''), '')
|
||||
dwg_name = str(dwg_name).strip() if pd.notna(dwg_name) and str(dwg_name).strip() not in ['', 'nan', 'None'] else None
|
||||
|
||||
line_num = row.get(mapped_columns.get('line_num', ''), '')
|
||||
line_num = str(line_num).strip() if pd.notna(line_num) and str(line_num).strip() not in ['', 'nan', 'None'] else None
|
||||
|
||||
if description and description not in ['nan', 'None', '']:
|
||||
materials.append({
|
||||
'original_description': description,
|
||||
'quantity': quantity,
|
||||
'unit': "EA",
|
||||
'size_spec': size_spec,
|
||||
'main_nom': main_nom,
|
||||
'red_nom': red_nom,
|
||||
'material_grade': material_grade,
|
||||
'length': length_value,
|
||||
'dwg_name': dwg_name,
|
||||
'line_num': line_num,
|
||||
'line_number': index + 1,
|
||||
'row_number': index + 1
|
||||
})
|
||||
|
||||
return materials
|
||||
|
||||
@staticmethod
|
||||
def _parse_inventor_bom(df: pd.DataFrame) -> List[Dict]:
|
||||
"""
|
||||
[신규] 인벤터 추출 양식 파서
|
||||
헤더: NO., NAME, Q'ty, LENGTH & THICKNESS, WEIGHT, DESCIPTION, REMARK
|
||||
특징: Size 컬럼 부재, NAME에 주요 정보 포함
|
||||
"""
|
||||
print("⚠️ [Inventor] 인벤터 양식 파서를 사용합니다.")
|
||||
|
||||
# 컬럼명 전처리 (좌우 공백 제거 및 대문자화)
|
||||
df.columns = df.columns.str.strip().str.upper()
|
||||
|
||||
# 인벤터 전용 매핑
|
||||
col_name = 'NAME'
|
||||
col_qty = "Q'TY"
|
||||
col_desc = 'DESCIPTION' # 오타 그대로 반영
|
||||
col_remark = 'REMARK'
|
||||
col_length = 'LENGTH & THICKNESS' # 길이 정보가 여기 있을 수 있음
|
||||
|
||||
materials = []
|
||||
for index, row in df.iterrows():
|
||||
# 1. 품명 (NAME 컬럼 우선 사용)
|
||||
name_val = str(row.get(col_name, '')).strip()
|
||||
desc_val = str(row.get(col_desc, '')).strip()
|
||||
|
||||
# NAME과 DESCIPTION 병합 (필요시)
|
||||
# 보통 인벤터에서 NAME이 'ADAPTER OD 1/4" x NPT(F) 1/2"' 같이 상세 스펙
|
||||
# DESCIPTION은 'SS-400-7-8' 같은 자재 코드일 수 있음
|
||||
# 일단 NAME을 메인 description으로 사용하고, DESCIPTION을 괄호에 추가
|
||||
if desc_val and desc_val not in ['nan', 'None', '']:
|
||||
full_description = f"{name_val} ({desc_val})"
|
||||
else:
|
||||
full_description = name_val
|
||||
|
||||
if not full_description or full_description in ['nan', 'None', '']:
|
||||
continue
|
||||
|
||||
# 2. 수량
|
||||
qty_raw = row.get(col_qty, 0)
|
||||
try:
|
||||
quantity = float(qty_raw) if pd.notna(qty_raw) else 0
|
||||
except:
|
||||
quantity = 0
|
||||
|
||||
# 3. 사이즈 추출 (NAME 컬럼 분석)
|
||||
# 패턴: 1/2", 1/4", 100A, 50A, 10x20 등
|
||||
size_spec = ""
|
||||
main_nom = None
|
||||
red_nom = None
|
||||
|
||||
# 인치/MM 사이즈 추출 시도
|
||||
# 예: "ADAPTER OD 1/4" x NPT(F) 1/2"" -> 1/4" x 1/2"
|
||||
# 예: "ELBOW 90D 100A" -> 100A
|
||||
|
||||
# 인치 패턴 (1/2", 3/4" 등)
|
||||
inch_sizes = re.findall(r'(\d+(?:/\d+)?)"', name_val)
|
||||
# A단위 패턴 (100A, 50A 등)
|
||||
a_sizes = re.findall(r'(\d+)A', name_val)
|
||||
|
||||
if inch_sizes:
|
||||
if len(inch_sizes) >= 2:
|
||||
main_nom = f'{inch_sizes[0]}"'
|
||||
red_nom = f'{inch_sizes[1]}"'
|
||||
size_spec = f'{main_nom} x {red_nom}'
|
||||
else:
|
||||
main_nom = f'{inch_sizes[0]}"'
|
||||
size_spec = main_nom
|
||||
elif a_sizes:
|
||||
if len(a_sizes) >= 2:
|
||||
main_nom = f'{a_sizes[0]}A'
|
||||
red_nom = f'{a_sizes[1]}A'
|
||||
size_spec = f'{main_nom} x {red_nom}'
|
||||
else:
|
||||
main_nom = f'{a_sizes[0]}A'
|
||||
size_spec = main_nom
|
||||
|
||||
# 4. 재질 정보
|
||||
material_grade = ""
|
||||
# NAME이나 DESCIPTION에서 재질 키워드 찾기 (SS, SUS, A105 등)
|
||||
combined_text = (full_description + " " + desc_val).upper()
|
||||
if "SUS" in combined_text or "SS" in combined_text:
|
||||
if "304" in combined_text: material_grade = "SUS304"
|
||||
elif "316" in combined_text: material_grade = "SUS316"
|
||||
else: material_grade = "SUS"
|
||||
elif "A105" in combined_text:
|
||||
material_grade = "A105"
|
||||
|
||||
# 5. 길이 정보
|
||||
length_value = None
|
||||
length_raw = row.get(col_length, '')
|
||||
# 값이 있고 숫자로 변환 가능하면 사용
|
||||
if pd.notna(length_raw) and str(length_raw).strip():
|
||||
try:
|
||||
# '100 mm' 등의 형식 처리 필요할 수 있음
|
||||
length_str = str(length_raw).lower().replace('mm', '').strip()
|
||||
length_value = float(length_str)
|
||||
except:
|
||||
pass
|
||||
|
||||
materials.append({
|
||||
'original_description': full_description,
|
||||
'quantity': quantity,
|
||||
'unit': "EA",
|
||||
'size_spec': size_spec,
|
||||
'main_nom': main_nom,
|
||||
'red_nom': red_nom,
|
||||
'material_grade': material_grade,
|
||||
'length': length_value,
|
||||
'dwg_name': None, # 인벤터 파일엔 도면명 컬럼이 없음
|
||||
'line_num': None,
|
||||
'line_number': index + 1,
|
||||
'row_number': index + 1
|
||||
})
|
||||
|
||||
return materials
|
||||
@@ -8,11 +8,6 @@ from typing import Dict, List, Optional
|
||||
|
||||
# ========== 제외 대상 타입 ==========
|
||||
EXCLUDE_TYPES = {
|
||||
"WELD_GAP": {
|
||||
"description_keywords": ["WELD GAP", "WELDING GAP", "GAP", "용접갭", "웰드갭"],
|
||||
"characteristics": "용접 시 수축 고려용 계산 항목",
|
||||
"reason": "실제 자재 아님 - 용접 갭 계산용"
|
||||
},
|
||||
"CUTTING_LOSS": {
|
||||
"description_keywords": ["CUTTING LOSS", "CUT LOSS", "절단로스", "컷팅로스"],
|
||||
"characteristics": "절단 시 손실 고려용 계산 항목",
|
||||
|
||||
@@ -6,13 +6,18 @@ FITTING 분류 시스템 V2
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
from .material_classifier import classify_material, get_manufacturing_method_from_material
|
||||
from .classifier_constants import PRESSURE_PATTERNS, PRESSURE_RATINGS_SPECS, OLET_KEYWORDS
|
||||
|
||||
# ========== FITTING 타입별 분류 (실제 BOM 기반) ==========
|
||||
FITTING_TYPES = {
|
||||
"ELBOW": {
|
||||
"dat_file_patterns": ["90L_", "45L_", "ELL_", "ELBOW_"],
|
||||
"description_keywords": ["ELBOW", "ELL", "엘보"],
|
||||
"description_keywords": ["ELBOW", "ELL", "엘보", "90 ELBOW", "45 ELBOW", "LR ELBOW", "SR ELBOW", "90 ELL", "45 ELL"],
|
||||
"subtypes": {
|
||||
"90DEG_LONG_RADIUS": ["90 LR", "90° LR", "90DEG LR", "90도 장반경", "90 LONG RADIUS", "LR 90"],
|
||||
"90DEG_SHORT_RADIUS": ["90 SR", "90° SR", "90DEG SR", "90도 단반경", "90 SHORT RADIUS", "SR 90"],
|
||||
"45DEG_LONG_RADIUS": ["45 LR", "45° LR", "45DEG LR", "45도 장반경", "45 LONG RADIUS", "LR 45"],
|
||||
"45DEG_SHORT_RADIUS": ["45 SR", "45° SR", "45DEG SR", "45도 단반경", "45 SHORT RADIUS", "SR 45"],
|
||||
"90DEG": ["90", "90°", "90DEG", "90도"],
|
||||
"45DEG": ["45", "45°", "45DEG", "45도"],
|
||||
"LONG_RADIUS": ["LR", "LONG RADIUS", "장반경"],
|
||||
@@ -98,11 +103,12 @@ FITTING_TYPES = {
|
||||
},
|
||||
|
||||
"OLET": {
|
||||
"dat_file_patterns": ["SOL_", "WOL_", "TOL_", "OLET_", "SOCK-O-LET", "WELD-O-LET"],
|
||||
"description_keywords": ["OLET", "올렛", "O-LET", "SOCK-O-LET", "WELD-O-LET", "SOCKOLET", "WELDOLET", "THREAD-O-LET", "THREADOLET", "SOCKLET", "SOCKET"],
|
||||
"dat_file_patterns": ["SOL_", "WOL_", "TOL_", "EOL_", "NOL_", "COL_", "OLET_", "SOCK-O-LET", "WELD-O-LET", "ELL-O-LET", "THREAD-O-LET", "ELB-O-LET", "NIP-O-LET", "COUP-O-LET"],
|
||||
"description_keywords": OLET_KEYWORDS,
|
||||
"subtypes": {
|
||||
"SOCKOLET": ["SOCK-O-LET", "SOCKOLET", "SOL", "SOCK O-LET", "SOCKET-O-LET", "SOCKLET"],
|
||||
"WELDOLET": ["WELD-O-LET", "WELDOLET", "WOL", "WELD O-LET", "WELDING-O-LET"],
|
||||
"ELLOLET": ["ELL-O-LET", "ELLOLET", "EOL", "ELL O-LET", "ELBOW-O-LET"],
|
||||
"THREADOLET": ["THREAD-O-LET", "THREADOLET", "TOL", "THREADED-O-LET"],
|
||||
"ELBOLET": ["ELB-O-LET", "ELBOLET", "EOL", "ELBOW-O-LET"],
|
||||
"NIPOLET": ["NIP-O-LET", "NIPOLET", "NOL", "NIPPLE-O-LET"],
|
||||
@@ -164,24 +170,8 @@ CONNECTION_METHODS = {
|
||||
|
||||
# ========== 압력 등급별 분류 ==========
|
||||
PRESSURE_RATINGS = {
|
||||
"patterns": [
|
||||
r"(\d+)LB",
|
||||
r"CLASS\s*(\d+)",
|
||||
r"CL\s*(\d+)",
|
||||
r"(\d+)#",
|
||||
r"(\d+)\s*LB"
|
||||
],
|
||||
"standard_ratings": {
|
||||
"150LB": {"max_pressure": "285 PSI", "common_use": "저압 일반용"},
|
||||
"300LB": {"max_pressure": "740 PSI", "common_use": "중압용"},
|
||||
"600LB": {"max_pressure": "1480 PSI", "common_use": "고압용"},
|
||||
"900LB": {"max_pressure": "2220 PSI", "common_use": "고압용"},
|
||||
"1500LB": {"max_pressure": "3705 PSI", "common_use": "고압용"},
|
||||
"2500LB": {"max_pressure": "6170 PSI", "common_use": "초고압용"},
|
||||
"3000LB": {"max_pressure": "7400 PSI", "common_use": "소구경 고압용"},
|
||||
"6000LB": {"max_pressure": "14800 PSI", "common_use": "소구경 초고압용"},
|
||||
"9000LB": {"max_pressure": "22200 PSI", "common_use": "소구경 극고압용"}
|
||||
}
|
||||
"patterns": PRESSURE_PATTERNS,
|
||||
"standard_ratings": PRESSURE_RATINGS_SPECS
|
||||
}
|
||||
|
||||
def classify_fitting(dat_file: str, description: str, main_nom: str,
|
||||
@@ -203,7 +193,11 @@ def classify_fitting(dat_file: str, description: str, main_nom: str,
|
||||
dat_upper = dat_file.upper()
|
||||
|
||||
# 1. 피팅 키워드 확인 (재질만 있어도 통합 분류기가 이미 피팅으로 분류했으므로 진행)
|
||||
fitting_keywords = ['ELBOW', 'ELL', 'TEE', 'REDUCER', 'RED', 'CAP', 'NIPPLE', 'SWAGE', 'OLET', 'COUPLING', 'PLUG', 'SOCKLET', 'SOCKET', '엘보', '티', '리듀서', '캡', '니플', '스웨지', '올렛', '커플링', '플러그', 'SOCK-O-LET', 'WELD-O-LET', 'SOCKOLET', 'WELDOLET']
|
||||
# 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)
|
||||
|
||||
# 피팅 재질 확인 (A234, A403, A420)
|
||||
@@ -239,71 +233,35 @@ def classify_fitting(dat_file: str, description: str, main_nom: str,
|
||||
)
|
||||
|
||||
# 6. 최종 결과 조합
|
||||
# --- 계장용(Instrument/Swagelok) 피팅 감지 로직 추가 ---
|
||||
instrument_keywords = ["SWAGELOK", "DK-LOK", "TUBE FITTING", "UNION", "FERRULE", "MALE CONNECTOR", "FEMALE CONNECTOR"]
|
||||
is_instrument = any(kw in desc_upper for kw in instrument_keywords)
|
||||
|
||||
if is_instrument:
|
||||
fitting_type_result["category"] = "INSTRUMENT_FITTING"
|
||||
if "SWAGELOK" in desc_upper: fitting_type_result["brand"] = "SWAGELOK"
|
||||
|
||||
# Tube OD 추출 (예: 1/4", 6MM, 12MM)
|
||||
tube_match = re.search(r'(\d+(?:/\d+)?)\s*(?:\"|INCH|MM)\s*(?:OD|TUBE)', desc_upper)
|
||||
if tube_match:
|
||||
fitting_type_result["tube_od"] = tube_match.group(0)
|
||||
|
||||
return {
|
||||
"category": "FITTING",
|
||||
|
||||
# 재질 정보 (공통 모듈)
|
||||
"material": {
|
||||
"standard": material_result.get('standard', 'UNKNOWN'),
|
||||
"grade": material_result.get('grade', 'UNKNOWN'),
|
||||
"material_type": material_result.get('material_type', 'UNKNOWN'),
|
||||
"confidence": material_result.get('confidence', 0.0)
|
||||
},
|
||||
|
||||
# 피팅 특화 정보
|
||||
"fitting_type": {
|
||||
"type": fitting_type_result.get('type', 'UNKNOWN'),
|
||||
"subtype": fitting_type_result.get('subtype', 'UNKNOWN'),
|
||||
"confidence": fitting_type_result.get('confidence', 0.0),
|
||||
"evidence": fitting_type_result.get('evidence', [])
|
||||
},
|
||||
|
||||
"connection_method": {
|
||||
"method": connection_result.get('method', 'UNKNOWN'),
|
||||
"confidence": connection_result.get('confidence', 0.0),
|
||||
"matched_code": connection_result.get('matched_code', ''),
|
||||
"size_range": connection_result.get('size_range', ''),
|
||||
"pressure_range": connection_result.get('pressure_range', '')
|
||||
},
|
||||
|
||||
"pressure_rating": {
|
||||
"rating": pressure_result.get('rating', 'UNKNOWN'),
|
||||
"confidence": pressure_result.get('confidence', 0.0),
|
||||
"max_pressure": pressure_result.get('max_pressure', ''),
|
||||
"common_use": pressure_result.get('common_use', '')
|
||||
},
|
||||
|
||||
"manufacturing": {
|
||||
"method": manufacturing_result.get('method', 'UNKNOWN'),
|
||||
"confidence": manufacturing_result.get('confidence', 0.0),
|
||||
"evidence": manufacturing_result.get('evidence', []),
|
||||
"characteristics": manufacturing_result.get('characteristics', '')
|
||||
},
|
||||
|
||||
"size_info": {
|
||||
"main_size": main_nom,
|
||||
"reduced_size": red_nom,
|
||||
"size_description": format_fitting_size(main_nom, red_nom),
|
||||
"requires_two_sizes": fitting_type_result.get('requires_two_sizes', False)
|
||||
},
|
||||
|
||||
"schedule_info": {
|
||||
"schedule": schedule_result.get('schedule', 'UNKNOWN'),
|
||||
"schedule_number": schedule_result.get('schedule_number', ''),
|
||||
"wall_thickness": schedule_result.get('wall_thickness', ''),
|
||||
"pressure_class": schedule_result.get('pressure_class', ''),
|
||||
"confidence": schedule_result.get('confidence', 0.0)
|
||||
},
|
||||
|
||||
# 전체 신뢰도
|
||||
"fitting_type": fitting_type_result,
|
||||
"connection_method": connection_result,
|
||||
"pressure_rating": pressure_result,
|
||||
"schedule": schedule_result,
|
||||
"manufacturing": manufacturing_result,
|
||||
"overall_confidence": calculate_fitting_confidence({
|
||||
"material": material_result.get('confidence', 0),
|
||||
"fitting_type": fitting_type_result.get('confidence', 0),
|
||||
"connection": connection_result.get('confidence', 0),
|
||||
"pressure": pressure_result.get('confidence', 0)
|
||||
"material": material_result.get("confidence", 0),
|
||||
"fitting_type": fitting_type_result.get("confidence", 0),
|
||||
"connection": connection_result.get("confidence", 0),
|
||||
"pressure": pressure_result.get("confidence", 0)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
def analyze_size_pattern_for_fitting_type(description: str, main_nom: str, red_nom: str = None) -> Dict:
|
||||
"""
|
||||
실제 BOM 패턴 기반 TEE vs REDUCER 구분
|
||||
@@ -428,12 +386,28 @@ def classify_fitting_type(dat_file: str, description: str,
|
||||
dat_upper = dat_file.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)
|
||||
if size_pattern_result.get("confidence", 0) > 0.85:
|
||||
return size_pattern_result
|
||||
|
||||
# 1. DAT_FILE 패턴으로 1차 분류 (가장 신뢰도 높음)
|
||||
# 2. DAT_FILE 패턴으로 1차 분류 (가장 신뢰도 높음)
|
||||
for fitting_type, type_data in FITTING_TYPES.items():
|
||||
for pattern in type_data["dat_file_patterns"]:
|
||||
if pattern in dat_upper:
|
||||
@@ -450,7 +424,7 @@ def classify_fitting_type(dat_file: str, description: str,
|
||||
"requires_two_sizes": type_data.get("requires_two_sizes", False)
|
||||
}
|
||||
|
||||
# 2. DESCRIPTION 키워드로 2차 분류
|
||||
# 3. DESCRIPTION 키워드로 2차 분류
|
||||
for fitting_type, type_data in FITTING_TYPES.items():
|
||||
for keyword in type_data["description_keywords"]:
|
||||
if keyword in desc_upper:
|
||||
@@ -467,7 +441,7 @@ def classify_fitting_type(dat_file: str, description: str,
|
||||
"requires_two_sizes": type_data.get("requires_two_sizes", False)
|
||||
}
|
||||
|
||||
# 3. 분류 실패
|
||||
# 4. 분류 실패
|
||||
return {
|
||||
"type": "UNKNOWN",
|
||||
"subtype": "UNKNOWN",
|
||||
@@ -480,18 +454,77 @@ def classify_fitting_subtype(fitting_type: str, description: str,
|
||||
main_nom: str, red_nom: str, type_data: Dict) -> Dict:
|
||||
"""피팅 서브타입 분류"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
subtypes = type_data.get("subtypes", {})
|
||||
|
||||
# 1. 키워드 기반 서브타입 분류 (우선)
|
||||
# 1. 키워드 기반 서브타입 분류 (우선) - 대소문자 구분 없이
|
||||
for subtype, keywords in subtypes.items():
|
||||
for keyword in keywords:
|
||||
if keyword in description:
|
||||
if keyword.upper() in desc_upper:
|
||||
return {
|
||||
"subtype": subtype,
|
||||
"confidence": 0.9,
|
||||
"evidence": [f"SUBTYPE_KEYWORD: {keyword}"]
|
||||
}
|
||||
|
||||
# 1.5. ELBOW 특별 처리 - 조합 키워드 우선 확인
|
||||
if fitting_type == "ELBOW":
|
||||
# 90도 + 반경 조합
|
||||
if ("90" in desc_upper or "90°" in desc_upper or "90DEG" in desc_upper):
|
||||
if ("LR" in desc_upper or "LONG RADIUS" in desc_upper or "장반경" in desc_upper):
|
||||
return {
|
||||
"subtype": "90DEG_LONG_RADIUS",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["90DEG + LONG_RADIUS"]
|
||||
}
|
||||
elif ("SR" in desc_upper or "SHORT RADIUS" in desc_upper or "단반경" in desc_upper):
|
||||
return {
|
||||
"subtype": "90DEG_SHORT_RADIUS",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["90DEG + SHORT_RADIUS"]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"subtype": "90DEG",
|
||||
"confidence": 0.85,
|
||||
"evidence": ["90DEG_DETECTED"]
|
||||
}
|
||||
|
||||
# 45도 + 반경 조합
|
||||
elif ("45" in desc_upper or "45°" in desc_upper or "45DEG" in desc_upper):
|
||||
if ("LR" in desc_upper or "LONG RADIUS" in desc_upper or "장반경" in desc_upper):
|
||||
return {
|
||||
"subtype": "45DEG_LONG_RADIUS",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["45DEG + LONG_RADIUS"]
|
||||
}
|
||||
elif ("SR" in desc_upper or "SHORT RADIUS" in desc_upper or "단반경" in desc_upper):
|
||||
return {
|
||||
"subtype": "45DEG_SHORT_RADIUS",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["45DEG + SHORT_RADIUS"]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"subtype": "45DEG",
|
||||
"confidence": 0.85,
|
||||
"evidence": ["45DEG_DETECTED"]
|
||||
}
|
||||
|
||||
# 반경만 있는 경우 (기본 90도 가정)
|
||||
elif ("LR" in desc_upper or "LONG RADIUS" in desc_upper or "장반경" in desc_upper):
|
||||
return {
|
||||
"subtype": "90DEG_LONG_RADIUS",
|
||||
"confidence": 0.8,
|
||||
"evidence": ["LONG_RADIUS_DEFAULT_90DEG"]
|
||||
}
|
||||
elif ("SR" in desc_upper or "SHORT RADIUS" in desc_upper or "단반경" in desc_upper):
|
||||
return {
|
||||
"subtype": "90DEG_SHORT_RADIUS",
|
||||
"confidence": 0.8,
|
||||
"evidence": ["SHORT_RADIUS_DEFAULT_90DEG"]
|
||||
}
|
||||
|
||||
# 2. 사이즈 분석이 필요한 경우 (TEE, REDUCER 등)
|
||||
if type_data.get("size_analysis"):
|
||||
if red_nom and str(red_nom).strip() and red_nom != main_nom:
|
||||
|
||||
@@ -182,6 +182,14 @@ def classify_flange(dat_file: str, description: str, main_nom: str,
|
||||
dat_upper = dat_file.upper()
|
||||
|
||||
# 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']
|
||||
has_flange_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in flange_keywords)
|
||||
|
||||
|
||||
@@ -6,71 +6,14 @@
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from .fitting_classifier import classify_fitting
|
||||
|
||||
# Level 1: 명확한 타입 키워드 (최우선)
|
||||
LEVEL1_TYPE_KEYWORDS = {
|
||||
"BOLT": ["FLANGE BOLT", "U-BOLT", "U BOLT", "BOLT", "STUD", "NUT", "SCREW", "WASHER", "볼트", "너트", "스터드", "나사", "와셔", "유볼트"],
|
||||
"VALVE": ["VALVE", "GATE", "BALL", "GLOBE", "CHECK", "BUTTERFLY", "NEEDLE", "RELIEF", "밸브", "게이트", "볼", "글로브", "체크", "버터플라이", "니들", "릴리프"],
|
||||
"FLANGE": ["FLG", "FLANGE", "플랜지", "프랜지", "ORIFICE", "SPECTACLE", "PADDLE", "SPACER", "BLIND"],
|
||||
"PIPE": ["PIPE", "TUBE", "파이프", "배관", "SMLS", "SEAMLESS"],
|
||||
"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"]
|
||||
}
|
||||
from .classifier_constants import (
|
||||
LEVEL1_TYPE_KEYWORDS,
|
||||
LEVEL2_SUBTYPE_KEYWORDS,
|
||||
LEVEL3_CONNECTION_KEYWORDS,
|
||||
LEVEL3_PRESSURE_KEYWORDS,
|
||||
LEVEL4_MATERIAL_KEYWORDS,
|
||||
GENERIC_MATERIALS
|
||||
)
|
||||
|
||||
def classify_material_integrated(description: str, main_nom: str = "",
|
||||
red_nom: str = "", length: float = None) -> Dict:
|
||||
@@ -90,26 +33,61 @@ def classify_material_integrated(description: str, main_nom: str = "",
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 최우선: SPECIAL 키워드 확인 (도면 업로드가 필요한 특수 자재)
|
||||
special_keywords = ['SPECIAL', '스페셜', 'SPEC', 'SPL']
|
||||
for keyword in special_keywords:
|
||||
if keyword in desc_upper:
|
||||
return {
|
||||
"category": "SPECIAL",
|
||||
"confidence": 1.0,
|
||||
"evidence": [f"SPECIAL_KEYWORD: {keyword}"],
|
||||
"classification_level": "LEVEL0_SPECIAL",
|
||||
"reason": f"스페셜 키워드 발견: {keyword}"
|
||||
}
|
||||
|
||||
# U-BOLT 및 관련 부품 우선 확인 (BOLT 카테고리보다 먼저)
|
||||
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):
|
||||
# SPECIAL이 포함된 경우 (단, SPECIFICATION은 제외)
|
||||
if 'SPECIAL' in desc_upper and 'SPECIFICATION' not in desc_upper:
|
||||
return {
|
||||
"category": "U_BOLT",
|
||||
"category": "SPECIAL",
|
||||
"confidence": 1.0,
|
||||
"evidence": ["U_BOLT_SYSTEM_KEYWORD"],
|
||||
"classification_level": "LEVEL0_U_BOLT",
|
||||
"reason": "U-BOLT 시스템 키워드 발견"
|
||||
"evidence": ["SPECIAL_KEYWORD"],
|
||||
"classification_level": "LEVEL0_SPECIAL",
|
||||
"reason": "SPECIAL 키워드 발견"
|
||||
}
|
||||
|
||||
# 스페셜 관련 한글 키워드
|
||||
if '스페셜' in desc_upper or 'SPL' in desc_upper:
|
||||
return {
|
||||
"category": "SPECIAL",
|
||||
"confidence": 1.0,
|
||||
"evidence": ["SPECIAL_KEYWORD"],
|
||||
"classification_level": "LEVEL0_SPECIAL",
|
||||
"reason": "스페셜 키워드 발견"
|
||||
}
|
||||
|
||||
|
||||
# VALVE 카테고리 우선 확인 (SIGHT GLASS, STRAINER)
|
||||
if ('SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or
|
||||
'사이트글라스' in desc_upper or '스트레이너' in desc_upper):
|
||||
return {
|
||||
"category": "VALVE",
|
||||
"confidence": 1.0,
|
||||
"evidence": ["VALVE_SPECIAL_KEYWORD"],
|
||||
"classification_level": "LEVEL0_VALVE",
|
||||
"reason": "SIGHT GLASS 또는 STRAINER 키워드 발견"
|
||||
}
|
||||
|
||||
# SUPPORT 카테고리 우선 확인 (BOLT 카테고리보다 먼저)
|
||||
# U-BOLT, CLAMP, URETHANE BLOCK 등
|
||||
if ('U-BOLT' in desc_upper or 'U BOLT' in desc_upper or '유볼트' in desc_upper or
|
||||
'URETHANE BLOCK' in desc_upper or 'BLOCK SHOE' in desc_upper or '우레탄' in desc_upper or
|
||||
'CLAMP' in desc_upper or '클램프' in desc_upper or 'PIPE CLAMP' in desc_upper):
|
||||
return {
|
||||
"category": "SUPPORT",
|
||||
"confidence": 1.0,
|
||||
"evidence": ["SUPPORT_SYSTEM_KEYWORD"],
|
||||
"classification_level": "LEVEL0_SUPPORT",
|
||||
"reason": "SUPPORT 시스템 키워드 발견"
|
||||
}
|
||||
|
||||
# [신규] Swagelok 스타일 파트 넘버 패턴 확인
|
||||
# 예: SS-400-1-4, SS-810-6, B-400-9, SS-1610-P
|
||||
swagelok_pattern = r'\b(SS|S|B|A|M)-([0-9]{3,4}|[0-9]+M[0-9]*)-([0-9A-Z])'
|
||||
if re.search(swagelok_pattern, desc_upper):
|
||||
return {
|
||||
"category": "TUBE_FITTING",
|
||||
"confidence": 0.98,
|
||||
"evidence": ["SWAGELOK_PART_NO"],
|
||||
"classification_level": "LEVEL0_PARTNO",
|
||||
"reason": "Swagelok 스타일 파트넘버 감지"
|
||||
}
|
||||
|
||||
# 쉼표로 구분된 각 부분을 별도로 체크 (예: "NIPPLE, SMLS, SCH 80")
|
||||
@@ -117,24 +95,81 @@ def classify_material_integrated(description: str, main_nom: str = "",
|
||||
|
||||
# 1단계: Level 1 키워드로 타입 식별
|
||||
detected_types = []
|
||||
for material_type, keywords in LEVEL1_TYPE_KEYWORDS.items():
|
||||
type_found = False
|
||||
# 긴 키워드부터 확인 (FLANGE BOLT가 FLANGE보다 먼저 매칭되도록)
|
||||
sorted_keywords = sorted(keywords, key=len, reverse=True)
|
||||
for keyword in sorted_keywords:
|
||||
# 전체 문자열에서 찾기
|
||||
if keyword in desc_upper:
|
||||
detected_types.append((material_type, keyword))
|
||||
type_found = True
|
||||
break
|
||||
# 각 부분에서도 정확히 매칭되는지 확인
|
||||
for part in desc_parts:
|
||||
if keyword == part or keyword in part:
|
||||
|
||||
# 특별 우선순위: REDUCING FLANGE 먼저 확인 (강화된 로직)
|
||||
reducing_flange_patterns = [
|
||||
"REDUCING FLANGE", "RED FLANGE", "REDUCER FLANGE",
|
||||
"REDUCING FLG", "RED FLG", "REDUCER FLG"
|
||||
]
|
||||
|
||||
# FLANGE와 REDUCING/RED/REDUCER가 함께 있는 경우도 확인
|
||||
has_flange = any(flange_word in desc_upper for flange_word in ["FLANGE", "FLG"])
|
||||
has_reducing = any(red_word in desc_upper for red_word in ["REDUCING", "RED", "REDUCER"])
|
||||
|
||||
# 직접 패턴 매칭 또는 FLANGE + REDUCING 조합
|
||||
reducing_flange_detected = False
|
||||
for pattern in reducing_flange_patterns:
|
||||
if pattern in desc_upper:
|
||||
detected_types.append(("FLANGE", "REDUCING FLANGE"))
|
||||
reducing_flange_detected = True
|
||||
break
|
||||
|
||||
# FLANGE와 REDUCING이 모두 있으면 REDUCING FLANGE로 분류
|
||||
if not reducing_flange_detected and has_flange and has_reducing:
|
||||
detected_types.append(("FLANGE", "REDUCING FLANGE"))
|
||||
reducing_flange_detected = True
|
||||
|
||||
# REDUCING FLANGE가 감지되지 않은 경우에만 일반 키워드 검사
|
||||
if not reducing_flange_detected:
|
||||
for material_type, keywords in LEVEL1_TYPE_KEYWORDS.items():
|
||||
type_found = False
|
||||
# 긴 키워드부터 확인 (FLANGE BOLT가 FLANGE보다 먼저 매칭되도록)
|
||||
sorted_keywords = sorted(keywords, key=len, reverse=True)
|
||||
for keyword in sorted_keywords:
|
||||
# [강화된 로직] 짧은 키워드나 중의적 키워드에 대한 엄격한 검사
|
||||
is_strict_match = True
|
||||
|
||||
# 1. "PL" 키워드 검사 (PLATE)
|
||||
if keyword == "PL":
|
||||
# 단독 단어이거나 숫자 뒤에 붙는 경우만 허용 (예: 10PL, 10 PL)
|
||||
# COUPLING, NIPPLE, PLUG 등에 포함된 PL은 제외
|
||||
pl_pattern = r'(\b|\d)PL\b'
|
||||
if not re.search(pl_pattern, desc_upper):
|
||||
is_strict_match = False
|
||||
|
||||
# 2. "ANGLE" 키워드 검사 (STRUCTURAL)
|
||||
elif keyword == "ANGLE" or keyword == "앵글":
|
||||
# VALVE와 함께 쓰이면 제외 (ANGLE VALVE)
|
||||
if "VALVE" in desc_upper or "밸브" in desc_upper:
|
||||
is_strict_match = False
|
||||
|
||||
# 3. "UNION" 키워드 검사 (FITTING)
|
||||
elif keyword == "UNION":
|
||||
# 계장용인지 파이프용인지 구분은 fitting_classifier에서 하되,
|
||||
# 여기서는 일단 FITTING으로 잡히도록 둠.
|
||||
pass
|
||||
|
||||
# 4. "BEAM" 키워드 검사 (STRUCTURAL)
|
||||
elif keyword == "BEAM":
|
||||
# "BEAM CLAMP" 같은 경우 SUPPORT로 가야 함 (SUPPORT가 우선순위 높으므로 괜찮음)
|
||||
pass
|
||||
|
||||
if not is_strict_match:
|
||||
continue
|
||||
|
||||
# 전체 문자열에서 찾기
|
||||
if keyword in desc_upper:
|
||||
detected_types.append((material_type, keyword))
|
||||
type_found = True
|
||||
break
|
||||
if type_found:
|
||||
break
|
||||
# 각 부분에서도 정확히 매칭되는지 확인
|
||||
for part in desc_parts:
|
||||
if keyword == part or keyword in part:
|
||||
detected_types.append((material_type, keyword))
|
||||
type_found = True
|
||||
break
|
||||
if type_found:
|
||||
break
|
||||
|
||||
# 2단계: 복수 타입 감지 시 Level 2로 구체화
|
||||
if len(detected_types) > 1:
|
||||
@@ -247,7 +282,7 @@ def classify_material_integrated(description: str, main_nom: str = "",
|
||||
|
||||
# 분류 실패
|
||||
return {
|
||||
"category": "UNKNOWN",
|
||||
"category": "UNCLASSIFIED",
|
||||
"confidence": 0.0,
|
||||
"evidence": ["NO_CLASSIFICATION_POSSIBLE"],
|
||||
"classification_level": "NONE"
|
||||
@@ -263,4 +298,4 @@ def should_exclude_material(description: str) -> bool:
|
||||
]
|
||||
|
||||
desc_upper = description.upper()
|
||||
return any(keyword in desc_upper for keyword in exclude_keywords)
|
||||
return any(keyword in desc_upper for keyword in exclude_keywords)
|
||||
592
backend/app/services/material_service.py
Normal file
592
backend/app/services/material_service.py
Normal file
@@ -0,0 +1,592 @@
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List, Dict, Optional
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from app.services.integrated_classifier import classify_material_integrated, should_exclude_material
|
||||
from app.services.bolt_classifier import classify_bolt
|
||||
from app.services.flange_classifier import classify_flange
|
||||
from app.services.fitting_classifier import classify_fitting
|
||||
from app.services.gasket_classifier import classify_gasket
|
||||
from app.services.instrument_classifier import classify_instrument
|
||||
from app.services.valve_classifier import classify_valve
|
||||
from app.services.support_classifier import classify_support
|
||||
from app.services.plate_classifier import classify_plate
|
||||
from app.services.structural_classifier import classify_structural
|
||||
from app.services.pipe_classifier import classify_pipe_for_purchase, extract_end_preparation_info
|
||||
from app.services.material_grade_extractor import extract_full_material_grade
|
||||
|
||||
class MaterialService:
|
||||
"""자재 처리 및 저장을 담당하는 서비스"""
|
||||
|
||||
@staticmethod
|
||||
def process_and_save_materials(
|
||||
db: Session,
|
||||
file_id: int,
|
||||
materials_data: List[Dict],
|
||||
revision_comparison: Optional[Dict] = None,
|
||||
parent_file_id: Optional[int] = None,
|
||||
purchased_materials_map: Optional[Dict] = None
|
||||
) -> int:
|
||||
"""
|
||||
자재 목록을 분류하고 DB에 저장합니다.
|
||||
|
||||
Args:
|
||||
db: DB 세션
|
||||
file_id: 파일 ID
|
||||
materials_data: 파싱된 자재 데이터 목록
|
||||
revision_comparison: 리비전 비교 결과
|
||||
parent_file_id: 이전 리비전 파일 ID
|
||||
purchased_materials_map: 구매 확정된 자재 매핑 정보
|
||||
|
||||
Returns:
|
||||
저장된 자재 수
|
||||
"""
|
||||
materials_inserted = 0
|
||||
|
||||
# 변경/신규 자재 키 집합 (리비전 추적용)
|
||||
changed_materials_keys = set()
|
||||
new_materials_keys = set()
|
||||
|
||||
# 리비전 업로드인 경우 변경사항 분석
|
||||
if parent_file_id is not None:
|
||||
MaterialService._analyze_changes(
|
||||
db, parent_file_id, materials_data,
|
||||
changed_materials_keys, new_materials_keys
|
||||
)
|
||||
|
||||
# 변경 없는 자재 (확정된 자재) 먼저 처리
|
||||
if revision_comparison and revision_comparison.get("has_previous_confirmation", False):
|
||||
unchanged_materials = revision_comparison.get("unchanged_materials", [])
|
||||
for material_data in unchanged_materials:
|
||||
MaterialService._save_unchanged_material(db, file_id, material_data)
|
||||
materials_inserted += 1
|
||||
|
||||
# 분류가 필요한 자재 처리 (신규 또는 변경된 자재)
|
||||
# revision_comparison에서 필터링된 목록이 있으면 그것을 사용, 아니면 전체
|
||||
materials_to_classify = materials_data
|
||||
if revision_comparison and revision_comparison.get("materials_to_classify"):
|
||||
materials_to_classify = revision_comparison.get("materials_to_classify")
|
||||
|
||||
print(f"🔧 자재 분류 및 저장 시작: {len(materials_to_classify)}개")
|
||||
|
||||
for material_data in materials_to_classify:
|
||||
MaterialService._classify_and_save_single_material(
|
||||
db, file_id, material_data,
|
||||
changed_materials_keys, new_materials_keys,
|
||||
purchased_materials_map
|
||||
)
|
||||
materials_inserted += 1
|
||||
|
||||
return materials_inserted
|
||||
|
||||
@staticmethod
|
||||
def _analyze_changes(db: Session, parent_file_id: int, materials_data: List[Dict],
|
||||
changed_keys: set, new_keys: set):
|
||||
"""이전 리비전과 비교하여 변경/신규 자재를 식별합니다."""
|
||||
try:
|
||||
prev_materials_query = text("""
|
||||
SELECT original_description, size_spec, material_grade, main_nom,
|
||||
drawing_name, line_no, quantity
|
||||
FROM materials
|
||||
WHERE file_id = :parent_file_id
|
||||
""")
|
||||
prev_materials = db.execute(prev_materials_query, {"parent_file_id": parent_file_id}).fetchall()
|
||||
|
||||
prev_dict = {}
|
||||
for pm in prev_materials:
|
||||
key = MaterialService._generate_material_key(
|
||||
pm.drawing_name, pm.line_no, pm.original_description,
|
||||
pm.size_spec, pm.material_grade
|
||||
)
|
||||
prev_dict[key] = float(pm.quantity) if pm.quantity else 0
|
||||
|
||||
for mat in materials_data:
|
||||
new_key = MaterialService._generate_material_key(
|
||||
mat.get("dwg_name"), mat.get("line_num"), mat["original_description"],
|
||||
mat.get("size_spec"), mat.get("material_grade")
|
||||
)
|
||||
|
||||
if new_key in prev_dict:
|
||||
if abs(prev_dict[new_key] - float(mat.get("quantity", 0))) > 0.001:
|
||||
changed_keys.add(new_key)
|
||||
else:
|
||||
new_keys.add(new_key)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 변경사항 분석 실패: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _generate_material_key(dwg, line, desc, size, grade):
|
||||
"""자재 고유 키 생성"""
|
||||
parts = []
|
||||
if dwg: parts.append(str(dwg))
|
||||
elif line: parts.append(str(line))
|
||||
|
||||
parts.append(str(desc))
|
||||
parts.append(str(size or ''))
|
||||
parts.append(str(grade or ''))
|
||||
return "|".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _save_unchanged_material(db: Session, file_id: int, material_data: Dict):
|
||||
"""변경 없는(확정된) 자재 저장"""
|
||||
previous_item = material_data.get("previous_item", {})
|
||||
|
||||
query = text("""
|
||||
INSERT INTO materials (
|
||||
file_id, original_description, classified_category, confidence,
|
||||
quantity, unit, size_spec, material_grade, specification,
|
||||
reused_from_confirmation, created_at
|
||||
) VALUES (
|
||||
:file_id, :desc, :category, 1.0,
|
||||
:qty, :unit, :size, :grade, :spec,
|
||||
TRUE, :created_at
|
||||
)
|
||||
""")
|
||||
|
||||
db.execute(query, {
|
||||
"file_id": file_id,
|
||||
"desc": material_data["original_description"],
|
||||
"category": previous_item.get("category", "UNCLASSIFIED"),
|
||||
"qty": material_data["quantity"],
|
||||
"unit": material_data.get("unit", "EA"),
|
||||
"size": material_data.get("size_spec", ""),
|
||||
"grade": previous_item.get("material", ""),
|
||||
"spec": previous_item.get("specification", ""),
|
||||
"created_at": datetime.now()
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _classify_and_save_single_material(
|
||||
db: Session, file_id: int, material_data: Dict,
|
||||
changed_keys: set, new_keys: set, purchased_map: Optional[Dict]
|
||||
):
|
||||
"""단일 자재 분류 및 저장 (상세 정보 포함)"""
|
||||
description = material_data["original_description"]
|
||||
main_nom = material_data.get("main_nom", "")
|
||||
red_nom = material_data.get("red_nom", "")
|
||||
length_val = material_data.get("length")
|
||||
|
||||
# 1. 통합 분류
|
||||
integrated_result = classify_material_integrated(description, main_nom, red_nom, length_val)
|
||||
classification_result = integrated_result
|
||||
|
||||
# 2. 상세 분류
|
||||
if not should_exclude_material(description):
|
||||
category = integrated_result.get('category')
|
||||
if category == "PIPE":
|
||||
classification_result = classify_pipe_for_purchase("", description, main_nom, length_val)
|
||||
elif category == "FITTING":
|
||||
classification_result = classify_fitting("", description, main_nom, red_nom)
|
||||
elif category == "FLANGE":
|
||||
classification_result = classify_flange("", description, main_nom, red_nom)
|
||||
elif category == "VALVE":
|
||||
classification_result = classify_valve("", description, main_nom)
|
||||
elif category == "BOLT":
|
||||
classification_result = classify_bolt("", description, main_nom)
|
||||
elif category == "GASKET":
|
||||
classification_result = classify_gasket("", description, main_nom)
|
||||
elif category == "INSTRUMENT":
|
||||
classification_result = classify_instrument("", description, main_nom)
|
||||
elif category == "SUPPORT":
|
||||
classification_result = classify_support("", description, main_nom)
|
||||
elif category == "PLATE":
|
||||
classification_result = classify_plate("", description, main_nom)
|
||||
elif category == "STRUCTURAL":
|
||||
classification_result = classify_structural("", description, main_nom)
|
||||
|
||||
# 신뢰도 조정
|
||||
if integrated_result.get('confidence', 0) < 0.5:
|
||||
classification_result['overall_confidence'] = min(
|
||||
classification_result.get('overall_confidence', 1.0),
|
||||
integrated_result.get('confidence', 0.0) + 0.2
|
||||
)
|
||||
else:
|
||||
classification_result = {"category": "EXCLUDE", "overall_confidence": 0.95}
|
||||
|
||||
# 3. 구매 확정 정보 상속 확인
|
||||
is_purchase_confirmed = False
|
||||
purchase_confirmed_at = None
|
||||
purchase_confirmed_by = None
|
||||
|
||||
if purchased_map:
|
||||
key = f"{description.strip().upper()}|{material_data.get('size_spec', '')}"
|
||||
if key in purchased_map:
|
||||
info = purchased_map[key]
|
||||
is_purchase_confirmed = True
|
||||
purchase_confirmed_at = info.get("purchase_confirmed_at")
|
||||
purchase_confirmed_by = info.get("purchase_confirmed_by")
|
||||
|
||||
# 4. 자재 기본 정보 저장
|
||||
full_grade = extract_full_material_grade(description) or material_data.get("material_grade", "")
|
||||
|
||||
insert_query = text("""
|
||||
INSERT INTO materials (
|
||||
file_id, original_description, quantity, unit, size_spec,
|
||||
main_nom, red_nom, material_grade, full_material_grade, line_number, row_number,
|
||||
classified_category, classification_confidence, is_verified,
|
||||
drawing_name, line_no, created_at,
|
||||
purchase_confirmed, purchase_confirmed_at, purchase_confirmed_by,
|
||||
revision_status
|
||||
) VALUES (
|
||||
:file_id, :desc, :qty, :unit, :size,
|
||||
:main, :red, :grade, :full_grade, :line_num, :row_num,
|
||||
:category, :confidence, :verified,
|
||||
:dwg, :line, :created_at,
|
||||
:confirmed, :confirmed_at, :confirmed_by,
|
||||
:status
|
||||
) RETURNING id
|
||||
""")
|
||||
|
||||
# 리비전 상태 결정
|
||||
mat_key = MaterialService._generate_material_key(
|
||||
material_data.get("dwg_name"), material_data.get("line_num"), description,
|
||||
material_data.get("size_spec"), material_data.get("material_grade")
|
||||
)
|
||||
rev_status = 'changed' if mat_key in changed_keys else ('active' if mat_key in new_keys else None)
|
||||
|
||||
result = db.execute(insert_query, {
|
||||
"file_id": file_id,
|
||||
"desc": description,
|
||||
"qty": material_data["quantity"],
|
||||
"unit": material_data["unit"],
|
||||
"size": material_data.get("size_spec", ""),
|
||||
"main": main_nom,
|
||||
"red": red_nom,
|
||||
"grade": material_data.get("material_grade", ""),
|
||||
"full_grade": full_grade,
|
||||
"line_num": material_data.get("line_number"),
|
||||
"row_num": material_data.get("row_number"),
|
||||
"category": classification_result.get("category", "UNCLASSIFIED"),
|
||||
"confidence": classification_result.get("overall_confidence", 0.0),
|
||||
"verified": False,
|
||||
"dwg": material_data.get("dwg_name"),
|
||||
"line": material_data.get("line_num"),
|
||||
"created_at": datetime.now(),
|
||||
"confirmed": is_purchase_confirmed,
|
||||
"confirmed_at": purchase_confirmed_at,
|
||||
"confirmed_by": purchase_confirmed_by,
|
||||
"status": rev_status
|
||||
})
|
||||
|
||||
material_id = result.fetchone()[0]
|
||||
|
||||
# 5. 상세 정보 저장 (별도 메서드로 분리)
|
||||
MaterialService._save_material_details(
|
||||
db, material_id, file_id, classification_result, material_data
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _save_material_details(db: Session, material_id: int, file_id: int,
|
||||
result: Dict, data: Dict):
|
||||
"""카테고리별 상세 정보 저장"""
|
||||
category = result.get("category")
|
||||
|
||||
if category == "PIPE":
|
||||
MaterialService._save_pipe_details(db, material_id, file_id, result, data)
|
||||
elif category == "FITTING":
|
||||
MaterialService._save_fitting_details(db, material_id, file_id, result, data)
|
||||
elif category == "FLANGE":
|
||||
MaterialService._save_flange_details(db, material_id, file_id, result, data)
|
||||
elif category == "BOLT":
|
||||
MaterialService._save_bolt_details(db, material_id, file_id, result, data)
|
||||
elif category == "VALVE":
|
||||
MaterialService._save_valve_details(db, material_id, file_id, result, data)
|
||||
elif category == "GASKET":
|
||||
MaterialService._save_gasket_details(db, material_id, file_id, result, data)
|
||||
elif category == "SUPPORT":
|
||||
MaterialService._save_support_details(db, material_id, file_id, result, data)
|
||||
elif category == "PLATE":
|
||||
MaterialService._save_plate_details(db, material_id, file_id, result, data)
|
||||
elif category == "STRUCTURAL":
|
||||
MaterialService._save_structural_details(db, material_id, file_id, result, data)
|
||||
|
||||
@staticmethod
|
||||
def _save_plate_details(db: Session, mid: int, fid: int, res: Dict, data: Dict):
|
||||
"""판재 정보 업데이트 (현재는 materials 테이블에 요약 정보 저장)"""
|
||||
details = res.get("details", {})
|
||||
spec = f"{details.get('thickness')}T x {details.get('dimensions')}"
|
||||
db.execute(text("""
|
||||
UPDATE materials
|
||||
SET size_spec = :size, material_grade = :mat
|
||||
WHERE id = :id
|
||||
"""), {"size": spec, "mat": details.get("material"), "id": mid})
|
||||
|
||||
@staticmethod
|
||||
def _save_structural_details(db: Session, mid: int, fid: int, res: Dict, data: Dict):
|
||||
"""형강 정보 업데이트 (현재는 materials 테이블에 요약 정보 저장)"""
|
||||
details = res.get("details", {})
|
||||
spec = f"{details.get('type')} {details.get('dimension')}"
|
||||
db.execute(text("""
|
||||
UPDATE materials
|
||||
SET size_spec = :size
|
||||
WHERE id = :id
|
||||
"""), {"size": spec, "id": mid})
|
||||
|
||||
# --- 각 카테고리별 상세 저장 메서드들 (기존 로직 이관) ---
|
||||
|
||||
@staticmethod
|
||||
def inherit_purchase_requests(db: Session, current_file_id: int, parent_file_id: int):
|
||||
"""이전 리비전의 구매신청 정보를 상속합니다."""
|
||||
try:
|
||||
print(f"🔄 구매신청 정보 상속 처리 시작...")
|
||||
|
||||
# 1. 이전 리비전에서 그룹별 구매신청 수량 집계
|
||||
prev_purchase_summary = text("""
|
||||
SELECT
|
||||
m.original_description,
|
||||
m.size_spec,
|
||||
m.material_grade,
|
||||
m.drawing_name,
|
||||
COUNT(DISTINCT pri.material_id) as purchased_count,
|
||||
SUM(pri.quantity) as total_purchased_qty,
|
||||
MIN(pri.request_id) as request_id
|
||||
FROM materials m
|
||||
JOIN purchase_request_items pri ON m.id = pri.material_id
|
||||
WHERE m.file_id = :parent_file_id
|
||||
GROUP BY m.original_description, m.size_spec, m.material_grade, m.drawing_name
|
||||
""")
|
||||
|
||||
prev_purchases = db.execute(prev_purchase_summary, {"parent_file_id": parent_file_id}).fetchall()
|
||||
|
||||
# 2. 새 리비전에서 같은 그룹의 자재에 수량만큼 구매신청 상속
|
||||
for prev_purchase in prev_purchases:
|
||||
purchased_count = prev_purchase.purchased_count
|
||||
|
||||
# 새 리비전에서 같은 그룹의 자재 조회 (순서대로)
|
||||
new_group_materials = text("""
|
||||
SELECT id, quantity
|
||||
FROM materials
|
||||
WHERE file_id = :file_id
|
||||
AND original_description = :description
|
||||
AND COALESCE(size_spec, '') = :size_spec
|
||||
AND COALESCE(material_grade, '') = :material_grade
|
||||
AND COALESCE(drawing_name, '') = :drawing_name
|
||||
ORDER BY id
|
||||
LIMIT :limit
|
||||
""")
|
||||
|
||||
new_materials = db.execute(new_group_materials, {
|
||||
"file_id": current_file_id,
|
||||
"description": prev_purchase.original_description,
|
||||
"size_spec": prev_purchase.size_spec or '',
|
||||
"material_grade": prev_purchase.material_grade or '',
|
||||
"drawing_name": prev_purchase.drawing_name or '',
|
||||
"limit": purchased_count
|
||||
}).fetchall()
|
||||
|
||||
# 구매신청 수량만큼만 상속
|
||||
for new_mat in new_materials:
|
||||
inherit_query = text("""
|
||||
INSERT INTO purchase_request_items (
|
||||
request_id, material_id, quantity, unit, user_requirement
|
||||
) VALUES (
|
||||
:request_id, :material_id, :quantity, 'EA', ''
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
""")
|
||||
db.execute(inherit_query, {
|
||||
"request_id": prev_purchase.request_id,
|
||||
"material_id": new_mat.id,
|
||||
"quantity": new_mat.quantity
|
||||
})
|
||||
|
||||
inherited_count = len(new_materials)
|
||||
if inherited_count > 0:
|
||||
print(f" ✅ {prev_purchase.original_description[:30]}... (도면: {prev_purchase.drawing_name or 'N/A'}) → {inherited_count}/{purchased_count}개 상속")
|
||||
|
||||
# 커밋은 호출하는 쪽에서 일괄 처리하거나 여기서 처리
|
||||
# db.commit()
|
||||
print(f"✅ 구매신청 정보 상속 완료")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 구매신청 정보 상속 실패: {str(e)}")
|
||||
# 상속 실패는 전체 프로세스를 중단하지 않음
|
||||
|
||||
@staticmethod
|
||||
def _save_pipe_details(db, mid, fid, res, data):
|
||||
# PIPE 상세 저장 로직
|
||||
end_prep_info = extract_end_preparation_info(data["original_description"])
|
||||
|
||||
# 1. End Prep 정보 저장
|
||||
db.execute(text("""
|
||||
INSERT INTO pipe_end_preparations (
|
||||
material_id, file_id, end_preparation_type, end_preparation_code,
|
||||
machining_required, cutting_note, original_description, confidence
|
||||
) VALUES (
|
||||
:mid, :fid, :type, :code, :req, :note, :desc, :conf
|
||||
)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": end_prep_info["end_preparation_type"],
|
||||
"code": end_prep_info["end_preparation_code"],
|
||||
"req": end_prep_info["machining_required"],
|
||||
"note": end_prep_info["cutting_note"],
|
||||
"desc": end_prep_info["original_description"],
|
||||
"conf": end_prep_info["confidence"]
|
||||
})
|
||||
|
||||
# 2. Pipe Details 저장
|
||||
length_info = res.get("length_info", {})
|
||||
length_mm = length_info.get("length_mm") or data.get("length", 0.0)
|
||||
|
||||
mat_info = res.get("material", {})
|
||||
sch_info = res.get("schedule", {})
|
||||
|
||||
# 재질 정보 업데이트
|
||||
if mat_info.get("grade"):
|
||||
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
|
||||
{"g": mat_info.get("grade"), "id": mid})
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO pipe_details (
|
||||
material_id, file_id, outer_diameter, schedule,
|
||||
material_spec, manufacturing_method, length_mm
|
||||
) VALUES (:mid, :fid, :od, :sch, :spec, :method, :len)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"od": data.get("main_nom") or data.get("size_spec"),
|
||||
"sch": sch_info.get("schedule", "UNKNOWN") if isinstance(sch_info, dict) else str(sch_info),
|
||||
"spec": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
|
||||
"method": res.get("manufacturing", {}).get("method", "UNKNOWN") if isinstance(res.get("manufacturing"), dict) else "UNKNOWN",
|
||||
"len": length_mm or 0.0
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_fitting_details(db, mid, fid, res, data):
|
||||
fit_type = res.get("fitting_type", {})
|
||||
mat_info = res.get("material", {})
|
||||
|
||||
if mat_info.get("grade"):
|
||||
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
|
||||
{"g": mat_info.get("grade"), "id": mid})
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO fitting_details (
|
||||
material_id, file_id, fitting_type, fitting_subtype,
|
||||
connection_method, pressure_rating, material_grade,
|
||||
main_size, reduced_size
|
||||
) VALUES (:mid, :fid, :type, :subtype, :conn, :rating, :grade, :main, :red)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": fit_type.get("type", "UNKNOWN") if isinstance(fit_type, dict) else str(fit_type),
|
||||
"subtype": fit_type.get("subtype", "UNKNOWN") if isinstance(fit_type, dict) else "UNKNOWN",
|
||||
"conn": res.get("connection_method", {}).get("method", "UNKNOWN") if isinstance(res.get("connection_method"), dict) else "UNKNOWN",
|
||||
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
|
||||
"grade": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
|
||||
"main": data.get("main_nom") or data.get("size_spec"),
|
||||
"red": data.get("red_nom", "")
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_flange_details(db, mid, fid, res, data):
|
||||
flg_type = res.get("flange_type", {})
|
||||
mat_info = res.get("material", {})
|
||||
|
||||
if mat_info.get("grade"):
|
||||
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
|
||||
{"g": mat_info.get("grade"), "id": mid})
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO flange_details (
|
||||
material_id, file_id, flange_type, pressure_rating,
|
||||
facing_type, material_grade, size_inches
|
||||
) VALUES (:mid, :fid, :type, :rating, :face, :grade, :size)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": flg_type.get("type", "UNKNOWN") if isinstance(flg_type, dict) else str(flg_type),
|
||||
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
|
||||
"face": res.get("face_finish", {}).get("finish", "UNKNOWN") if isinstance(res.get("face_finish"), dict) else "UNKNOWN",
|
||||
"grade": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
|
||||
"size": data.get("main_nom") or data.get("size_spec")
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_bolt_details(db, mid, fid, res, data):
|
||||
fast_type = res.get("fastener_type", {})
|
||||
mat_info = res.get("material", {})
|
||||
dim_info = res.get("dimensions", {})
|
||||
|
||||
if mat_info.get("grade"):
|
||||
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
|
||||
{"g": mat_info.get("grade"), "id": mid})
|
||||
|
||||
# 볼트 타입 결정 (특수 용도 고려)
|
||||
bolt_type = fast_type.get("type", "UNKNOWN") if isinstance(fast_type, dict) else str(fast_type)
|
||||
special_apps = res.get("special_applications", {}).get("detected_applications", [])
|
||||
if "LT" in special_apps: bolt_type = "LT_BOLT"
|
||||
elif "PSV" in special_apps: bolt_type = "PSV_BOLT"
|
||||
|
||||
# 코팅 타입
|
||||
desc_upper = data["original_description"].upper()
|
||||
coating = "UNKNOWN"
|
||||
if "GALV" in desc_upper: coating = "GALVANIZED"
|
||||
elif "ZINC" in desc_upper: coating = "ZINC_PLATED"
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO bolt_details (
|
||||
material_id, file_id, bolt_type, thread_type,
|
||||
diameter, length, material_grade, coating_type
|
||||
) VALUES (:mid, :fid, :type, :thread, :dia, :len, :grade, :coating)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": bolt_type,
|
||||
"thread": res.get("thread_specification", {}).get("standard", "UNKNOWN") if isinstance(res.get("thread_specification"), dict) else "UNKNOWN",
|
||||
"dia": dim_info.get("nominal_size", data.get("main_nom", "")),
|
||||
"len": dim_info.get("length", ""),
|
||||
"grade": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
|
||||
"coating": coating
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_valve_details(db, mid, fid, res, data):
|
||||
val_type = res.get("valve_type", {})
|
||||
mat_info = res.get("material", {})
|
||||
|
||||
if mat_info.get("grade"):
|
||||
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
|
||||
{"g": mat_info.get("grade"), "id": mid})
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO valve_details (
|
||||
material_id, file_id, valve_type, connection_method,
|
||||
pressure_rating, body_material, size_inches
|
||||
) VALUES (:mid, :fid, :type, :conn, :rating, :body, :size)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": val_type.get("type", "UNKNOWN") if isinstance(val_type, dict) else str(val_type),
|
||||
"conn": res.get("connection_method", {}).get("method", "UNKNOWN") if isinstance(res.get("connection_method"), dict) else "UNKNOWN",
|
||||
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
|
||||
"body": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
|
||||
"size": data.get("main_nom") or data.get("size_spec")
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_gasket_details(db, mid, fid, res, data):
|
||||
gask_type = res.get("gasket_type", {})
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO gasket_details (
|
||||
material_id, file_id, gasket_type, pressure_rating, size_inches
|
||||
) VALUES (:mid, :fid, :type, :rating, :size)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": gask_type.get("type", "UNKNOWN") if isinstance(gask_type, dict) else str(gask_type),
|
||||
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
|
||||
"size": data.get("main_nom") or data.get("size_spec")
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_support_details(db, mid, fid, res, data):
|
||||
db.execute(text("""
|
||||
INSERT INTO support_details (
|
||||
material_id, file_id, support_type, pipe_size
|
||||
) VALUES (:mid, :fid, :type, :size)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": res.get("support_type", "UNKNOWN"),
|
||||
"size": res.get("size_info", {}).get("pipe_size", "")
|
||||
})
|
||||
@@ -7,6 +7,60 @@ import re
|
||||
from typing import Dict, List, Optional
|
||||
from .material_classifier import classify_material, get_manufacturing_method_from_material
|
||||
|
||||
# ========== PIPE USER 요구사항 키워드 ==========
|
||||
PIPE_USER_REQUIREMENTS = {
|
||||
"IMPACT_TEST": {
|
||||
"keywords": ["IMPACT", "충격시험", "CHARPY", "CVN", "IMPACT TEST", "충격", "NOTCH"],
|
||||
"description": "충격시험 요구",
|
||||
"confidence": 0.95
|
||||
},
|
||||
"ASME_CODE": {
|
||||
"keywords": ["ASME", "ASME CODE", "CODE", "B31.1", "B31.3", "B31.4", "B31.8", "VIII"],
|
||||
"description": "ASME 코드 준수",
|
||||
"confidence": 0.95
|
||||
},
|
||||
"STRESS_RELIEF": {
|
||||
"keywords": ["STRESS RELIEF", "SR", "응력제거", "열처리", "HEAT TREATMENT"],
|
||||
"description": "응력제거 열처리",
|
||||
"confidence": 0.90
|
||||
},
|
||||
"RADIOGRAPHIC_TEST": {
|
||||
"keywords": ["RT", "RADIOGRAPHIC", "방사선시험", "X-RAY", "엑스레이"],
|
||||
"description": "방사선 시험",
|
||||
"confidence": 0.90
|
||||
},
|
||||
"ULTRASONIC_TEST": {
|
||||
"keywords": ["UT", "ULTRASONIC", "초음파시험", "초음파"],
|
||||
"description": "초음파 시험",
|
||||
"confidence": 0.90
|
||||
},
|
||||
"MAGNETIC_PARTICLE": {
|
||||
"keywords": ["MT", "MAGNETIC PARTICLE", "자분탐상", "자분"],
|
||||
"description": "자분탐상 시험",
|
||||
"confidence": 0.90
|
||||
},
|
||||
"LIQUID_PENETRANT": {
|
||||
"keywords": ["PT", "LIQUID PENETRANT", "침투탐상", "침투"],
|
||||
"description": "침투탐상 시험",
|
||||
"confidence": 0.90
|
||||
},
|
||||
"HYDROSTATIC_TEST": {
|
||||
"keywords": ["HYDROSTATIC", "수압시험", "PRESSURE TEST", "압력시험"],
|
||||
"description": "수압 시험",
|
||||
"confidence": 0.90
|
||||
},
|
||||
"LOW_TEMPERATURE": {
|
||||
"keywords": ["LOW TEMP", "저온", "LTCS", "LOW TEMPERATURE", "CRYOGENIC"],
|
||||
"description": "저온용",
|
||||
"confidence": 0.85
|
||||
},
|
||||
"HIGH_TEMPERATURE": {
|
||||
"keywords": ["HIGH TEMP", "고온", "HTCS", "HIGH TEMPERATURE"],
|
||||
"description": "고온용",
|
||||
"confidence": 0.85
|
||||
}
|
||||
}
|
||||
|
||||
# ========== PIPE 제조 방법별 분류 ==========
|
||||
PIPE_MANUFACTURING = {
|
||||
"SEAMLESS": {
|
||||
@@ -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,
|
||||
length: Optional[float] = None) -> Dict:
|
||||
"""구매용 파이프 분류 - 끝단 가공 정보 제외"""
|
||||
@@ -215,13 +290,16 @@ def classify_pipe(dat_file: str, description: str, main_nom: str,
|
||||
# 3. 끝 가공 분류
|
||||
end_prep_result = classify_pipe_end_preparation(description)
|
||||
|
||||
# 4. 스케줄 분류
|
||||
schedule_result = classify_pipe_schedule(description)
|
||||
# 4. 스케줄 분류 (재질 정보 전달)
|
||||
schedule_result = classify_pipe_schedule(description, material_result)
|
||||
|
||||
# 5. 길이(절단 치수) 처리
|
||||
length_info = extract_pipe_length_info(length, description)
|
||||
|
||||
# 6. 최종 결과 조합
|
||||
# 6. User 요구사항 추출
|
||||
user_requirements = extract_pipe_user_requirements(description)
|
||||
|
||||
# 7. 최종 결과 조합
|
||||
return {
|
||||
"category": "PIPE",
|
||||
|
||||
@@ -260,6 +338,9 @@ def classify_pipe(dat_file: str, description: str, main_nom: str,
|
||||
"length_mm": length_info.get('length_mm')
|
||||
},
|
||||
|
||||
# User 요구사항
|
||||
"user_requirements": user_requirements,
|
||||
|
||||
# 전체 신뢰도
|
||||
"overall_confidence": calculate_pipe_confidence({
|
||||
"material": material_result.get('confidence', 0),
|
||||
@@ -328,19 +409,43 @@ def classify_pipe_end_preparation(description: str) -> Dict:
|
||||
"matched_code": "DEFAULT"
|
||||
}
|
||||
|
||||
def classify_pipe_schedule(description: str) -> Dict:
|
||||
"""파이프 스케줄 분류"""
|
||||
def classify_pipe_schedule(description: str, material_result: Dict = None) -> Dict:
|
||||
"""파이프 스케줄 분류 - 재질별 표현 개선"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 재질 정보 확인
|
||||
material_type = "CARBON" # 기본값
|
||||
if material_result:
|
||||
material_grade = material_result.get('grade', '').upper()
|
||||
material_standard = material_result.get('standard', '').upper()
|
||||
|
||||
# 스테인리스 스틸 판단
|
||||
if any(sus_indicator in material_grade or sus_indicator in material_standard
|
||||
for sus_indicator in ['SUS', 'SS', 'A312', 'A358', 'A376', '304', '316', '321', '347']):
|
||||
material_type = "STAINLESS"
|
||||
|
||||
# 1. 스케줄 패턴 확인
|
||||
for pattern in PIPE_SCHEDULE["patterns"]:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
schedule_num = match.group(1)
|
||||
|
||||
# 재질별 스케줄 표현
|
||||
if material_type == "STAINLESS":
|
||||
# 스테인리스 스틸: SCH 40S, SCH 80S
|
||||
if schedule_num in ["10", "20", "40", "80", "120", "160"]:
|
||||
schedule_display = f"SCH {schedule_num}S"
|
||||
else:
|
||||
schedule_display = f"SCH {schedule_num}"
|
||||
else:
|
||||
# 카본 스틸: SCH 40, SCH 80
|
||||
schedule_display = f"SCH {schedule_num}"
|
||||
|
||||
return {
|
||||
"schedule": f"SCH {schedule_num}",
|
||||
"schedule": schedule_display,
|
||||
"schedule_number": schedule_num,
|
||||
"material_type": material_type,
|
||||
"confidence": 0.95,
|
||||
"matched_pattern": pattern
|
||||
}
|
||||
@@ -353,6 +458,7 @@ def classify_pipe_schedule(description: str) -> Dict:
|
||||
return {
|
||||
"schedule": f"{thickness}mm THK",
|
||||
"wall_thickness": f"{thickness}mm",
|
||||
"material_type": material_type,
|
||||
"confidence": 0.9,
|
||||
"matched_pattern": pattern
|
||||
}
|
||||
@@ -360,6 +466,7 @@ def classify_pipe_schedule(description: str) -> Dict:
|
||||
# 3. 기본값
|
||||
return {
|
||||
"schedule": "UNKNOWN",
|
||||
"material_type": material_type,
|
||||
"confidence": 0.0
|
||||
}
|
||||
|
||||
|
||||
50
backend/app/services/plate_classifier.py
Normal file
50
backend/app/services/plate_classifier.py
Normal file
@@ -0,0 +1,50 @@
|
||||
|
||||
import re
|
||||
from typing import Dict, Optional
|
||||
|
||||
def classify_plate(tag: str, description: str, main_nom: str = "") -> Dict:
|
||||
"""
|
||||
판재(PLATE) 분류기
|
||||
규격 예: PLATE 10T x 1219 x 2438
|
||||
"""
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 1. 두께(Thickness) 추출
|
||||
# 패턴: 10T, 10.5T, THK 10, THK. 10, t=10
|
||||
thickness = None
|
||||
t_match = re.search(r'(\d+(?:\.\d+)?)\s*T\b', desc_upper)
|
||||
if not t_match:
|
||||
t_match = re.search(r'(?:THK\.?|t=)\s*(\d+(?:\.\d+)?)', desc_upper, re.IGNORECASE)
|
||||
|
||||
if t_match:
|
||||
thickness = t_match.group(1)
|
||||
|
||||
# 2. 규격(Dimensions) 추출
|
||||
# 패턴: 1219x2438, 4'x8', 1000*2000
|
||||
dimensions = ""
|
||||
dim_match = re.search(r'(\d+(?:\.\d+)?)\s*[X\*]\s*(\d+(?:\.\d+)?)(?:\s*[X\*]\s*(\d+(?:\.\d+)?))?', desc_upper)
|
||||
if dim_match:
|
||||
groups = [g for g in dim_match.groups() if g]
|
||||
dimensions = " x ".join(groups)
|
||||
|
||||
# 3. 재질 추출
|
||||
material = "UNKNOWN"
|
||||
# 압력용기용 및 일반 구조용 강판 재질 추가
|
||||
plate_materials = [
|
||||
"SUS304", "SUS316", "SUS321", "SS400", "A36", "SM490",
|
||||
"SA516", "A516", "SA283", "A283", "SA537", "A537", "POS-M"
|
||||
]
|
||||
for mat in plate_materials:
|
||||
if mat in desc_upper:
|
||||
material = mat
|
||||
break
|
||||
|
||||
return {
|
||||
"category": "PLATE",
|
||||
"overall_confidence": 0.9,
|
||||
"details": {
|
||||
"thickness": thickness,
|
||||
"dimensions": dimensions,
|
||||
"material": material
|
||||
}
|
||||
}
|
||||
@@ -93,16 +93,11 @@ class RevisionComparator:
|
||||
def compare_materials(self, previous_confirmed: Dict, new_materials: List[Dict]) -> Dict:
|
||||
"""
|
||||
기존 확정 자재와 신규 자재 비교
|
||||
|
||||
Args:
|
||||
previous_confirmed: 이전 확정 자재 정보
|
||||
new_materials: 신규 업로드된 자재 목록
|
||||
|
||||
Returns:
|
||||
비교 결과 딕셔너리
|
||||
"""
|
||||
try:
|
||||
# 이전 확정 자재를 해시맵으로 변환 (빠른 검색을 위해)
|
||||
from rapidfuzz import fuzz
|
||||
|
||||
# 이전 확정 자재 해시맵 생성
|
||||
confirmed_materials = {}
|
||||
for item in previous_confirmed["items"]:
|
||||
material_hash = self._generate_material_hash(
|
||||
@@ -112,13 +107,19 @@ class RevisionComparator:
|
||||
)
|
||||
confirmed_materials[material_hash] = item
|
||||
|
||||
# 해시 역참조 맵 (유사도 비교용)
|
||||
# 해시 -> 정규화된 설명 문자열 (비교 대상)
|
||||
# 여기서는 specification 자체를 비교 대상으로 사용 (가장 정보량이 많음)
|
||||
confirmed_specs = {
|
||||
h: item["specification"] for h, item in confirmed_materials.items()
|
||||
}
|
||||
|
||||
# 신규 자재 분석
|
||||
unchanged_materials = [] # 변경 없음 (분류 불필요)
|
||||
changed_materials = [] # 변경됨 (재분류 필요)
|
||||
new_materials_list = [] # 신규 추가 (분류 필요)
|
||||
unchanged_materials = []
|
||||
changed_materials = []
|
||||
new_materials_list = []
|
||||
|
||||
for new_material in new_materials:
|
||||
# 자재 해시 생성 (description 기반)
|
||||
description = new_material.get("description", "")
|
||||
size = self._extract_size_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)
|
||||
|
||||
if material_hash in confirmed_materials:
|
||||
# 정확히 일치하는 자재 발견 (해시 일치)
|
||||
confirmed_item = confirmed_materials[material_hash]
|
||||
|
||||
# 수량 비교
|
||||
new_qty = float(new_material.get("quantity", 0))
|
||||
confirmed_qty = float(confirmed_item["bom_quantity"])
|
||||
|
||||
if abs(new_qty - confirmed_qty) > 0.001: # 수량 변경
|
||||
if abs(new_qty - confirmed_qty) > 0.001:
|
||||
changed_materials.append({
|
||||
**new_material,
|
||||
"change_type": "QUANTITY_CHANGED",
|
||||
@@ -140,27 +141,49 @@ class RevisionComparator:
|
||||
"previous_item": confirmed_item
|
||||
})
|
||||
else:
|
||||
# 수량 동일 - 기존 분류 결과 재사용
|
||||
unchanged_materials.append({
|
||||
**new_material,
|
||||
"reuse_classification": True,
|
||||
"previous_item": confirmed_item
|
||||
})
|
||||
else:
|
||||
# 신규 자재
|
||||
new_materials_list.append({
|
||||
**new_material,
|
||||
"change_type": "NEW_MATERIAL"
|
||||
})
|
||||
# 해시 불일치 - 유사도 검사 (Fuzzy Matching)
|
||||
# 신규 자재 설명과 기존 확정 자재들의 스펙 비교
|
||||
best_match_hash = None
|
||||
best_match_score = 0
|
||||
|
||||
# 성능을 위해 간단한 필터링 후 정밀 비교 권장되나,
|
||||
# 현재는 전체 비교 (데이터량이 많지 않다고 가정)
|
||||
for h, spec in confirmed_specs.items():
|
||||
score = fuzz.ratio(description.lower(), spec.lower())
|
||||
if score > 85: # 85점 이상이면 매우 유사
|
||||
if score > best_match_score:
|
||||
best_match_score = score
|
||||
best_match_hash = h
|
||||
|
||||
if best_match_hash:
|
||||
# 유사한 자재 발견 (오타 또는 미세 변경 가능성)
|
||||
similar_item = confirmed_materials[best_match_hash]
|
||||
new_materials_list.append({
|
||||
**new_material,
|
||||
"change_type": "NEW_BUT_SIMILAR",
|
||||
"similarity_score": best_match_score,
|
||||
"similar_to": similar_item
|
||||
})
|
||||
else:
|
||||
# 완전히 새로운 자재
|
||||
new_materials_list.append({
|
||||
**new_material,
|
||||
"change_type": "NEW_MATERIAL"
|
||||
})
|
||||
|
||||
# 삭제된 자재 찾기 (이전에는 있었지만 현재는 없는 것)
|
||||
# 삭제된 자재 찾기
|
||||
new_material_hashes = set()
|
||||
for material in new_materials:
|
||||
description = material.get("description", "")
|
||||
size = self._extract_size_from_description(description)
|
||||
material_grade = self._extract_material_from_description(description)
|
||||
hash_key = self._generate_material_hash(description, size, material_grade)
|
||||
new_material_hashes.add(hash_key)
|
||||
d = material.get("description", "")
|
||||
s = self._extract_size_from_description(d)
|
||||
m = self._extract_material_from_description(d)
|
||||
new_material_hashes.add(self._generate_material_hash(d, s, m))
|
||||
|
||||
removed_materials = []
|
||||
for hash_key, confirmed_item in confirmed_materials.items():
|
||||
@@ -186,7 +209,7 @@ class RevisionComparator:
|
||||
"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(removed_materials)}")
|
||||
|
||||
@@ -195,7 +218,7 @@ class RevisionComparator:
|
||||
except Exception as e:
|
||||
logger.error(f"자재 비교 실패: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
def _extract_revision_number(self, revision: str) -> int:
|
||||
"""리비전 문자열에서 숫자 추출 (Rev.1 → 1)"""
|
||||
try:
|
||||
@@ -206,37 +229,136 @@ class RevisionComparator:
|
||||
return 0
|
||||
|
||||
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()
|
||||
|
||||
def _extract_size_from_description(self, description: str) -> str:
|
||||
"""자재 설명에서 사이즈 정보 추출"""
|
||||
# 간단한 사이즈 패턴 추출 (실제로는 더 정교한 로직 필요)
|
||||
"""
|
||||
자재 설명에서 사이즈 정보 추출
|
||||
|
||||
지원하는 패턴 (단어 경계 \b 추가하여 정확도 향상):
|
||||
- 1/2" (인치)
|
||||
- 100A (A단위)
|
||||
- 50mm (밀리미터)
|
||||
- 10x20 (가로x세로)
|
||||
- DN100 (DN단위)
|
||||
"""
|
||||
if not description:
|
||||
return ""
|
||||
|
||||
import re
|
||||
size_patterns = [
|
||||
r'(\d+(?:\.\d+)?)\s*(?:mm|MM|인치|inch|")',
|
||||
r'(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)',
|
||||
r'DN\s*(\d+)',
|
||||
r'(\d+)\s*A'
|
||||
# 인치 패턴 (분수 포함): 1/2", 1.5", 1-1/2"
|
||||
r'\b(\d+(?:[-/.]\d+)?)\s*(?:inch|인치|")',
|
||||
# 밀리미터 패턴: 100mm, 100.5 MM
|
||||
r'\b(\d+(?:\.\d+)?)\s*(?:mm|MM)\b',
|
||||
# A단위 패턴: 100A, 100 A
|
||||
r'\b(\d+)\s*A\b',
|
||||
# DN단위 패턴: DN100, DN 100
|
||||
r'DN\s*(\d+)\b',
|
||||
# 치수 패턴: 10x20, 10*20
|
||||
r'\b(\d+(?:\.\d+)?)\s*[xX*]\s*(\d+(?:\.\d+)?)\b'
|
||||
]
|
||||
|
||||
for pattern in size_patterns:
|
||||
match = re.search(pattern, description, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(0)
|
||||
return match.group(0).strip()
|
||||
|
||||
return ""
|
||||
|
||||
def _load_materials_from_db(self) -> List[str]:
|
||||
"""DB에서 자재 목록 동적 로딩 (캐싱 적용 고려 가능)"""
|
||||
try:
|
||||
# MaterialSpecification 및 SpecialMaterial 테이블에서 자재 코드 조회
|
||||
query = text("""
|
||||
SELECT spec_code FROM material_specifications
|
||||
WHERE is_active = TRUE
|
||||
UNION
|
||||
SELECT grade_code FROM material_grades
|
||||
WHERE is_active = TRUE
|
||||
UNION
|
||||
SELECT material_name FROM special_materials
|
||||
WHERE is_active = TRUE
|
||||
""")
|
||||
result = self.db.execute(query).fetchall()
|
||||
db_materials = [row[0] for row in result]
|
||||
|
||||
# 기본 하드코딩 리스트 (DB 조회 실패 시 또는 보완용)
|
||||
default_materials = [
|
||||
"SUS316L", "SUS316", "SUS304L", "SUS304",
|
||||
"SS316L", "SS316", "SS304L", "SS304",
|
||||
"A105N", "A105",
|
||||
"A234 WPB", "A234",
|
||||
"A106 Gr.B", "A106",
|
||||
"WCB", "CF8M", "CF8",
|
||||
"CS", "STS", "PVC", "PP", "PE"
|
||||
]
|
||||
|
||||
# 합치고 중복 제거 후 길이 역순 정렬 (긴 단어 우선 매칭)
|
||||
combined = list(set(db_materials + default_materials))
|
||||
combined.sort(key=len, reverse=True)
|
||||
|
||||
return combined
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"DB 자재 로딩 실패 (기본값 사용): {str(e)}")
|
||||
materials = [
|
||||
"SUS316L", "SUS316", "SUS304L", "SUS304",
|
||||
"SS316L", "SS316", "SS304L", "SS304",
|
||||
"A105N", "A105",
|
||||
"A234 WPB", "A234",
|
||||
"A106 Gr.B", "A106",
|
||||
"WCB", "CF8M", "CF8",
|
||||
"CS", "STS", "PVC", "PP", "PE"
|
||||
]
|
||||
return materials
|
||||
|
||||
def _extract_material_from_description(self, description: str) -> str:
|
||||
"""자재 설명에서 재질 정보 추출"""
|
||||
# 일반적인 재질 패턴
|
||||
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()
|
||||
|
||||
for material in materials:
|
||||
if material in description_upper:
|
||||
# 단어 매칭을 위해 간단한 검사 수행 (부분 문자열이 다른 단어의 일부가 아닌지)
|
||||
if material.upper() in description_upper:
|
||||
return material
|
||||
|
||||
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. 사이즈 정보 추출
|
||||
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 {
|
||||
"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_capacity": load_result.get("capacity", ""),
|
||||
|
||||
# 재질 정보 (공통 모듈)
|
||||
# 재질 정보 (공통 모듈) - 우레탄 블럭슈 두께 정보 포함
|
||||
"material": {
|
||||
"standard": material_result.get('standard', 'UNKNOWN'),
|
||||
"grade": material_result.get('grade', 'UNKNOWN'),
|
||||
"grade": enhanced_material_grade,
|
||||
"material_type": material_result.get('material_type', 'UNKNOWN'),
|
||||
"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,
|
||||
|
||||
# 사용자 요구사항
|
||||
"user_requirements": user_requirements,
|
||||
|
||||
# 전체 신뢰도
|
||||
"overall_confidence": calculate_support_confidence({
|
||||
"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"]
|
||||
}
|
||||
|
||||
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:
|
||||
"""하중 등급 분류"""
|
||||
|
||||
|
||||
@@ -89,6 +89,24 @@ VALVE_TYPES = {
|
||||
"typical_connections": ["FLANGED", "THREADED"],
|
||||
"pressure_range": "150LB ~ 600LB",
|
||||
"special_features": ["LUBRICATED", "NON_LUBRICATED"]
|
||||
},
|
||||
|
||||
"SIGHT_GLASS": {
|
||||
"dat_file_patterns": ["SIGHT_", "SG_"],
|
||||
"description_keywords": ["SIGHT GLASS", "SIGHT", "사이트글라스", "사이트 글라스"],
|
||||
"characteristics": "유체 확인용 관찰창",
|
||||
"typical_connections": ["FLANGED", "THREADED"],
|
||||
"pressure_range": "150LB ~ 600LB",
|
||||
"special_features": ["TRANSPARENT", "VISUAL_INSPECTION"]
|
||||
},
|
||||
|
||||
"STRAINER": {
|
||||
"dat_file_patterns": ["STRAINER_", "STR_"],
|
||||
"description_keywords": ["STRAINER", "스트레이너", "여과기"],
|
||||
"characteristics": "이물질 여과용",
|
||||
"typical_connections": ["FLANGED", "THREADED"],
|
||||
"pressure_range": "150LB ~ 600LB",
|
||||
"special_features": ["MESH_FILTER", "Y_TYPE", "BASKET_TYPE"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +230,13 @@ def classify_valve(dat_file: str, description: str, main_nom: str, length: float
|
||||
desc_upper = description.upper()
|
||||
dat_upper = dat_file.upper()
|
||||
|
||||
# 1. 밸브 키워드 확인 (재질만 있어도 통합 분류기가 이미 밸브로 분류했으므로 진행)
|
||||
valve_keywords = ['VALVE', 'GATE', 'BALL', 'GLOBE', 'CHECK', 'BUTTERFLY', 'NEEDLE', 'RELIEF', 'SOLENOID', '밸브', '게이트', '볼', '글로브', '체크', '버터플라이', '니들', '릴리프', '솔레노이드']
|
||||
# 1. 사이트 글라스와 스트레이너 우선 확인
|
||||
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)
|
||||
|
||||
# 밸브 재질 확인 (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
|
||||
|
||||
# 파일 처리
|
||||
pandas==2.1.4
|
||||
annotated-types==0.7.0
|
||||
anyio==3.7.1
|
||||
async-timeout==5.0.1
|
||||
bcrypt==4.1.2
|
||||
black==23.11.0
|
||||
certifi==2026.1.4
|
||||
click==8.1.8
|
||||
coverage==7.10.7
|
||||
dnspython==2.7.0
|
||||
email-validator==2.3.0
|
||||
et_xmlfile==2.0.0
|
||||
exceptiongroup==1.3.1
|
||||
fastapi==0.104.1
|
||||
flake8==6.1.0
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httptools==0.7.1
|
||||
httpx==0.25.2
|
||||
idna==3.11
|
||||
iniconfig==2.1.0
|
||||
Mako==1.3.10
|
||||
MarkupSafe==3.0.3
|
||||
mccabe==0.7.0
|
||||
mypy_extensions==1.1.0
|
||||
numpy==1.26.4
|
||||
openpyxl==3.1.2
|
||||
xlrd>=2.0.1
|
||||
python-multipart==0.0.6
|
||||
|
||||
# 데이터 검증
|
||||
packaging==25.0
|
||||
pandas==2.1.4
|
||||
pathspec==1.0.1
|
||||
platformdirs==4.4.0
|
||||
pluggy==1.6.0
|
||||
psycopg2-binary==2.9.9
|
||||
pycodestyle==2.11.1
|
||||
pydantic==2.5.2
|
||||
pydantic-settings==2.1.0
|
||||
|
||||
# 기타 유틸리티
|
||||
python-dotenv==1.0.0
|
||||
httpx==0.25.2
|
||||
redis==5.0.1
|
||||
python-magic==0.4.27
|
||||
|
||||
# 인증 시스템
|
||||
pydantic_core==2.14.5
|
||||
pyflakes==3.1.0
|
||||
PyJWT==2.8.0
|
||||
bcrypt==4.1.2
|
||||
python-multipart==0.0.6
|
||||
email-validator==2.3.0
|
||||
|
||||
# 개발 도구
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
pytest-cov==4.1.0
|
||||
pytest-mock==3.12.0
|
||||
black==23.11.0
|
||||
flake8==6.1.0
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.0.0
|
||||
python-magic==0.4.27
|
||||
python-multipart==0.0.6
|
||||
openpyxl==3.1.2
|
||||
pytz==2025.2
|
||||
PyYAML==6.0.3
|
||||
RapidFuzz==3.13.0
|
||||
redis==5.0.1
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
SQLAlchemy==2.0.23
|
||||
starlette==0.27.0
|
||||
tomli==2.3.0
|
||||
typing_extensions==4.15.0
|
||||
tzdata==2025.3
|
||||
uvicorn==0.24.0
|
||||
uvloop==0.22.1
|
||||
watchfiles==1.1.1
|
||||
websockets==15.0.1
|
||||
xlrd==2.0.1
|
||||
|
||||
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:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "${POSTGRES_PORT:-15432}:5432"
|
||||
|
||||
redis:
|
||||
ports:
|
||||
- "6379:6379"
|
||||
- "${REDIS_PORT:-16379}:6379"
|
||||
|
||||
pgadmin:
|
||||
ports:
|
||||
- "5050:80"
|
||||
- "${PGADMIN_PORT:-15050}:80"
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
container_name: tk-mp-nginx-proxy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "8808:80"
|
||||
volumes:
|
||||
- ./nginx-proxy.conf:/etc/nginx/conf.d/default.conf
|
||||
networks:
|
||||
|
||||
@@ -78,13 +78,13 @@ services:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_API_URL=${VITE_API_URL:-http://localhost:18000}
|
||||
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||
container_name: tk-mp-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-13000}:5173"
|
||||
- "${FRONTEND_PORT:-13000}:3000"
|
||||
environment:
|
||||
- VITE_API_URL=${VITE_API_URL:-http://localhost:18000}
|
||||
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||
depends_on:
|
||||
- backend
|
||||
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/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── Dashboard.jsx # 대시보드
|
||||
│ │ ├── FileUpload.jsx # 파일 업로드
|
||||
│ │ ├── MaterialList.jsx # 자재 목록
|
||||
│ │ └── ProjectManager.jsx # 프로젝트 관리
|
||||
│ ├── App.jsx # 메인 앱
|
||||
│ ├── main.jsx # 엔트리 포인트
|
||||
│ └── index.css # 전역 스타일
|
||||
│ │ ├── common/ # 공통 컴포넌트
|
||||
│ │ ├── bom/ # BOM 관련 컴포넌트
|
||||
│ │ │ ├── materials/ # 카테고리별 자재 뷰
|
||||
│ │ │ └── shared/ # BOM 공통 컴포넌트
|
||||
│ │ └── ... # 기타 컴포넌트
|
||||
│ ├── pages/ # 페이지 컴포넌트
|
||||
│ │ ├── DashboardPage.jsx # 메인 대시보드
|
||||
│ │ ├── BOMManagementPage.jsx # BOM 관리
|
||||
│ │ ├── PurchaseRequestPage.jsx # 구매신청 관리
|
||||
│ │ └── ... # 기타 페이지들
|
||||
│ ├── App.jsx # 메인 앱
|
||||
│ ├── main.jsx # 엔트리 포인트
|
||||
│ └── index.css # 전역 스타일
|
||||
├── PAGES_GUIDE.md # 📋 페이지 역할 가이드
|
||||
├── package.json
|
||||
└── vite.config.js
|
||||
```
|
||||
|
||||
## 🎯 주요 컴포넌트
|
||||
## 📋 페이지 가이드
|
||||
|
||||
### Dashboard
|
||||
- 프로젝트 통계 및 현황 표시
|
||||
- 최근 활동 목록
|
||||
- 실시간 데이터 업데이트
|
||||
**모든 페이지의 역할과 기능은 [`PAGES_GUIDE.md`](./PAGES_GUIDE.md)에 상세히 문서화되어 있습니다.**
|
||||
|
||||
### FileUpload
|
||||
- 드래그&드롭 인터페이스
|
||||
- Excel 파일 검증
|
||||
- 업로드 진행률 표시
|
||||
- 배치 파일 처리
|
||||
### 🔄 페이지 개발 규칙
|
||||
|
||||
### MaterialList
|
||||
- 페이지네이션이 있는 데이터 그리드
|
||||
- 실시간 검색 및 필터링
|
||||
- CSV 내보내기
|
||||
- 정렬 및 컬럼 관리
|
||||
1. **새 페이지 생성 시 `PAGES_GUIDE.md` 업데이트 필수**
|
||||
2. **페이지 역할, 기능, 라우팅, 접근 권한을 명확히 문서화**
|
||||
3. **관련 컴포넌트와의 연관성 설명**
|
||||
4. **디자인 시스템 준수 (데본씽크 스타일, 글래스모피즘)**
|
||||
|
||||
### ProjectManager
|
||||
- 프로젝트 CRUD 작업
|
||||
- 카드 형태의 프로젝트 표시
|
||||
- 모달 기반 편집
|
||||
### ⚠️ **Docker 배포 시 주의사항**
|
||||
|
||||
**프론트엔드 변경사항이 반영되지 않을 때:**
|
||||
|
||||
```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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
@@ -63,9 +49,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/compat-data": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
|
||||
"integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz",
|
||||
"integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -73,22 +59,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
|
||||
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
|
||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@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-module-transforms": "^7.27.3",
|
||||
"@babel/helpers": "^7.27.6",
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/helper-module-transforms": "^7.28.3",
|
||||
"@babel/helpers": "^7.28.4",
|
||||
"@babel/parser": "^7.28.4",
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/traverse": "^7.28.0",
|
||||
"@babel/types": "^7.28.0",
|
||||
"@babel/traverse": "^7.28.4",
|
||||
"@babel/types": "^7.28.4",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
"gensync": "^1.0.0-beta.2",
|
||||
@@ -111,13 +97,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
|
||||
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
|
||||
"integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/types": "^7.28.0",
|
||||
"@babel/parser": "^7.28.3",
|
||||
"@babel/types": "^7.28.2",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -166,15 +152,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz",
|
||||
"integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==",
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
|
||||
"integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
"@babel/traverse": "^7.27.3"
|
||||
"@babel/traverse": "^7.28.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -222,26 +208,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.27.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz",
|
||||
"integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==",
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
|
||||
"integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/types": "^7.27.6"
|
||||
"@babel/types": "^7.28.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
|
||||
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
|
||||
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.0"
|
||||
"@babel/types": "^7.28.4"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -283,9 +269,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.27.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
|
||||
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -306,17 +292,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
|
||||
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz",
|
||||
"integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.0",
|
||||
"@babel/generator": "^7.28.3",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/parser": "^7.28.4",
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/types": "^7.28.0",
|
||||
"@babel/types": "^7.28.4",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -324,9 +310,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz",
|
||||
"integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==",
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
|
||||
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
@@ -375,9 +361,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/is-prop-valid": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz",
|
||||
"integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
|
||||
"integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/memoize": "^0.9.0"
|
||||
@@ -857,9 +843,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
|
||||
"integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
|
||||
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -958,15 +944,26 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
|
||||
"integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@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": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
@@ -977,15 +974,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
|
||||
"integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.29",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
|
||||
"integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz",
|
||||
"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",
|
||||
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz",
|
||||
"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",
|
||||
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz",
|
||||
"integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==",
|
||||
@@ -1179,7 +1176,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/material/node_modules/@mui/types": {
|
||||
"node_modules/@mui/types": {
|
||||
"version": "7.2.24",
|
||||
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz",
|
||||
"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",
|
||||
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz",
|
||||
"integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==",
|
||||
@@ -1281,9 +1278,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.19",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz",
|
||||
"integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==",
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1323,13 +1320,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__traverse": {
|
||||
"version": "7.20.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz",
|
||||
"integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==",
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
|
||||
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.20.7"
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/parse-json": {
|
||||
@@ -1345,9 +1342,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
|
||||
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
|
||||
"version": "18.3.26",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
|
||||
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -1381,16 +1378,16 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz",
|
||||
"integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==",
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"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-source": "^7.27.1",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.19",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.27",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"react-refresh": "^0.17.0"
|
||||
},
|
||||
@@ -1398,7 +1395,7 @@
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"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": {
|
||||
@@ -1663,13 +1660,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
|
||||
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -1695,6 +1692,16 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
@@ -1707,9 +1714,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.25.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
|
||||
"integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==",
|
||||
"version": "4.26.3",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
|
||||
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1727,9 +1734,10 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001726",
|
||||
"electron-to-chromium": "^1.5.173",
|
||||
"node-releases": "^2.0.19",
|
||||
"baseline-browser-mapping": "^2.8.9",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
"electron-to-chromium": "^1.5.227",
|
||||
"node-releases": "^2.0.21",
|
||||
"update-browserslist-db": "^1.1.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1798,9 +1806,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001727",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
|
||||
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
|
||||
"version": "1.0.30001750",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz",
|
||||
"integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1849,9 +1857,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
@@ -1939,15 +1947,6 @@
|
||||
"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": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
@@ -2036,9 +2035,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -2142,16 +2141,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.182",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.182.tgz",
|
||||
"integrity": "sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==",
|
||||
"version": "1.5.237",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
|
||||
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
||||
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.2.1"
|
||||
@@ -2494,9 +2493,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-refresh": {
|
||||
"version": "0.4.20",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz",
|
||||
"integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==",
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz",
|
||||
"integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
@@ -2736,9 +2735,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
||||
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -2772,9 +2771,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
|
||||
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
@@ -2858,6 +2857,16 @@
|
||||
"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": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -3353,14 +3362,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/is-generator-function": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz",
|
||||
"integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||
"integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.3",
|
||||
"get-proto": "^1.0.0",
|
||||
"call-bound": "^1.0.4",
|
||||
"generator-function": "^2.0.0",
|
||||
"get-proto": "^1.0.1",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"safe-regex-test": "^1.1.0"
|
||||
},
|
||||
@@ -3852,9 +3862,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.19",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
|
||||
"version": "2.0.23",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
|
||||
"integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -4280,9 +4290,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz",
|
||||
"integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==",
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
|
||||
"integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
@@ -5308,6 +5318,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -71,6 +71,17 @@ body {
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* 접근 거부 페이지 */
|
||||
.access-denied-container {
|
||||
display: flex;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,7 @@ import { logApiError } from './utils/errorLogger';
|
||||
|
||||
// 환경변수에서 API URL을 읽음 (Vite 기준)
|
||||
// 프로덕션에서는 nginx 프록시를 통해 /api 경로 사용
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL ||
|
||||
(import.meta.env.DEV ? 'http://localhost:18000' : '/api');
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
console.log('API Base URL:', API_BASE_URL);
|
||||
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);
|
||||
overflow: hidden;
|
||||
animation: dropdownSlide 0.3s ease-out;
|
||||
z-index: 1000;
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
@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