From e27020ae9b2b36ba4269e7828de7be1a13bd717b Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 14 Oct 2025 12:39:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B5=AC=EB=A7=A4=EC=8B=A0=EC=B2=AD=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=99=84=EC=84=B1=20=EB=B0=8F=20SUPPORT/S?= =?UTF-8?q?PECIAL=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모든 카테고리 구매신청 기능 완성 (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 페이지와 동일한 형식) - 그룹화된 자재 정보 포함하여 저장 및 다운로드 --- backend/app/auth/auth_controller.py | 290 +- backend/app/auth/auth_service.py | 40 +- backend/app/auth/models.py | 13 +- backend/app/auth/signup_routes.py | 23 +- backend/app/config.py | 2 +- backend/app/main.py | 22 + backend/app/routers/export_manager.py | 591 +++ backend/app/routers/files.py | 85 +- backend/app/routers/purchase_request.py | 390 ++ backend/app/routers/purchase_tracking.py | 452 +++ backend/app/services/integrated_classifier.py | 47 +- backend/exports/PR-20251014-001.json | 393 ++ backend/exports/PR-20251014-001.xlsx | Bin 0 -> 6092 bytes backend/exports/PR-20251014-002.json | 369 ++ backend/exports/PR-20251014-002.xlsx | Bin 0 -> 6032 bytes backend/exports/PR-20251014-003.json | 3405 +++++++++++++++++ backend/exports/PR-20251014-003.xlsx | Bin 0 -> 6035 bytes backend/exports/PR-20251014-004.json | 90 + backend/exports/PR-20251014-005.json | 32 + backend/exports/PR-20251014-006.json | 32 + backend/exports/PR-20251014-007.json | 32 + backend/exports/PR-20251014-008.json | 495 +++ backend/exports/PR-20251014-009.json | 55 + backend/exports/PR-20251014-010.json | 55 + backend/exports/PR-20251014-011.json | 32 + backend/exports/PR-20251014-012.json | 32 + backend/exports/PR-20251014-013.json | 32 + backend/exports/PR-20251014-014.json | 3405 +++++++++++++++++ backend/exports/PR-20251014-015.json | 7 + backend/exports/PR-20251014-016.json | 43 + backend/exports/PR-20251014-017.json | 132 + backend/exports/PR-20251014-018.json | 109 + backend/scripts/26_add_user_status_column.sql | 28 + backend/scripts/27_add_purchase_tracking.sql | 135 + backend/scripts/28_add_purchase_requests.sql | 44 + frontend/src/App.css | 11 + frontend/src/App.jsx | 144 +- frontend/src/pages/NewMaterialsPage.css | 42 +- frontend/src/pages/NewMaterialsPage.jsx | 726 +++- frontend/src/pages/PurchaseBatchPage.jsx | 388 ++ frontend/src/pages/PurchaseRequestPage.css | 211 + frontend/src/pages/PurchaseRequestPage.jsx | 274 ++ frontend/src/pages/UserManagementPage.jsx | 541 ++- frontend/src/utils/excelExport.js | 29 +- 44 files changed, 13102 insertions(+), 176 deletions(-) create mode 100644 backend/app/routers/export_manager.py create mode 100644 backend/app/routers/purchase_request.py create mode 100644 backend/app/routers/purchase_tracking.py create mode 100644 backend/exports/PR-20251014-001.json create mode 100644 backend/exports/PR-20251014-001.xlsx create mode 100644 backend/exports/PR-20251014-002.json create mode 100644 backend/exports/PR-20251014-002.xlsx create mode 100644 backend/exports/PR-20251014-003.json create mode 100644 backend/exports/PR-20251014-003.xlsx create mode 100644 backend/exports/PR-20251014-004.json create mode 100644 backend/exports/PR-20251014-005.json create mode 100644 backend/exports/PR-20251014-006.json create mode 100644 backend/exports/PR-20251014-007.json create mode 100644 backend/exports/PR-20251014-008.json create mode 100644 backend/exports/PR-20251014-009.json create mode 100644 backend/exports/PR-20251014-010.json create mode 100644 backend/exports/PR-20251014-011.json create mode 100644 backend/exports/PR-20251014-012.json create mode 100644 backend/exports/PR-20251014-013.json create mode 100644 backend/exports/PR-20251014-014.json create mode 100644 backend/exports/PR-20251014-015.json create mode 100644 backend/exports/PR-20251014-016.json create mode 100644 backend/exports/PR-20251014-017.json create mode 100644 backend/exports/PR-20251014-018.json create mode 100644 backend/scripts/26_add_user_status_column.sql create mode 100644 backend/scripts/27_add_purchase_tracking.sql create mode 100644 backend/scripts/28_add_purchase_requests.sql create mode 100644 frontend/src/pages/PurchaseBatchPage.jsx create mode 100644 frontend/src/pages/PurchaseRequestPage.css create mode 100644 frontend/src/pages/PurchaseRequestPage.jsx diff --git a/backend/app/auth/auth_controller.py b/backend/app/auth/auth_controller.py index 93414b1..5d99a02 100644 --- a/backend/app/auth/auth_controller.py +++ b/backend/app/auth/auth_controller.py @@ -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 diff --git a/backend/app/auth/auth_service.py b/backend/app/auth/auth_service.py index 0b76edf..33037c0 100644 --- a/backend/app/auth/auth_service.py +++ b/backend/app/auth/auth_service.py @@ -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(): diff --git a/backend/app/auth/models.py b/backend/app/auth/models.py index 4f98339..53f1c60 100644 --- a/backend/app/auth/models.py +++ b/backend/app/auth/models.py @@ -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 [] diff --git a/backend/app/auth/signup_routes.py b/backend/app/auth/signup_routes.py index 048363a..d2bd4cc 100644 --- a/backend/app/auth/signup_routes.py +++ b/backend/app/auth/signup_routes.py @@ -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 """) diff --git a/backend/app/config.py b/backend/app/config.py index 37e1f37..68d5a0e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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( diff --git a/backend/app/main.py b/backend/app/main.py index d514e54..dd3e7ff 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/routers/export_manager.py b/backend/app/routers/export_manager.py new file mode 100644 index 0000000..dcdaec3 --- /dev/null +++ b/backend/app/routers/export_manager.py @@ -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)}" + ) diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index c9ea55e..f189499 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -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) diff --git a/backend/app/routers/purchase_request.py b/backend/app/routers/purchase_request.py new file mode 100644 index 0000000..ff07a16 --- /dev/null +++ b/backend/app/routers/purchase_request.py @@ -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) diff --git a/backend/app/routers/purchase_tracking.py b/backend/app/routers/purchase_tracking.py new file mode 100644 index 0000000..82d3533 --- /dev/null +++ b/backend/app/routers/purchase_tracking.py @@ -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)}" + ) diff --git a/backend/app/services/integrated_classifier.py b/backend/app/services/integrated_classifier.py index adfd38d..fafd339 100644 --- a/backend/app/services/integrated_classifier.py +++ b/backend/app/services/integrated_classifier.py @@ -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") diff --git a/backend/exports/PR-20251014-001.json b/backend/exports/PR-20251014-001.json new file mode 100644 index 0000000..d83ee4e --- /dev/null +++ b/backend/exports/PR-20251014-001.json @@ -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": "" + } + ] +} \ No newline at end of file diff --git a/backend/exports/PR-20251014-001.xlsx b/backend/exports/PR-20251014-001.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d4b28c093f86301f4aa5f79d6e5ad25a487264d6 GIT binary patch literal 6092 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 literal 0 HcmV?d00001 diff --git a/backend/exports/PR-20251014-002.json b/backend/exports/PR-20251014-002.json new file mode 100644 index 0000000..0e04b04 --- /dev/null +++ b/backend/exports/PR-20251014-002.json @@ -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": "" + } + ] +} \ No newline at end of file diff --git a/backend/exports/PR-20251014-002.xlsx b/backend/exports/PR-20251014-002.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..42255d9e1ffc60695e1e5ebe9b97fda0800a1ec9 GIT binary patch literal 6032 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 literal 0 HcmV?d00001 diff --git a/backend/exports/PR-20251014-003.json b/backend/exports/PR-20251014-003.json new file mode 100644 index 0000000..7f5dbd4 --- /dev/null +++ b/backend/exports/PR-20251014-003.json @@ -0,0 +1,3405 @@ +{ + "request_no": "PR-20251014-003", + "job_no": "J24-002", + "created_at": "2025-10-14T02:04:48.781382", + "materials": [ + { + "material_id": 91684, + "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": 91692, + "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": 91696, + "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": 91706, + "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": 91715, + "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": 91729, + "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": 91985, + "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": 92067, + "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": 92071, + "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": 92072, + "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": 92122, + "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": 92131, + "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": 92139, + "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": 92164, + "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": 92267, + "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": 92279, + "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": [ + 91684 + ], + "description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304", + "category": "PIPE", + "size": "1/2\"", + "material_grade": "ASTM A312 TP304", + "quantity": 11, + "unit": "m", + "total_length": 1395.1, + "pipe_lengths": [ + { + "length": 70, + "quantity": 1, + "totalLength": 70 + }, + { + "length": 70, + "quantity": 1, + "totalLength": 70 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 155, + "quantity": 1, + "totalLength": 155 + }, + { + "length": 155, + "quantity": 1, + "totalLength": 155 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 245.1, + "quantity": 1, + "totalLength": 245.1 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + } + ], + "user_requirement": "" + }, + { + "group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|3/4\"|undefined|ASTM A106 B", + "material_ids": [ + 91692 + ], + "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": "" + }, + { + "group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|1\"|undefined|ASTM A312 TP304", + "material_ids": [ + 91696 + ], + "description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304", + "category": "PIPE", + "size": "1\"", + "material_grade": "ASTM A312 TP304", + "quantity": 23, + "unit": "m", + "total_length": 7448.47, + "pipe_lengths": [ + { + "length": 82.1, + "quantity": 1, + "totalLength": 82.1 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 150, + "quantity": 1, + "totalLength": 150 + }, + { + "length": 157.4, + "quantity": 1, + "totalLength": 157.4 + }, + { + "length": 283.4, + "quantity": 1, + "totalLength": 283.4 + }, + { + "length": 450.9, + "quantity": 1, + "totalLength": 450.9 + }, + { + "length": 800, + "quantity": 1, + "totalLength": 800 + }, + { + "length": 945.1, + "quantity": 1, + "totalLength": 945.1 + }, + { + "length": 1228.9, + "quantity": 1, + "totalLength": 1228.9 + }, + { + "length": 1321.87, + "quantity": 1, + "totalLength": 1321.87 + }, + { + "length": 200.8, + "quantity": 1, + "totalLength": 200.8 + }, + { + "length": 70, + "quantity": 1, + "totalLength": 70 + }, + { + "length": 149.8, + "quantity": 1, + "totalLength": 149.8 + }, + { + "length": 149.8, + "quantity": 1, + "totalLength": 149.8 + }, + { + "length": 149.8, + "quantity": 1, + "totalLength": 149.8 + }, + { + "length": 149.8, + "quantity": 1, + "totalLength": 149.8 + }, + { + "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": 152.4, + "quantity": 1, + "totalLength": 152.4 + }, + { + "length": 156.4, + "quantity": 1, + "totalLength": 156.4 + } + ], + "user_requirement": "" + }, + { + "group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|1\"|undefined|ASTM A106 B", + "material_ids": [ + 91706 + ], + "description": "PIPE, SMLS, SCH 80, ASTM A106 B", + "category": "PIPE", + "size": "1\"", + "material_grade": "ASTM A106 B", + "quantity": 139, + "unit": "m", + "total_length": 43978.780000000006, + "pipe_lengths": [ + { + "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": 1250, + "quantity": 1, + "totalLength": 1250 + }, + { + "length": 1500, + "quantity": 1, + "totalLength": 1500 + }, + { + "length": 1520, + "quantity": 1, + "totalLength": 1520 + }, + { + "length": 1523.15, + "quantity": 1, + "totalLength": 1523.15 + }, + { + "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": 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": 80, + "quantity": 1, + "totalLength": 80 + }, + { + "length": 96, + "quantity": 1, + "totalLength": 96 + }, + { + "length": 98, + "quantity": 1, + "totalLength": 98 + }, + { + "length": 98.16, + "quantity": 1, + "totalLength": 98.16 + }, + { + "length": 99.8, + "quantity": 1, + "totalLength": 99.8 + }, + { + "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": 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": 104, + "quantity": 1, + "totalLength": 104 + }, + { + "length": 104.5, + "quantity": 1, + "totalLength": 104.5 + }, + { + "length": 112.16, + "quantity": 1, + "totalLength": 112.16 + }, + { + "length": 125, + "quantity": 1, + "totalLength": 125 + }, + { + "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": 154.61, + "quantity": 1, + "totalLength": 154.61 + }, + { + "length": 165, + "quantity": 1, + "totalLength": 165 + }, + { + "length": 165, + "quantity": 1, + "totalLength": 165 + }, + { + "length": 169.6, + "quantity": 1, + "totalLength": 169.6 + }, + { + "length": 180, + "quantity": 1, + "totalLength": 180 + }, + { + "length": 182.4, + "quantity": 1, + "totalLength": 182.4 + }, + { + "length": 195.2, + "quantity": 1, + "totalLength": 195.2 + }, + { + "length": 199.2, + "quantity": 1, + "totalLength": 199.2 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 208, + "quantity": 1, + "totalLength": 208 + }, + { + "length": 211.75, + "quantity": 1, + "totalLength": 211.75 + }, + { + "length": 219, + "quantity": 1, + "totalLength": 219 + }, + { + "length": 224.7, + "quantity": 1, + "totalLength": 224.7 + }, + { + "length": 230, + "quantity": 1, + "totalLength": 230 + }, + { + "length": 249.23, + "quantity": 1, + "totalLength": 249.23 + }, + { + "length": 250.6, + "quantity": 1, + "totalLength": 250.6 + }, + { + "length": 259.91, + "quantity": 1, + "totalLength": 259.91 + }, + { + "length": 271.55, + "quantity": 1, + "totalLength": 271.55 + }, + { + "length": 292.4, + "quantity": 1, + "totalLength": 292.4 + }, + { + "length": 319.2, + "quantity": 1, + "totalLength": 319.2 + }, + { + "length": 330.23, + "quantity": 1, + "totalLength": 330.23 + }, + { + "length": 367.79, + "quantity": 1, + "totalLength": 367.79 + }, + { + "length": 396.1, + "quantity": 1, + "totalLength": 396.1 + }, + { + "length": 400, + "quantity": 1, + "totalLength": 400 + }, + { + "length": 404, + "quantity": 1, + "totalLength": 404 + }, + { + "length": 408.68, + "quantity": 1, + "totalLength": 408.68 + }, + { + "length": 433.3, + "quantity": 1, + "totalLength": 433.3 + }, + { + "length": 450.26, + "quantity": 1, + "totalLength": 450.26 + }, + { + "length": 466.4, + "quantity": 1, + "totalLength": 466.4 + }, + { + "length": 485.3, + "quantity": 1, + "totalLength": 485.3 + }, + { + "length": 485.3, + "quantity": 1, + "totalLength": 485.3 + }, + { + "length": 500, + "quantity": 1, + "totalLength": 500 + }, + { + "length": 510.2, + "quantity": 1, + "totalLength": 510.2 + }, + { + "length": 510.2, + "quantity": 1, + "totalLength": 510.2 + }, + { + "length": 545, + "quantity": 1, + "totalLength": 545 + }, + { + "length": 549, + "quantity": 1, + "totalLength": 549 + }, + { + "length": 576.68, + "quantity": 1, + "totalLength": 576.68 + }, + { + "length": 579, + "quantity": 1, + "totalLength": 579 + }, + { + "length": 579.7, + "quantity": 1, + "totalLength": 579.7 + }, + { + "length": 579.7, + "quantity": 1, + "totalLength": 579.7 + }, + { + "length": 613.8, + "quantity": 1, + "totalLength": 613.8 + }, + { + "length": 687.6, + "quantity": 1, + "totalLength": 687.6 + }, + { + "length": 718, + "quantity": 1, + "totalLength": 718 + }, + { + "length": 750, + "quantity": 1, + "totalLength": 750 + }, + { + "length": 865.9, + "quantity": 1, + "totalLength": 865.9 + }, + { + "length": 1032.3, + "quantity": 1, + "totalLength": 1032.3 + }, + { + "length": 1107.83, + "quantity": 1, + "totalLength": 1107.83 + }, + { + "length": 1117.83, + "quantity": 1, + "totalLength": 1117.83 + }, + { + "length": 1164.5, + "quantity": 1, + "totalLength": 1164.5 + }, + { + "length": 1180.83, + "quantity": 1, + "totalLength": 1180.83 + }, + { + "length": 1180.83, + "quantity": 1, + "totalLength": 1180.83 + }, + { + "length": 1279.8, + "quantity": 1, + "totalLength": 1279.8 + }, + { + "length": 1387.4, + "quantity": 1, + "totalLength": 1387.4 + }, + { + "length": 3137.2, + "quantity": 1, + "totalLength": 3137.2 + } + ], + "user_requirement": "" + }, + { + "group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|1 1/2\"|undefined|ASTM A312 TP304", + "material_ids": [ + 91715 + ], + "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": 5372.6, + "pipe_lengths": [ + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 400, + "quantity": 1, + "totalLength": 400 + }, + { + "length": 68.8, + "quantity": 1, + "totalLength": 68.8 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 120, + "quantity": 1, + "totalLength": 120 + }, + { + "length": 153.2, + "quantity": 1, + "totalLength": 153.2 + }, + { + "length": 189.7, + "quantity": 1, + "totalLength": 189.7 + }, + { + "length": 189.7, + "quantity": 1, + "totalLength": 189.7 + }, + { + "length": 356.5, + "quantity": 1, + "totalLength": 356.5 + }, + { + "length": 650, + "quantity": 1, + "totalLength": 650 + }, + { + "length": 824.7, + "quantity": 1, + "totalLength": 824.7 + }, + { + "length": 1000, + "quantity": 1, + "totalLength": 1000 + }, + { + "length": 1120, + "quantity": 1, + "totalLength": 1120 + } + ], + "user_requirement": "" + }, + { + "group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|1 1/2\"|undefined|ASTM A106 B", + "material_ids": [ + 91729 + ], + "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": 33891.09, + "pipe_lengths": [ + { + "length": 50, + "quantity": 1, + "totalLength": 50 + }, + { + "length": 70, + "quantity": 1, + "totalLength": 70 + }, + { + "length": 90, + "quantity": 1, + "totalLength": 90 + }, + { + "length": 90, + "quantity": 1, + "totalLength": 90 + }, + { + "length": 95, + "quantity": 1, + "totalLength": 95 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 145, + "quantity": 1, + "totalLength": 145 + }, + { + "length": 150, + "quantity": 1, + "totalLength": 150 + }, + { + "length": 150, + "quantity": 1, + "totalLength": 150 + }, + { + "length": 150, + "quantity": 1, + "totalLength": 150 + }, + { + "length": 300, + "quantity": 1, + "totalLength": 300 + }, + { + "length": 562.3, + "quantity": 1, + "totalLength": 562.3 + }, + { + "length": 575.3, + "quantity": 1, + "totalLength": 575.3 + }, + { + "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.6, + "quantity": 1, + "totalLength": 76.6 + }, + { + "length": 76.6, + "quantity": 1, + "totalLength": 76.6 + }, + { + "length": 85.8, + "quantity": 1, + "totalLength": 85.8 + }, + { + "length": 93.1, + "quantity": 1, + "totalLength": 93.1 + }, + { + "length": 93.1, + "quantity": 1, + "totalLength": 93.1 + }, + { + "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": 103.2, + "quantity": 1, + "totalLength": 103.2 + }, + { + "length": 116.6, + "quantity": 1, + "totalLength": 116.6 + }, + { + "length": 116.6, + "quantity": 1, + "totalLength": 116.6 + }, + { + "length": 130, + "quantity": 1, + "totalLength": 130 + }, + { + "length": 130, + "quantity": 1, + "totalLength": 130 + }, + { + "length": 133.2, + "quantity": 1, + "totalLength": 133.2 + }, + { + "length": 137.7, + "quantity": 1, + "totalLength": 137.7 + }, + { + "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": 160, + "quantity": 1, + "totalLength": 160 + }, + { + "length": 161.4, + "quantity": 1, + "totalLength": 161.4 + }, + { + "length": 171.9, + "quantity": 1, + "totalLength": 171.9 + }, + { + "length": 172.58, + "quantity": 1, + "totalLength": 172.58 + }, + { + "length": 176.9, + "quantity": 1, + "totalLength": 176.9 + }, + { + "length": 180, + "quantity": 1, + "totalLength": 180 + }, + { + "length": 183, + "quantity": 1, + "totalLength": 183 + }, + { + "length": 183.2, + "quantity": 1, + "totalLength": 183.2 + }, + { + "length": 191.2, + "quantity": 1, + "totalLength": 191.2 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 202.4, + "quantity": 1, + "totalLength": 202.4 + }, + { + "length": 202.4, + "quantity": 1, + "totalLength": 202.4 + }, + { + "length": 207.8, + "quantity": 1, + "totalLength": 207.8 + }, + { + "length": 210, + "quantity": 1, + "totalLength": 210 + }, + { + "length": 218.2, + "quantity": 1, + "totalLength": 218.2 + }, + { + "length": 218.2, + "quantity": 1, + "totalLength": 218.2 + }, + { + "length": 218.89, + "quantity": 1, + "totalLength": 218.89 + }, + { + "length": 220, + "quantity": 1, + "totalLength": 220 + }, + { + "length": 235.2, + "quantity": 1, + "totalLength": 235.2 + }, + { + "length": 250, + "quantity": 1, + "totalLength": 250 + }, + { + "length": 250, + "quantity": 1, + "totalLength": 250 + }, + { + "length": 300, + "quantity": 1, + "totalLength": 300 + }, + { + "length": 300, + "quantity": 1, + "totalLength": 300 + }, + { + "length": 320, + "quantity": 1, + "totalLength": 320 + }, + { + "length": 360.4, + "quantity": 1, + "totalLength": 360.4 + }, + { + "length": 376.2, + "quantity": 1, + "totalLength": 376.2 + }, + { + "length": 383.89, + "quantity": 1, + "totalLength": 383.89 + }, + { + "length": 450, + "quantity": 1, + "totalLength": 450 + }, + { + "length": 485.3, + "quantity": 1, + "totalLength": 485.3 + }, + { + "length": 544, + "quantity": 1, + "totalLength": 544 + }, + { + "length": 553.2, + "quantity": 1, + "totalLength": 553.2 + }, + { + "length": 584, + "quantity": 1, + "totalLength": 584 + }, + { + "length": 733.2, + "quantity": 1, + "totalLength": 733.2 + }, + { + "length": 751.1, + "quantity": 1, + "totalLength": 751.1 + }, + { + "length": 782.3, + "quantity": 1, + "totalLength": 782.3 + }, + { + "length": 796.91, + "quantity": 1, + "totalLength": 796.91 + }, + { + "length": 879.7, + "quantity": 1, + "totalLength": 879.7 + }, + { + "length": 930.1, + "quantity": 1, + "totalLength": 930.1 + }, + { + "length": 960, + "quantity": 1, + "totalLength": 960 + }, + { + "length": 981.8, + "quantity": 1, + "totalLength": 981.8 + }, + { + "length": 1039.9, + "quantity": 1, + "totalLength": 1039.9 + }, + { + "length": 1293.6, + "quantity": 1, + "totalLength": 1293.6 + }, + { + "length": 2133.72, + "quantity": 1, + "totalLength": 2133.72 + }, + { + "length": 3134.2, + "quantity": 1, + "totalLength": 3134.2 + }, + { + "length": 3134.2, + "quantity": 1, + "totalLength": 3134.2 + }, + { + "length": 160, + "quantity": 1, + "totalLength": 160 + }, + { + "length": 160, + "quantity": 1, + "totalLength": 160 + }, + { + "length": 180, + "quantity": 1, + "totalLength": 180 + }, + { + "length": 180, + "quantity": 1, + "totalLength": 180 + } + ], + "user_requirement": "" + }, + { + "group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|1/2\"|undefined|ASTM A106 B", + "material_ids": [ + 91985 + ], + "description": "PIPE, SMLS, SCH 80, ASTM A106 B", + "category": "PIPE", + "size": "1/2\"", + "material_grade": "ASTM A106 B", + "quantity": 82, + "unit": "m", + "total_length": 37225.89, + "pipe_lengths": [ + { + "length": 50, + "quantity": 1, + "totalLength": 50 + }, + { + "length": 50, + "quantity": 1, + "totalLength": 50 + }, + { + "length": 57.43, + "quantity": 1, + "totalLength": 57.43 + }, + { + "length": 60, + "quantity": 1, + "totalLength": 60 + }, + { + "length": 60, + "quantity": 1, + "totalLength": 60 + }, + { + "length": 62.53, + "quantity": 1, + "totalLength": 62.53 + }, + { + "length": 68.4, + "quantity": 1, + "totalLength": 68.4 + }, + { + "length": 68.4, + "quantity": 1, + "totalLength": 68.4 + }, + { + "length": 70, + "quantity": 1, + "totalLength": 70 + }, + { + "length": 70, + "quantity": 1, + "totalLength": 70 + }, + { + "length": 70, + "quantity": 1, + "totalLength": 70 + }, + { + "length": 79.81, + "quantity": 1, + "totalLength": 79.81 + }, + { + "length": 80, + "quantity": 1, + "totalLength": 80 + }, + { + "length": 96.88, + "quantity": 1, + "totalLength": 96.88 + }, + { + "length": 96.88, + "quantity": 1, + "totalLength": 96.88 + }, + { + "length": 99.08, + "quantity": 1, + "totalLength": 99.08 + }, + { + "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": 105.2, + "quantity": 1, + "totalLength": 105.2 + }, + { + "length": 130, + "quantity": 1, + "totalLength": 130 + }, + { + "length": 140, + "quantity": 1, + "totalLength": 140 + }, + { + "length": 144.6, + "quantity": 1, + "totalLength": 144.6 + }, + { + "length": 148.4, + "quantity": 1, + "totalLength": 148.4 + }, + { + "length": 150, + "quantity": 1, + "totalLength": 150 + }, + { + "length": 160, + "quantity": 1, + "totalLength": 160 + }, + { + "length": 167.25, + "quantity": 1, + "totalLength": 167.25 + }, + { + "length": 180, + "quantity": 1, + "totalLength": 180 + }, + { + "length": 182.35, + "quantity": 1, + "totalLength": 182.35 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 212.7, + "quantity": 1, + "totalLength": 212.7 + }, + { + "length": 215.08, + "quantity": 1, + "totalLength": 215.08 + }, + { + "length": 228.65, + "quantity": 1, + "totalLength": 228.65 + }, + { + "length": 230, + "quantity": 1, + "totalLength": 230 + }, + { + "length": 250, + "quantity": 1, + "totalLength": 250 + }, + { + "length": 324.5, + "quantity": 1, + "totalLength": 324.5 + }, + { + "length": 328.3, + "quantity": 1, + "totalLength": 328.3 + }, + { + "length": 330, + "quantity": 1, + "totalLength": 330 + }, + { + "length": 370.4, + "quantity": 1, + "totalLength": 370.4 + }, + { + "length": 400, + "quantity": 1, + "totalLength": 400 + }, + { + "length": 400, + "quantity": 1, + "totalLength": 400 + }, + { + "length": 400, + "quantity": 1, + "totalLength": 400 + }, + { + "length": 457, + "quantity": 1, + "totalLength": 457 + }, + { + "length": 470.5, + "quantity": 1, + "totalLength": 470.5 + }, + { + "length": 482.33, + "quantity": 1, + "totalLength": 482.33 + }, + { + "length": 483.13, + "quantity": 1, + "totalLength": 483.13 + }, + { + "length": 483.13, + "quantity": 1, + "totalLength": 483.13 + }, + { + "length": 494.5, + "quantity": 1, + "totalLength": 494.5 + }, + { + "length": 494.5, + "quantity": 1, + "totalLength": 494.5 + }, + { + "length": 494.5, + "quantity": 1, + "totalLength": 494.5 + }, + { + "length": 500, + "quantity": 1, + "totalLength": 500 + }, + { + "length": 508.4, + "quantity": 1, + "totalLength": 508.4 + }, + { + "length": 520.21, + "quantity": 1, + "totalLength": 520.21 + }, + { + "length": 562.63, + "quantity": 1, + "totalLength": 562.63 + }, + { + "length": 569.38, + "quantity": 1, + "totalLength": 569.38 + }, + { + "length": 598.4, + "quantity": 1, + "totalLength": 598.4 + }, + { + "length": 625, + "quantity": 1, + "totalLength": 625 + }, + { + "length": 660, + "quantity": 1, + "totalLength": 660 + }, + { + "length": 683.13, + "quantity": 1, + "totalLength": 683.13 + }, + { + "length": 688.4, + "quantity": 1, + "totalLength": 688.4 + }, + { + "length": 720, + "quantity": 1, + "totalLength": 720 + }, + { + "length": 774.36, + "quantity": 1, + "totalLength": 774.36 + }, + { + "length": 800, + "quantity": 1, + "totalLength": 800 + }, + { + "length": 800, + "quantity": 1, + "totalLength": 800 + }, + { + "length": 859.5, + "quantity": 1, + "totalLength": 859.5 + }, + { + "length": 1059.36, + "quantity": 1, + "totalLength": 1059.36 + }, + { + "length": 1240.23, + "quantity": 1, + "totalLength": 1240.23 + }, + { + "length": 1250, + "quantity": 1, + "totalLength": 1250 + }, + { + "length": 1345.23, + "quantity": 1, + "totalLength": 1345.23 + }, + { + "length": 1348.4, + "quantity": 1, + "totalLength": 1348.4 + }, + { + "length": 1500, + "quantity": 1, + "totalLength": 1500 + }, + { + "length": 1550, + "quantity": 1, + "totalLength": 1550 + }, + { + "length": 1715.73, + "quantity": 1, + "totalLength": 1715.73 + }, + { + "length": 2176.6, + "quantity": 1, + "totalLength": 2176.6 + }, + { + "length": 2374.5, + "quantity": 1, + "totalLength": 2374.5 + } + ], + "user_requirement": "" + }, + { + "group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|10\"|undefined|ASTM A312 TP304", + "material_ids": [ + 92067 + ], + "description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304", + "category": "PIPE", + "size": "10\"", + "material_grade": "ASTM A312 TP304", + "quantity": 4, + "unit": "m", + "total_length": 3635.8, + "pipe_lengths": [ + { + "length": 96.2, + "quantity": 1, + "totalLength": 96.2 + }, + { + "length": 300, + "quantity": 1, + "totalLength": 300 + }, + { + "length": 1410.3, + "quantity": 1, + "totalLength": 1410.3 + }, + { + "length": 1829.3, + "quantity": 1, + "totalLength": 1829.3 + } + ], + "user_requirement": "" + }, + { + "group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|12\"|undefined|ASTM A312 TP304", + "material_ids": [ + 92071 + ], + "description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304", + "category": "PIPE", + "size": "12\"", + "material_grade": "ASTM A312 TP304", + "quantity": 1, + "unit": "m", + "total_length": 545.3, + "pipe_lengths": [ + { + "length": 545.3, + "quantity": 1, + "totalLength": 545.3 + } + ], + "user_requirement": "" + }, + { + "group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|2\"|undefined|ASTM A106 B", + "material_ids": [ + 92072 + ], + "description": "PIPE, SMLS, SCH 40, ASTM A106 B", + "category": "PIPE", + "size": "2\"", + "material_grade": "ASTM A106 B", + "quantity": 50, + "unit": "m", + "total_length": 25269.879999999997, + "pipe_lengths": [ + { + "length": 36.5, + "quantity": 1, + "totalLength": 36.5 + }, + { + "length": 49.8, + "quantity": 1, + "totalLength": 49.8 + }, + { + "length": 66.54, + "quantity": 1, + "totalLength": 66.54 + }, + { + "length": 67.57, + "quantity": 1, + "totalLength": 67.57 + }, + { + "length": 67.57, + "quantity": 1, + "totalLength": 67.57 + }, + { + "length": 77.8, + "quantity": 1, + "totalLength": 77.8 + }, + { + "length": 80, + "quantity": 1, + "totalLength": 80 + }, + { + "length": 87.3, + "quantity": 1, + "totalLength": 87.3 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 105.7, + "quantity": 1, + "totalLength": 105.7 + }, + { + "length": 124.59, + "quantity": 1, + "totalLength": 124.59 + }, + { + "length": 135.8, + "quantity": 1, + "totalLength": 135.8 + }, + { + "length": 144.4, + "quantity": 1, + "totalLength": 144.4 + }, + { + "length": 150, + "quantity": 1, + "totalLength": 150 + }, + { + "length": 157.1, + "quantity": 1, + "totalLength": 157.1 + }, + { + "length": 168.5, + "quantity": 1, + "totalLength": 168.5 + }, + { + "length": 187.3, + "quantity": 1, + "totalLength": 187.3 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 211.2, + "quantity": 1, + "totalLength": 211.2 + }, + { + "length": 211.2, + "quantity": 1, + "totalLength": 211.2 + }, + { + "length": 300, + "quantity": 1, + "totalLength": 300 + }, + { + "length": 300, + "quantity": 1, + "totalLength": 300 + }, + { + "length": 301.5, + "quantity": 1, + "totalLength": 301.5 + }, + { + "length": 302.5, + "quantity": 1, + "totalLength": 302.5 + }, + { + "length": 308.3, + "quantity": 1, + "totalLength": 308.3 + }, + { + "length": 350.1, + "quantity": 1, + "totalLength": 350.1 + }, + { + "length": 374.4, + "quantity": 1, + "totalLength": 374.4 + }, + { + "length": 383.4, + "quantity": 1, + "totalLength": 383.4 + }, + { + "length": 400, + "quantity": 1, + "totalLength": 400 + }, + { + "length": 440, + "quantity": 1, + "totalLength": 440 + }, + { + "length": 457.1, + "quantity": 1, + "totalLength": 457.1 + }, + { + "length": 722.9, + "quantity": 1, + "totalLength": 722.9 + }, + { + "length": 737.81, + "quantity": 1, + "totalLength": 737.81 + }, + { + "length": 820.2, + "quantity": 1, + "totalLength": 820.2 + }, + { + "length": 917.93, + "quantity": 1, + "totalLength": 917.93 + }, + { + "length": 1085.5, + "quantity": 1, + "totalLength": 1085.5 + }, + { + "length": 1214.4, + "quantity": 1, + "totalLength": 1214.4 + }, + { + "length": 1219.7, + "quantity": 1, + "totalLength": 1219.7 + }, + { + "length": 1268.41, + "quantity": 1, + "totalLength": 1268.41 + }, + { + "length": 1269.3, + "quantity": 1, + "totalLength": 1269.3 + }, + { + "length": 1285.93, + "quantity": 1, + "totalLength": 1285.93 + }, + { + "length": 1335.93, + "quantity": 1, + "totalLength": 1335.93 + }, + { + "length": 1382.1, + "quantity": 1, + "totalLength": 1382.1 + }, + { + "length": 1811.3, + "quantity": 1, + "totalLength": 1811.3 + }, + { + "length": 2665.3, + "quantity": 1, + "totalLength": 2665.3 + }, + { + "length": 150, + "quantity": 1, + "totalLength": 150 + }, + { + "length": 650, + "quantity": 1, + "totalLength": 650 + }, + { + "length": 80, + "quantity": 1, + "totalLength": 80 + }, + { + "length": 140, + "quantity": 1, + "totalLength": 140 + }, + { + "length": 167, + "quantity": 1, + "totalLength": 167 + } + ], + "user_requirement": "" + }, + { + "group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|2\"|undefined|ASTM A312 TP304", + "material_ids": [ + 92122 + ], + "description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304", + "category": "PIPE", + "size": "2\"", + "material_grade": "ASTM A312 TP304", + "quantity": 9, + "unit": "m", + "total_length": 3063.6099999999997, + "pipe_lengths": [ + { + "length": 57.1, + "quantity": 1, + "totalLength": 57.1 + }, + { + "length": 99.8, + "quantity": 1, + "totalLength": 99.8 + }, + { + "length": 157.1, + "quantity": 1, + "totalLength": 157.1 + }, + { + "length": 157.41, + "quantity": 1, + "totalLength": 157.41 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 233.9, + "quantity": 1, + "totalLength": 233.9 + }, + { + "length": 1758.3, + "quantity": 1, + "totalLength": 1758.3 + } + ], + "user_requirement": "" + }, + { + "group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|3\"|undefined|ASTM A106 B", + "material_ids": [ + 92131 + ], + "description": "PIPE, SMLS, SCH 40, ASTM A106 B", + "category": "PIPE", + "size": "3\"", + "material_grade": "ASTM A106 B", + "quantity": 25, + "unit": "m", + "total_length": 14786.890000000001, + "pipe_lengths": [ + { + "length": 87.2, + "quantity": 1, + "totalLength": 87.2 + }, + { + "length": 250.4, + "quantity": 1, + "totalLength": 250.4 + }, + { + "length": 337.5, + "quantity": 1, + "totalLength": 337.5 + }, + { + "length": 561.89, + "quantity": 1, + "totalLength": 561.89 + }, + { + "length": 690.4, + "quantity": 1, + "totalLength": 690.4 + }, + { + "length": 706.59, + "quantity": 1, + "totalLength": 706.59 + }, + { + "length": 1000, + "quantity": 1, + "totalLength": 1000 + }, + { + "length": 1687.4, + "quantity": 1, + "totalLength": 1687.4 + }, + { + "length": 88.8, + "quantity": 1, + "totalLength": 88.8 + }, + { + "length": 144.3, + "quantity": 1, + "totalLength": 144.3 + }, + { + "length": 150, + "quantity": 1, + "totalLength": 150 + }, + { + "length": 227, + "quantity": 1, + "totalLength": 227 + }, + { + "length": 230, + "quantity": 1, + "totalLength": 230 + }, + { + "length": 269.9, + "quantity": 1, + "totalLength": 269.9 + }, + { + "length": 285.51, + "quantity": 1, + "totalLength": 285.51 + }, + { + "length": 298.5, + "quantity": 1, + "totalLength": 298.5 + }, + { + "length": 300, + "quantity": 1, + "totalLength": 300 + }, + { + "length": 346.8, + "quantity": 1, + "totalLength": 346.8 + }, + { + "length": 508, + "quantity": 1, + "totalLength": 508 + }, + { + "length": 572.2, + "quantity": 1, + "totalLength": 572.2 + }, + { + "length": 702.5, + "quantity": 1, + "totalLength": 702.5 + }, + { + "length": 1133.2, + "quantity": 1, + "totalLength": 1133.2 + }, + { + "length": 1238, + "quantity": 1, + "totalLength": 1238 + }, + { + "length": 1476.5, + "quantity": 1, + "totalLength": 1476.5 + }, + { + "length": 1494.3, + "quantity": 1, + "totalLength": 1494.3 + } + ], + "user_requirement": "" + }, + { + "group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|3\"|undefined|ASTM A312 TP304", + "material_ids": [ + 92139 + ], + "description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304", + "category": "PIPE", + "size": "3\"", + "material_grade": "ASTM A312 TP304", + "quantity": 8, + "unit": "m", + "total_length": 1773.3, + "pipe_lengths": [ + { + "length": 300, + "quantity": 1, + "totalLength": 300 + }, + { + "length": 71.8, + "quantity": 1, + "totalLength": 71.8 + }, + { + "length": 71.8, + "quantity": 1, + "totalLength": 71.8 + }, + { + "length": 180, + "quantity": 1, + "totalLength": 180 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 549.7, + "quantity": 1, + "totalLength": 549.7 + } + ], + "user_requirement": "" + }, + { + "group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|3/4\"|undefined|ASTM A312 TP304", + "material_ids": [ + 92164 + ], + "description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304", + "category": "PIPE", + "size": "3/4\"", + "material_grade": "ASTM A312 TP304", + "quantity": 15, + "unit": "m", + "total_length": 3193.44, + "pipe_lengths": [ + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "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": 150, + "quantity": 1, + "totalLength": 150 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 234.89, + "quantity": 1, + "totalLength": 234.89 + }, + { + "length": 300, + "quantity": 1, + "totalLength": 300 + }, + { + "length": 339.15, + "quantity": 1, + "totalLength": 339.15 + }, + { + "length": 350, + "quantity": 1, + "totalLength": 350 + }, + { + "length": 549.4, + "quantity": 1, + "totalLength": 549.4 + } + ], + "user_requirement": "" + }, + { + "group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|4\"|undefined|ASTM A106 B", + "material_ids": [ + 92267 + ], + "description": "PIPE, SMLS, SCH 40, ASTM A106 B", + "category": "PIPE", + "size": "4\"", + "material_grade": "ASTM A106 B", + "quantity": 12, + "unit": "m", + "total_length": 6229.51, + "pipe_lengths": [ + { + "length": 130, + "quantity": 1, + "totalLength": 130 + }, + { + "length": 311.24, + "quantity": 1, + "totalLength": 311.24 + }, + { + "length": 380, + "quantity": 1, + "totalLength": 380 + }, + { + "length": 381.69, + "quantity": 1, + "totalLength": 381.69 + }, + { + "length": 1015.7, + "quantity": 1, + "totalLength": 1015.7 + }, + { + "length": 1081.96, + "quantity": 1, + "totalLength": 1081.96 + }, + { + "length": 1454.2, + "quantity": 1, + "totalLength": 1454.2 + }, + { + "length": 90, + "quantity": 1, + "totalLength": 90 + }, + { + "length": 100.49, + "quantity": 1, + "totalLength": 100.49 + }, + { + "length": 180, + "quantity": 1, + "totalLength": 180 + }, + { + "length": 350, + "quantity": 1, + "totalLength": 350 + }, + { + "length": 754.23, + "quantity": 1, + "totalLength": 754.23 + } + ], + "user_requirement": "" + }, + { + "group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|6\"|undefined|ASTM A106 B", + "material_ids": [ + 92279 + ], + "description": "PIPE, SMLS, SCH 40, ASTM A106 B", + "category": "PIPE", + "size": "6\"", + "material_grade": "ASTM A106 B", + "quantity": 13, + "unit": "m", + "total_length": 5794.42, + "pipe_lengths": [ + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 368.2, + "quantity": 1, + "totalLength": 368.2 + }, + { + "length": 875.8, + "quantity": 1, + "totalLength": 875.8 + }, + { + "length": 1070.7, + "quantity": 1, + "totalLength": 1070.7 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 116.1, + "quantity": 1, + "totalLength": 116.1 + }, + { + "length": 198.7, + "quantity": 1, + "totalLength": 198.7 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 234.81, + "quantity": 1, + "totalLength": 234.81 + }, + { + "length": 274.8, + "quantity": 1, + "totalLength": 274.8 + }, + { + "length": 530, + "quantity": 1, + "totalLength": 530 + }, + { + "length": 1625.31, + "quantity": 1, + "totalLength": 1625.31 + } + ], + "user_requirement": "" + } + ] +} \ No newline at end of file diff --git a/backend/exports/PR-20251014-003.xlsx b/backend/exports/PR-20251014-003.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..e2f52ee229d1ed5d11639569771f9e96434ba24a GIT binary patch literal 6035 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 literal 0 HcmV?d00001 diff --git a/backend/exports/PR-20251014-004.json b/backend/exports/PR-20251014-004.json new file mode 100644 index 0000000..ba68e47 --- /dev/null +++ b/backend/exports/PR-20251014-004.json @@ -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": "열처리?" + } + ] +} \ No newline at end of file diff --git a/backend/exports/PR-20251014-005.json b/backend/exports/PR-20251014-005.json new file mode 100644 index 0000000..daa9f1b --- /dev/null +++ b/backend/exports/PR-20251014-005.json @@ -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": "" + } + ] +} \ No newline at end of file diff --git a/backend/exports/PR-20251014-006.json b/backend/exports/PR-20251014-006.json new file mode 100644 index 0000000..911f946 --- /dev/null +++ b/backend/exports/PR-20251014-006.json @@ -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": "" + } + ] +} \ No newline at end of file diff --git a/backend/exports/PR-20251014-007.json b/backend/exports/PR-20251014-007.json new file mode 100644 index 0000000..c9443b1 --- /dev/null +++ b/backend/exports/PR-20251014-007.json @@ -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": "" + } + ] +} \ No newline at end of file diff --git a/backend/exports/PR-20251014-008.json b/backend/exports/PR-20251014-008.json new file mode 100644 index 0000000..52ed55a --- /dev/null +++ b/backend/exports/PR-20251014-008.json @@ -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": "" + } + ] +} \ No newline at end of file diff --git a/backend/exports/PR-20251014-009.json b/backend/exports/PR-20251014-009.json new file mode 100644 index 0000000..7607398 --- /dev/null +++ b/backend/exports/PR-20251014-009.json @@ -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": "" + } + ] +} \ No newline at end of file diff --git a/backend/exports/PR-20251014-010.json b/backend/exports/PR-20251014-010.json new file mode 100644 index 0000000..02062d5 --- /dev/null +++ b/backend/exports/PR-20251014-010.json @@ -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": "" + } + ] +} \ No newline at end of file diff --git a/backend/exports/PR-20251014-011.json b/backend/exports/PR-20251014-011.json new file mode 100644 index 0000000..aaf6848 --- /dev/null +++ b/backend/exports/PR-20251014-011.json @@ -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": "" + } + ] +} \ No newline at end of file diff --git a/backend/exports/PR-20251014-012.json b/backend/exports/PR-20251014-012.json new file mode 100644 index 0000000..39bae0e --- /dev/null +++ b/backend/exports/PR-20251014-012.json @@ -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": "" + } + ] +} \ No newline at end of file diff --git a/backend/exports/PR-20251014-013.json b/backend/exports/PR-20251014-013.json new file mode 100644 index 0000000..793cc61 --- /dev/null +++ b/backend/exports/PR-20251014-013.json @@ -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": "" + } + ] +} \ No newline at end of file diff --git a/backend/exports/PR-20251014-014.json b/backend/exports/PR-20251014-014.json new file mode 100644 index 0000000..e1c9bfd --- /dev/null +++ b/backend/exports/PR-20251014-014.json @@ -0,0 +1,3405 @@ +{ + "request_no": "PR-20251014-014", + "job_no": "TK-MP-TEST-001", + "created_at": "2025-10-14T02:48:19.117324", + "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": 1395.1, + "pipe_lengths": [ + { + "length": 70, + "quantity": 1, + "totalLength": 70 + }, + { + "length": 70, + "quantity": 1, + "totalLength": 70 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 155, + "quantity": 1, + "totalLength": 155 + }, + { + "length": 155, + "quantity": 1, + "totalLength": 155 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 245.1, + "quantity": 1, + "totalLength": 245.1 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + } + ], + "user_requirement": "" + }, + { + "group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|3/4\"|undefined|ASTM A106 B", + "material_ids": [ + 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": 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": "" + }, + { + "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": 7448.47, + "pipe_lengths": [ + { + "length": 82.1, + "quantity": 1, + "totalLength": 82.1 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 150, + "quantity": 1, + "totalLength": 150 + }, + { + "length": 157.4, + "quantity": 1, + "totalLength": 157.4 + }, + { + "length": 283.4, + "quantity": 1, + "totalLength": 283.4 + }, + { + "length": 450.9, + "quantity": 1, + "totalLength": 450.9 + }, + { + "length": 800, + "quantity": 1, + "totalLength": 800 + }, + { + "length": 945.1, + "quantity": 1, + "totalLength": 945.1 + }, + { + "length": 1228.9, + "quantity": 1, + "totalLength": 1228.9 + }, + { + "length": 1321.87, + "quantity": 1, + "totalLength": 1321.87 + }, + { + "length": 200.8, + "quantity": 1, + "totalLength": 200.8 + }, + { + "length": 70, + "quantity": 1, + "totalLength": 70 + }, + { + "length": 149.8, + "quantity": 1, + "totalLength": 149.8 + }, + { + "length": 149.8, + "quantity": 1, + "totalLength": 149.8 + }, + { + "length": 149.8, + "quantity": 1, + "totalLength": 149.8 + }, + { + "length": 149.8, + "quantity": 1, + "totalLength": 149.8 + }, + { + "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": 152.4, + "quantity": 1, + "totalLength": 152.4 + }, + { + "length": 156.4, + "quantity": 1, + "totalLength": 156.4 + } + ], + "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": 43978.780000000006, + "pipe_lengths": [ + { + "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": 1250, + "quantity": 1, + "totalLength": 1250 + }, + { + "length": 1500, + "quantity": 1, + "totalLength": 1500 + }, + { + "length": 1520, + "quantity": 1, + "totalLength": 1520 + }, + { + "length": 1523.15, + "quantity": 1, + "totalLength": 1523.15 + }, + { + "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": 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": 80, + "quantity": 1, + "totalLength": 80 + }, + { + "length": 96, + "quantity": 1, + "totalLength": 96 + }, + { + "length": 98, + "quantity": 1, + "totalLength": 98 + }, + { + "length": 98.16, + "quantity": 1, + "totalLength": 98.16 + }, + { + "length": 99.8, + "quantity": 1, + "totalLength": 99.8 + }, + { + "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": 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": 104, + "quantity": 1, + "totalLength": 104 + }, + { + "length": 104.5, + "quantity": 1, + "totalLength": 104.5 + }, + { + "length": 112.16, + "quantity": 1, + "totalLength": 112.16 + }, + { + "length": 125, + "quantity": 1, + "totalLength": 125 + }, + { + "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": 154.61, + "quantity": 1, + "totalLength": 154.61 + }, + { + "length": 165, + "quantity": 1, + "totalLength": 165 + }, + { + "length": 165, + "quantity": 1, + "totalLength": 165 + }, + { + "length": 169.6, + "quantity": 1, + "totalLength": 169.6 + }, + { + "length": 180, + "quantity": 1, + "totalLength": 180 + }, + { + "length": 182.4, + "quantity": 1, + "totalLength": 182.4 + }, + { + "length": 195.2, + "quantity": 1, + "totalLength": 195.2 + }, + { + "length": 199.2, + "quantity": 1, + "totalLength": 199.2 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 208, + "quantity": 1, + "totalLength": 208 + }, + { + "length": 211.75, + "quantity": 1, + "totalLength": 211.75 + }, + { + "length": 219, + "quantity": 1, + "totalLength": 219 + }, + { + "length": 224.7, + "quantity": 1, + "totalLength": 224.7 + }, + { + "length": 230, + "quantity": 1, + "totalLength": 230 + }, + { + "length": 249.23, + "quantity": 1, + "totalLength": 249.23 + }, + { + "length": 250.6, + "quantity": 1, + "totalLength": 250.6 + }, + { + "length": 259.91, + "quantity": 1, + "totalLength": 259.91 + }, + { + "length": 271.55, + "quantity": 1, + "totalLength": 271.55 + }, + { + "length": 292.4, + "quantity": 1, + "totalLength": 292.4 + }, + { + "length": 319.2, + "quantity": 1, + "totalLength": 319.2 + }, + { + "length": 330.23, + "quantity": 1, + "totalLength": 330.23 + }, + { + "length": 367.79, + "quantity": 1, + "totalLength": 367.79 + }, + { + "length": 396.1, + "quantity": 1, + "totalLength": 396.1 + }, + { + "length": 400, + "quantity": 1, + "totalLength": 400 + }, + { + "length": 404, + "quantity": 1, + "totalLength": 404 + }, + { + "length": 408.68, + "quantity": 1, + "totalLength": 408.68 + }, + { + "length": 433.3, + "quantity": 1, + "totalLength": 433.3 + }, + { + "length": 450.26, + "quantity": 1, + "totalLength": 450.26 + }, + { + "length": 466.4, + "quantity": 1, + "totalLength": 466.4 + }, + { + "length": 485.3, + "quantity": 1, + "totalLength": 485.3 + }, + { + "length": 485.3, + "quantity": 1, + "totalLength": 485.3 + }, + { + "length": 500, + "quantity": 1, + "totalLength": 500 + }, + { + "length": 510.2, + "quantity": 1, + "totalLength": 510.2 + }, + { + "length": 510.2, + "quantity": 1, + "totalLength": 510.2 + }, + { + "length": 545, + "quantity": 1, + "totalLength": 545 + }, + { + "length": 549, + "quantity": 1, + "totalLength": 549 + }, + { + "length": 576.68, + "quantity": 1, + "totalLength": 576.68 + }, + { + "length": 579, + "quantity": 1, + "totalLength": 579 + }, + { + "length": 579.7, + "quantity": 1, + "totalLength": 579.7 + }, + { + "length": 579.7, + "quantity": 1, + "totalLength": 579.7 + }, + { + "length": 613.8, + "quantity": 1, + "totalLength": 613.8 + }, + { + "length": 687.6, + "quantity": 1, + "totalLength": 687.6 + }, + { + "length": 718, + "quantity": 1, + "totalLength": 718 + }, + { + "length": 750, + "quantity": 1, + "totalLength": 750 + }, + { + "length": 865.9, + "quantity": 1, + "totalLength": 865.9 + }, + { + "length": 1032.3, + "quantity": 1, + "totalLength": 1032.3 + }, + { + "length": 1107.83, + "quantity": 1, + "totalLength": 1107.83 + }, + { + "length": 1117.83, + "quantity": 1, + "totalLength": 1117.83 + }, + { + "length": 1164.5, + "quantity": 1, + "totalLength": 1164.5 + }, + { + "length": 1180.83, + "quantity": 1, + "totalLength": 1180.83 + }, + { + "length": 1180.83, + "quantity": 1, + "totalLength": 1180.83 + }, + { + "length": 1279.8, + "quantity": 1, + "totalLength": 1279.8 + }, + { + "length": 1387.4, + "quantity": 1, + "totalLength": 1387.4 + }, + { + "length": 3137.2, + "quantity": 1, + "totalLength": 3137.2 + } + ], + "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": 5372.6, + "pipe_lengths": [ + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 400, + "quantity": 1, + "totalLength": 400 + }, + { + "length": 68.8, + "quantity": 1, + "totalLength": 68.8 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 120, + "quantity": 1, + "totalLength": 120 + }, + { + "length": 153.2, + "quantity": 1, + "totalLength": 153.2 + }, + { + "length": 189.7, + "quantity": 1, + "totalLength": 189.7 + }, + { + "length": 189.7, + "quantity": 1, + "totalLength": 189.7 + }, + { + "length": 356.5, + "quantity": 1, + "totalLength": 356.5 + }, + { + "length": 650, + "quantity": 1, + "totalLength": 650 + }, + { + "length": 824.7, + "quantity": 1, + "totalLength": 824.7 + }, + { + "length": 1000, + "quantity": 1, + "totalLength": 1000 + }, + { + "length": 1120, + "quantity": 1, + "totalLength": 1120 + } + ], + "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": 33891.09, + "pipe_lengths": [ + { + "length": 50, + "quantity": 1, + "totalLength": 50 + }, + { + "length": 70, + "quantity": 1, + "totalLength": 70 + }, + { + "length": 90, + "quantity": 1, + "totalLength": 90 + }, + { + "length": 90, + "quantity": 1, + "totalLength": 90 + }, + { + "length": 95, + "quantity": 1, + "totalLength": 95 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 145, + "quantity": 1, + "totalLength": 145 + }, + { + "length": 150, + "quantity": 1, + "totalLength": 150 + }, + { + "length": 150, + "quantity": 1, + "totalLength": 150 + }, + { + "length": 150, + "quantity": 1, + "totalLength": 150 + }, + { + "length": 300, + "quantity": 1, + "totalLength": 300 + }, + { + "length": 562.3, + "quantity": 1, + "totalLength": 562.3 + }, + { + "length": 575.3, + "quantity": 1, + "totalLength": 575.3 + }, + { + "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.6, + "quantity": 1, + "totalLength": 76.6 + }, + { + "length": 76.6, + "quantity": 1, + "totalLength": 76.6 + }, + { + "length": 85.8, + "quantity": 1, + "totalLength": 85.8 + }, + { + "length": 93.1, + "quantity": 1, + "totalLength": 93.1 + }, + { + "length": 93.1, + "quantity": 1, + "totalLength": 93.1 + }, + { + "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": 103.2, + "quantity": 1, + "totalLength": 103.2 + }, + { + "length": 116.6, + "quantity": 1, + "totalLength": 116.6 + }, + { + "length": 116.6, + "quantity": 1, + "totalLength": 116.6 + }, + { + "length": 130, + "quantity": 1, + "totalLength": 130 + }, + { + "length": 130, + "quantity": 1, + "totalLength": 130 + }, + { + "length": 133.2, + "quantity": 1, + "totalLength": 133.2 + }, + { + "length": 137.7, + "quantity": 1, + "totalLength": 137.7 + }, + { + "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": 160, + "quantity": 1, + "totalLength": 160 + }, + { + "length": 161.4, + "quantity": 1, + "totalLength": 161.4 + }, + { + "length": 171.9, + "quantity": 1, + "totalLength": 171.9 + }, + { + "length": 172.58, + "quantity": 1, + "totalLength": 172.58 + }, + { + "length": 176.9, + "quantity": 1, + "totalLength": 176.9 + }, + { + "length": 180, + "quantity": 1, + "totalLength": 180 + }, + { + "length": 183, + "quantity": 1, + "totalLength": 183 + }, + { + "length": 183.2, + "quantity": 1, + "totalLength": 183.2 + }, + { + "length": 191.2, + "quantity": 1, + "totalLength": 191.2 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 202.4, + "quantity": 1, + "totalLength": 202.4 + }, + { + "length": 202.4, + "quantity": 1, + "totalLength": 202.4 + }, + { + "length": 207.8, + "quantity": 1, + "totalLength": 207.8 + }, + { + "length": 210, + "quantity": 1, + "totalLength": 210 + }, + { + "length": 218.2, + "quantity": 1, + "totalLength": 218.2 + }, + { + "length": 218.2, + "quantity": 1, + "totalLength": 218.2 + }, + { + "length": 218.89, + "quantity": 1, + "totalLength": 218.89 + }, + { + "length": 220, + "quantity": 1, + "totalLength": 220 + }, + { + "length": 235.2, + "quantity": 1, + "totalLength": 235.2 + }, + { + "length": 250, + "quantity": 1, + "totalLength": 250 + }, + { + "length": 250, + "quantity": 1, + "totalLength": 250 + }, + { + "length": 300, + "quantity": 1, + "totalLength": 300 + }, + { + "length": 300, + "quantity": 1, + "totalLength": 300 + }, + { + "length": 320, + "quantity": 1, + "totalLength": 320 + }, + { + "length": 360.4, + "quantity": 1, + "totalLength": 360.4 + }, + { + "length": 376.2, + "quantity": 1, + "totalLength": 376.2 + }, + { + "length": 383.89, + "quantity": 1, + "totalLength": 383.89 + }, + { + "length": 450, + "quantity": 1, + "totalLength": 450 + }, + { + "length": 485.3, + "quantity": 1, + "totalLength": 485.3 + }, + { + "length": 544, + "quantity": 1, + "totalLength": 544 + }, + { + "length": 553.2, + "quantity": 1, + "totalLength": 553.2 + }, + { + "length": 584, + "quantity": 1, + "totalLength": 584 + }, + { + "length": 733.2, + "quantity": 1, + "totalLength": 733.2 + }, + { + "length": 751.1, + "quantity": 1, + "totalLength": 751.1 + }, + { + "length": 782.3, + "quantity": 1, + "totalLength": 782.3 + }, + { + "length": 796.91, + "quantity": 1, + "totalLength": 796.91 + }, + { + "length": 879.7, + "quantity": 1, + "totalLength": 879.7 + }, + { + "length": 930.1, + "quantity": 1, + "totalLength": 930.1 + }, + { + "length": 960, + "quantity": 1, + "totalLength": 960 + }, + { + "length": 981.8, + "quantity": 1, + "totalLength": 981.8 + }, + { + "length": 1039.9, + "quantity": 1, + "totalLength": 1039.9 + }, + { + "length": 1293.6, + "quantity": 1, + "totalLength": 1293.6 + }, + { + "length": 2133.72, + "quantity": 1, + "totalLength": 2133.72 + }, + { + "length": 3134.2, + "quantity": 1, + "totalLength": 3134.2 + }, + { + "length": 3134.2, + "quantity": 1, + "totalLength": 3134.2 + }, + { + "length": 160, + "quantity": 1, + "totalLength": 160 + }, + { + "length": 160, + "quantity": 1, + "totalLength": 160 + }, + { + "length": 180, + "quantity": 1, + "totalLength": 180 + }, + { + "length": 180, + "quantity": 1, + "totalLength": 180 + } + ], + "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": 37225.89, + "pipe_lengths": [ + { + "length": 50, + "quantity": 1, + "totalLength": 50 + }, + { + "length": 50, + "quantity": 1, + "totalLength": 50 + }, + { + "length": 57.43, + "quantity": 1, + "totalLength": 57.43 + }, + { + "length": 60, + "quantity": 1, + "totalLength": 60 + }, + { + "length": 60, + "quantity": 1, + "totalLength": 60 + }, + { + "length": 62.53, + "quantity": 1, + "totalLength": 62.53 + }, + { + "length": 68.4, + "quantity": 1, + "totalLength": 68.4 + }, + { + "length": 68.4, + "quantity": 1, + "totalLength": 68.4 + }, + { + "length": 70, + "quantity": 1, + "totalLength": 70 + }, + { + "length": 70, + "quantity": 1, + "totalLength": 70 + }, + { + "length": 70, + "quantity": 1, + "totalLength": 70 + }, + { + "length": 79.81, + "quantity": 1, + "totalLength": 79.81 + }, + { + "length": 80, + "quantity": 1, + "totalLength": 80 + }, + { + "length": 96.88, + "quantity": 1, + "totalLength": 96.88 + }, + { + "length": 96.88, + "quantity": 1, + "totalLength": 96.88 + }, + { + "length": 99.08, + "quantity": 1, + "totalLength": 99.08 + }, + { + "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": 105.2, + "quantity": 1, + "totalLength": 105.2 + }, + { + "length": 130, + "quantity": 1, + "totalLength": 130 + }, + { + "length": 140, + "quantity": 1, + "totalLength": 140 + }, + { + "length": 144.6, + "quantity": 1, + "totalLength": 144.6 + }, + { + "length": 148.4, + "quantity": 1, + "totalLength": 148.4 + }, + { + "length": 150, + "quantity": 1, + "totalLength": 150 + }, + { + "length": 160, + "quantity": 1, + "totalLength": 160 + }, + { + "length": 167.25, + "quantity": 1, + "totalLength": 167.25 + }, + { + "length": 180, + "quantity": 1, + "totalLength": 180 + }, + { + "length": 182.35, + "quantity": 1, + "totalLength": 182.35 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 212.7, + "quantity": 1, + "totalLength": 212.7 + }, + { + "length": 215.08, + "quantity": 1, + "totalLength": 215.08 + }, + { + "length": 228.65, + "quantity": 1, + "totalLength": 228.65 + }, + { + "length": 230, + "quantity": 1, + "totalLength": 230 + }, + { + "length": 250, + "quantity": 1, + "totalLength": 250 + }, + { + "length": 324.5, + "quantity": 1, + "totalLength": 324.5 + }, + { + "length": 328.3, + "quantity": 1, + "totalLength": 328.3 + }, + { + "length": 330, + "quantity": 1, + "totalLength": 330 + }, + { + "length": 370.4, + "quantity": 1, + "totalLength": 370.4 + }, + { + "length": 400, + "quantity": 1, + "totalLength": 400 + }, + { + "length": 400, + "quantity": 1, + "totalLength": 400 + }, + { + "length": 400, + "quantity": 1, + "totalLength": 400 + }, + { + "length": 457, + "quantity": 1, + "totalLength": 457 + }, + { + "length": 470.5, + "quantity": 1, + "totalLength": 470.5 + }, + { + "length": 482.33, + "quantity": 1, + "totalLength": 482.33 + }, + { + "length": 483.13, + "quantity": 1, + "totalLength": 483.13 + }, + { + "length": 483.13, + "quantity": 1, + "totalLength": 483.13 + }, + { + "length": 494.5, + "quantity": 1, + "totalLength": 494.5 + }, + { + "length": 494.5, + "quantity": 1, + "totalLength": 494.5 + }, + { + "length": 494.5, + "quantity": 1, + "totalLength": 494.5 + }, + { + "length": 500, + "quantity": 1, + "totalLength": 500 + }, + { + "length": 508.4, + "quantity": 1, + "totalLength": 508.4 + }, + { + "length": 520.21, + "quantity": 1, + "totalLength": 520.21 + }, + { + "length": 562.63, + "quantity": 1, + "totalLength": 562.63 + }, + { + "length": 569.38, + "quantity": 1, + "totalLength": 569.38 + }, + { + "length": 598.4, + "quantity": 1, + "totalLength": 598.4 + }, + { + "length": 625, + "quantity": 1, + "totalLength": 625 + }, + { + "length": 660, + "quantity": 1, + "totalLength": 660 + }, + { + "length": 683.13, + "quantity": 1, + "totalLength": 683.13 + }, + { + "length": 688.4, + "quantity": 1, + "totalLength": 688.4 + }, + { + "length": 720, + "quantity": 1, + "totalLength": 720 + }, + { + "length": 774.36, + "quantity": 1, + "totalLength": 774.36 + }, + { + "length": 800, + "quantity": 1, + "totalLength": 800 + }, + { + "length": 800, + "quantity": 1, + "totalLength": 800 + }, + { + "length": 859.5, + "quantity": 1, + "totalLength": 859.5 + }, + { + "length": 1059.36, + "quantity": 1, + "totalLength": 1059.36 + }, + { + "length": 1240.23, + "quantity": 1, + "totalLength": 1240.23 + }, + { + "length": 1250, + "quantity": 1, + "totalLength": 1250 + }, + { + "length": 1345.23, + "quantity": 1, + "totalLength": 1345.23 + }, + { + "length": 1348.4, + "quantity": 1, + "totalLength": 1348.4 + }, + { + "length": 1500, + "quantity": 1, + "totalLength": 1500 + }, + { + "length": 1550, + "quantity": 1, + "totalLength": 1550 + }, + { + "length": 1715.73, + "quantity": 1, + "totalLength": 1715.73 + }, + { + "length": 2176.6, + "quantity": 1, + "totalLength": 2176.6 + }, + { + "length": 2374.5, + "quantity": 1, + "totalLength": 2374.5 + } + ], + "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": 3635.8, + "pipe_lengths": [ + { + "length": 96.2, + "quantity": 1, + "totalLength": 96.2 + }, + { + "length": 300, + "quantity": 1, + "totalLength": 300 + }, + { + "length": 1410.3, + "quantity": 1, + "totalLength": 1410.3 + }, + { + "length": 1829.3, + "quantity": 1, + "totalLength": 1829.3 + } + ], + "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": 545.3, + "pipe_lengths": [ + { + "length": 545.3, + "quantity": 1, + "totalLength": 545.3 + } + ], + "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": 25269.879999999997, + "pipe_lengths": [ + { + "length": 36.5, + "quantity": 1, + "totalLength": 36.5 + }, + { + "length": 49.8, + "quantity": 1, + "totalLength": 49.8 + }, + { + "length": 66.54, + "quantity": 1, + "totalLength": 66.54 + }, + { + "length": 67.57, + "quantity": 1, + "totalLength": 67.57 + }, + { + "length": 67.57, + "quantity": 1, + "totalLength": 67.57 + }, + { + "length": 77.8, + "quantity": 1, + "totalLength": 77.8 + }, + { + "length": 80, + "quantity": 1, + "totalLength": 80 + }, + { + "length": 87.3, + "quantity": 1, + "totalLength": 87.3 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 105.7, + "quantity": 1, + "totalLength": 105.7 + }, + { + "length": 124.59, + "quantity": 1, + "totalLength": 124.59 + }, + { + "length": 135.8, + "quantity": 1, + "totalLength": 135.8 + }, + { + "length": 144.4, + "quantity": 1, + "totalLength": 144.4 + }, + { + "length": 150, + "quantity": 1, + "totalLength": 150 + }, + { + "length": 157.1, + "quantity": 1, + "totalLength": 157.1 + }, + { + "length": 168.5, + "quantity": 1, + "totalLength": 168.5 + }, + { + "length": 187.3, + "quantity": 1, + "totalLength": 187.3 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 211.2, + "quantity": 1, + "totalLength": 211.2 + }, + { + "length": 211.2, + "quantity": 1, + "totalLength": 211.2 + }, + { + "length": 300, + "quantity": 1, + "totalLength": 300 + }, + { + "length": 300, + "quantity": 1, + "totalLength": 300 + }, + { + "length": 301.5, + "quantity": 1, + "totalLength": 301.5 + }, + { + "length": 302.5, + "quantity": 1, + "totalLength": 302.5 + }, + { + "length": 308.3, + "quantity": 1, + "totalLength": 308.3 + }, + { + "length": 350.1, + "quantity": 1, + "totalLength": 350.1 + }, + { + "length": 374.4, + "quantity": 1, + "totalLength": 374.4 + }, + { + "length": 383.4, + "quantity": 1, + "totalLength": 383.4 + }, + { + "length": 400, + "quantity": 1, + "totalLength": 400 + }, + { + "length": 440, + "quantity": 1, + "totalLength": 440 + }, + { + "length": 457.1, + "quantity": 1, + "totalLength": 457.1 + }, + { + "length": 722.9, + "quantity": 1, + "totalLength": 722.9 + }, + { + "length": 737.81, + "quantity": 1, + "totalLength": 737.81 + }, + { + "length": 820.2, + "quantity": 1, + "totalLength": 820.2 + }, + { + "length": 917.93, + "quantity": 1, + "totalLength": 917.93 + }, + { + "length": 1085.5, + "quantity": 1, + "totalLength": 1085.5 + }, + { + "length": 1214.4, + "quantity": 1, + "totalLength": 1214.4 + }, + { + "length": 1219.7, + "quantity": 1, + "totalLength": 1219.7 + }, + { + "length": 1268.41, + "quantity": 1, + "totalLength": 1268.41 + }, + { + "length": 1269.3, + "quantity": 1, + "totalLength": 1269.3 + }, + { + "length": 1285.93, + "quantity": 1, + "totalLength": 1285.93 + }, + { + "length": 1335.93, + "quantity": 1, + "totalLength": 1335.93 + }, + { + "length": 1382.1, + "quantity": 1, + "totalLength": 1382.1 + }, + { + "length": 1811.3, + "quantity": 1, + "totalLength": 1811.3 + }, + { + "length": 2665.3, + "quantity": 1, + "totalLength": 2665.3 + }, + { + "length": 150, + "quantity": 1, + "totalLength": 150 + }, + { + "length": 650, + "quantity": 1, + "totalLength": 650 + }, + { + "length": 80, + "quantity": 1, + "totalLength": 80 + }, + { + "length": 140, + "quantity": 1, + "totalLength": 140 + }, + { + "length": 167, + "quantity": 1, + "totalLength": 167 + } + ], + "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": 3063.6099999999997, + "pipe_lengths": [ + { + "length": 57.1, + "quantity": 1, + "totalLength": 57.1 + }, + { + "length": 99.8, + "quantity": 1, + "totalLength": 99.8 + }, + { + "length": 157.1, + "quantity": 1, + "totalLength": 157.1 + }, + { + "length": 157.41, + "quantity": 1, + "totalLength": 157.41 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 233.9, + "quantity": 1, + "totalLength": 233.9 + }, + { + "length": 1758.3, + "quantity": 1, + "totalLength": 1758.3 + } + ], + "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": 14786.890000000001, + "pipe_lengths": [ + { + "length": 87.2, + "quantity": 1, + "totalLength": 87.2 + }, + { + "length": 250.4, + "quantity": 1, + "totalLength": 250.4 + }, + { + "length": 337.5, + "quantity": 1, + "totalLength": 337.5 + }, + { + "length": 561.89, + "quantity": 1, + "totalLength": 561.89 + }, + { + "length": 690.4, + "quantity": 1, + "totalLength": 690.4 + }, + { + "length": 706.59, + "quantity": 1, + "totalLength": 706.59 + }, + { + "length": 1000, + "quantity": 1, + "totalLength": 1000 + }, + { + "length": 1687.4, + "quantity": 1, + "totalLength": 1687.4 + }, + { + "length": 88.8, + "quantity": 1, + "totalLength": 88.8 + }, + { + "length": 144.3, + "quantity": 1, + "totalLength": 144.3 + }, + { + "length": 150, + "quantity": 1, + "totalLength": 150 + }, + { + "length": 227, + "quantity": 1, + "totalLength": 227 + }, + { + "length": 230, + "quantity": 1, + "totalLength": 230 + }, + { + "length": 269.9, + "quantity": 1, + "totalLength": 269.9 + }, + { + "length": 285.51, + "quantity": 1, + "totalLength": 285.51 + }, + { + "length": 298.5, + "quantity": 1, + "totalLength": 298.5 + }, + { + "length": 300, + "quantity": 1, + "totalLength": 300 + }, + { + "length": 346.8, + "quantity": 1, + "totalLength": 346.8 + }, + { + "length": 508, + "quantity": 1, + "totalLength": 508 + }, + { + "length": 572.2, + "quantity": 1, + "totalLength": 572.2 + }, + { + "length": 702.5, + "quantity": 1, + "totalLength": 702.5 + }, + { + "length": 1133.2, + "quantity": 1, + "totalLength": 1133.2 + }, + { + "length": 1238, + "quantity": 1, + "totalLength": 1238 + }, + { + "length": 1476.5, + "quantity": 1, + "totalLength": 1476.5 + }, + { + "length": 1494.3, + "quantity": 1, + "totalLength": 1494.3 + } + ], + "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": 1773.3, + "pipe_lengths": [ + { + "length": 300, + "quantity": 1, + "totalLength": 300 + }, + { + "length": 71.8, + "quantity": 1, + "totalLength": 71.8 + }, + { + "length": 71.8, + "quantity": 1, + "totalLength": 71.8 + }, + { + "length": 180, + "quantity": 1, + "totalLength": 180 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 549.7, + "quantity": 1, + "totalLength": 549.7 + } + ], + "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": 3193.44, + "pipe_lengths": [ + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "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": 150, + "quantity": 1, + "totalLength": 150 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 234.89, + "quantity": 1, + "totalLength": 234.89 + }, + { + "length": 300, + "quantity": 1, + "totalLength": 300 + }, + { + "length": 339.15, + "quantity": 1, + "totalLength": 339.15 + }, + { + "length": 350, + "quantity": 1, + "totalLength": 350 + }, + { + "length": 549.4, + "quantity": 1, + "totalLength": 549.4 + } + ], + "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": 6229.51, + "pipe_lengths": [ + { + "length": 130, + "quantity": 1, + "totalLength": 130 + }, + { + "length": 311.24, + "quantity": 1, + "totalLength": 311.24 + }, + { + "length": 380, + "quantity": 1, + "totalLength": 380 + }, + { + "length": 381.69, + "quantity": 1, + "totalLength": 381.69 + }, + { + "length": 1015.7, + "quantity": 1, + "totalLength": 1015.7 + }, + { + "length": 1081.96, + "quantity": 1, + "totalLength": 1081.96 + }, + { + "length": 1454.2, + "quantity": 1, + "totalLength": 1454.2 + }, + { + "length": 90, + "quantity": 1, + "totalLength": 90 + }, + { + "length": 100.49, + "quantity": 1, + "totalLength": 100.49 + }, + { + "length": 180, + "quantity": 1, + "totalLength": 180 + }, + { + "length": 350, + "quantity": 1, + "totalLength": 350 + }, + { + "length": 754.23, + "quantity": 1, + "totalLength": 754.23 + } + ], + "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": 5794.42, + "pipe_lengths": [ + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 368.2, + "quantity": 1, + "totalLength": 368.2 + }, + { + "length": 875.8, + "quantity": 1, + "totalLength": 875.8 + }, + { + "length": 1070.7, + "quantity": 1, + "totalLength": 1070.7 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 100, + "quantity": 1, + "totalLength": 100 + }, + { + "length": 116.1, + "quantity": 1, + "totalLength": 116.1 + }, + { + "length": 198.7, + "quantity": 1, + "totalLength": 198.7 + }, + { + "length": 200, + "quantity": 1, + "totalLength": 200 + }, + { + "length": 234.81, + "quantity": 1, + "totalLength": 234.81 + }, + { + "length": 274.8, + "quantity": 1, + "totalLength": 274.8 + }, + { + "length": 530, + "quantity": 1, + "totalLength": 530 + }, + { + "length": 1625.31, + "quantity": 1, + "totalLength": 1625.31 + } + ], + "user_requirement": "" + } + ] +} \ No newline at end of file diff --git a/backend/exports/PR-20251014-015.json b/backend/exports/PR-20251014-015.json new file mode 100644 index 0000000..f5bae09 --- /dev/null +++ b/backend/exports/PR-20251014-015.json @@ -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": [] +} \ No newline at end of file diff --git a/backend/exports/PR-20251014-016.json b/backend/exports/PR-20251014-016.json new file mode 100644 index 0000000..3e4b113 --- /dev/null +++ b/backend/exports/PR-20251014-016.json @@ -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": "" + } + ] +} \ No newline at end of file diff --git a/backend/exports/PR-20251014-017.json b/backend/exports/PR-20251014-017.json new file mode 100644 index 0000000..99f10df --- /dev/null +++ b/backend/exports/PR-20251014-017.json @@ -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": "" + } + ] +} \ No newline at end of file diff --git a/backend/exports/PR-20251014-018.json b/backend/exports/PR-20251014-018.json new file mode 100644 index 0000000..b06a053 --- /dev/null +++ b/backend/exports/PR-20251014-018.json @@ -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": "" + } + ] +} \ No newline at end of file diff --git a/backend/scripts/26_add_user_status_column.sql b/backend/scripts/26_add_user_status_column.sql new file mode 100644 index 0000000..2c90b4f --- /dev/null +++ b/backend/scripts/26_add_user_status_column.sql @@ -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.'; diff --git a/backend/scripts/27_add_purchase_tracking.sql b/backend/scripts/27_add_purchase_tracking.sql new file mode 100644 index 0000000..8e55fae --- /dev/null +++ b/backend/scripts/27_add_purchase_tracking.sql @@ -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: 취소'; diff --git a/backend/scripts/28_add_purchase_requests.sql b/backend/scripts/28_add_purchase_requests.sql new file mode 100644 index 0000000..5c69688 --- /dev/null +++ b/backend/scripts/28_add_purchase_requests.sql @@ -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 '구매신청 자재 상세'; diff --git a/frontend/src/App.css b/frontend/src/App.css index 90c0c6c..0c24079 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 219b4d7..6745dbb 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { )} - {/* 핵심 기능 */} + {/* 핵심 기능 - 프로젝트 선택 시만 표시 */} {selectedProject && ( - <> -
-

- 📋 BOM 관리 워크플로우 -

+
+

+ 📋 BOM 관리 워크플로우 +

+ )} {/* selectedProject 조건문 닫기 */} - {/* 관리자 기능 (있는 경우만) */} + {/* 관리자 기능 (프로젝트 선택과 무관하게 항상 표시) */} {adminFeatures.length > 0 && (

@@ -753,8 +820,29 @@ function App() { e.currentTarget.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.07)'; }} > -

- {feature.title} +

+ {feature.title} + {feature.id === 'user-management' && feature.pendingCount > 0 && ( + + {feature.pendingCount}명 대기 + + )}

{feature.description} @@ -853,8 +941,7 @@ function App() {

- - )} {/* selectedProject 조건문 닫기 */} + )} {/* adminFeatures 조건문 닫기 */} ); @@ -880,6 +967,25 @@ function App() { filename={pageParams.filename} /> ); + + case 'purchase-batch': + return ( + + ); + + case 'purchase-request': + return ( + + ); case 'system-settings': return ( diff --git a/frontend/src/pages/NewMaterialsPage.css b/frontend/src/pages/NewMaterialsPage.css index bbd5425..2b520ed 100644 --- a/frontend/src/pages/NewMaterialsPage.css +++ b/frontend/src/pages/NewMaterialsPage.css @@ -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%; } diff --git a/frontend/src/pages/NewMaterialsPage.jsx b/frontend/src/pages/NewMaterialsPage.jsx index 4203966..029d4e1 100644 --- a/frontend/src/pages/NewMaterialsPage.jsx +++ b/frontend/src/pages/NewMaterialsPage.jsx @@ -19,6 +19,7 @@ const NewMaterialsPage = ({ const [loading, setLoading] = useState(true); const [selectedCategory, setSelectedCategory] = useState('PIPE'); const [selectedMaterials, setSelectedMaterials] = useState(new Set()); + const [exportHistory, setExportHistory] = useState([]); // 내보내기 이력 const [viewMode, setViewMode] = useState('detailed'); // 'detailed' or 'simple' const [availableRevisions, setAvailableRevisions] = useState([]); const [currentRevision, setCurrentRevision] = useState(revision || 'Rev.0'); @@ -32,6 +33,9 @@ const NewMaterialsPage = ({ const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); const [columnFilters, setColumnFilters] = useState({}); const [showFilterDropdown, setShowFilterDropdown] = useState(null); + + // 구매신청된 자재 ID 관리 + const [purchasedMaterials, setPurchasedMaterials] = useState(new Set()); // 같은 BOM의 다른 리비전들 조회 const loadAvailableRevisions = async () => { @@ -82,27 +86,191 @@ const NewMaterialsPage = ({ }; }, []); + // 구매신청된 자재 확인 + const loadPurchasedMaterials = async (jobNo) => { + try { + const response = await api.get('/purchase-request/list', { + params: { job_no: jobNo } + }); + + if (response.data?.requests) { + const allPurchasedIds = new Set(); + + // 모든 구매신청에서 자재 ID 수집 + for (const request of response.data.requests) { + try { + const detailResponse = await api.get(`/purchase-request/${request.request_id}/materials`); + if (detailResponse.data?.materials) { + detailResponse.data.materials.forEach(m => { + if (m.material_ids) { + // 그룹화된 자재의 모든 ID 추가 + m.material_ids.forEach(id => allPurchasedIds.add(id)); + } else if (m.material_id) { + // 개별 자재 ID 추가 + allPurchasedIds.add(m.material_id); + } + }); + } + } catch (err) { + console.error('구매신청 상세 로드 실패:', err); + } + } + + setPurchasedMaterials(allPurchasedIds); + console.log(`✅ 구매신청된 자재 ${allPurchasedIds.size}개 확인`); + } + } catch (error) { + console.error('구매신청 목록 로드 실패:', error); + } + }; + const loadMaterials = async (id) => { try { setLoading(true); console.log('🔍 자재 데이터 로딩 중...', { file_id: id }); + // 구매신청된 자재 먼저 확인 + await loadPurchasedMaterials(jobNo); + const response = await fetchMaterials({ file_id: parseInt(id), - limit: 10000 + limit: 10000, + exclude_requested: false // 구매신청된 자재도 포함하여 표시 }); if (response.data?.materials) { const materialsData = response.data.materials; - console.log(`✅ ${materialsData.length}개 자재 로드 완료`); + console.log(`✅ ${materialsData.length}개 원본 자재 로드 완료`); + + // PIPE 데이터 샘플 확인 + const pipeSample = materialsData.find(m => m.classified_category === 'PIPE'); + if (pipeSample) { + console.log('📊 PIPE 샘플 데이터:', pipeSample); + console.log('📊 pipe_details:', pipeSample.pipe_details); + } + + // 같은 사양끼리 그룹화 (PIPE는 특별 처리) + const groupedMap = new Map(); + + materialsData.forEach(material => { + let groupKey; + + if (material.classified_category === 'PIPE') { + // PIPE는 길이를 제외한 사양으로 그룹화 + // 예: "PIPE 2" SCH40 120mm"과 "PIPE 2" SCH40 60mm"은 같은 그룹 + const descWithoutLength = material.original_description.replace(/\d+(?:\.\d+)?\s*mm/gi, '').trim(); + groupKey = `PIPE|${descWithoutLength}|${material.size_spec || ''}|${material.schedule || ''}|${material.material_grade || ''}`; + } else { + // PIPE가 아닌 경우 기존 방식대로 그룹화 + groupKey = `${material.original_description}|${material.size_spec || ''}|${material.schedule || ''}|${material.material_grade || ''}|${material.classified_category}`; + } + + if (groupedMap.has(groupKey)) { + // 이미 있는 그룹에 추가 + const existing = groupedMap.get(groupKey); + + if (material.classified_category === 'PIPE') { + // PIPE의 경우 - 백엔드에서 이미 그룹화되어 온 경우 처리 + if (material.pipe_details && material.pipe_details.individual_pipes) { + // 백엔드에서 그룹화된 개별 파이프 정보 사용 + const individualPipes = material.pipe_details.individual_pipes; + + individualPipes.forEach(pipe => { + const pipeLength = parseFloat(pipe.length); + const pipeQty = parseFloat(pipe.quantity || 1); + const pipeTotalLength = pipeLength * pipeQty; + + if (existing.pipe_lengths) { + existing.pipe_lengths.push({ length: pipeLength, quantity: pipeQty, totalLength: pipeTotalLength }); + existing.total_length = (existing.total_length || 0) + pipeTotalLength; + } else { + existing.pipe_lengths = [{ length: pipeLength, quantity: pipeQty, totalLength: pipeTotalLength }]; + existing.total_length = pipeTotalLength; + } + }); + + existing.quantity = material.pipe_details.group_total_quantity || material.quantity; + } else { + // 개별 파이프 처리 (백엔드 그룹화 없는 경우) + const qty = parseFloat(material.quantity); + let length = 6000; // 기본값 6m + + if (material.pipe_details && material.pipe_details.length_mm) { + length = parseFloat(material.pipe_details.length_mm); + } + + const totalLength = length * qty; + + if (existing.pipe_lengths) { + existing.pipe_lengths.push({ length, quantity: qty, totalLength }); + existing.total_length = (existing.total_length || 0) + totalLength; + } else { + existing.pipe_lengths = [{ length, quantity: qty, totalLength }]; + existing.total_length = totalLength; + } + + existing.quantity = (parseFloat(existing.quantity) + qty).toFixed(3); + } + + // 6,000mm를 1본으로 계산 + existing.pipe_count = Math.ceil((existing.total_length || 0) / 6000); + } else { + // PIPE가 아닌 경우 수량만 합산 + existing.quantity = (parseFloat(existing.quantity) + parseFloat(material.quantity)).toFixed(3); + } + + existing.grouped_ids = [...existing.grouped_ids, material.id]; + } else { + // 새 그룹 생성 + const newGroup = { + ...material, + grouped_ids: [material.id], // 그룹에 속한 모든 자재 ID 저장 + is_grouped: true + }; + + // PIPE의 경우 길이 정보 초기화 + if (material.classified_category === 'PIPE') { + // 백엔드에서 이미 그룹화된 경우 + if (material.pipe_details && material.pipe_details.individual_pipes) { + newGroup.pipe_lengths = material.pipe_details.individual_pipes.map(pipe => ({ + length: parseFloat(pipe.length), + quantity: parseFloat(pipe.quantity || 1), + totalLength: parseFloat(pipe.length) * parseFloat(pipe.quantity || 1) + })); + newGroup.total_length = material.pipe_details.total_length_mm || + newGroup.pipe_lengths.reduce((sum, p) => sum + p.totalLength, 0); + newGroup.quantity = material.pipe_details.group_total_quantity || material.quantity; + } else { + // 개별 파이프 + const qty = parseFloat(material.quantity); + let length = 6000; // 기본값 6m + + if (material.pipe_details && material.pipe_details.length_mm) { + length = parseFloat(material.pipe_details.length_mm); + } + + const totalLength = length * qty; + newGroup.pipe_lengths = [{ length, quantity: qty, totalLength }]; + newGroup.total_length = totalLength; + } + + newGroup.pipe_count = Math.ceil(newGroup.total_length / 6000); // 6,000mm를 1본으로 계산 + } + + groupedMap.set(groupKey, newGroup); + } + }); + + const groupedMaterials = Array.from(groupedMap.values()); + console.log(`📦 ${groupedMaterials.length}개 그룹으로 집계 완료`); // 파이프 데이터 검증 - const pipes = materialsData.filter(m => m.classified_category === 'PIPE'); + const pipes = groupedMaterials.filter(m => m.classified_category === 'PIPE'); if (pipes.length > 0) { console.log('📊 파이프 데이터 샘플:', pipes[0]); } - setMaterials(materialsData); + setMaterials(groupedMaterials); } } catch (error) { console.error('❌ 자재 로딩 실패:', error); @@ -292,8 +460,8 @@ const NewMaterialsPage = ({ const getCategoryDisplayName = (category) => { const categoryMap = { 'SPECIAL': 'SPECIAL', - 'U_BOLT': 'U-BOLT', - 'SUPPORT': 'U-BOLT', + 'U_BOLT': 'SUPPORT', + 'SUPPORT': 'SUPPORT', 'PIPE': 'PIPE', 'FITTING': 'FITTING', 'FLANGE': 'FLANGE', @@ -724,6 +892,64 @@ const NewMaterialsPage = ({ unit: '개', isGasket: true }; + } else if (category === 'SUPPORT' || category === 'U_BOLT') { + const desc = material.original_description || ''; + const isUrethaneBlock = desc.includes('URETHANE') || desc.includes('BLOCK SHOE') || desc.includes('우레탄'); + const isClamp = desc.includes('CLAMP') || desc.includes('클램프'); + + let subtypeText = ''; + if (isUrethaneBlock) { + subtypeText = '우레탄블럭슈'; + } else if (isClamp) { + subtypeText = '클램프'; + } else { + subtypeText = '유볼트'; + } + + return { + type: 'SUPPORT', + subtype: subtypeText, + size: material.main_nom || material.size_inch || material.size_spec || '-', + description: material.original_description || '-', + grade: material.full_material_grade || material.material_grade || '-', + additionalReq: '-', + quantity: Math.round(material.quantity || 0), + unit: '개', + isSupport: true + }; + } else if (category === 'SPECIAL') { + // SPECIAL 카테고리: 크기/스케줄/재질을 제외한 나머지를 타입으로 표시 + const desc = material.original_description || ''; + + // 크기, 스케줄, 재질 패턴 제거하여 타입 추출 + let typeText = desc; + + // 재질 제거 (ASTM A105 등) + typeText = typeText.replace(/ASTM\s+[A-Z0-9]+/gi, '').trim(); + typeText = typeText.replace(/[A-Z]{2,}\s+[A-Z0-9]+/g, '').trim(); + + // 크기 제거 (1", 2", 3" 등) + typeText = typeText.replace(/\d+["']\s*/g, '').trim(); + + // 스케줄 제거 (SCH 40, 150LB 등) + typeText = typeText.replace(/SCH\s*\d+[A-Z]*/gi, '').trim(); + typeText = typeText.replace(/\d+LB/gi, '').trim(); + + // 쉼표 정리 + typeText = typeText.replace(/,\s*,/g, ',').replace(/^,\s*|,\s*$/g, '').trim(); + + return { + type: 'SPECIAL', + subtype: typeText || desc, + size: material.main_nom || material.size_inch || material.size_spec || '-', + schedule: '-', + grade: material.full_material_grade || material.material_grade || '-', + drawingNo: material.drawing_name || material.line_no || material.dwg_name || 'N/A', + additionalReq: '-', + quantity: Math.round(material.quantity || 0), + unit: '개', + isSpecial: true + }; } else if (category === 'UNKNOWN') { return { type: 'UNKNOWN', @@ -886,12 +1112,25 @@ const NewMaterialsPage = ({ // 카테고리 색상 (제거 - CSS에서 처리) - // 전체 선택/해제 + // 전체 선택/해제 (구매신청된 항목 제외) const toggleAllSelection = () => { - if (selectedMaterials.size === filteredMaterials.length) { + // 구매신청되지 않은 항목들만 필터링 + const selectableMaterials = filteredMaterials.filter(material => { + const isPurchased = material.grouped_ids ? + material.grouped_ids.some(id => purchasedMaterials.has(id)) : + purchasedMaterials.has(material.id); + return !isPurchased; + }); + + // 현재 선택 가능한 모든 항목이 선택되어 있는지 확인 + const allSelectableSelected = selectableMaterials.every(m => selectedMaterials.has(m.id)); + + if (allSelectableSelected) { + // 전체 해제 setSelectedMaterials(new Set()); } else { - setSelectedMaterials(new Set(filteredMaterials.map(m => m.id))); + // 구매신청되지 않은 항목만 선택 + setSelectedMaterials(new Set(selectableMaterials.map(m => m.id))); } }; @@ -1023,14 +1262,54 @@ const NewMaterialsPage = ({ }; // 엑셀 내보내기 - 개선된 버전 사용 - const exportToExcel = () => { + const exportToExcel = async () => { try { - // 내보낼 데이터 결정 (선택 항목 또는 현재 카테고리 전체) - const dataToExport = selectedMaterials.size > 0 - ? filteredMaterials.filter(m => selectedMaterials.has(m.id)) - : filteredMaterials; + // 내보낼 데이터 결정 + let dataToExport; - console.log('📊 엑셀 내보내기:', dataToExport.length, '개 항목'); + if (selectedMaterials.size > 0) { + // 선택된 항목만 내보내기 + dataToExport = materials.filter(m => selectedMaterials.has(m.id)); + } else { + // 선택된 항목이 없으면 현재 보이는 카테고리의 구매신청되지 않은 자재만 + dataToExport = filteredMaterials.filter(material => { + const isPurchased = material.grouped_ids ? + material.grouped_ids.some(id => purchasedMaterials.has(id)) : + purchasedMaterials.has(material.id); + return !isPurchased; + }); + } + + // 그룹화된 자재의 경우 모든 ID를 포함 + const allMaterialIds = []; + const expandedMaterialsData = []; + + dataToExport.forEach(material => { + if (material.grouped_ids && material.grouped_ids.length > 0) { + // 그룹화된 자재의 모든 ID 추가 + allMaterialIds.push(...material.grouped_ids); + // 각 ID에 대해 동일한 데이터 복사 + material.grouped_ids.forEach(id => { + expandedMaterialsData.push({ + ...material, + material_id: id, + id: id + }); + }); + } else { + // 그룹화되지 않은 자재 + allMaterialIds.push(material.id); + expandedMaterialsData.push(material); + } + }); + + console.log('📊 엑셀 내보내기:', dataToExport.length, '개 그룹, ', allMaterialIds.length, '개 실제 자재'); + console.log('🔍 전송할 자재 ID들:', allMaterialIds); + console.log('📦 그룹화 정보:', dataToExport.map(m => ({ + id: m.id, + grouped_ids: m.grouped_ids, + description: m.original_description + }))); // 사용자 요구사항을 자재에 추가 const dataWithRequirements = dataToExport.map(material => ({ @@ -1038,6 +1317,88 @@ const NewMaterialsPage = ({ user_requirement: userRequirements[material.id] || '' })); + // 서버에 구매신청 생성 + try { + // 그룹화된 데이터를 구매신청 형식으로 변환 + const groupedMaterialsForRequest = dataToExport.map(material => { + if (material.classified_category === 'PIPE' && material.total_length) { + // PIPE의 경우 길이 정보 포함 + return { + group_key: `${material.original_description}|${material.size_spec}|${material.schedule}|${material.material_grade}`, + material_ids: material.grouped_ids || [material.id], + description: material.original_description, + category: material.classified_category, + size: material.size_inch || material.size_spec, + schedule: material.schedule, + material_grade: material.material_grade || material.full_material_grade, + quantity: material.quantity, + unit: 'm', // 파이프는 미터 단위 + total_length: material.total_length, + pipe_lengths: material.pipe_lengths, + user_requirement: userRequirements[material.id] || '' + }; + } else { + // 다른 자재들 + return { + group_key: `${material.original_description}|${material.size_spec}|${material.schedule}|${material.material_grade}`, + material_ids: material.grouped_ids || [material.id], + description: material.original_description, + category: material.classified_category, + size: material.size_inch || material.size_spec, + schedule: material.schedule, + material_grade: material.material_grade || material.full_material_grade, + quantity: material.quantity, + unit: material.unit || '개', + user_requirement: userRequirements[material.id] || '' + }; + } + }); + + const response = await api.post('/purchase-request/create', { + file_id: fileId, + job_no: jobNo, + category: selectedCategory, + material_ids: allMaterialIds, // 그룹화된 모든 ID 전송 + materials_data: expandedMaterialsData.map(m => ({ // 확장된 데이터 전송 + material_id: m.id, + description: m.original_description, + category: m.classified_category, + size: m.size_inch || m.size_spec, + schedule: m.schedule, + material_grade: m.material_grade || m.full_material_grade, + quantity: m.quantity, + unit: m.unit, + user_requirement: userRequirements[m.id] || '' + })), + grouped_materials: groupedMaterialsForRequest // 그룹화 정보 추가 전송 + }); + + if (response.data.success) { + console.log(`✅ 구매신청 완료: ${response.data.request_no}`); + console.log(`📊 구매신청된 자재 ID: ${allMaterialIds.length}개`, allMaterialIds); + alert(`구매신청 ${response.data.request_no}이 생성되었습니다.\n구매신청 관리 페이지에서 확인하세요.`); + + // 구매신청된 자재 ID를 즉시 purchasedMaterials에 추가 + setPurchasedMaterials(prev => { + const newSet = new Set(prev); + allMaterialIds.forEach(id => newSet.add(id)); + console.log(`📦 구매신청 자재 추가: 기존 ${prev.size}개 → 신규 ${newSet.size}개`); + return newSet; + }); + + // 선택된 항목 초기화 + setSelectedMaterials(new Set()); + + // 페이지 새로고침하여 신청된 자재 숨기기 + console.log('🔄 구매신청 후 자재 목록 새로고침 시작'); + await loadMaterials(fileId); + console.log('✅ 자재 목록 새로고침 완료'); + } + } catch (error) { + console.error('구매신청 생성 실패:', error); + // 실패해도 엑셀은 내보내기 + } + // 개선된 엑셀 내보내기 함수 사용 const additionalInfo = { filename: filename || bomName, @@ -1219,13 +1580,61 @@ const NewMaterialsPage = ({ + + {/* 구매신청 이력 표시 */} + {exportHistory.length > 0 && ( +
+ ✅ 최근 구매신청: {new Date(exportHistory[0].export_date).toLocaleString()} + ({exportHistory[0].category} {exportHistory[0].material_count}개) + {exportHistory.length > 1 && ` 외 ${exportHistory.length - 1}건`} +
+ )} {/* 자재 테이블 */}
@@ -1307,13 +1716,13 @@ const NewMaterialsPage = ({
사용자요구
수량
- ) : selectedCategory === 'U_BOLT' ? ( -
+ ) : selectedCategory === 'SUPPORT' || selectedCategory === 'U_BOLT' ? ( +
선택
종류 타입 크기 -
재질
+ 디스크립션
추가요구
사용자요구
수량 @@ -1345,10 +1754,16 @@ const NewMaterialsPage = ({ if (material.classified_category === 'SPECIAL') { // SPECIAL 카테고리 (10개 컬럼) + // 구매신청 여부 확인 + const isPurchased = material.grouped_ids ? + material.grouped_ids.some(id => purchasedMaterials.has(id)) : + purchasedMaterials.has(material.id); + return (
{/* 선택 */}
@@ -1356,6 +1771,8 @@ const NewMaterialsPage = ({ type="checkbox" checked={selectedMaterials.has(material.id)} onChange={() => toggleMaterialSelection(material.id)} + disabled={isPurchased} + title={isPurchased ? '이미 구매신청된 자재입니다' : ''} />
@@ -1388,7 +1805,7 @@ const NewMaterialsPage = ({ {/* 도면번호 */}
- {material.dat_file || 'N/A'} + {info.drawingNo || material.drawing_name || material.line_no || material.dwg_name || 'N/A'}
{/* 추가요구 */} @@ -1409,17 +1826,38 @@ const NewMaterialsPage = ({ {/* 수량 */}
- {info.quantity || material.quantity || 1} + {isPurchased && ( + + 구매신청완료 + + )} + + {info.quantity || material.quantity || 1}개 +
); } if (material.classified_category === 'PIPE') { // PIPE 또는 카테고리 없는 경우 (기본 9개 컬럼) + // 구매신청 여부 확인 + const isPurchased = material.grouped_ids ? + material.grouped_ids.some(id => purchasedMaterials.has(id)) : + purchasedMaterials.has(material.id); + return (
{/* 선택 */}
@@ -1427,6 +1865,8 @@ const NewMaterialsPage = ({ type="checkbox" checked={selectedMaterials.has(material.id)} onChange={() => toggleMaterialSelection(material.id)} + disabled={isPurchased} + title={isPurchased ? '이미 구매신청된 자재입니다' : ''} />
@@ -1475,17 +1915,65 @@ const NewMaterialsPage = ({ {/* 수량 */}
- {info.quantity} {info.unit} +
+ {isPurchased && ( + + 구매신청완료 + + )} + + {(() => { + // 총 길이와 개수 계산 + 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.grouped_ids && material.grouped_ids.length > 0) { + totalCount = material.grouped_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}개)`; + })()} + +
); } if (material.classified_category === 'FITTING') { + // 구매신청 여부 확인 + const isPurchased = material.grouped_ids ? + material.grouped_ids.some(id => purchasedMaterials.has(id)) : + purchasedMaterials.has(material.id); + return (
{/* 선택 */}
@@ -1493,6 +1981,8 @@ const NewMaterialsPage = ({ type="checkbox" checked={selectedMaterials.has(material.id)} onChange={() => toggleMaterialSelection(material.id)} + disabled={isPurchased} + title={isPurchased ? '이미 구매신청된 자재입니다' : ''} />
@@ -1549,7 +2039,20 @@ const NewMaterialsPage = ({ {/* 수량 */}
- + {isPurchased && ( + + 구매신청완료 + + )} + {info.quantity} {info.unit}
@@ -1558,10 +2061,16 @@ const NewMaterialsPage = ({ } if (material.classified_category === 'VALVE') { + // 구매신청 여부 확인 + const isPurchased = material.grouped_ids ? + material.grouped_ids.some(id => purchasedMaterials.has(id)) : + purchasedMaterials.has(material.id); + return (
{/* 선택 */}
@@ -1569,6 +2078,8 @@ const NewMaterialsPage = ({ type="checkbox" checked={selectedMaterials.has(material.id)} onChange={() => toggleMaterialSelection(material.id)} + disabled={isPurchased} + title={isPurchased ? '이미 구매신청된 자재입니다' : ''} />
@@ -1618,7 +2129,20 @@ const NewMaterialsPage = ({ {/* 수량 */}
- + {isPurchased && ( + + 구매신청완료 + + )} + {info.quantity} {info.unit}
@@ -1627,10 +2151,16 @@ const NewMaterialsPage = ({ } if (material.classified_category === 'FLANGE') { + // 구매신청 여부 확인 + const isPurchased = material.grouped_ids ? + material.grouped_ids.some(id => purchasedMaterials.has(id)) : + purchasedMaterials.has(material.id); + return (
{/* 선택 */}
@@ -1638,6 +2168,8 @@ const NewMaterialsPage = ({ type="checkbox" checked={selectedMaterials.has(material.id)} onChange={() => toggleMaterialSelection(material.id)} + disabled={isPurchased} + title={isPurchased ? '이미 구매신청된 자재입니다' : ''} />
@@ -1694,7 +2226,20 @@ const NewMaterialsPage = ({ {/* 수량 */}
- + {isPurchased && ( + + 구매신청완료 + + )} + {info.quantity} {info.unit}
@@ -1703,10 +2248,16 @@ const NewMaterialsPage = ({ } if (material.classified_category === 'UNKNOWN') { + // 구매신청 여부 확인 + const isPurchased = material.grouped_ids ? + material.grouped_ids.some(id => purchasedMaterials.has(id)) : + purchasedMaterials.has(material.id); + return (
{/* 선택 */}
@@ -1714,6 +2265,8 @@ const NewMaterialsPage = ({ type="checkbox" checked={selectedMaterials.has(material.id)} onChange={() => toggleMaterialSelection(material.id)} + disabled={isPurchased} + title={isPurchased ? '이미 구매신청된 자재입니다' : ''} />
@@ -1747,8 +2300,21 @@ const NewMaterialsPage = ({ {/* 수량 */}
+ {isPurchased && ( + + 구매신청완료 + + )}
- + {info.quantity} {info.unit}
@@ -1758,10 +2324,16 @@ const NewMaterialsPage = ({ } if (material.classified_category === 'GASKET') { + // 구매신청 여부 확인 + const isPurchased = material.grouped_ids ? + material.grouped_ids.some(id => purchasedMaterials.has(id)) : + purchasedMaterials.has(material.id); + return (
{/* 선택 */}
@@ -1769,6 +2341,8 @@ const NewMaterialsPage = ({ type="checkbox" checked={selectedMaterials.has(material.id)} onChange={() => toggleMaterialSelection(material.id)} + disabled={isPurchased} + title={isPurchased ? '이미 구매신청된 자재입니다' : ''} />
@@ -1830,8 +2404,21 @@ const NewMaterialsPage = ({ {/* 수량 */}
+ {isPurchased && ( + + 구매신청완료 + + )}
- + {info.quantity} {info.unit}
@@ -1842,10 +2429,16 @@ const NewMaterialsPage = ({ if (material.classified_category === 'BOLT') { // BOLT 카테고리 (9개 컬럼, 길이 표시) + // 구매신청 여부 확인 + const isPurchased = material.grouped_ids ? + material.grouped_ids.some(id => purchasedMaterials.has(id)) : + purchasedMaterials.has(material.id); + return (
{/* 선택 */}
@@ -1853,6 +2446,8 @@ const NewMaterialsPage = ({ type="checkbox" checked={selectedMaterials.has(material.id)} onChange={() => toggleMaterialSelection(material.id)} + disabled={isPurchased} + title={isPurchased ? '이미 구매신청된 자재입니다' : ''} />
@@ -1901,36 +2496,53 @@ const NewMaterialsPage = ({ {/* 수량 */}
- {info.quantity || material.quantity || 1} {info.unit || 'SETS'} + {isPurchased && ( + + 구매신청완료 + + )} + {info.quantity || material.quantity || 1} {info.unit || 'SETS'}
); } - if (material.classified_category === 'U_BOLT') { - // U_BOLT 카테고리 - 자재 타입별 다른 표시 + if (material.classified_category === 'SUPPORT' || material.classified_category === 'U_BOLT') { + // SUPPORT 카테고리 - 자재 타입별 다른 표시 const desc = material.original_description || ''; const isUrethaneBlock = desc.includes('URETHANE') || desc.includes('BLOCK SHOE') || desc.includes('우레탄'); const isClamp = desc.includes('CLAMP') || desc.includes('클램프'); - let badgeType = 'ubolt'; - let badgeText = 'U-BOLT'; - let subtypeText = info.subtype || 'U_BOLT'; + // 구매신청 여부 확인 + const isPurchased = material.grouped_ids ? + material.grouped_ids.some(id => purchasedMaterials.has(id)) : + purchasedMaterials.has(material.id); + + let badgeType = 'support'; + let badgeText = 'SUPPORT'; + let subtypeText = ''; if (isUrethaneBlock) { - badgeType = 'urethane'; - badgeText = 'URETHANE'; - subtypeText = 'BLOCK SHOE'; + subtypeText = '우레탄블럭슈'; } else if (isClamp) { - badgeType = 'clamp'; - badgeText = 'CLAMP'; - subtypeText = 'CLAMP'; + subtypeText = '클램프'; + } else { + subtypeText = '유볼트'; } return (
{/* 선택 */}
@@ -1956,20 +2568,14 @@ const NewMaterialsPage = ({ {/* 크기 */}
- {isUrethaneBlock ? - (material.original_description?.match(/\d+t/i)?.[0] || material.main_nom) : - (info.size || material.main_nom) - } + {material.main_nom || material.size_inch || info.size || '-'}
- {/* 재질 */} + {/* 디스크립션 (재질정보 포함) */}
- - {isUrethaneBlock ? - 'URETHANE' : - (info.grade || material.full_material_grade || '재질 확인 필요') - } + + {material.original_description || '-'}
diff --git a/frontend/src/pages/PurchaseBatchPage.jsx b/frontend/src/pages/PurchaseBatchPage.jsx new file mode 100644 index 0000000..742eb30 --- /dev/null +++ b/frontend/src/pages/PurchaseBatchPage.jsx @@ -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 ( + + {style.text} + + ); + }; + + const filteredBatches = activeTab === 'all' + ? batches + : batches.filter(b => b.batch_status === activeTab); + + return ( +
+ {/* 헤더 */} +
+

+ 구매 배치 관리 +

+

+ 엑셀로 내보낸 자재들을 배치 단위로 관리합니다 +

+
+ + {/* 메시지 */} + {message.text && ( +
+ {message.text} +
+ )} + + {/* 탭 네비게이션 */} +
+ {[ + { key: 'all', label: '전체' }, + { key: 'pending', label: '구매 전' }, + { key: 'requested', label: '구매신청' }, + { key: 'in_progress', label: '진행중' }, + { key: 'completed', label: '완료' } + ].map(tab => ( + + ))} +
+ + {/* 메인 컨텐츠 */} +
+ {/* 배치 목록 */} +
+
+

+ 배치 목록 ({filteredBatches.length}) +

+
+ +
+ {isLoading ? ( +
+ 로딩중... +
+ ) : filteredBatches.length === 0 ? ( +
+ 배치가 없습니다 +
+ ) : ( + filteredBatches.map(batch => ( +
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'; + } + }} + > +
+ + {batch.batch_no} + + {getStatusBadge(batch.batch_status)} +
+ +
+ {batch.job_no} - {batch.job_name} +
+ +
+ {batch.category || '전체'} | {batch.material_count}개 자재 +
+ +
+ + 대기: {batch.status_detail.pending} + + + 신청: {batch.status_detail.requested} + + + 발주: {batch.status_detail.ordered} + + + 입고: {batch.status_detail.received} + +
+ +
+ {new Date(batch.export_date).toLocaleDateString()} | {batch.exported_by} +
+
+ )) + )} +
+
+ + {/* 선택된 배치 상세 */} + {selectedBatch ? ( +
+
+
+

+ 배치 {selectedBatch.batch_no} +

+
+ + +
+
+
+ +
+
+ + + + + + + + + + + + + + {batchMaterials.map((material, idx) => ( + + + + + + + + + + ))} + +
No카테고리자재 설명수량상태PR번호PO번호
{idx + 1} + + {material.category} + + {material.description} + {material.quantity} {material.unit} + + {getStatusBadge(material.purchase_status)} + + {material.purchase_request_no || '-'} + + {material.purchase_order_no || '-'} +
+
+
+
+ ) : ( +
+
+
📦
+
배치를 선택하세요
+
+
+ )} +
+
+ ); +}; + +export default PurchaseBatchPage; diff --git a/frontend/src/pages/PurchaseRequestPage.css b/frontend/src/pages/PurchaseRequestPage.css new file mode 100644 index 0000000..b7b383c --- /dev/null +++ b/frontend/src/pages/PurchaseRequestPage.css @@ -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; +} diff --git a/frontend/src/pages/PurchaseRequestPage.jsx b/frontend/src/pages/PurchaseRequestPage.jsx new file mode 100644 index 0000000..7eadde7 --- /dev/null +++ b/frontend/src/pages/PurchaseRequestPage.jsx @@ -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 ( +
+
+ +

구매신청 관리

+

구매신청한 자재들을 그룹별로 관리합니다

+
+ +
+ {/* 구매신청 목록 */} +
+
+

구매신청 목록 ({requests.length})

+
+ +
+ {isLoading ? ( +
로딩중...
+ ) : requests.length === 0 ? ( +
구매신청이 없습니다
+ ) : ( + requests.map(request => ( +
handleRequestSelect(request)} + > +
+ {request.request_no} + + {new Date(request.requested_at).toLocaleDateString()} + +
+
+
{request.job_no} - {request.job_name}
+
+ {request.category || '전체'} | {request.material_count}개 자재 +
+
+
+ {request.requested_by} + +
+
+ )) + )} +
+
+ + {/* 선택된 구매신청 상세 */} +
+ {selectedRequest ? ( + <> +
+

{selectedRequest.request_no}

+ +
+ +
+ {/* 카테고리별로 그룹화하여 표시 */} + {(() => { + // 카테고리별로 자재 그룹화 + 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]) => ( +
+

+ {category} ({materials.length}개) +

+ + + + + + + + {category === 'BOLT' ? : } + + + + + + + {materials.map((material, idx) => ( + + + + + + + + + + + ))} + +
No카테고리자재 설명크기길이스케줄재질수량사용자요구
{idx + 1} + + {material.category} + + {material.description}{material.size || '-'}{material.schedule || '-'}{material.material_grade || '-'} + {material.category === 'PIPE' ? ( +
+ + {(() => { + // 총 길이와 개수 계산 + 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}개)`; + })()} + +
+ ) : ( + `${material.quantity} ${material.unit || 'EA'}` + )} +
{material.user_requirement || '-'}
+
+ )); + })()} +
+ + ) : ( +
+
📦
+
구매신청을 선택하세요
+
+ )} +
+
+
+ ); +}; + +export default PurchaseRequestPage; diff --git a/frontend/src/pages/UserManagementPage.jsx b/frontend/src/pages/UserManagementPage.jsx index f6b33f5..a839a14 100644 --- a/frontend/src/pages/UserManagementPage.jsx +++ b/frontend/src/pages/UserManagementPage.jsx @@ -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 }) => {
)} - {/* 사용자 목록 */} + {/* 탭 네비게이션 */}
+ + + +
+ + {/* 승인 대기 사용자 목록 */} + {activeTab === 'pending' && (
-

- 등록된 사용자 ({users.length}명) -

+
+

+ 승인 대기 사용자 ({pendingUsers.length}명) +

+
+ + {pendingUsers.length === 0 ? ( +
+
승인 대기 중인 사용자가 없습니다
+
+ ) : ( +
+ + + + + + + + + + + + + {pendingUsers.map((userItem) => ( + + + + + + + + + ))} + +
사용자역할부서/직책상태가입일관리
+
+
+ {userItem.name ? userItem.name.charAt(0).toUpperCase() : '?'} +
+
+
+ {userItem.name || '이름 없음'} +
+
+ @{userItem.username} +
+ {userItem.email && ( +
+ {userItem.email} +
+ )} +
+
+
+ + {userItem.role || '사용자'} + + +
+ {userItem.department || '-'} +
+
+ {userItem.position || '-'} +
+
+ + 승인 대기 + + +
+ {userItem.created_at ? new Date(userItem.created_at).toLocaleDateString() : '-'} +
+
+
+ + +
+
+
+ )}
+ )} + + {/* 등록된 사용자 목록 */} + {activeTab === 'active' && ( +
+
+

+ 등록된 사용자 ({users.length}명) +

+
{isLoading ? (
@@ -388,6 +727,26 @@ const UserManagementPage = ({ onNavigate, user }) => { > 역할 변경 + {userItem.user_id !== user?.user_id && ( + + )} {userItem.user_id !== user?.user_id && (
)} -
+
+ )} + + {/* 정지된 사용자 목록 */} + {activeTab === 'suspended' && ( +
+
+

+ 정지된 사용자 ({suspendedUsers.length}명) +

+
+ + {suspendedUsers.length === 0 ? ( +
+
정지된 사용자가 없습니다
+
+ ) : ( +
+ + + + + + + + + + + + + {suspendedUsers.map((userItem) => ( + + + + + + + + + ))} + +
사용자역할부서/직책상태정지일관리
+
+
+ {userItem.name ? userItem.name.charAt(0).toUpperCase() : '?'} +
+
+
+ {userItem.name || '이름 없음'} +
+
+ @{userItem.username} +
+ {userItem.email && ( +
+ {userItem.email} +
+ )} +
+
+
+ + {getRoleDisplayName(userItem.role)} + + +
+ {userItem.department || '-'} +
+
+ {userItem.position || '-'} +
+
+ + 정지됨 + + +
+ {userItem.updated_at ? new Date(userItem.updated_at).toLocaleDateString() : '-'} +
+
+
+ + {userItem.user_id !== user?.user_id && ( + + )} +
+
+
+ )} +
+ )}
{/* 사용자 생성 모달 */} diff --git a/frontend/src/utils/excelExport.js b/frontend/src/utils/excelExport.js index cb89159..8a87753 100644 --- a/frontend/src/utils/excelExport.js +++ b/frontend/src/utils/excelExport.js @@ -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 || '-',