feat: 구매신청 기능 완성 및 SUPPORT/SPECIAL 카테고리 개선
- 모든 카테고리 구매신청 기능 완성 (PIPE, FITTING, VALVE, FLANGE, GASKET, BOLT, SUPPORT, SPECIAL, UNKNOWN) - 구매신청 완료 항목: 회색 배경, 체크박스 비활성화, '구매신청완료' 배지 표시 - 전체 선택/구매신청 시 이미 구매신청된 항목 자동 제외 - 구매신청 quantity 타입 에러 수정 (문자열 -> 정수 변환) SUPPORT 카테고리 (구 U-BOLT): - U-BOLT -> SUPPORT로 카테고리명 변경 - 클램프, 유볼트, 우레탄블럭슈 분류 개선 - 테이블 헤더: 선택-종류-타입-크기-디스크립션-추가요구-사용자요구-수량 - 크기 정보 main_nom 필드에서 가져오기 (배관 인치) - 엑셀 내보내기 형식 조정 SPECIAL 카테고리: - SPECIAL 키워드 자재 자동 분류 (SPECIFICATION 제외) - 파일 업로드 시 SPECIAL 카테고리 처리 로직 추가 - 도면번호 필드 추가 (drawing_name, line_no) - 타입 필드: 크기/스케줄/재질 제외한 핵심 정보 표시 - 엑셀 DWG_NAME, LINE_NUM 컬럼 파싱 및 저장 FITTING 카테고리: - 테이블 컬럼 너비 조정 (선택 2%, 종류 8.5%, 수량 12%) 구매신청 관리: - 엑셀 재다운로드 형식 개선 (BOM 페이지와 동일한 형식) - 그룹화된 자재 정보 포함하여 저장 및 다운로드
This commit is contained in:
@@ -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 {
|
||||
@@ -201,9 +205,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(
|
||||
|
||||
@@ -97,6 +97,28 @@ try:
|
||||
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}")
|
||||
|
||||
# 파일 관리 API 라우터 등록 (비활성화 - files 라우터와 충돌 방지)
|
||||
# try:
|
||||
# from .api import file_management
|
||||
|
||||
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)}"
|
||||
)
|
||||
@@ -218,7 +218,8 @@ def parse_dataframe(df):
|
||||
mapped_columns[standard_col] = possible_name
|
||||
break
|
||||
|
||||
# 로그 제거
|
||||
print(f"📋 엑셀 컬럼 매핑 결과: {mapped_columns}")
|
||||
print(f"📋 원본 컬럼명들: {list(df.columns)}")
|
||||
|
||||
materials = []
|
||||
for index, row in df.iterrows():
|
||||
@@ -262,16 +263,34 @@ def parse_dataframe(df):
|
||||
except (ValueError, TypeError):
|
||||
length_value = None
|
||||
|
||||
# DWG_NAME 정보 추출
|
||||
dwg_name_raw = row.get(mapped_columns.get('dwg_name', ''), '')
|
||||
dwg_name = None
|
||||
if pd.notna(dwg_name_raw) and str(dwg_name_raw).strip() not in ['', 'nan', 'None']:
|
||||
dwg_name = str(dwg_name_raw).strip()
|
||||
if index < 3: # 처음 3개만 로그
|
||||
print(f"📐 도면번호 파싱: {dwg_name}")
|
||||
|
||||
# LINE_NUM 정보 추출
|
||||
line_num_raw = row.get(mapped_columns.get('line_num', ''), '')
|
||||
line_num = None
|
||||
if pd.notna(line_num_raw) and str(line_num_raw).strip() not in ['', 'nan', 'None']:
|
||||
line_num = str(line_num_raw).strip()
|
||||
if index < 3: # 처음 3개만 로그
|
||||
print(f"📍 라인번호 파싱: {line_num}")
|
||||
|
||||
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, # 추가
|
||||
'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
|
||||
})
|
||||
@@ -651,6 +670,18 @@ async def upload_file(
|
||||
elif material_type == "SUPPORT":
|
||||
from ..services.support_classifier import classify_support
|
||||
classification_result = classify_support("", description, main_nom or "")
|
||||
elif material_type == "SPECIAL":
|
||||
# SPECIAL 카테고리는 별도 분류기 없이 통합 분류 결과 사용
|
||||
classification_result = {
|
||||
"category": "SPECIAL",
|
||||
"overall_confidence": integrated_result.get('confidence', 1.0),
|
||||
"reason": integrated_result.get('reason', 'SPECIAL 키워드 발견'),
|
||||
"details": {
|
||||
"description": description,
|
||||
"main_nom": main_nom or "",
|
||||
"drawing_required": True # 도면 필요
|
||||
}
|
||||
}
|
||||
else:
|
||||
# UNKNOWN 처리
|
||||
classification_result = {
|
||||
@@ -679,12 +710,14 @@ async def upload_file(
|
||||
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, created_at
|
||||
classified_category, classification_confidence, is_verified,
|
||||
drawing_name, line_no, created_at
|
||||
)
|
||||
VALUES (
|
||||
: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, :created_at
|
||||
:classified_category, :classification_confidence, :is_verified,
|
||||
:drawing_name, :line_no, :created_at
|
||||
)
|
||||
RETURNING id
|
||||
""")
|
||||
@@ -702,8 +735,8 @@ async def upload_file(
|
||||
"quantity": material_data["quantity"],
|
||||
"unit": material_data["unit"],
|
||||
"size_spec": material_data["size_spec"],
|
||||
"main_nom": material_data.get("main_nom"), # 추가
|
||||
"red_nom": material_data.get("red_nom"), # 추가
|
||||
"main_nom": material_data.get("main_nom"),
|
||||
"red_nom": material_data.get("red_nom"),
|
||||
"material_grade": material_data["material_grade"],
|
||||
"full_material_grade": full_material_grade,
|
||||
"line_number": material_data["line_number"],
|
||||
@@ -711,6 +744,8 @@ async def upload_file(
|
||||
"classified_category": classification_result.get("category", "UNKNOWN"),
|
||||
"classification_confidence": classification_result.get("overall_confidence", 0.0),
|
||||
"is_verified": False,
|
||||
"drawing_name": material_data.get("dwg_name"),
|
||||
"line_no": material_data.get("line_num"),
|
||||
"created_at": datetime.now()
|
||||
})
|
||||
|
||||
@@ -1565,6 +1600,8 @@ async def get_materials(
|
||||
size_spec: Optional[str] = None,
|
||||
file_filter: Optional[str] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
exclude_requested: bool = True, # 구매신청된 자재 제외 여부
|
||||
group_by_spec: bool = False, # 같은 사양끼리 그룹화
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
@@ -1575,6 +1612,7 @@ async def get_materials(
|
||||
query = """
|
||||
SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit,
|
||||
m.size_spec, m.main_nom, m.red_nom, m.material_grade, m.full_material_grade, m.line_number, m.row_number,
|
||||
m.drawing_name, m.line_no,
|
||||
m.created_at, m.classified_category, m.classification_confidence,
|
||||
m.classification_details,
|
||||
m.is_verified, m.verified_by, m.verified_at,
|
||||
@@ -1612,6 +1650,8 @@ async def get_materials(
|
||||
FROM materials m
|
||||
LEFT JOIN files f ON m.file_id = f.id
|
||||
LEFT JOIN projects p ON f.project_id = p.id
|
||||
-- 구매신청된 자재 제외
|
||||
LEFT JOIN purchase_request_items pri ON m.id = pri.material_id
|
||||
LEFT JOIN pipe_details pd ON m.id = pd.material_id
|
||||
LEFT JOIN pipe_end_preparations pep ON m.id = pep.material_id
|
||||
LEFT JOIN fitting_details fd ON m.id = fd.material_id
|
||||
@@ -1625,6 +1665,11 @@ async def get_materials(
|
||||
WHERE 1=1
|
||||
"""
|
||||
params = {}
|
||||
|
||||
# 구매신청된 자재 제외
|
||||
if exclude_requested:
|
||||
query += " AND pri.material_id IS NULL"
|
||||
|
||||
if project_id:
|
||||
query += " AND f.project_id = :project_id"
|
||||
params["project_id"] = project_id
|
||||
@@ -1769,11 +1814,13 @@ async def get_materials(
|
||||
"quantity": float(m.quantity) if m.quantity else 0,
|
||||
"unit": m.unit,
|
||||
"size_spec": m.size_spec,
|
||||
"main_nom": m.main_nom, # 추가
|
||||
"red_nom": m.red_nom, # 추가
|
||||
"material_grade": m.full_material_grade or enhanced_material_grade, # 전체 재질명 우선 사용
|
||||
"original_material_grade": m.material_grade, # 원본 재질 정보도 보존
|
||||
"full_material_grade": m.full_material_grade, # 전체 재질명
|
||||
"main_nom": m.main_nom,
|
||||
"red_nom": m.red_nom,
|
||||
"material_grade": m.full_material_grade or enhanced_material_grade,
|
||||
"original_material_grade": m.material_grade,
|
||||
"full_material_grade": m.full_material_grade,
|
||||
"drawing_name": m.drawing_name,
|
||||
"line_no": m.line_no,
|
||||
"line_number": m.line_number,
|
||||
"row_number": m.row_number,
|
||||
# 구매수량 계산에서 분류된 정보를 우선 사용
|
||||
@@ -2093,6 +2140,20 @@ async def get_materials(
|
||||
# 평균 단위 길이 계산
|
||||
if group_info["total_quantity"] > 0:
|
||||
representative_pipe['pipe_details']['avg_length_mm'] = group_info["total_length_mm"] / group_info["total_quantity"]
|
||||
|
||||
# 개별 파이프 길이 정보 수집
|
||||
individual_pipes = []
|
||||
for mat in group_info["materials"]:
|
||||
if 'pipe_details' in mat and mat['pipe_details'].get('length_mm'):
|
||||
individual_pipes.append({
|
||||
'length': mat['pipe_details']['length_mm'],
|
||||
'quantity': 1,
|
||||
'id': mat['id']
|
||||
})
|
||||
representative_pipe['pipe_details']['individual_pipes'] = individual_pipes
|
||||
|
||||
# 그룹화된 모든 자재 ID 저장
|
||||
representative_pipe['grouped_ids'] = [mat['id'] for mat in group_info["materials"]]
|
||||
|
||||
material_list.append(representative_pipe)
|
||||
|
||||
|
||||
390
backend/app/routers/purchase_request.py
Normal file
390
backend/app/routers/purchase_request.py
Normal file
@@ -0,0 +1,390 @@
|
||||
"""
|
||||
구매신청 관리 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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
|
||||
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 = "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:
|
||||
# 구매신청 번호 생성
|
||||
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"
|
||||
json_path = os.path.join(EXCEL_DIR, json_filename)
|
||||
save_materials_data(
|
||||
request_data.materials_data,
|
||||
json_path,
|
||||
request_no,
|
||||
request_data.job_no,
|
||||
request_data.grouped_materials # 그룹화 정보 추가
|
||||
)
|
||||
|
||||
# 구매신청 레코드 생성
|
||||
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_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_path": json_filename,
|
||||
"requested_by": 1 # 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, quantity, unit, user_requirement
|
||||
) VALUES (
|
||||
:request_id, :material_id, :quantity, :unit, :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,
|
||||
"quantity": quantity,
|
||||
"unit": material_data.get("unit", ""),
|
||||
"user_requirement": material_data.get("user_requirement", "")
|
||||
})
|
||||
inserted_count += 1
|
||||
else:
|
||||
logger.warning(f"Material {material_id} already in another purchase request, skipping")
|
||||
|
||||
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}개 저장됨")
|
||||
|
||||
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=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
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.category,
|
||||
pr.material_count,
|
||||
pr.excel_file_path,
|
||||
pr.requested_at,
|
||||
pr.status,
|
||||
u.name as requested_by,
|
||||
f.original_filename,
|
||||
j.job_name,
|
||||
COUNT(pri.item_id) as item_count
|
||||
FROM purchase_requests pr
|
||||
LEFT JOIN users u ON pr.requested_by = u.user_id
|
||||
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.category, pr.material_count, pr.excel_file_path,
|
||||
pr.requested_at, pr.status, u.name, f.original_filename, j.job_name
|
||||
ORDER BY pr.requested_at 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": row.category,
|
||||
"material_count": row.material_count,
|
||||
"item_count": row.item_count,
|
||||
"excel_file_path": row.excel_file_path,
|
||||
"requested_at": row.requested_at.isoformat() if row.requested_at 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=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
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):
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
grouped_materials = data.get("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.schedule,
|
||||
m.material_grade,
|
||||
m.quantity as original_quantity,
|
||||
m.unit as original_unit
|
||||
FROM purchase_request_items pri
|
||||
JOIN materials m ON pri.material_id = m.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 = []
|
||||
for row in results:
|
||||
materials.append({
|
||||
"item_id": row.item_id,
|
||||
"material_id": row.material_id,
|
||||
"description": row.original_description,
|
||||
"category": row.classified_category,
|
||||
"size": row.size_spec,
|
||||
"schedule": row.schedule,
|
||||
"material_grade": row.material_grade,
|
||||
"quantity": row.requested_quantity or row.original_quantity,
|
||||
"unit": row.requested_unit or row.original_unit,
|
||||
"user_requirement": row.user_requirement,
|
||||
"is_ordered": row.is_ordered,
|
||||
"is_received": row.is_received
|
||||
})
|
||||
|
||||
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=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
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)
|
||||
):
|
||||
"""
|
||||
구매신청 자재 데이터 조회 (프론트엔드에서 엑셀 생성용)
|
||||
"""
|
||||
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="구매신청을 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
file_path = os.path.join(EXCEL_DIR, result.excel_file_path)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="데이터 파일을 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
# JSON 파일 읽기
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"request_no": result.request_no,
|
||||
"job_no": result.job_no,
|
||||
"materials": data.get("materials", []),
|
||||
"grouped_materials": data.get("grouped_materials", []) # 그룹화 정보도 반환
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download request excel: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
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으로 저장 (프론트엔드에서 동일한 엑셀 포맷으로 생성하기 위해)
|
||||
"""
|
||||
data_to_save = {
|
||||
"request_no": request_no,
|
||||
"job_no": job_no,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"materials": materials_data,
|
||||
"grouped_materials": grouped_materials or [] # 그룹화된 자재 정보 저장
|
||||
}
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data_to_save, f, ensure_ascii=False, indent=2)
|
||||
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)}"
|
||||
)
|
||||
@@ -90,26 +90,37 @@ 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": "스페셜 키워드 발견"
|
||||
}
|
||||
|
||||
# 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 시스템 키워드 발견"
|
||||
}
|
||||
|
||||
# 쉼표로 구분된 각 부분을 별도로 체크 (예: "NIPPLE, SMLS, SCH 80")
|
||||
|
||||
393
backend/exports/PR-20251014-001.json
Normal file
393
backend/exports/PR-20251014-001.json
Normal file
@@ -0,0 +1,393 @@
|
||||
{
|
||||
"request_no": "PR-20251014-001",
|
||||
"job_no": "TK-MP-TEST-001",
|
||||
"created_at": "2025-10-14T01:43:47.625634",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 88145,
|
||||
"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": 88153,
|
||||
"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": 88157,
|
||||
"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": 88167,
|
||||
"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": 88176,
|
||||
"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": 88190,
|
||||
"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": 88446,
|
||||
"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": 88528,
|
||||
"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": 88532,
|
||||
"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": 88533,
|
||||
"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": 88583,
|
||||
"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": 88592,
|
||||
"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": 88600,
|
||||
"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": 88625,
|
||||
"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": 88728,
|
||||
"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": 88740,
|
||||
"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": [
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|1/2\"|undefined|ASTM A312 TP304",
|
||||
"material_ids": [
|
||||
88145
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 11,
|
||||
"unit": "m",
|
||||
"total_length": 66000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|3/4\"|undefined|ASTM A106 B",
|
||||
"material_ids": [
|
||||
88153
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 92,
|
||||
"unit": "m",
|
||||
"total_length": 552000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|1\"|undefined|ASTM A312 TP304",
|
||||
"material_ids": [
|
||||
88157
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 23,
|
||||
"unit": "m",
|
||||
"total_length": 138000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|1\"|undefined|ASTM A106 B",
|
||||
"material_ids": [
|
||||
88167
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 139,
|
||||
"unit": "m",
|
||||
"total_length": 834000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|1 1/2\"|undefined|ASTM A312 TP304",
|
||||
"material_ids": [
|
||||
88176
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 14,
|
||||
"unit": "m",
|
||||
"total_length": 84000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|1 1/2\"|undefined|ASTM A106 B",
|
||||
"material_ids": [
|
||||
88190
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 98,
|
||||
"unit": "m",
|
||||
"total_length": 588000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|1/2\"|undefined|ASTM A106 B",
|
||||
"material_ids": [
|
||||
88446
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 82,
|
||||
"unit": "m",
|
||||
"total_length": 492000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|10\"|undefined|ASTM A312 TP304",
|
||||
"material_ids": [
|
||||
88528
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "10\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 4,
|
||||
"unit": "m",
|
||||
"total_length": 24000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|12\"|undefined|ASTM A312 TP304",
|
||||
"material_ids": [
|
||||
88532
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "12\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 1,
|
||||
"unit": "m",
|
||||
"total_length": 6000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|2\"|undefined|ASTM A106 B",
|
||||
"material_ids": [
|
||||
88533
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 50,
|
||||
"unit": "m",
|
||||
"total_length": 300000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|2\"|undefined|ASTM A312 TP304",
|
||||
"material_ids": [
|
||||
88583
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 9,
|
||||
"unit": "m",
|
||||
"total_length": 54000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|3\"|undefined|ASTM A106 B",
|
||||
"material_ids": [
|
||||
88592
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 25,
|
||||
"unit": "m",
|
||||
"total_length": 150000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|3\"|undefined|ASTM A312 TP304",
|
||||
"material_ids": [
|
||||
88600
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 8,
|
||||
"unit": "m",
|
||||
"total_length": 48000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|3/4\"|undefined|ASTM A312 TP304",
|
||||
"material_ids": [
|
||||
88625
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 15,
|
||||
"unit": "m",
|
||||
"total_length": 90000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|4\"|undefined|ASTM A106 B",
|
||||
"material_ids": [
|
||||
88728
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "4\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 12,
|
||||
"unit": "m",
|
||||
"total_length": 72000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|6\"|undefined|ASTM A106 B",
|
||||
"material_ids": [
|
||||
88740
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "6\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 13,
|
||||
"unit": "m",
|
||||
"total_length": 78000,
|
||||
"user_requirement": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
backend/exports/PR-20251014-001.xlsx
Normal file
BIN
backend/exports/PR-20251014-001.xlsx
Normal file
Binary file not shown.
369
backend/exports/PR-20251014-002.json
Normal file
369
backend/exports/PR-20251014-002.json
Normal file
@@ -0,0 +1,369 @@
|
||||
{
|
||||
"request_no": "PR-20251014-002",
|
||||
"job_no": "TK-MP-TEST-001",
|
||||
"created_at": "2025-10-14T01:43:58.851391",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 88146,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 10,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 88154,
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 91,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 88158,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 22,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 88168,
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 138,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 88177,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 13,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 88191,
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 97,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 88447,
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 81,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 88529,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "10\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 3,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 88534,
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 49,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 88584,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 8,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 88593,
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 24,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 88618,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 7,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 88626,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 14,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 88729,
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "4\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 11,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 88741,
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "6\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 12,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": [
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|1/2\"|undefined|ASTM A312 TP304",
|
||||
"material_ids": [
|
||||
88146
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 10,
|
||||
"unit": "m",
|
||||
"total_length": 60000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|3/4\"|undefined|ASTM A106 B",
|
||||
"material_ids": [
|
||||
88154
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 91,
|
||||
"unit": "m",
|
||||
"total_length": 546000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|1\"|undefined|ASTM A312 TP304",
|
||||
"material_ids": [
|
||||
88158
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 22,
|
||||
"unit": "m",
|
||||
"total_length": 132000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|1\"|undefined|ASTM A106 B",
|
||||
"material_ids": [
|
||||
88168
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 138,
|
||||
"unit": "m",
|
||||
"total_length": 828000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|1 1/2\"|undefined|ASTM A312 TP304",
|
||||
"material_ids": [
|
||||
88177
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 13,
|
||||
"unit": "m",
|
||||
"total_length": 78000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|1 1/2\"|undefined|ASTM A106 B",
|
||||
"material_ids": [
|
||||
88191
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 97,
|
||||
"unit": "m",
|
||||
"total_length": 582000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|1/2\"|undefined|ASTM A106 B",
|
||||
"material_ids": [
|
||||
88447
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 81,
|
||||
"unit": "m",
|
||||
"total_length": 486000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|10\"|undefined|ASTM A312 TP304",
|
||||
"material_ids": [
|
||||
88529
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "10\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 3,
|
||||
"unit": "m",
|
||||
"total_length": 18000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|2\"|undefined|ASTM A106 B",
|
||||
"material_ids": [
|
||||
88534
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 49,
|
||||
"unit": "m",
|
||||
"total_length": 294000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|2\"|undefined|ASTM A312 TP304",
|
||||
"material_ids": [
|
||||
88584
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 8,
|
||||
"unit": "m",
|
||||
"total_length": 48000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|3\"|undefined|ASTM A106 B",
|
||||
"material_ids": [
|
||||
88593
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 24,
|
||||
"unit": "m",
|
||||
"total_length": 144000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|3\"|undefined|ASTM A312 TP304",
|
||||
"material_ids": [
|
||||
88618
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "3\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 7,
|
||||
"unit": "m",
|
||||
"total_length": 42000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|3/4\"|undefined|ASTM A312 TP304",
|
||||
"material_ids": [
|
||||
88626
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 14,
|
||||
"unit": "m",
|
||||
"total_length": 84000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|4\"|undefined|ASTM A106 B",
|
||||
"material_ids": [
|
||||
88729
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "4\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 11,
|
||||
"unit": "m",
|
||||
"total_length": 66000,
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|6\"|undefined|ASTM A106 B",
|
||||
"material_ids": [
|
||||
88741
|
||||
],
|
||||
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
|
||||
"category": "PIPE",
|
||||
"size": "6\"",
|
||||
"material_grade": "ASTM A106 B",
|
||||
"quantity": 12,
|
||||
"unit": "m",
|
||||
"total_length": 72000,
|
||||
"user_requirement": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
backend/exports/PR-20251014-002.xlsx
Normal file
BIN
backend/exports/PR-20251014-002.xlsx
Normal file
Binary file not shown.
3405
backend/exports/PR-20251014-003.json
Normal file
3405
backend/exports/PR-20251014-003.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
backend/exports/PR-20251014-003.xlsx
Normal file
BIN
backend/exports/PR-20251014-003.xlsx
Normal file
Binary file not shown.
90
backend/exports/PR-20251014-004.json
Normal file
90
backend/exports/PR-20251014-004.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"request_no": "PR-20251014-004",
|
||||
"job_no": "TKG-25000P",
|
||||
"created_at": "2025-10-14T02:10:22.262092",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 77528,
|
||||
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
|
||||
"category": "PIPE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": 11,
|
||||
"unit": "EA",
|
||||
"user_requirement": "열처리?"
|
||||
}
|
||||
],
|
||||
"grouped_materials": [
|
||||
{
|
||||
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|1/2\"|undefined|ASTM A312 TP304",
|
||||
"material_ids": [
|
||||
77528
|
||||
],
|
||||
"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": "열처리?"
|
||||
}
|
||||
]
|
||||
}
|
||||
32
backend/exports/PR-20251014-005.json
Normal file
32
backend/exports/PR-20251014-005.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"request_no": "PR-20251014-005",
|
||||
"job_no": "TKG-25000P",
|
||||
"created_at": "2025-10-14T02:14:05.318457",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 78247,
|
||||
"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": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": [
|
||||
{
|
||||
"group_key": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304|10\"|undefined|ASTM A182 F304",
|
||||
"material_ids": [
|
||||
78247
|
||||
],
|
||||
"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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
32
backend/exports/PR-20251014-006.json
Normal file
32
backend/exports/PR-20251014-006.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"request_no": "PR-20251014-006",
|
||||
"job_no": "TKG-25000P",
|
||||
"created_at": "2025-10-14T02:17:13.397257",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 78599,
|
||||
"description": "(4) 0.5, 75 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",
|
||||
"category": "BOLT",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A193/A194 GR B7/2H",
|
||||
"quantity": 51,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": [
|
||||
{
|
||||
"group_key": "(4) 0.5, 75 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV|1\"|undefined|ASTM A193/A194 GR B7/2H",
|
||||
"material_ids": [
|
||||
78599
|
||||
],
|
||||
"description": "(4) 0.5, 75 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",
|
||||
"category": "BOLT",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A193/A194 GR B7/2H",
|
||||
"quantity": 51,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
32
backend/exports/PR-20251014-007.json
Normal file
32
backend/exports/PR-20251014-007.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"request_no": "PR-20251014-007",
|
||||
"job_no": "TKG-25000P",
|
||||
"created_at": "2025-10-14T02:17:26.376309",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 78599,
|
||||
"description": "(4) 0.5, 75 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",
|
||||
"category": "BOLT",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A193/A194 GR B7/2H",
|
||||
"quantity": 51,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": [
|
||||
{
|
||||
"group_key": "(4) 0.5, 75 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV|1\"|undefined|ASTM A193/A194 GR B7/2H",
|
||||
"material_ids": [
|
||||
78599
|
||||
],
|
||||
"description": "(4) 0.5, 75 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",
|
||||
"category": "BOLT",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A193/A194 GR B7/2H",
|
||||
"quantity": 51,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
495
backend/exports/PR-20251014-008.json
Normal file
495
backend/exports/PR-20251014-008.json
Normal file
@@ -0,0 +1,495 @@
|
||||
{
|
||||
"request_no": "PR-20251014-008",
|
||||
"job_no": "TKG-25000P",
|
||||
"created_at": "2025-10-14T02:17:50.004262",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 77536,
|
||||
"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 80, ASTM A106 B|3/4\"|undefined|ASTM A106 B",
|
||||
"material_ids": [
|
||||
77536
|
||||
],
|
||||
"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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
55
backend/exports/PR-20251014-009.json
Normal file
55
backend/exports/PR-20251014-009.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"request_no": "PR-20251014-009",
|
||||
"job_no": "TK-MP-TEST-001",
|
||||
"created_at": "2025-10-14T02:24:08.046686",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 88864,
|
||||
"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": 88869,
|
||||
"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": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": [
|
||||
{
|
||||
"group_key": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304|10\"|undefined|ASTM A182 F304",
|
||||
"material_ids": [
|
||||
88864
|
||||
],
|
||||
"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": [
|
||||
88869
|
||||
],
|
||||
"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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
55
backend/exports/PR-20251014-010.json
Normal file
55
backend/exports/PR-20251014-010.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"request_no": "PR-20251014-010",
|
||||
"job_no": "TK-MP-TEST-001",
|
||||
"created_at": "2025-10-14T02:24:14.814790",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 90052,
|
||||
"description": "ON/OFF VALVE, FLG, 600LB",
|
||||
"category": "VALVE",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 90053,
|
||||
"description": "CHECK VALVE, LIFT, SW, 800LB",
|
||||
"category": "VALVE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "-",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": [
|
||||
{
|
||||
"group_key": "ON/OFF VALVE, FLG, 600LB|1\"|undefined|-",
|
||||
"material_ids": [
|
||||
90052
|
||||
],
|
||||
"description": "ON/OFF VALVE, FLG, 600LB",
|
||||
"category": "VALVE",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "CHECK VALVE, LIFT, SW, 800LB|1 1/2\"|undefined|-",
|
||||
"material_ids": [
|
||||
90053
|
||||
],
|
||||
"description": "CHECK VALVE, LIFT, SW, 800LB",
|
||||
"category": "VALVE",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "-",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
32
backend/exports/PR-20251014-011.json
Normal file
32
backend/exports/PR-20251014-011.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"request_no": "PR-20251014-011",
|
||||
"job_no": "TK-MP-TEST-001",
|
||||
"created_at": "2025-10-14T02:24:21.733349",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 89216,
|
||||
"description": "(4) 0.5, 75 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",
|
||||
"category": "BOLT",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A193/A194 GR B7/2H",
|
||||
"quantity": 51,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": [
|
||||
{
|
||||
"group_key": "(4) 0.5, 75 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV|1\"|undefined|ASTM A193/A194 GR B7/2H",
|
||||
"material_ids": [
|
||||
89216
|
||||
],
|
||||
"description": "(4) 0.5, 75 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",
|
||||
"category": "BOLT",
|
||||
"size": "1\"",
|
||||
"material_grade": "ASTM A193/A194 GR B7/2H",
|
||||
"quantity": 51,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
32
backend/exports/PR-20251014-012.json
Normal file
32
backend/exports/PR-20251014-012.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"request_no": "PR-20251014-012",
|
||||
"job_no": "TK-MP-TEST-001",
|
||||
"created_at": "2025-10-14T02:42:08.351432",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 89220,
|
||||
"description": "(4) 0.5, 80 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",
|
||||
"category": "BOLT",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A193/A194 GR B7/2H",
|
||||
"quantity": 32,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": [
|
||||
{
|
||||
"group_key": "(4) 0.5, 80 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV|1 1/2\"|undefined|ASTM A193/A194 GR B7/2H",
|
||||
"material_ids": [
|
||||
89220
|
||||
],
|
||||
"description": "(4) 0.5, 80 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",
|
||||
"category": "BOLT",
|
||||
"size": "1 1/2\"",
|
||||
"material_grade": "ASTM A193/A194 GR B7/2H",
|
||||
"quantity": 32,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
32
backend/exports/PR-20251014-013.json
Normal file
32
backend/exports/PR-20251014-013.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"request_no": "PR-20251014-013",
|
||||
"job_no": "TK-MP-TEST-001",
|
||||
"created_at": "2025-10-14T02:47:17.256790",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 89465,
|
||||
"description": "SWG, 150LB, H/F/I/O SS304/GRAPHITE/SS304/SS304, 4.5mm",
|
||||
"category": "GASKET",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "SS304",
|
||||
"quantity": 44,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": [
|
||||
{
|
||||
"group_key": "SWG, 150LB, H/F/I/O SS304/GRAPHITE/SS304/SS304, 4.5mm|1/2\"|undefined|SS304",
|
||||
"material_ids": [
|
||||
89465
|
||||
],
|
||||
"description": "SWG, 150LB, H/F/I/O SS304/GRAPHITE/SS304/SS304, 4.5mm",
|
||||
"category": "GASKET",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "SS304",
|
||||
"quantity": 44,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
3405
backend/exports/PR-20251014-014.json
Normal file
3405
backend/exports/PR-20251014-014.json
Normal file
File diff suppressed because it is too large
Load Diff
7
backend/exports/PR-20251014-015.json
Normal file
7
backend/exports/PR-20251014-015.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"request_no": "PR-20251014-015",
|
||||
"job_no": "TK-MP-TEST-001",
|
||||
"created_at": "2025-10-14T02:54:15.899037",
|
||||
"materials": [],
|
||||
"grouped_materials": []
|
||||
}
|
||||
43
backend/exports/PR-20251014-016.json
Normal file
43
backend/exports/PR-20251014-016.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"request_no": "PR-20251014-016",
|
||||
"job_no": "TK-MP-TEST-001",
|
||||
"created_at": "2025-10-14T02:54:33.149908",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 88142,
|
||||
"description": "HALF NIPPLE, SMLS, SCH 80S, ASTM A312 TP304 SW X NPT",
|
||||
"category": "FITTING",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": "3.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 88144,
|
||||
"description": "HALF NIPPLE, SMLS, SCH 80S, ASTM A312 TP304 SW X NPT",
|
||||
"category": "FITTING",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": "3.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": [
|
||||
{
|
||||
"group_key": "HALF NIPPLE, SMLS, SCH 80S, ASTM A312 TP304 SW X NPT|1/2\"|undefined|ASTM A312 TP304",
|
||||
"material_ids": [
|
||||
88142,
|
||||
88144
|
||||
],
|
||||
"description": "HALF NIPPLE, SMLS, SCH 80S, ASTM A312 TP304 SW X NPT",
|
||||
"category": "FITTING",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "ASTM A312 TP304",
|
||||
"quantity": "3.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
132
backend/exports/PR-20251014-017.json
Normal file
132
backend/exports/PR-20251014-017.json
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"request_no": "PR-20251014-017",
|
||||
"job_no": "TK-MP-TEST-001",
|
||||
"created_at": "2025-10-14T02:54:45.118843",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 91515,
|
||||
"description": "CLAMP CL-1",
|
||||
"category": "SUPPORT",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": "8.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 91516,
|
||||
"description": "CLAMP CL-1",
|
||||
"category": "SUPPORT",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": "8.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 91517,
|
||||
"description": "CLAMP CL-1",
|
||||
"category": "SUPPORT",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": "8.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 91518,
|
||||
"description": "CLAMP CL-1",
|
||||
"category": "SUPPORT",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": "8.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 91552,
|
||||
"description": "CLAMP CL-1",
|
||||
"category": "SUPPORT",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": "8.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 91553,
|
||||
"description": "CLAMP CL-1",
|
||||
"category": "SUPPORT",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": "8.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 91554,
|
||||
"description": "CLAMP CL-1",
|
||||
"category": "SUPPORT",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": "8.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 91555,
|
||||
"description": "CLAMP CL-1",
|
||||
"category": "SUPPORT",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": "8.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 89471,
|
||||
"description": "SWG, 150LB, H/F/I/O SS304/GRAPHITE/SS304/SS304, 4.5mm",
|
||||
"category": "GASKET",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "SS304",
|
||||
"quantity": 18,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": [
|
||||
{
|
||||
"group_key": "CLAMP CL-1|1\"|undefined|-",
|
||||
"material_ids": [
|
||||
91515,
|
||||
91516,
|
||||
91517,
|
||||
91518,
|
||||
91552,
|
||||
91553,
|
||||
91554,
|
||||
91555
|
||||
],
|
||||
"description": "CLAMP CL-1",
|
||||
"category": "SUPPORT",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": "8.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "SWG, 150LB, H/F/I/O SS304/GRAPHITE/SS304/SS304, 4.5mm|3/4\"|undefined|SS304",
|
||||
"material_ids": [
|
||||
89471
|
||||
],
|
||||
"description": "SWG, 150LB, H/F/I/O SS304/GRAPHITE/SS304/SS304, 4.5mm",
|
||||
"category": "GASKET",
|
||||
"size": "3/4\"",
|
||||
"material_grade": "SS304",
|
||||
"quantity": 18,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
109
backend/exports/PR-20251014-018.json
Normal file
109
backend/exports/PR-20251014-018.json
Normal file
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"request_no": "PR-20251014-018",
|
||||
"job_no": "TK-MP-TEST-001",
|
||||
"created_at": "2025-10-14T02:54:50.900910",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 91515,
|
||||
"description": "CLAMP CL-1",
|
||||
"category": "SUPPORT",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": "8.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 91516,
|
||||
"description": "CLAMP CL-1",
|
||||
"category": "SUPPORT",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": "8.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 91517,
|
||||
"description": "CLAMP CL-1",
|
||||
"category": "SUPPORT",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": "8.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 91518,
|
||||
"description": "CLAMP CL-1",
|
||||
"category": "SUPPORT",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": "8.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 91552,
|
||||
"description": "CLAMP CL-1",
|
||||
"category": "SUPPORT",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": "8.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 91553,
|
||||
"description": "CLAMP CL-1",
|
||||
"category": "SUPPORT",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": "8.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 91554,
|
||||
"description": "CLAMP CL-1",
|
||||
"category": "SUPPORT",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": "8.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 91555,
|
||||
"description": "CLAMP CL-1",
|
||||
"category": "SUPPORT",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": "8.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": [
|
||||
{
|
||||
"group_key": "CLAMP CL-1|1\"|undefined|-",
|
||||
"material_ids": [
|
||||
91515,
|
||||
91516,
|
||||
91517,
|
||||
91518,
|
||||
91552,
|
||||
91553,
|
||||
91554,
|
||||
91555
|
||||
],
|
||||
"description": "CLAMP CL-1",
|
||||
"category": "SUPPORT",
|
||||
"size": "1\"",
|
||||
"material_grade": "-",
|
||||
"quantity": "8.000",
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
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 '구매신청 자재 상세';
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,8 @@ import NewMaterialsPage from './pages/NewMaterialsPage';
|
||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||
import AccountSettingsPage from './pages/AccountSettingsPage';
|
||||
import UserManagementPage from './pages/UserManagementPage';
|
||||
import PurchaseBatchPage from './pages/PurchaseBatchPage';
|
||||
import PurchaseRequestPage from './pages/PurchaseRequestPage';
|
||||
import SystemLogsPage from './pages/SystemLogsPage';
|
||||
import LogMonitoringPage from './pages/LogMonitoringPage';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
@@ -27,6 +29,20 @@ function App() {
|
||||
const [newProjectCode, setNewProjectCode] = useState('');
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [newClientName, setNewClientName] = useState('');
|
||||
const [pendingSignupCount, setPendingSignupCount] = useState(0);
|
||||
|
||||
// 승인 대기 중인 회원가입 수 조회
|
||||
const loadPendingSignups = async () => {
|
||||
try {
|
||||
const response = await api.get('/auth/signup-requests');
|
||||
// API 응답이 { requests: [...], count: ... } 형태
|
||||
const pendingCount = response.data.count || 0;
|
||||
setPendingSignupCount(pendingCount);
|
||||
} catch (error) {
|
||||
console.error('승인 대기 조회 실패:', error);
|
||||
setPendingSignupCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
// 프로젝트 목록 로드
|
||||
const loadProjects = async () => {
|
||||
@@ -143,11 +159,32 @@ function App() {
|
||||
};
|
||||
}, [showUserMenu]);
|
||||
|
||||
// 관리자인 경우 주기적으로 승인 대기 수 확인
|
||||
useEffect(() => {
|
||||
if ((user?.role === 'admin' || user?.role === 'system') && isAuthenticated) {
|
||||
// 초기 로드
|
||||
loadPendingSignups();
|
||||
|
||||
// 30초마다 확인
|
||||
const interval = setInterval(() => {
|
||||
loadPendingSignups();
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [user?.role, isAuthenticated]);
|
||||
|
||||
// 로그인 성공 시 호출될 함수
|
||||
const handleLoginSuccess = () => {
|
||||
const userData = localStorage.getItem('user_data');
|
||||
if (userData) {
|
||||
setUser(JSON.parse(userData));
|
||||
const parsedUser = JSON.parse(userData);
|
||||
setUser(parsedUser);
|
||||
|
||||
// 관리자인 경우 승인 대기 수 확인
|
||||
if (parsedUser?.role === 'admin' || parsedUser?.role === 'system') {
|
||||
loadPendingSignups();
|
||||
}
|
||||
}
|
||||
setIsAuthenticated(true);
|
||||
};
|
||||
@@ -173,8 +210,14 @@ function App() {
|
||||
{
|
||||
id: 'bom',
|
||||
title: '📋 BOM 업로드 & 분류',
|
||||
description: '엑셀 파일 업로드 → 자동 분류 → 검토 → 자재 확인 → 엑셀 내보내기',
|
||||
description: '엑셀 파일 업로드 → 자동 분류 → 검토 → 자재 확인 → 구매신청 (엑셀 내보내기)',
|
||||
color: '#4299e1'
|
||||
},
|
||||
{
|
||||
id: 'purchase-request',
|
||||
title: '📦 구매신청 관리',
|
||||
description: '구매신청한 자재들을 그룹별로 조회하고 엑셀 재다운로드',
|
||||
color: '#10b981'
|
||||
}
|
||||
];
|
||||
};
|
||||
@@ -183,15 +226,20 @@ function App() {
|
||||
const getAdminFeatures = () => {
|
||||
const features = [];
|
||||
|
||||
// 시스템 관리자 전용 기능
|
||||
if (user?.role === 'system') {
|
||||
console.log('getAdminFeatures - Current user:', user);
|
||||
console.log('getAdminFeatures - User role:', user?.role);
|
||||
console.log('getAdminFeatures - Pending count:', pendingSignupCount);
|
||||
|
||||
// 시스템 관리자 기능 (admin role이 시스템 관리자)
|
||||
if (user?.role === 'admin') {
|
||||
features.push(
|
||||
{
|
||||
id: 'user-management',
|
||||
title: '👥 사용자 관리',
|
||||
description: '계정 생성, 역할 변경, 사용자 삭제',
|
||||
description: '계정 생성, 역할 변경, 회원가입 승인',
|
||||
color: '#dc2626',
|
||||
badge: '시스템 관리자'
|
||||
badge: '시스템 관리자',
|
||||
pendingCount: pendingSignupCount
|
||||
},
|
||||
{
|
||||
id: 'system-logs',
|
||||
@@ -203,8 +251,24 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 관리자 기능
|
||||
if (user?.role === 'manager') {
|
||||
// 일반 관리자는 회원가입 승인만 가능
|
||||
features.push(
|
||||
{
|
||||
id: 'user-management',
|
||||
title: '👥 회원가입 승인',
|
||||
description: '신규 회원가입 승인 및 거부',
|
||||
color: '#dc2626',
|
||||
badge: '관리자',
|
||||
pendingCount: pendingSignupCount
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 관리자 이상 공통 기능
|
||||
if (user?.role === 'admin' || user?.role === 'system') {
|
||||
if (user?.role === 'admin' || user?.role === 'manager') {
|
||||
|
||||
features.push(
|
||||
{
|
||||
id: 'log-monitoring',
|
||||
@@ -661,13 +725,12 @@ function App() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핵심 기능 */}
|
||||
{/* 핵심 기능 - 프로젝트 선택 시만 표시 */}
|
||||
{selectedProject && (
|
||||
<>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748', marginBottom: '16px' }}>
|
||||
📋 BOM 관리 워크플로우
|
||||
</h2>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748', marginBottom: '16px' }}>
|
||||
📋 BOM 관리 워크플로우
|
||||
</h2>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
@@ -701,7 +764,10 @@ function App() {
|
||||
{feature.description}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigateToPage(feature.id, { selectedProject })}
|
||||
onClick={() => navigateToPage(feature.id, {
|
||||
selectedProject,
|
||||
jobNo: selectedProject?.official_project_code
|
||||
})}
|
||||
style={{
|
||||
background: feature.color,
|
||||
color: 'white',
|
||||
@@ -720,8 +786,9 @@ function App() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)} {/* selectedProject 조건문 닫기 */}
|
||||
|
||||
{/* 관리자 기능 (있는 경우만) */}
|
||||
{/* 관리자 기능 (프로젝트 선택과 무관하게 항상 표시) */}
|
||||
{adminFeatures.length > 0 && (
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748', marginBottom: '16px' }}>
|
||||
@@ -753,8 +820,29 @@ function App() {
|
||||
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.07)';
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', marginBottom: '12px' }}>
|
||||
{feature.title}
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#2d3748',
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<span>{feature.title}</span>
|
||||
{feature.id === 'user-management' && feature.pendingCount > 0 && (
|
||||
<span style={{
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '2px 8px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
animation: 'pulse 2s ease-in-out infinite'
|
||||
}}>
|
||||
{feature.pendingCount}명 대기
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p style={{ color: '#718096', marginBottom: '16px', fontSize: '14px' }}>
|
||||
{feature.description}
|
||||
@@ -853,8 +941,7 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)} {/* selectedProject 조건문 닫기 */}
|
||||
)} {/* adminFeatures 조건문 닫기 */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -880,6 +967,25 @@ function App() {
|
||||
filename={pageParams.filename}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'purchase-batch':
|
||||
return (
|
||||
<PurchaseBatchPage
|
||||
onNavigate={navigateToPage}
|
||||
fileId={pageParams.file_id}
|
||||
jobNo={pageParams.jobNo}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'purchase-request':
|
||||
return (
|
||||
<PurchaseRequestPage
|
||||
onNavigate={navigateToPage}
|
||||
fileId={pageParams.file_id}
|
||||
jobNo={pageParams.jobNo}
|
||||
selectedProject={pageParams.selectedProject}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'system-settings':
|
||||
return (
|
||||
|
||||
@@ -294,7 +294,7 @@
|
||||
background: white;
|
||||
margin: 16px 24px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overflow-x: auto; /* 좌우 스크롤 가능하도록 변경 */
|
||||
max-height: calc(100vh - 220px);
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
@@ -431,40 +431,40 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* U-BOLT 전용 헤더 - 8개 컬럼 */
|
||||
.detailed-grid-header.ubolt-header {
|
||||
grid-template-columns: 3% 11% 15% 10% 20% 12% 18% 10%;
|
||||
/* SUPPORT 전용 헤더 - 8개 컬럼 */
|
||||
.detailed-grid-header.support-header {
|
||||
grid-template-columns: 3% 10% 12% 10% 25% 10% 18% 12%;
|
||||
|
||||
|
||||
}
|
||||
|
||||
/* U-BOLT 전용 행 - 8개 컬럼 */
|
||||
.detailed-material-row.ubolt-row {
|
||||
grid-template-columns: 3% 11% 15% 10% 20% 12% 18% 10%;
|
||||
/* SUPPORT 전용 행 - 8개 컬럼 */
|
||||
.detailed-material-row.support-row {
|
||||
grid-template-columns: 3% 10% 12% 10% 25% 10% 18% 12%;
|
||||
|
||||
|
||||
}
|
||||
|
||||
/* U-BOLT 헤더 테두리 */
|
||||
.detailed-grid-header.ubolt-header > div,
|
||||
.detailed-grid-header.ubolt-header .filterable-header {
|
||||
/* SUPPORT 헤더 테두리 */
|
||||
.detailed-grid-header.support-header > div,
|
||||
.detailed-grid-header.support-header .filterable-header {
|
||||
border-right: 1px solid #d1d5db;
|
||||
}
|
||||
.detailed-grid-header.ubolt-header > div:last-child,
|
||||
.detailed-grid-header.ubolt-header .filterable-header:last-child {
|
||||
.detailed-grid-header.support-header > div:last-child,
|
||||
.detailed-grid-header.support-header .filterable-header:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* U-BOLT 행 테두리 */
|
||||
.detailed-material-row.ubolt-row .material-cell {
|
||||
/* SUPPORT 행 테두리 */
|
||||
.detailed-material-row.support-row .material-cell {
|
||||
border-right: 1px solid #d1d5db;
|
||||
}
|
||||
.detailed-material-row.ubolt-row .material-cell:last-child {
|
||||
.detailed-material-row.support-row .material-cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* U-BOLT 타입 배지 */
|
||||
.type-badge.ubolt {
|
||||
/* SUPPORT 타입 배지 */
|
||||
.type-badge.support {
|
||||
background: #059669;
|
||||
color: white;
|
||||
border: 2px solid #047857;
|
||||
@@ -533,7 +533,7 @@
|
||||
|
||||
/* 플랜지 전용 헤더 - 10개 컬럼 */
|
||||
.detailed-grid-header.flange-header {
|
||||
grid-template-columns: 2% 8% 12% 8% 10% 10% 18% 10% 15% 6%;
|
||||
grid-template-columns: 1.5% 8.5% 12% 8% 10% 10% 15% 8% 12% 11.5%;
|
||||
|
||||
|
||||
}
|
||||
@@ -550,7 +550,7 @@
|
||||
|
||||
/* 플랜지 전용 행 - 10개 컬럼 */
|
||||
.detailed-material-row.flange-row {
|
||||
grid-template-columns: 1.5% 8.5% 12% 8% 10% 10% 18% 10% 15% 6%;
|
||||
grid-template-columns: 1.5% 8.5% 12% 8% 10% 10% 15% 8% 12% 11.5%;
|
||||
|
||||
|
||||
}
|
||||
@@ -565,7 +565,7 @@
|
||||
|
||||
/* 피팅 전용 헤더 - 10개 컬럼 */
|
||||
.detailed-grid-header.fitting-header {
|
||||
grid-template-columns: 2% 8% 20% 8% 8% 10% 18% 10% 15% 0%;
|
||||
grid-template-columns: 2% 8.5% 16% 7.5% 7.5% 9% 15% 9% 13% 12%;
|
||||
|
||||
|
||||
}
|
||||
@@ -582,7 +582,7 @@
|
||||
|
||||
/* 피팅 전용 행 - 10개 컬럼 */
|
||||
.detailed-material-row.fitting-row {
|
||||
grid-template-columns: 2% 8% 20% 8% 8% 10% 18% 10% 15% 0%;
|
||||
grid-template-columns: 2% 8.5% 16% 7.5% 7.5% 9% 15% 9% 13% 12%;
|
||||
|
||||
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
388
frontend/src/pages/PurchaseBatchPage.jsx
Normal file
388
frontend/src/pages/PurchaseBatchPage.jsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
const PurchaseBatchPage = ({ onNavigate, fileId, jobNo }) => {
|
||||
const [batches, setBatches] = useState([]);
|
||||
const [selectedBatch, setSelectedBatch] = useState(null);
|
||||
const [batchMaterials, setBatchMaterials] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('all'); // all, pending, requested, ordered, received
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
|
||||
useEffect(() => {
|
||||
loadBatches();
|
||||
}, [fileId, jobNo]);
|
||||
|
||||
const loadBatches = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const params = {};
|
||||
if (fileId) params.file_id = fileId;
|
||||
if (jobNo) params.job_no = jobNo;
|
||||
|
||||
const response = await api.get('/export/batches', { params });
|
||||
setBatches(response.data.batches || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load batches:', error);
|
||||
setMessage({ type: 'error', text: '배치 목록 로드 실패' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadBatchMaterials = async (exportId) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await api.get(`/export/batch/${exportId}/materials`);
|
||||
setBatchMaterials(response.data.materials || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load batch materials:', error);
|
||||
setMessage({ type: 'error', text: '자재 목록 로드 실패' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchSelect = (batch) => {
|
||||
setSelectedBatch(batch);
|
||||
loadBatchMaterials(batch.export_id);
|
||||
};
|
||||
|
||||
const handleDownloadExcel = async (exportId, batchNo) => {
|
||||
try {
|
||||
const response = await api.get(`/export/batch/${exportId}/download`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `batch_${batchNo}.xlsx`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
setMessage({ type: 'success', text: '엑셀 다운로드 완료' });
|
||||
} catch (error) {
|
||||
console.error('Failed to download excel:', error);
|
||||
setMessage({ type: 'error', text: '엑셀 다운로드 실패' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchStatusUpdate = async (exportId, newStatus) => {
|
||||
try {
|
||||
const prNo = prompt('구매요청 번호 (PR)를 입력하세요:');
|
||||
const response = await api.patch(`/export/batch/${exportId}/status`, {
|
||||
status: newStatus,
|
||||
purchase_request_no: prNo
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setMessage({ type: 'success', text: response.data.message });
|
||||
loadBatches();
|
||||
if (selectedBatch?.export_id === exportId) {
|
||||
loadBatchMaterials(exportId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update batch status:', error);
|
||||
setMessage({ type: 'error', text: '상태 업데이트 실패' });
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const styles = {
|
||||
pending: { bg: '#FFFFE0', color: '#856404', text: '구매 전' },
|
||||
requested: { bg: '#FFE4B5', color: '#8B4513', text: '구매신청' },
|
||||
in_progress: { bg: '#ADD8E6', color: '#00008B', text: '진행중' },
|
||||
ordered: { bg: '#87CEEB', color: '#4682B4', text: '발주완료' },
|
||||
received: { bg: '#90EE90', color: '#228B22', text: '입고완료' },
|
||||
completed: { bg: '#98FB98', color: '#006400', text: '완료' }
|
||||
};
|
||||
|
||||
const style = styles[status] || styles.pending;
|
||||
|
||||
return (
|
||||
<span style={{
|
||||
background: style.bg,
|
||||
color: style.color,
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{style.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const filteredBatches = activeTab === 'all'
|
||||
? batches
|
||||
: batches.filter(b => b.batch_status === activeTab);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
구매 배치 관리
|
||||
</h1>
|
||||
<p style={{ color: '#6c757d' }}>
|
||||
엑셀로 내보낸 자재들을 배치 단위로 관리합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 메시지 */}
|
||||
{message.text && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
marginBottom: '16px',
|
||||
borderRadius: '8px',
|
||||
background: message.type === 'error' ? '#fee' : '#e6ffe6',
|
||||
color: message.type === 'error' ? '#dc3545' : '#28a745',
|
||||
border: `1px solid ${message.type === 'error' ? '#fcc' : '#cfc'}`
|
||||
}}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '20px' }}>
|
||||
{[
|
||||
{ key: 'all', label: '전체' },
|
||||
{ key: 'pending', label: '구매 전' },
|
||||
{ key: 'requested', label: '구매신청' },
|
||||
{ key: 'in_progress', label: '진행중' },
|
||||
{ key: 'completed', label: '완료' }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border: activeTab === tab.key ? '2px solid #007bff' : '1px solid #dee2e6',
|
||||
background: activeTab === tab.key ? '#007bff' : 'white',
|
||||
color: activeTab === tab.key ? 'white' : '#495057',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '400px 1fr', gap: '24px' }}>
|
||||
{/* 배치 목록 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
background: '#f8f9fa'
|
||||
}}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: '600', margin: 0 }}>
|
||||
배치 목록 ({filteredBatches.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ maxHeight: '600px', overflow: 'auto' }}>
|
||||
{isLoading ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||
로딩중...
|
||||
</div>
|
||||
) : filteredBatches.length === 0 ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#6c757d' }}>
|
||||
배치가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
filteredBatches.map(batch => (
|
||||
<div
|
||||
key={batch.export_id}
|
||||
onClick={() => handleBatchSelect(batch)}
|
||||
style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #f1f3f4',
|
||||
cursor: 'pointer',
|
||||
background: selectedBatch?.export_id === batch.export_id ? '#f0f8ff' : 'white',
|
||||
transition: 'background 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedBatch?.export_id !== batch.export_id) {
|
||||
e.currentTarget.style.background = '#f8f9fa';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedBatch?.export_id !== batch.export_id) {
|
||||
e.currentTarget.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontWeight: '600', fontSize: '14px' }}>
|
||||
{batch.batch_no}
|
||||
</span>
|
||||
{getStatusBadge(batch.batch_status)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '12px', color: '#6c757d', marginBottom: '4px' }}>
|
||||
{batch.job_no} - {batch.job_name}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '12px', color: '#6c757d', marginBottom: '8px' }}>
|
||||
{batch.category || '전체'} | {batch.material_count}개 자재
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', fontSize: '11px' }}>
|
||||
<span style={{ background: '#e9ecef', padding: '2px 6px', borderRadius: '4px' }}>
|
||||
대기: {batch.status_detail.pending}
|
||||
</span>
|
||||
<span style={{ background: '#fff3cd', padding: '2px 6px', borderRadius: '4px' }}>
|
||||
신청: {batch.status_detail.requested}
|
||||
</span>
|
||||
<span style={{ background: '#cce5ff', padding: '2px 6px', borderRadius: '4px' }}>
|
||||
발주: {batch.status_detail.ordered}
|
||||
</span>
|
||||
<span style={{ background: '#d4edda', padding: '2px 6px', borderRadius: '4px' }}>
|
||||
입고: {batch.status_detail.received}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '8px', fontSize: '11px', color: '#6c757d' }}>
|
||||
{new Date(batch.export_date).toLocaleDateString()} | {batch.exported_by}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 배치 상세 */}
|
||||
{selectedBatch ? (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
background: '#f8f9fa'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: '600', margin: 0 }}>
|
||||
배치 {selectedBatch.batch_no}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => handleDownloadExcel(selectedBatch.export_id, selectedBatch.batch_no)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
📥 엑셀 다운로드
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBatchStatusUpdate(selectedBatch.export_id, 'requested')}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#ffc107',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
구매신청
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px' }}>
|
||||
<div style={{ overflow: 'auto', maxHeight: '500px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8f9fa' }}>
|
||||
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>No</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>카테고리</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>자재 설명</th>
|
||||
<th style={{ padding: '8px', textAlign: 'center', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>수량</th>
|
||||
<th style={{ padding: '8px', textAlign: 'center', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>상태</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>PR번호</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>PO번호</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batchMaterials.map((material, idx) => (
|
||||
<tr key={material.exported_material_id} style={{ borderBottom: '1px solid #f1f3f4' }}>
|
||||
<td style={{ padding: '8px', fontSize: '12px' }}>{idx + 1}</td>
|
||||
<td style={{ padding: '8px', fontSize: '12px' }}>
|
||||
<span style={{
|
||||
background: '#e9ecef',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
{material.category}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '8px', fontSize: '12px' }}>{material.description}</td>
|
||||
<td style={{ padding: '8px', fontSize: '12px', textAlign: 'center' }}>
|
||||
{material.quantity} {material.unit}
|
||||
</td>
|
||||
<td style={{ padding: '8px', textAlign: 'center' }}>
|
||||
{getStatusBadge(material.purchase_status)}
|
||||
</td>
|
||||
<td style={{ padding: '8px', fontSize: '12px' }}>
|
||||
{material.purchase_request_no || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '8px', fontSize: '12px' }}>
|
||||
{material.purchase_order_no || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '400px'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', color: '#6c757d' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📦</div>
|
||||
<div style={{ fontSize: '16px' }}>배치를 선택하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseBatchPage;
|
||||
211
frontend/src/pages/PurchaseRequestPage.css
Normal file
211
frontend/src/pages/PurchaseRequestPage.css
Normal file
@@ -0,0 +1,211 @@
|
||||
.purchase-request-page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #007bff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #6c757d;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: grid;
|
||||
grid-template-columns: 400px 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 구매신청 목록 패널 */
|
||||
.requests-panel {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
background: #f8f9fa;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.requests-list {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.request-card {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f1f3f4;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.request-card:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.request-card.selected {
|
||||
background: #e7f3ff;
|
||||
}
|
||||
|
||||
.request-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.request-no {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.request-date {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.request-info {
|
||||
font-size: 13px;
|
||||
color: #495057;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.material-count {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.request-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.requested-by {
|
||||
font-size: 11px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
/* 상세 패널 */
|
||||
.details-panel {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.excel-btn {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.excel-btn:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.materials-table {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.materials-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.materials-table th {
|
||||
background: #f8f9fa;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.materials-table td {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #f1f3f4;
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
background: #e9ecef;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
}
|
||||
274
frontend/src/pages/PurchaseRequestPage.jsx
Normal file
274
frontend/src/pages/PurchaseRequestPage.jsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
import { exportMaterialsToExcel } from '../utils/excelExport';
|
||||
import './PurchaseRequestPage.css';
|
||||
|
||||
const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) => {
|
||||
const [requests, setRequests] = useState([]);
|
||||
const [selectedRequest, setSelectedRequest] = useState(null);
|
||||
const [requestMaterials, setRequestMaterials] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadRequests();
|
||||
}, [fileId, jobNo]);
|
||||
|
||||
const loadRequests = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const params = {};
|
||||
if (fileId) params.file_id = fileId;
|
||||
if (jobNo) params.job_no = jobNo;
|
||||
|
||||
const response = await api.get('/purchase-request/list', { params });
|
||||
setRequests(response.data.requests || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load requests:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRequestMaterials = async (requestId) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await api.get(`/purchase-request/${requestId}/materials`);
|
||||
// 그룹화된 자재가 있으면 우선 표시, 없으면 개별 자재 표시
|
||||
if (response.data.grouped_materials && response.data.grouped_materials.length > 0) {
|
||||
setRequestMaterials(response.data.grouped_materials);
|
||||
} else {
|
||||
setRequestMaterials(response.data.materials || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load materials:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestSelect = (request) => {
|
||||
setSelectedRequest(request);
|
||||
loadRequestMaterials(request.request_id);
|
||||
};
|
||||
|
||||
const handleDownloadExcel = async (requestId, requestNo) => {
|
||||
try {
|
||||
// 서버에서 자재 데이터 가져오기
|
||||
const response = await api.get(`/purchase-request/${requestId}/download-excel`);
|
||||
|
||||
if (response.data.success) {
|
||||
const materials = response.data.materials;
|
||||
const groupedMaterials = response.data.grouped_materials || [];
|
||||
const jobNo = response.data.job_no;
|
||||
|
||||
// 사용자 요구사항 매핑
|
||||
const userRequirements = {};
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement) {
|
||||
userRequirements[material.material_id || material.id] = material.user_requirement;
|
||||
}
|
||||
});
|
||||
|
||||
// 그룹화된 자재가 있으면 그것을 사용, 없으면 원본 자재 사용
|
||||
const dataToExport = groupedMaterials.length > 0 ? groupedMaterials : materials;
|
||||
|
||||
// 파일명 생성
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
const fileName = `${jobNo}_${requestNo}_${timestamp}.xlsx`;
|
||||
|
||||
// 기존 엑셀 유틸리티 사용하여 엑셀 생성
|
||||
// 그룹화된 데이터와 사용자 요구사항 전달
|
||||
exportMaterialsToExcel(dataToExport, fileName, {
|
||||
jobNo,
|
||||
requestNo,
|
||||
userRequirements
|
||||
});
|
||||
} else {
|
||||
alert('데이터를 가져올 수 없습니다');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to download excel:', error);
|
||||
alert('엑셀 다운로드 실패');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="purchase-request-page">
|
||||
<div className="page-header">
|
||||
<button onClick={() => onNavigate('bom', { selectedProject })} className="back-btn">
|
||||
← BOM 관리로 돌아가기
|
||||
</button>
|
||||
<h1>구매신청 관리</h1>
|
||||
<p className="subtitle">구매신청한 자재들을 그룹별로 관리합니다</p>
|
||||
</div>
|
||||
|
||||
<div className="main-content">
|
||||
{/* 구매신청 목록 */}
|
||||
<div className="requests-panel">
|
||||
<div className="panel-header">
|
||||
<h2>구매신청 목록 ({requests.length})</h2>
|
||||
</div>
|
||||
|
||||
<div className="requests-list">
|
||||
{isLoading ? (
|
||||
<div className="loading">로딩중...</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="empty-state">구매신청이 없습니다</div>
|
||||
) : (
|
||||
requests.map(request => (
|
||||
<div
|
||||
key={request.request_id}
|
||||
className={`request-card ${selectedRequest?.request_id === request.request_id ? 'selected' : ''}`}
|
||||
onClick={() => handleRequestSelect(request)}
|
||||
>
|
||||
<div className="request-header">
|
||||
<span className="request-no">{request.request_no}</span>
|
||||
<span className="request-date">
|
||||
{new Date(request.requested_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="request-info">
|
||||
<div>{request.job_no} - {request.job_name}</div>
|
||||
<div className="material-count">
|
||||
{request.category || '전체'} | {request.material_count}개 자재
|
||||
</div>
|
||||
</div>
|
||||
<div className="request-footer">
|
||||
<span className="requested-by">{request.requested_by}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownloadExcel(request.request_id, request.request_no);
|
||||
}}
|
||||
className="download-btn"
|
||||
>
|
||||
📥 엑셀
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 구매신청 상세 */}
|
||||
<div className="details-panel">
|
||||
{selectedRequest ? (
|
||||
<>
|
||||
<div className="panel-header">
|
||||
<h2>{selectedRequest.request_no}</h2>
|
||||
<button
|
||||
onClick={() => handleDownloadExcel(selectedRequest.request_id, selectedRequest.request_no)}
|
||||
className="excel-btn"
|
||||
>
|
||||
📥 엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="materials-table">
|
||||
{/* 카테고리별로 그룹화하여 표시 */}
|
||||
{(() => {
|
||||
// 카테고리별로 자재 그룹화
|
||||
const groupedByCategory = requestMaterials.reduce((acc, material) => {
|
||||
const category = material.category || 'UNKNOWN';
|
||||
if (!acc[category]) acc[category] = [];
|
||||
acc[category].push(material);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.entries(groupedByCategory).map(([category, materials]) => (
|
||||
<div key={category} style={{ marginBottom: '30px' }}>
|
||||
<h3 style={{
|
||||
background: '#f0f0f0',
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{category} ({materials.length}개)
|
||||
</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>No</th>
|
||||
<th>카테고리</th>
|
||||
<th>자재 설명</th>
|
||||
<th>크기</th>
|
||||
{category === 'BOLT' ? <th>길이</th> : <th>스케줄</th>}
|
||||
<th>재질</th>
|
||||
<th>수량</th>
|
||||
<th>사용자요구</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{materials.map((material, idx) => (
|
||||
<tr key={material.item_id || `${category}-${idx}`}>
|
||||
<td>{idx + 1}</td>
|
||||
<td>
|
||||
<span className="category-badge">
|
||||
{material.category}
|
||||
</span>
|
||||
</td>
|
||||
<td>{material.description}</td>
|
||||
<td>{material.size || '-'}</td>
|
||||
<td>{material.schedule || '-'}</td>
|
||||
<td>{material.material_grade || '-'}</td>
|
||||
<td>
|
||||
{material.category === 'PIPE' ? (
|
||||
<div>
|
||||
<span style={{ fontWeight: 'bold' }}>
|
||||
{(() => {
|
||||
// 총 길이와 개수 계산
|
||||
let totalLengthMm = material.total_length || 0;
|
||||
let totalCount = 0;
|
||||
|
||||
if (material.pipe_lengths && material.pipe_lengths.length > 0) {
|
||||
// pipe_lengths 배열에서 총 개수 계산
|
||||
totalCount = material.pipe_lengths.reduce((sum, p) => sum + parseFloat(p.quantity || 0), 0);
|
||||
} else if (material.material_ids && material.material_ids.length > 0) {
|
||||
totalCount = material.material_ids.length;
|
||||
if (!totalLengthMm) {
|
||||
totalLengthMm = totalCount * 6000;
|
||||
}
|
||||
} else {
|
||||
totalCount = parseFloat(material.quantity) || 1;
|
||||
if (!totalLengthMm) {
|
||||
totalLengthMm = totalCount * 6000;
|
||||
}
|
||||
}
|
||||
|
||||
// 6,000mm를 1본으로 계산
|
||||
const pipeCount = Math.ceil(totalLengthMm / 6000);
|
||||
|
||||
// 형식: 2본(11,000mm/40개)
|
||||
return `${pipeCount}본(${totalLengthMm.toLocaleString()}mm/${totalCount}개)`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
`${material.quantity} ${material.unit || 'EA'}`
|
||||
)}
|
||||
</td>
|
||||
<td>{material.user_requirement || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">📦</div>
|
||||
<div>구매신청을 선택하세요</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseRequestPage;
|
||||
@@ -4,6 +4,9 @@ import { reportError, logUserActionError } from '../utils/errorLogger';
|
||||
|
||||
const UserManagementPage = ({ onNavigate, user }) => {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [pendingUsers, setPendingUsers] = useState([]);
|
||||
const [suspendedUsers, setSuspendedUsers] = useState([]);
|
||||
const [activeTab, setActiveTab] = useState('pending'); // 'pending', 'active', or 'suspended'
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
@@ -25,6 +28,8 @@ const UserManagementPage = ({ onNavigate, user }) => {
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
loadPendingUsers();
|
||||
loadSuspendedUsers();
|
||||
}, []);
|
||||
|
||||
const loadUsers = async () => {
|
||||
@@ -46,6 +51,95 @@ const UserManagementPage = ({ onNavigate, user }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadPendingUsers = async () => {
|
||||
try {
|
||||
const response = await api.get('/auth/signup-requests');
|
||||
// API 응답에서 requests 배열을 가져옴
|
||||
if (response.data.success && response.data.requests) {
|
||||
setPendingUsers(response.data.requests);
|
||||
} else {
|
||||
setPendingUsers([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load pending users error:', err);
|
||||
// 에러는 무시하고 빈 배열로 설정
|
||||
setPendingUsers([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveUser = async (userId) => {
|
||||
try {
|
||||
const response = await api.post(`/auth/approve-signup/${userId}`);
|
||||
if (response.data.success) {
|
||||
setMessage({ type: 'success', text: '사용자가 승인되었습니다' });
|
||||
loadPendingUsers();
|
||||
loadUsers();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Approve user error:', err);
|
||||
setMessage({ type: 'error', text: '승인 중 오류가 발생했습니다' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectUser = async (userId) => {
|
||||
if (!window.confirm('정말로 이 가입 요청을 거부하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const response = await api.delete(`/auth/reject-signup/${userId}`);
|
||||
if (response.data.success) {
|
||||
setMessage({ type: 'success', text: '가입 요청이 거부되었습니다' });
|
||||
loadPendingUsers();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Reject user error:', err);
|
||||
setMessage({ type: 'error', text: '거부 중 오류가 발생했습니다' });
|
||||
}
|
||||
};
|
||||
|
||||
const loadSuspendedUsers = async () => {
|
||||
try {
|
||||
const response = await api.get('/auth/users/suspended');
|
||||
if (response.data.success) {
|
||||
setSuspendedUsers(response.data.users);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load suspended users error:', err);
|
||||
setSuspendedUsers([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuspendUser = async (userToSuspend) => {
|
||||
if (!window.confirm(`정말로 ${userToSuspend.name} 사용자를 정지하시겠습니까?`)) return;
|
||||
|
||||
try {
|
||||
const response = await api.patch(`/auth/users/${userToSuspend.user_id}/suspend`);
|
||||
if (response.data.success) {
|
||||
setMessage({ type: 'success', text: response.data.message });
|
||||
loadUsers();
|
||||
loadSuspendedUsers();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Suspend user error:', err);
|
||||
setMessage({ type: 'error', text: '사용자 정지 중 오류가 발생했습니다' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleReactivateUser = async (userToReactivate) => {
|
||||
if (!window.confirm(`${userToReactivate.name} 사용자를 재활성화하시겠습니까?`)) return;
|
||||
|
||||
try {
|
||||
const response = await api.patch(`/auth/users/${userToReactivate.user_id}/reactivate`);
|
||||
if (response.data.success) {
|
||||
setMessage({ type: 'success', text: response.data.message });
|
||||
loadUsers();
|
||||
loadSuspendedUsers();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Reactivate user error:', err);
|
||||
setMessage({ type: 'error', text: '사용자 재활성화 중 오류가 발생했습니다' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateUser = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -256,22 +350,267 @@ const UserManagementPage = ({ onNavigate, user }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 사용자 목록 */}
|
||||
{/* 탭 네비게이션 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden'
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
marginBottom: '24px',
|
||||
borderBottom: '2px solid #e9ecef',
|
||||
paddingBottom: '0'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setActiveTab('pending')}
|
||||
style={{
|
||||
background: activeTab === 'pending' ? 'white' : 'transparent',
|
||||
color: activeTab === 'pending' ? '#007bff' : '#6c757d',
|
||||
border: activeTab === 'pending' ? '2px solid #e9ecef' : '2px solid transparent',
|
||||
borderBottom: activeTab === 'pending' ? '2px solid white' : '2px solid transparent',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '-2px',
|
||||
position: 'relative',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
승인 대기
|
||||
{pendingUsers.length > 0 && (
|
||||
<span style={{
|
||||
background: '#dc3545',
|
||||
color: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '11px',
|
||||
marginLeft: '8px'
|
||||
}}>
|
||||
{pendingUsers.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('active')}
|
||||
style={{
|
||||
background: activeTab === 'active' ? 'white' : 'transparent',
|
||||
color: activeTab === 'active' ? '#007bff' : '#6c757d',
|
||||
border: activeTab === 'active' ? '2px solid #e9ecef' : '2px solid transparent',
|
||||
borderBottom: activeTab === 'active' ? '2px solid white' : '2px solid transparent',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '-2px',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
등록된 사용자 ({users.length}명)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('suspended')}
|
||||
style={{
|
||||
background: activeTab === 'suspended' ? 'white' : 'transparent',
|
||||
color: activeTab === 'suspended' ? '#007bff' : '#6c757d',
|
||||
border: activeTab === 'suspended' ? '2px solid #e9ecef' : '2px solid transparent',
|
||||
borderBottom: activeTab === 'suspended' ? '2px solid white' : '2px solid transparent',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '-2px',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
정지된 사용자
|
||||
{suspendedUsers.length > 0 && (
|
||||
<span style={{
|
||||
background: '#ffc107',
|
||||
color: '#856404',
|
||||
borderRadius: '12px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '11px',
|
||||
marginLeft: '8px'
|
||||
}}>
|
||||
{suspendedUsers.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 승인 대기 사용자 목록 */}
|
||||
{activeTab === 'pending' && (
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
background: '#f8f9fa'
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
|
||||
등록된 사용자 ({users.length}명)
|
||||
</h2>
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
background: '#fff5f5'
|
||||
}}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
|
||||
승인 대기 사용자 ({pendingUsers.length}명)
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{pendingUsers.length === 0 ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '16px', color: '#6c757d' }}>승인 대기 중인 사용자가 없습니다</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8f9fa' }}>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>사용자</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>역할</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>부서/직책</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>상태</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>가입일</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'center', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pendingUsers.map((userItem) => (
|
||||
<tr key={userItem.user_id} style={{ borderBottom: '1px solid #f1f3f4' }}>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
background: '#e9ecef',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#6c757d',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{userItem.name ? userItem.name.charAt(0).toUpperCase() : '?'}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||
{userItem.name || '이름 없음'}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
@{userItem.username}
|
||||
</div>
|
||||
{userItem.email && (
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
{userItem.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<span style={{
|
||||
background: '#e9ecef',
|
||||
color: '#495057',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{userItem.role || '사용자'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#495057' }}>
|
||||
{userItem.department || '-'}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
{userItem.position || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<span style={{
|
||||
background: '#fff3cd',
|
||||
color: '#856404',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
승인 대기
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
{userItem.created_at ? new Date(userItem.created_at).toLocaleDateString() : '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
|
||||
<button
|
||||
onClick={() => handleApproveUser(userItem.user_id)}
|
||||
style={{
|
||||
background: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.backgroundColor = '#218838'}
|
||||
onMouseLeave={(e) => e.target.style.backgroundColor = '#28a745'}
|
||||
>
|
||||
승인
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRejectUser(userItem.user_id)}
|
||||
style={{
|
||||
background: '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.backgroundColor = '#c82333'}
|
||||
onMouseLeave={(e) => e.target.style.backgroundColor = '#dc3545'}
|
||||
>
|
||||
거부
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 등록된 사용자 목록 */}
|
||||
{activeTab === 'active' && (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
background: '#f8f9fa'
|
||||
}}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
|
||||
등록된 사용자 ({users.length}명)
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||
@@ -388,6 +727,26 @@ const UserManagementPage = ({ onNavigate, user }) => {
|
||||
>
|
||||
역할 변경
|
||||
</button>
|
||||
{userItem.user_id !== user?.user_id && (
|
||||
<button
|
||||
onClick={() => handleSuspendUser(userItem)}
|
||||
style={{
|
||||
background: '#ff9800',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.backgroundColor = '#e68900'}
|
||||
onMouseLeave={(e) => e.target.style.backgroundColor = '#ff9800'}
|
||||
title="사용자 정지"
|
||||
>
|
||||
정지
|
||||
</button>
|
||||
)}
|
||||
{userItem.user_id !== user?.user_id && (
|
||||
<button
|
||||
onClick={() => handleDeleteUser(userItem)}
|
||||
@@ -417,7 +776,165 @@ const UserManagementPage = ({ onNavigate, user }) => {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 정지된 사용자 목록 */}
|
||||
{activeTab === 'suspended' && (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
background: '#fff8e1'
|
||||
}}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
|
||||
정지된 사용자 ({suspendedUsers.length}명)
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{suspendedUsers.length === 0 ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '16px', color: '#6c757d' }}>정지된 사용자가 없습니다</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8f9fa' }}>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>사용자</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>역할</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>부서/직책</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>상태</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>정지일</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'center', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{suspendedUsers.map((userItem) => (
|
||||
<tr key={userItem.user_id} style={{ borderBottom: '1px solid #f1f3f4' }}>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
background: '#ffc107',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{userItem.name ? userItem.name.charAt(0).toUpperCase() : '?'}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||
{userItem.name || '이름 없음'}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
@{userItem.username}
|
||||
</div>
|
||||
{userItem.email && (
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
{userItem.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<span style={{
|
||||
background: getRoleBadgeColor(userItem.role).bg,
|
||||
color: getRoleBadgeColor(userItem.role).color,
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{getRoleDisplayName(userItem.role)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#495057' }}>
|
||||
{userItem.department || '-'}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
{userItem.position || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<span style={{
|
||||
background: '#fff3cd',
|
||||
color: '#856404',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
정지됨
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
{userItem.updated_at ? new Date(userItem.updated_at).toLocaleDateString() : '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
|
||||
<button
|
||||
onClick={() => handleReactivateUser(userItem)}
|
||||
style={{
|
||||
background: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.backgroundColor = '#218838'}
|
||||
onMouseLeave={(e) => e.target.style.backgroundColor = '#28a745'}
|
||||
>
|
||||
재활성화
|
||||
</button>
|
||||
{userItem.user_id !== user?.user_id && (
|
||||
<button
|
||||
onClick={() => handleDeleteUser(userItem)}
|
||||
style={{
|
||||
background: '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.backgroundColor = '#c82333'}
|
||||
onMouseLeave={(e) => e.target.style.backgroundColor = '#dc3545'}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 사용자 생성 모달 */}
|
||||
|
||||
@@ -189,6 +189,18 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
}
|
||||
} else if (category === 'BOLT') {
|
||||
itemName = 'BOLT';
|
||||
} else if (category === 'SUPPORT' || category === 'U_BOLT') {
|
||||
// SUPPORT 카테고리: 타입별 구분
|
||||
const desc = cleanDescription.toUpperCase();
|
||||
if (desc.includes('URETHANE') || desc.includes('BLOCK SHOE')) {
|
||||
itemName = 'URETHANE BLOCK SHOE';
|
||||
} else if (desc.includes('CLAMP')) {
|
||||
itemName = 'CLAMP';
|
||||
} else if (desc.includes('U-BOLT') || desc.includes('U BOLT')) {
|
||||
itemName = 'U-BOLT';
|
||||
} else {
|
||||
itemName = 'SUPPORT';
|
||||
}
|
||||
} else {
|
||||
itemName = category || 'UNKNOWN';
|
||||
}
|
||||
@@ -392,11 +404,26 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
detailInfo = otherDetails.join(', ');
|
||||
}
|
||||
|
||||
// 수량 계산 (PIPE는 본 단위)
|
||||
let quantity = purchaseInfo.purchaseQuantity || material.quantity || 0;
|
||||
|
||||
if (category === 'PIPE') {
|
||||
// PIPE의 경우 본 단위로 계산
|
||||
if (material.total_length) {
|
||||
// 총 길이를 6000mm로 나누어 본 수 계산
|
||||
quantity = Math.ceil(material.total_length / 6000);
|
||||
} else if (material.pipe_details && material.pipe_details.total_length_mm) {
|
||||
quantity = Math.ceil(material.pipe_details.total_length_mm / 6000);
|
||||
} else if (material.pipe_count) {
|
||||
quantity = material.pipe_count;
|
||||
}
|
||||
}
|
||||
|
||||
// 새로운 엑셀 양식에 맞춘 데이터 구조
|
||||
const base = {
|
||||
'TAGNO': '', // 비워둠
|
||||
'품목명': itemName,
|
||||
'수량': purchaseInfo.purchaseQuantity || material.quantity || 0,
|
||||
'수량': quantity,
|
||||
'통화구분': 'KRW', // 기본값
|
||||
'단가': 1, // 일괄 1로 설정
|
||||
'크기': material.size_spec || '-',
|
||||
|
||||
Reference in New Issue
Block a user