feat: 구매신청 기능 완성 및 SUPPORT/SPECIAL 카테고리 개선

- 모든 카테고리 구매신청 기능 완성 (PIPE, FITTING, VALVE, FLANGE, GASKET, BOLT, SUPPORT, SPECIAL, UNKNOWN)
- 구매신청 완료 항목: 회색 배경, 체크박스 비활성화, '구매신청완료' 배지 표시
- 전체 선택/구매신청 시 이미 구매신청된 항목 자동 제외
- 구매신청 quantity 타입 에러 수정 (문자열 -> 정수 변환)

SUPPORT 카테고리 (구 U-BOLT):
- U-BOLT -> SUPPORT로 카테고리명 변경
- 클램프, 유볼트, 우레탄블럭슈 분류 개선
- 테이블 헤더: 선택-종류-타입-크기-디스크립션-추가요구-사용자요구-수량
- 크기 정보 main_nom 필드에서 가져오기 (배관 인치)
- 엑셀 내보내기 형식 조정

SPECIAL 카테고리:
- SPECIAL 키워드 자재 자동 분류 (SPECIFICATION 제외)
- 파일 업로드 시 SPECIAL 카테고리 처리 로직 추가
- 도면번호 필드 추가 (drawing_name, line_no)
- 타입 필드: 크기/스케줄/재질 제외한 핵심 정보 표시
- 엑셀 DWG_NAME, LINE_NUM 컬럼 파싱 및 저장

FITTING 카테고리:
- 테이블 컬럼 너비 조정 (선택 2%, 종류 8.5%, 수량 12%)

구매신청 관리:
- 엑셀 재다운로드 형식 개선 (BOM 페이지와 동일한 형식)
- 그룹화된 자재 정보 포함하여 저장 및 다운로드
This commit is contained in:
Hyungi Ahn
2025-10-14 12:39:25 +09:00
parent e468663386
commit e27020ae9b
44 changed files with 13102 additions and 176 deletions

View File

@@ -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

View File

@@ -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():

View File

@@ -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 []

View File

@@ -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
""")

View File

@@ -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(

View File

@@ -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

View File

@@ -0,0 +1,591 @@
"""
엑셀 내보내기 및 구매 배치 관리 API
"""
from fastapi import APIRouter, Depends, HTTPException, status, Response
from fastapi.responses import FileResponse
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Optional, List, Dict, Any
from datetime import datetime
import json
import os
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
import uuid
from ..database import get_db
from ..auth.jwt_service import get_current_user
from ..utils.logger import logger
router = APIRouter(prefix="/export", tags=["Export Management"])
# 엑셀 파일 저장 경로
EXPORT_DIR = "exports"
os.makedirs(EXPORT_DIR, exist_ok=True)
def create_excel_from_materials(materials: List[Dict], batch_info: Dict) -> str:
"""
자재 목록으로 엑셀 파일 생성
"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = batch_info.get("category", "자재목록")
# 헤더 스타일
header_fill = PatternFill(start_color="B8E6FF", end_color="B8E6FF", fill_type="solid")
header_font = Font(bold=True, size=11)
header_alignment = Alignment(horizontal="center", vertical="center")
thin_border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# 배치 정보 추가 (상단 3줄)
ws.merge_cells('A1:J1')
ws['A1'] = f"구매 배치: {batch_info.get('batch_no', 'N/A')}"
ws['A1'].font = Font(bold=True, size=14)
ws.merge_cells('A2:J2')
ws['A2'] = f"프로젝트: {batch_info.get('job_no', '')} - {batch_info.get('job_name', '')}"
ws.merge_cells('A3:J3')
ws['A3'] = f"내보낸 날짜: {batch_info.get('export_date', datetime.now().strftime('%Y-%m-%d %H:%M'))}"
# 빈 줄
ws.append([])
# 헤더 행
headers = [
"No.", "카테고리", "자재 설명", "크기", "스케줄/등급",
"재질", "수량", "단위", "추가요구", "사용자요구",
"구매상태", "PR번호", "PO번호", "공급업체", "예정일"
]
for col, header in enumerate(headers, 1):
cell = ws.cell(row=5, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = header_alignment
cell.border = thin_border
# 데이터 행
row_num = 6
for idx, material in enumerate(materials, 1):
row_data = [
idx,
material.get("category", ""),
material.get("description", ""),
material.get("size", ""),
material.get("schedule", ""),
material.get("material_grade", ""),
material.get("quantity", ""),
material.get("unit", ""),
material.get("additional_req", ""),
material.get("user_requirement", ""),
material.get("purchase_status", "pending"),
material.get("purchase_request_no", ""),
material.get("purchase_order_no", ""),
material.get("vendor_name", ""),
material.get("expected_date", "")
]
for col, value in enumerate(row_data, 1):
cell = ws.cell(row=row_num, column=col, value=value)
cell.border = thin_border
if col == 11: # 구매상태 컬럼
if value == "pending":
cell.fill = PatternFill(start_color="FFFFE0", end_color="FFFFE0", fill_type="solid")
elif value == "requested":
cell.fill = PatternFill(start_color="FFE4B5", end_color="FFE4B5", fill_type="solid")
elif value == "ordered":
cell.fill = PatternFill(start_color="ADD8E6", end_color="ADD8E6", fill_type="solid")
elif value == "received":
cell.fill = PatternFill(start_color="90EE90", end_color="90EE90", fill_type="solid")
row_num += 1
# 열 너비 자동 조정
for column in ws.columns:
max_length = 0
column_letter = get_column_letter(column[0].column)
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column_letter].width = adjusted_width
# 파일 저장
file_name = f"batch_{batch_info.get('batch_no', uuid.uuid4().hex[:8])}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
file_path = os.path.join(EXPORT_DIR, file_name)
wb.save(file_path)
return file_name
@router.post("/create-batch")
async def create_export_batch(
file_id: int,
job_no: Optional[str] = None,
category: Optional[str] = None,
materials: List[Dict] = [],
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
엑셀 내보내기 배치 생성 (자재 그룹화)
"""
try:
# 배치 번호 생성 (YYYYMMDD-XXX 형식)
batch_date = datetime.now().strftime('%Y%m%d')
# 오늘 생성된 배치 수 확인
count_query = text("""
SELECT COUNT(*) as count
FROM excel_export_history
WHERE DATE(export_date) = CURRENT_DATE
""")
count_result = db.execute(count_query).fetchone()
batch_seq = (count_result.count + 1) if count_result else 1
batch_no = f"{batch_date}-{str(batch_seq).zfill(3)}"
# Job 정보 조회
job_name = ""
if job_no:
job_query = text("SELECT job_name FROM jobs WHERE job_no = :job_no")
job_result = db.execute(job_query, {"job_no": job_no}).fetchone()
if job_result:
job_name = job_result.job_name
# 배치 정보
batch_info = {
"batch_no": batch_no,
"job_no": job_no,
"job_name": job_name,
"category": category,
"export_date": datetime.now().strftime('%Y-%m-%d %H:%M')
}
# 엑셀 파일 생성
excel_file_name = create_excel_from_materials(materials, batch_info)
# 내보내기 이력 저장
insert_history = text("""
INSERT INTO excel_export_history (
file_id, job_no, exported_by, export_type,
category, material_count, file_name, notes
) VALUES (
:file_id, :job_no, :exported_by, :export_type,
:category, :material_count, :file_name, :notes
) RETURNING export_id
""")
result = db.execute(insert_history, {
"file_id": file_id,
"job_no": job_no,
"exported_by": current_user.get("user_id"),
"export_type": "batch",
"category": category,
"material_count": len(materials),
"file_name": excel_file_name,
"notes": f"배치번호: {batch_no}"
})
export_id = result.fetchone().export_id
# 자재별 내보내기 기록
material_ids = []
for material in materials:
material_id = material.get("id")
if material_id:
material_ids.append(material_id)
insert_material = text("""
INSERT INTO exported_materials (
export_id, material_id, purchase_status,
quantity_exported
) VALUES (
:export_id, :material_id, 'pending',
:quantity
)
""")
db.execute(insert_material, {
"export_id": export_id,
"material_id": material_id,
"quantity": material.get("quantity", 0)
})
db.commit()
logger.info(f"Export batch created: {batch_no} with {len(materials)} materials")
return {
"success": True,
"batch_no": batch_no,
"export_id": export_id,
"file_name": excel_file_name,
"material_count": len(materials),
"message": f"배치 {batch_no}가 생성되었습니다"
}
except Exception as e:
db.rollback()
logger.error(f"Failed to create export batch: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"배치 생성 실패: {str(e)}"
)
@router.get("/batches")
async def get_export_batches(
file_id: Optional[int] = None,
job_no: Optional[str] = None,
status: Optional[str] = None,
limit: int = 50,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
내보내기 배치 목록 조회
"""
try:
query = text("""
SELECT
eeh.export_id,
eeh.file_id,
eeh.job_no,
eeh.export_date,
eeh.category,
eeh.material_count,
eeh.file_name,
eeh.notes,
u.name as exported_by,
j.job_name,
f.original_filename,
-- 상태별 집계
COUNT(DISTINCT em.material_id) as total_materials,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'pending' THEN em.material_id END) as pending_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) as requested_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) as ordered_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) as received_count,
-- 전체 상태 계산
CASE
WHEN COUNT(DISTINCT em.material_id) = COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END)
THEN 'completed'
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) > 0
THEN 'in_progress'
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) > 0
THEN 'requested'
ELSE 'pending'
END as batch_status
FROM excel_export_history eeh
LEFT JOIN users u ON eeh.exported_by = u.user_id
LEFT JOIN jobs j ON eeh.job_no = j.job_no
LEFT JOIN files f ON eeh.file_id = f.id
LEFT JOIN exported_materials em ON eeh.export_id = em.export_id
WHERE eeh.export_type = 'batch'
AND (:file_id IS NULL OR eeh.file_id = :file_id)
AND (:job_no IS NULL OR eeh.job_no = :job_no)
GROUP BY
eeh.export_id, eeh.file_id, eeh.job_no, eeh.export_date,
eeh.category, eeh.material_count, eeh.file_name, eeh.notes,
u.name, j.job_name, f.original_filename
HAVING (:status IS NULL OR
CASE
WHEN COUNT(DISTINCT em.material_id) = COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END)
THEN 'completed'
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) > 0
THEN 'in_progress'
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) > 0
THEN 'requested'
ELSE 'pending'
END = :status)
ORDER BY eeh.export_date DESC
LIMIT :limit
""")
results = db.execute(query, {
"file_id": file_id,
"job_no": job_no,
"status": status,
"limit": limit
}).fetchall()
batches = []
for row in results:
# 배치 번호 추출 (notes에서)
batch_no = ""
if row.notes and "배치번호:" in row.notes:
batch_no = row.notes.split("배치번호:")[1].strip()
batches.append({
"export_id": row.export_id,
"batch_no": batch_no,
"file_id": row.file_id,
"job_no": row.job_no,
"job_name": row.job_name,
"export_date": row.export_date.isoformat() if row.export_date else None,
"category": row.category,
"material_count": row.total_materials,
"file_name": row.file_name,
"exported_by": row.exported_by,
"source_file": row.original_filename,
"batch_status": row.batch_status,
"status_detail": {
"pending": row.pending_count,
"requested": row.requested_count,
"ordered": row.ordered_count,
"received": row.received_count,
"total": row.total_materials
}
})
return {
"success": True,
"batches": batches,
"count": len(batches)
}
except Exception as e:
logger.error(f"Failed to get export batches: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"배치 목록 조회 실패: {str(e)}"
)
@router.get("/batch/{export_id}/materials")
async def get_batch_materials(
export_id: int,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
배치에 포함된 자재 목록 조회
"""
try:
query = text("""
SELECT
em.id as exported_material_id,
em.material_id,
m.original_description,
m.classified_category,
m.size_inch,
m.schedule,
m.material_grade,
m.quantity,
m.unit,
em.purchase_status,
em.purchase_request_no,
em.purchase_order_no,
em.vendor_name,
em.expected_date,
em.quantity_ordered,
em.quantity_received,
em.unit_price,
em.total_price,
em.notes,
ur.requirement as user_requirement
FROM exported_materials em
JOIN materials m ON em.material_id = m.id
LEFT JOIN user_requirements ur ON m.id = ur.material_id
WHERE em.export_id = :export_id
ORDER BY m.classified_category, m.original_description
""")
results = db.execute(query, {"export_id": export_id}).fetchall()
materials = []
for row in results:
materials.append({
"exported_material_id": row.exported_material_id,
"material_id": row.material_id,
"description": row.original_description,
"category": row.classified_category,
"size": row.size_inch,
"schedule": row.schedule,
"material_grade": row.material_grade,
"quantity": row.quantity,
"unit": row.unit,
"user_requirement": row.user_requirement,
"purchase_status": row.purchase_status,
"purchase_request_no": row.purchase_request_no,
"purchase_order_no": row.purchase_order_no,
"vendor_name": row.vendor_name,
"expected_date": row.expected_date.isoformat() if row.expected_date else None,
"quantity_ordered": row.quantity_ordered,
"quantity_received": row.quantity_received,
"unit_price": float(row.unit_price) if row.unit_price else None,
"total_price": float(row.total_price) if row.total_price else None,
"notes": row.notes
})
return {
"success": True,
"materials": materials,
"count": len(materials)
}
except Exception as e:
logger.error(f"Failed to get batch materials: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"배치 자재 조회 실패: {str(e)}"
)
@router.get("/batch/{export_id}/download")
async def download_batch_excel(
export_id: int,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
저장된 배치 엑셀 파일 다운로드
"""
try:
# 배치 정보 조회
query = text("""
SELECT file_name, notes
FROM excel_export_history
WHERE export_id = :export_id
""")
result = db.execute(query, {"export_id": export_id}).fetchone()
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="배치를 찾을 수 없습니다"
)
file_path = os.path.join(EXPORT_DIR, result.file_name)
if not os.path.exists(file_path):
# 파일이 없으면 재생성
materials = await get_batch_materials(export_id, current_user, db)
batch_no = ""
if result.notes and "배치번호:" in result.notes:
batch_no = result.notes.split("배치번호:")[1].strip()
batch_info = {
"batch_no": batch_no,
"export_date": datetime.now().strftime('%Y-%m-%d %H:%M')
}
file_name = create_excel_from_materials(materials["materials"], batch_info)
file_path = os.path.join(EXPORT_DIR, file_name)
# DB 업데이트
update_query = text("""
UPDATE excel_export_history
SET file_name = :file_name
WHERE export_id = :export_id
""")
db.execute(update_query, {
"file_name": file_name,
"export_id": export_id
})
db.commit()
return FileResponse(
path=file_path,
filename=result.file_name,
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to download batch excel: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"엑셀 다운로드 실패: {str(e)}"
)
@router.patch("/batch/{export_id}/status")
async def update_batch_status(
export_id: int,
status: str,
purchase_request_no: Optional[str] = None,
purchase_order_no: Optional[str] = None,
vendor_name: Optional[str] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
배치 전체 상태 일괄 업데이트
"""
try:
# 배치의 모든 자재 상태 업데이트
update_query = text("""
UPDATE exported_materials
SET
purchase_status = :status,
purchase_request_no = COALESCE(:pr_no, purchase_request_no),
purchase_order_no = COALESCE(:po_no, purchase_order_no),
vendor_name = COALESCE(:vendor, vendor_name),
updated_by = :updated_by,
requested_date = CASE WHEN :status = 'requested' THEN CURRENT_TIMESTAMP ELSE requested_date END,
ordered_date = CASE WHEN :status = 'ordered' THEN CURRENT_TIMESTAMP ELSE ordered_date END,
received_date = CASE WHEN :status = 'received' THEN CURRENT_TIMESTAMP ELSE received_date END
WHERE export_id = :export_id
""")
result = db.execute(update_query, {
"export_id": export_id,
"status": status,
"pr_no": purchase_request_no,
"po_no": purchase_order_no,
"vendor": vendor_name,
"updated_by": current_user.get("user_id")
})
# 이력 기록
history_query = text("""
INSERT INTO purchase_status_history (
exported_material_id, material_id,
previous_status, new_status,
changed_by, reason
)
SELECT
em.id, em.material_id,
em.purchase_status, :new_status,
:changed_by, :reason
FROM exported_materials em
WHERE em.export_id = :export_id
""")
db.execute(history_query, {
"export_id": export_id,
"new_status": status,
"changed_by": current_user.get("user_id"),
"reason": f"배치 일괄 업데이트"
})
db.commit()
logger.info(f"Batch {export_id} status updated to {status}")
return {
"success": True,
"message": f"배치의 모든 자재가 {status} 상태로 변경되었습니다",
"updated_count": result.rowcount
}
except Exception as e:
db.rollback()
logger.error(f"Failed to update batch status: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"배치 상태 업데이트 실패: {str(e)}"
)

View File

@@ -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)

View File

@@ -0,0 +1,390 @@
"""
구매신청 관리 API
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Optional, List, Dict
from datetime import datetime
import os
import json
from ..database import get_db
from ..auth.middleware import get_current_user
from ..utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/purchase-request", tags=["Purchase Request"])
# 엑셀 파일 저장 경로
EXCEL_DIR = "exports"
os.makedirs(EXCEL_DIR, exist_ok=True)
class PurchaseRequestCreate(BaseModel):
file_id: int
job_no: Optional[str] = None
category: Optional[str] = None
material_ids: List[int] = []
materials_data: List[Dict] = []
grouped_materials: Optional[List[Dict]] = [] # 그룹화된 자재 정보
@router.post("/create")
async def create_purchase_request(
request_data: PurchaseRequestCreate,
# current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매신청 생성 (엑셀 내보내기 = 구매신청)
"""
try:
# 구매신청 번호 생성
today = datetime.now().strftime('%Y%m%d')
count_query = text("""
SELECT COUNT(*) as count
FROM purchase_requests
WHERE request_no LIKE :pattern
""")
count = db.execute(count_query, {"pattern": f"PR-{today}%"}).fetchone().count
request_no = f"PR-{today}-{str(count + 1).zfill(3)}"
# 자재 데이터를 JSON 파일로 저장 (나중에 재다운로드 시 사용)
json_filename = f"{request_no}.json"
json_path = os.path.join(EXCEL_DIR, json_filename)
save_materials_data(
request_data.materials_data,
json_path,
request_no,
request_data.job_no,
request_data.grouped_materials # 그룹화 정보 추가
)
# 구매신청 레코드 생성
insert_request = text("""
INSERT INTO purchase_requests (
request_no, file_id, job_no, category,
material_count, excel_file_path, requested_by
) VALUES (
:request_no, :file_id, :job_no, :category,
:material_count, :excel_path, :requested_by
) RETURNING request_id
""")
result = db.execute(insert_request, {
"request_no": request_no,
"file_id": request_data.file_id,
"job_no": request_data.job_no,
"category": request_data.category,
"material_count": len(request_data.material_ids),
"excel_path": json_filename,
"requested_by": 1 # current_user.get("user_id")
})
request_id = result.fetchone().request_id
# 구매신청 자재 상세 저장
logger.info(f"Processing {len(request_data.material_ids)} material IDs for purchase request {request_no}")
logger.info(f"First 10 Material IDs: {request_data.material_ids[:10]}") # 처음 10개만 로그
logger.info(f"Category: {request_data.category}, Job: {request_data.job_no}")
inserted_count = 0
for i, material_id in enumerate(request_data.material_ids):
material_data = request_data.materials_data[i] if i < len(request_data.materials_data) else {}
# 이미 구매신청된 자재인지 확인
check_existing = text("""
SELECT 1 FROM purchase_request_items
WHERE material_id = :material_id
""")
existing = db.execute(check_existing, {"material_id": material_id}).fetchone()
if not existing:
insert_item = text("""
INSERT INTO purchase_request_items (
request_id, material_id, quantity, unit, user_requirement
) VALUES (
:request_id, :material_id, :quantity, :unit, :user_requirement
)
""")
# quantity를 정수로 변환 (소수점 제거)
quantity_str = str(material_data.get("quantity", 0))
try:
quantity = int(float(quantity_str))
except (ValueError, TypeError):
quantity = 0
db.execute(insert_item, {
"request_id": request_id,
"material_id": material_id,
"quantity": quantity,
"unit": material_data.get("unit", ""),
"user_requirement": material_data.get("user_requirement", "")
})
inserted_count += 1
else:
logger.warning(f"Material {material_id} already in another purchase request, skipping")
db.commit()
logger.info(f"Purchase request created: {request_no} with {inserted_count} materials (out of {len(request_data.material_ids)} requested)")
# 실제 저장된 자재 확인
verify_query = text("""
SELECT COUNT(*) as count FROM purchase_request_items WHERE request_id = :request_id
""")
verified_count = db.execute(verify_query, {"request_id": request_id}).fetchone().count
logger.info(f"✅ DB 검증: purchase_request_items에 {verified_count}개 저장됨")
return {
"success": True,
"request_no": request_no,
"request_id": request_id,
"material_count": len(request_data.material_ids),
"inserted_count": inserted_count,
"verified_count": verified_count,
"message": f"구매신청 {request_no}이 생성되었습니다"
}
except Exception as e:
db.rollback()
logger.error(f"Failed to create purchase request: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"구매신청 생성 실패: {str(e)}"
)
@router.get("/list")
async def get_purchase_requests(
file_id: Optional[int] = None,
job_no: Optional[str] = None,
status: Optional[str] = None,
# current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매신청 목록 조회
"""
try:
query = text("""
SELECT
pr.request_id,
pr.request_no,
pr.file_id,
pr.job_no,
pr.category,
pr.material_count,
pr.excel_file_path,
pr.requested_at,
pr.status,
u.name as requested_by,
f.original_filename,
j.job_name,
COUNT(pri.item_id) as item_count
FROM purchase_requests pr
LEFT JOIN users u ON pr.requested_by = u.user_id
LEFT JOIN files f ON pr.file_id = f.id
LEFT JOIN jobs j ON pr.job_no = j.job_no
LEFT JOIN purchase_request_items pri ON pr.request_id = pri.request_id
WHERE 1=1
AND (:file_id IS NULL OR pr.file_id = :file_id)
AND (:job_no IS NULL OR pr.job_no = :job_no)
AND (:status IS NULL OR pr.status = :status)
GROUP BY
pr.request_id, pr.request_no, pr.file_id, pr.job_no,
pr.category, pr.material_count, pr.excel_file_path,
pr.requested_at, pr.status, u.name, f.original_filename, j.job_name
ORDER BY pr.requested_at DESC
""")
results = db.execute(query, {
"file_id": file_id,
"job_no": job_no,
"status": status
}).fetchall()
requests = []
for row in results:
requests.append({
"request_id": row.request_id,
"request_no": row.request_no,
"file_id": row.file_id,
"job_no": row.job_no,
"job_name": row.job_name,
"category": row.category,
"material_count": row.material_count,
"item_count": row.item_count,
"excel_file_path": row.excel_file_path,
"requested_at": row.requested_at.isoformat() if row.requested_at else None,
"status": row.status,
"requested_by": row.requested_by,
"source_file": row.original_filename
})
return {
"success": True,
"requests": requests,
"count": len(requests)
}
except Exception as e:
logger.error(f"Failed to get purchase requests: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"구매신청 목록 조회 실패: {str(e)}"
)
@router.get("/{request_id}/materials")
async def get_request_materials(
request_id: int,
# current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매신청에 포함된 자재 목록 조회 (그룹화 정보 포함)
"""
try:
# 구매신청 정보 조회하여 JSON 파일 경로 가져오기
info_query = text("""
SELECT excel_file_path
FROM purchase_requests
WHERE request_id = :request_id
""")
info_result = db.execute(info_query, {"request_id": request_id}).fetchone()
grouped_materials = []
if info_result and info_result.excel_file_path:
json_path = os.path.join(EXCEL_DIR, info_result.excel_file_path)
if os.path.exists(json_path):
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
grouped_materials = data.get("grouped_materials", [])
# 개별 자재 정보 조회 (기존 코드)
query = text("""
SELECT
pri.item_id,
pri.material_id,
pri.quantity as requested_quantity,
pri.unit as requested_unit,
pri.user_requirement,
pri.is_ordered,
pri.is_received,
m.original_description,
m.classified_category,
m.size_spec,
m.schedule,
m.material_grade,
m.quantity as original_quantity,
m.unit as original_unit
FROM purchase_request_items pri
JOIN materials m ON pri.material_id = m.id
WHERE pri.request_id = :request_id
ORDER BY m.classified_category, m.original_description
""")
results = db.execute(query, {"request_id": request_id}).fetchall()
materials = []
for row in results:
materials.append({
"item_id": row.item_id,
"material_id": row.material_id,
"description": row.original_description,
"category": row.classified_category,
"size": row.size_spec,
"schedule": row.schedule,
"material_grade": row.material_grade,
"quantity": row.requested_quantity or row.original_quantity,
"unit": row.requested_unit or row.original_unit,
"user_requirement": row.user_requirement,
"is_ordered": row.is_ordered,
"is_received": row.is_received
})
return {
"success": True,
"materials": materials,
"grouped_materials": grouped_materials, # 그룹화 정보 추가
"count": len(grouped_materials) if grouped_materials else len(materials)
}
except Exception as e:
logger.error(f"Failed to get request materials: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"구매신청 자재 조회 실패: {str(e)}"
)
@router.get("/{request_id}/download-excel")
async def download_request_excel(
request_id: int,
# current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매신청 자재 데이터 조회 (프론트엔드에서 엑셀 생성용)
"""
try:
# 구매신청 정보 조회
query = text("""
SELECT request_no, excel_file_path, job_no
FROM purchase_requests
WHERE request_id = :request_id
""")
result = db.execute(query, {"request_id": request_id}).fetchone()
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="구매신청을 찾을 수 없습니다"
)
file_path = os.path.join(EXCEL_DIR, result.excel_file_path)
if not os.path.exists(file_path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="데이터 파일을 찾을 수 없습니다"
)
# JSON 파일 읽기
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return {
"success": True,
"request_no": result.request_no,
"job_no": result.job_no,
"materials": data.get("materials", []),
"grouped_materials": data.get("grouped_materials", []) # 그룹화 정보도 반환
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to download request excel: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"엑셀 다운로드 실패: {str(e)}"
)
def save_materials_data(materials_data: List[Dict], file_path: str, request_no: str, job_no: str, grouped_materials: List[Dict] = None):
"""
자재 데이터를 JSON으로 저장 (프론트엔드에서 동일한 엑셀 포맷으로 생성하기 위해)
"""
data_to_save = {
"request_no": request_no,
"job_no": job_no,
"created_at": datetime.now().isoformat(),
"materials": materials_data,
"grouped_materials": grouped_materials or [] # 그룹화된 자재 정보 저장
}
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data_to_save, f, ensure_ascii=False, indent=2)

View File

@@ -0,0 +1,452 @@
"""
구매 추적 및 관리 API
엑셀 내보내기 이력 및 구매 상태 관리
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text
from sqlalchemy.orm import Session
from typing import Optional, List, Dict, Any
from datetime import datetime, date
import json
from ..database import get_db
from ..auth.jwt_service import get_current_user
from ..utils.logger import logger
router = APIRouter(prefix="/purchase", tags=["Purchase Tracking"])
@router.post("/export-history")
async def create_export_history(
file_id: int,
job_no: Optional[str] = None,
export_type: str = "full",
category: Optional[str] = None,
material_ids: List[int] = [],
filters_applied: Optional[Dict] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
엑셀 내보내기 이력 생성 및 자재 추적
"""
try:
# 내보내기 이력 생성
insert_history = text("""
INSERT INTO excel_export_history (
file_id, job_no, exported_by, export_type,
category, material_count, filters_applied
) VALUES (
:file_id, :job_no, :exported_by, :export_type,
:category, :material_count, :filters_applied
) RETURNING export_id
""")
result = db.execute(insert_history, {
"file_id": file_id,
"job_no": job_no,
"exported_by": current_user.get("user_id"),
"export_type": export_type,
"category": category,
"material_count": len(material_ids),
"filters_applied": json.dumps(filters_applied) if filters_applied else None
})
export_id = result.fetchone().export_id
# 내보낸 자재들 기록
if material_ids:
for material_id in material_ids:
insert_material = text("""
INSERT INTO exported_materials (
export_id, material_id, purchase_status
) VALUES (
:export_id, :material_id, 'pending'
)
""")
db.execute(insert_material, {
"export_id": export_id,
"material_id": material_id
})
db.commit()
logger.info(f"Export history created: {export_id} with {len(material_ids)} materials")
return {
"success": True,
"export_id": export_id,
"message": f"{len(material_ids)}개 자재의 내보내기 이력이 저장되었습니다"
}
except Exception as e:
db.rollback()
logger.error(f"Failed to create export history: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"내보내기 이력 생성 실패: {str(e)}"
)
@router.get("/export-history")
async def get_export_history(
file_id: Optional[int] = None,
job_no: Optional[str] = None,
limit: int = 50,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
엑셀 내보내기 이력 조회
"""
try:
query = text("""
SELECT
eeh.export_id,
eeh.file_id,
eeh.job_no,
eeh.export_date,
eeh.export_type,
eeh.category,
eeh.material_count,
u.name as exported_by_name,
f.original_filename,
COUNT(DISTINCT em.material_id) as actual_material_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'pending' THEN em.material_id END) as pending_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) as requested_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) as ordered_count,
COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) as received_count
FROM excel_export_history eeh
LEFT JOIN users u ON eeh.exported_by = u.user_id
LEFT JOIN files f ON eeh.file_id = f.id
LEFT JOIN exported_materials em ON eeh.export_id = em.export_id
WHERE 1=1
AND (:file_id IS NULL OR eeh.file_id = :file_id)
AND (:job_no IS NULL OR eeh.job_no = :job_no)
GROUP BY
eeh.export_id, eeh.file_id, eeh.job_no, eeh.export_date,
eeh.export_type, eeh.category, eeh.material_count,
u.name, f.original_filename
ORDER BY eeh.export_date DESC
LIMIT :limit
""")
results = db.execute(query, {
"file_id": file_id,
"job_no": job_no,
"limit": limit
}).fetchall()
history = []
for row in results:
history.append({
"export_id": row.export_id,
"file_id": row.file_id,
"job_no": row.job_no,
"export_date": row.export_date.isoformat() if row.export_date else None,
"export_type": row.export_type,
"category": row.category,
"material_count": row.material_count,
"exported_by": row.exported_by_name,
"file_name": row.original_filename,
"status_summary": {
"total": row.actual_material_count,
"pending": row.pending_count,
"requested": row.requested_count,
"ordered": row.ordered_count,
"received": row.received_count
}
})
return {
"success": True,
"history": history,
"count": len(history)
}
except Exception as e:
logger.error(f"Failed to get export history: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"내보내기 이력 조회 실패: {str(e)}"
)
@router.get("/materials/status")
async def get_materials_by_status(
status: Optional[str] = None,
export_id: Optional[int] = None,
file_id: Optional[int] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매 상태별 자재 목록 조회
"""
try:
query = text("""
SELECT
em.id as exported_material_id,
em.material_id,
m.original_description,
m.classified_category,
m.quantity,
m.unit,
em.purchase_status,
em.purchase_request_no,
em.purchase_order_no,
em.vendor_name,
em.expected_date,
em.quantity_ordered,
em.quantity_received,
em.unit_price,
em.total_price,
em.notes,
em.updated_at,
eeh.export_date,
f.original_filename as file_name,
j.job_no,
j.job_name
FROM exported_materials em
JOIN materials m ON em.material_id = m.id
JOIN excel_export_history eeh ON em.export_id = eeh.export_id
LEFT JOIN files f ON eeh.file_id = f.id
LEFT JOIN jobs j ON eeh.job_no = j.job_no
WHERE 1=1
AND (:status IS NULL OR em.purchase_status = :status)
AND (:export_id IS NULL OR em.export_id = :export_id)
AND (:file_id IS NULL OR eeh.file_id = :file_id)
ORDER BY em.updated_at DESC
""")
results = db.execute(query, {
"status": status,
"export_id": export_id,
"file_id": file_id
}).fetchall()
materials = []
for row in results:
materials.append({
"exported_material_id": row.exported_material_id,
"material_id": row.material_id,
"description": row.original_description,
"category": row.classified_category,
"quantity": row.quantity,
"unit": row.unit,
"purchase_status": row.purchase_status,
"purchase_request_no": row.purchase_request_no,
"purchase_order_no": row.purchase_order_no,
"vendor_name": row.vendor_name,
"expected_date": row.expected_date.isoformat() if row.expected_date else None,
"quantity_ordered": row.quantity_ordered,
"quantity_received": row.quantity_received,
"unit_price": float(row.unit_price) if row.unit_price else None,
"total_price": float(row.total_price) if row.total_price else None,
"notes": row.notes,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"export_date": row.export_date.isoformat() if row.export_date else None,
"file_name": row.file_name,
"job_no": row.job_no,
"job_name": row.job_name
})
return {
"success": True,
"materials": materials,
"count": len(materials)
}
except Exception as e:
logger.error(f"Failed to get materials by status: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"구매 상태별 자재 조회 실패: {str(e)}"
)
@router.patch("/materials/{exported_material_id}/status")
async def update_purchase_status(
exported_material_id: int,
new_status: str,
purchase_request_no: Optional[str] = None,
purchase_order_no: Optional[str] = None,
vendor_name: Optional[str] = None,
expected_date: Optional[date] = None,
quantity_ordered: Optional[int] = None,
quantity_received: Optional[int] = None,
unit_price: Optional[float] = None,
notes: Optional[str] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
자재 구매 상태 업데이트
"""
try:
# 현재 상태 조회
get_current = text("""
SELECT purchase_status, material_id
FROM exported_materials
WHERE id = :id
""")
current = db.execute(get_current, {"id": exported_material_id}).fetchone()
if not current:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="해당 자재를 찾을 수 없습니다"
)
# 상태 업데이트
update_query = text("""
UPDATE exported_materials
SET
purchase_status = :new_status,
purchase_request_no = COALESCE(:pr_no, purchase_request_no),
purchase_order_no = COALESCE(:po_no, purchase_order_no),
vendor_name = COALESCE(:vendor, vendor_name),
expected_date = COALESCE(:expected_date, expected_date),
quantity_ordered = COALESCE(:qty_ordered, quantity_ordered),
quantity_received = COALESCE(:qty_received, quantity_received),
unit_price = COALESCE(:unit_price, unit_price),
total_price = CASE
WHEN :unit_price IS NOT NULL AND :qty_ordered IS NOT NULL
THEN :unit_price * :qty_ordered
WHEN :unit_price IS NOT NULL AND quantity_ordered IS NOT NULL
THEN :unit_price * quantity_ordered
ELSE total_price
END,
notes = COALESCE(:notes, notes),
updated_by = :updated_by,
requested_date = CASE WHEN :new_status = 'requested' THEN CURRENT_TIMESTAMP ELSE requested_date END,
ordered_date = CASE WHEN :new_status = 'ordered' THEN CURRENT_TIMESTAMP ELSE ordered_date END,
received_date = CASE WHEN :new_status = 'received' THEN CURRENT_TIMESTAMP ELSE received_date END
WHERE id = :id
""")
db.execute(update_query, {
"id": exported_material_id,
"new_status": new_status,
"pr_no": purchase_request_no,
"po_no": purchase_order_no,
"vendor": vendor_name,
"expected_date": expected_date,
"qty_ordered": quantity_ordered,
"qty_received": quantity_received,
"unit_price": unit_price,
"notes": notes,
"updated_by": current_user.get("user_id")
})
# 이력 기록
insert_history = text("""
INSERT INTO purchase_status_history (
exported_material_id, material_id,
previous_status, new_status,
changed_by, reason
) VALUES (
:em_id, :material_id,
:prev_status, :new_status,
:changed_by, :reason
)
""")
db.execute(insert_history, {
"em_id": exported_material_id,
"material_id": current.material_id,
"prev_status": current.purchase_status,
"new_status": new_status,
"changed_by": current_user.get("user_id"),
"reason": notes
})
db.commit()
logger.info(f"Purchase status updated: {exported_material_id} from {current.purchase_status} to {new_status}")
return {
"success": True,
"message": f"구매 상태가 {new_status}로 변경되었습니다"
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to update purchase status: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"구매 상태 업데이트 실패: {str(e)}"
)
@router.get("/status-summary")
async def get_status_summary(
file_id: Optional[int] = None,
job_no: Optional[str] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매 상태 요약 통계
"""
try:
query = text("""
SELECT
em.purchase_status,
COUNT(DISTINCT em.material_id) as material_count,
SUM(em.quantity_exported) as total_quantity,
SUM(em.total_price) as total_amount,
COUNT(DISTINCT em.export_id) as export_count
FROM exported_materials em
JOIN excel_export_history eeh ON em.export_id = eeh.export_id
WHERE 1=1
AND (:file_id IS NULL OR eeh.file_id = :file_id)
AND (:job_no IS NULL OR eeh.job_no = :job_no)
GROUP BY em.purchase_status
""")
results = db.execute(query, {
"file_id": file_id,
"job_no": job_no
}).fetchall()
summary = {}
total_materials = 0
total_amount = 0
for row in results:
summary[row.purchase_status] = {
"material_count": row.material_count,
"total_quantity": row.total_quantity,
"total_amount": float(row.total_amount) if row.total_amount else 0,
"export_count": row.export_count
}
total_materials += row.material_count
if row.total_amount:
total_amount += float(row.total_amount)
# 기본 상태들 추가 (없는 경우 0으로)
for status in ['pending', 'requested', 'ordered', 'received', 'cancelled']:
if status not in summary:
summary[status] = {
"material_count": 0,
"total_quantity": 0,
"total_amount": 0,
"export_count": 0
}
return {
"success": True,
"summary": summary,
"total_materials": total_materials,
"total_amount": total_amount
}
except Exception as e:
logger.error(f"Failed to get status summary: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"구매 상태 요약 조회 실패: {str(e)}"
)

View File

@@ -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")

View File

@@ -0,0 +1,393 @@
{
"request_no": "PR-20251014-001",
"job_no": "TK-MP-TEST-001",
"created_at": "2025-10-14T01:43:47.625634",
"materials": [
{
"material_id": 88145,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 11,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88153,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A106 B",
"quantity": 92,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88157,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1\"",
"material_grade": "ASTM A312 TP304",
"quantity": 23,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88167,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1\"",
"material_grade": "ASTM A106 B",
"quantity": 139,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88176,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1 1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 14,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88190,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1 1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 98,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88446,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 82,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88528,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "10\"",
"material_grade": "ASTM A312 TP304",
"quantity": 4,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88532,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "12\"",
"material_grade": "ASTM A312 TP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88533,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "2\"",
"material_grade": "ASTM A106 B",
"quantity": 50,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88583,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88592,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "3\"",
"material_grade": "ASTM A106 B",
"quantity": 25,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88600,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "3\"",
"material_grade": "ASTM A312 TP304",
"quantity": 8,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88625,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A312 TP304",
"quantity": 15,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88728,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "4\"",
"material_grade": "ASTM A106 B",
"quantity": 12,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88740,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "6\"",
"material_grade": "ASTM A106 B",
"quantity": 13,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": [
{
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|1/2\"|undefined|ASTM A312 TP304",
"material_ids": [
88145
],
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 11,
"unit": "m",
"total_length": 66000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|3/4\"|undefined|ASTM A106 B",
"material_ids": [
88153
],
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A106 B",
"quantity": 92,
"unit": "m",
"total_length": 552000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|1\"|undefined|ASTM A312 TP304",
"material_ids": [
88157
],
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1\"",
"material_grade": "ASTM A312 TP304",
"quantity": 23,
"unit": "m",
"total_length": 138000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|1\"|undefined|ASTM A106 B",
"material_ids": [
88167
],
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1\"",
"material_grade": "ASTM A106 B",
"quantity": 139,
"unit": "m",
"total_length": 834000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|1 1/2\"|undefined|ASTM A312 TP304",
"material_ids": [
88176
],
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1 1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 14,
"unit": "m",
"total_length": 84000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|1 1/2\"|undefined|ASTM A106 B",
"material_ids": [
88190
],
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1 1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 98,
"unit": "m",
"total_length": 588000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|1/2\"|undefined|ASTM A106 B",
"material_ids": [
88446
],
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 82,
"unit": "m",
"total_length": 492000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|10\"|undefined|ASTM A312 TP304",
"material_ids": [
88528
],
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "10\"",
"material_grade": "ASTM A312 TP304",
"quantity": 4,
"unit": "m",
"total_length": 24000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|12\"|undefined|ASTM A312 TP304",
"material_ids": [
88532
],
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "12\"",
"material_grade": "ASTM A312 TP304",
"quantity": 1,
"unit": "m",
"total_length": 6000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|2\"|undefined|ASTM A106 B",
"material_ids": [
88533
],
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "2\"",
"material_grade": "ASTM A106 B",
"quantity": 50,
"unit": "m",
"total_length": 300000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|2\"|undefined|ASTM A312 TP304",
"material_ids": [
88583
],
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 9,
"unit": "m",
"total_length": 54000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|3\"|undefined|ASTM A106 B",
"material_ids": [
88592
],
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "3\"",
"material_grade": "ASTM A106 B",
"quantity": 25,
"unit": "m",
"total_length": 150000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|3\"|undefined|ASTM A312 TP304",
"material_ids": [
88600
],
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "3\"",
"material_grade": "ASTM A312 TP304",
"quantity": 8,
"unit": "m",
"total_length": 48000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|3/4\"|undefined|ASTM A312 TP304",
"material_ids": [
88625
],
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A312 TP304",
"quantity": 15,
"unit": "m",
"total_length": 90000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|4\"|undefined|ASTM A106 B",
"material_ids": [
88728
],
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "4\"",
"material_grade": "ASTM A106 B",
"quantity": 12,
"unit": "m",
"total_length": 72000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|6\"|undefined|ASTM A106 B",
"material_ids": [
88740
],
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "6\"",
"material_grade": "ASTM A106 B",
"quantity": 13,
"unit": "m",
"total_length": 78000,
"user_requirement": ""
}
]
}

Binary file not shown.

View File

@@ -0,0 +1,369 @@
{
"request_no": "PR-20251014-002",
"job_no": "TK-MP-TEST-001",
"created_at": "2025-10-14T01:43:58.851391",
"materials": [
{
"material_id": 88146,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 10,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88154,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A106 B",
"quantity": 91,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88158,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1\"",
"material_grade": "ASTM A312 TP304",
"quantity": 22,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88168,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1\"",
"material_grade": "ASTM A106 B",
"quantity": 138,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88177,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1 1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 13,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88191,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1 1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 97,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88447,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 81,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88529,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "10\"",
"material_grade": "ASTM A312 TP304",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88534,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "2\"",
"material_grade": "ASTM A106 B",
"quantity": 49,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88584,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 8,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88593,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "3\"",
"material_grade": "ASTM A106 B",
"quantity": 24,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88618,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "3\"",
"material_grade": "ASTM A312 TP304",
"quantity": 7,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88626,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A312 TP304",
"quantity": 14,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88729,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "4\"",
"material_grade": "ASTM A106 B",
"quantity": 11,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88741,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "6\"",
"material_grade": "ASTM A106 B",
"quantity": 12,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": [
{
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|1/2\"|undefined|ASTM A312 TP304",
"material_ids": [
88146
],
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 10,
"unit": "m",
"total_length": 60000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|3/4\"|undefined|ASTM A106 B",
"material_ids": [
88154
],
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A106 B",
"quantity": 91,
"unit": "m",
"total_length": 546000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|1\"|undefined|ASTM A312 TP304",
"material_ids": [
88158
],
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1\"",
"material_grade": "ASTM A312 TP304",
"quantity": 22,
"unit": "m",
"total_length": 132000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|1\"|undefined|ASTM A106 B",
"material_ids": [
88168
],
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1\"",
"material_grade": "ASTM A106 B",
"quantity": 138,
"unit": "m",
"total_length": 828000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|1 1/2\"|undefined|ASTM A312 TP304",
"material_ids": [
88177
],
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1 1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 13,
"unit": "m",
"total_length": 78000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|1 1/2\"|undefined|ASTM A106 B",
"material_ids": [
88191
],
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1 1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 97,
"unit": "m",
"total_length": 582000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|1/2\"|undefined|ASTM A106 B",
"material_ids": [
88447
],
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 81,
"unit": "m",
"total_length": 486000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|10\"|undefined|ASTM A312 TP304",
"material_ids": [
88529
],
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "10\"",
"material_grade": "ASTM A312 TP304",
"quantity": 3,
"unit": "m",
"total_length": 18000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|2\"|undefined|ASTM A106 B",
"material_ids": [
88534
],
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "2\"",
"material_grade": "ASTM A106 B",
"quantity": 49,
"unit": "m",
"total_length": 294000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|2\"|undefined|ASTM A312 TP304",
"material_ids": [
88584
],
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 8,
"unit": "m",
"total_length": 48000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|3\"|undefined|ASTM A106 B",
"material_ids": [
88593
],
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "3\"",
"material_grade": "ASTM A106 B",
"quantity": 24,
"unit": "m",
"total_length": 144000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|3\"|undefined|ASTM A312 TP304",
"material_ids": [
88618
],
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "3\"",
"material_grade": "ASTM A312 TP304",
"quantity": 7,
"unit": "m",
"total_length": 42000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|3/4\"|undefined|ASTM A312 TP304",
"material_ids": [
88626
],
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A312 TP304",
"quantity": 14,
"unit": "m",
"total_length": 84000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|4\"|undefined|ASTM A106 B",
"material_ids": [
88729
],
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "4\"",
"material_grade": "ASTM A106 B",
"quantity": 11,
"unit": "m",
"total_length": 66000,
"user_requirement": ""
},
{
"group_key": "PIPE, SMLS, SCH 40, ASTM A106 B|6\"|undefined|ASTM A106 B",
"material_ids": [
88741
],
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "6\"",
"material_grade": "ASTM A106 B",
"quantity": 12,
"unit": "m",
"total_length": 72000,
"user_requirement": ""
}
]
}

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,90 @@
{
"request_no": "PR-20251014-004",
"job_no": "TKG-25000P",
"created_at": "2025-10-14T02:10:22.262092",
"materials": [
{
"material_id": 77528,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 11,
"unit": "EA",
"user_requirement": "열처리?"
}
],
"grouped_materials": [
{
"group_key": "PIPE, SMLS, SCH 40S, ASTM A312 TP304|1/2\"|undefined|ASTM A312 TP304",
"material_ids": [
77528
],
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 11,
"unit": "m",
"total_length": 1395.1,
"pipe_lengths": [
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 155,
"quantity": 1,
"totalLength": 155
},
{
"length": 155,
"quantity": 1,
"totalLength": 155
},
{
"length": 200,
"quantity": 1,
"totalLength": 200
},
{
"length": 245.1,
"quantity": 1,
"totalLength": 245.1
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
}
],
"user_requirement": "열처리?"
}
]
}

View File

@@ -0,0 +1,32 @@
{
"request_no": "PR-20251014-005",
"job_no": "TKG-25000P",
"created_at": "2025-10-14T02:14:05.318457",
"materials": [
{
"material_id": 78247,
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "10\"",
"material_grade": "ASTM A182 F304",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": [
{
"group_key": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304|10\"|undefined|ASTM A182 F304",
"material_ids": [
78247
],
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "10\"",
"material_grade": "ASTM A182 F304",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
}
]
}

View File

@@ -0,0 +1,32 @@
{
"request_no": "PR-20251014-006",
"job_no": "TKG-25000P",
"created_at": "2025-10-14T02:17:13.397257",
"materials": [
{
"material_id": 78599,
"description": "(4) 0.5, 75 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",
"category": "BOLT",
"size": "1\"",
"material_grade": "ASTM A193/A194 GR B7/2H",
"quantity": 51,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": [
{
"group_key": "(4) 0.5, 75 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV|1\"|undefined|ASTM A193/A194 GR B7/2H",
"material_ids": [
78599
],
"description": "(4) 0.5, 75 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",
"category": "BOLT",
"size": "1\"",
"material_grade": "ASTM A193/A194 GR B7/2H",
"quantity": 51,
"unit": "EA",
"user_requirement": ""
}
]
}

View File

@@ -0,0 +1,32 @@
{
"request_no": "PR-20251014-007",
"job_no": "TKG-25000P",
"created_at": "2025-10-14T02:17:26.376309",
"materials": [
{
"material_id": 78599,
"description": "(4) 0.5, 75 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",
"category": "BOLT",
"size": "1\"",
"material_grade": "ASTM A193/A194 GR B7/2H",
"quantity": 51,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": [
{
"group_key": "(4) 0.5, 75 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV|1\"|undefined|ASTM A193/A194 GR B7/2H",
"material_ids": [
78599
],
"description": "(4) 0.5, 75 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",
"category": "BOLT",
"size": "1\"",
"material_grade": "ASTM A193/A194 GR B7/2H",
"quantity": 51,
"unit": "EA",
"user_requirement": ""
}
]
}

View File

@@ -0,0 +1,495 @@
{
"request_no": "PR-20251014-008",
"job_no": "TKG-25000P",
"created_at": "2025-10-14T02:17:50.004262",
"materials": [
{
"material_id": 77536,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A106 B",
"quantity": 92,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": [
{
"group_key": "PIPE, SMLS, SCH 80, ASTM A106 B|3/4\"|undefined|ASTM A106 B",
"material_ids": [
77536
],
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A106 B",
"quantity": 92,
"unit": "m",
"total_length": 7920.2,
"pipe_lengths": [
{
"length": 60,
"quantity": 1,
"totalLength": 60
},
{
"length": 60,
"quantity": 1,
"totalLength": 60
},
{
"length": 60,
"quantity": 1,
"totalLength": 60
},
{
"length": 60,
"quantity": 1,
"totalLength": 60
},
{
"length": 43.3,
"quantity": 1,
"totalLength": 43.3
},
{
"length": 43.3,
"quantity": 1,
"totalLength": 43.3
},
{
"length": 43.3,
"quantity": 1,
"totalLength": 43.3
},
{
"length": 43.3,
"quantity": 1,
"totalLength": 43.3
},
{
"length": 43.3,
"quantity": 1,
"totalLength": 43.3
},
{
"length": 43.3,
"quantity": 1,
"totalLength": 43.3
},
{
"length": 50,
"quantity": 1,
"totalLength": 50
},
{
"length": 50,
"quantity": 1,
"totalLength": 50
},
{
"length": 50,
"quantity": 1,
"totalLength": 50
},
{
"length": 50,
"quantity": 1,
"totalLength": 50
},
{
"length": 50,
"quantity": 1,
"totalLength": 50
},
{
"length": 50,
"quantity": 1,
"totalLength": 50
},
{
"length": 50,
"quantity": 1,
"totalLength": 50
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 70,
"quantity": 1,
"totalLength": 70
},
{
"length": 76.2,
"quantity": 1,
"totalLength": 76.2
},
{
"length": 76.2,
"quantity": 1,
"totalLength": 76.2
},
{
"length": 76.2,
"quantity": 1,
"totalLength": 76.2
},
{
"length": 76.2,
"quantity": 1,
"totalLength": 76.2
},
{
"length": 76.2,
"quantity": 1,
"totalLength": 76.2
},
{
"length": 76.2,
"quantity": 1,
"totalLength": 76.2
},
{
"length": 77.6,
"quantity": 1,
"totalLength": 77.6
},
{
"length": 77.6,
"quantity": 1,
"totalLength": 77.6
},
{
"length": 77.6,
"quantity": 1,
"totalLength": 77.6
},
{
"length": 77.6,
"quantity": 1,
"totalLength": 77.6
},
{
"length": 77.6,
"quantity": 1,
"totalLength": 77.6
},
{
"length": 77.6,
"quantity": 1,
"totalLength": 77.6
},
{
"length": 80,
"quantity": 1,
"totalLength": 80
},
{
"length": 80,
"quantity": 1,
"totalLength": 80
},
{
"length": 80,
"quantity": 1,
"totalLength": 80
},
{
"length": 80,
"quantity": 1,
"totalLength": 80
},
{
"length": 80,
"quantity": 1,
"totalLength": 80
},
{
"length": 80,
"quantity": 1,
"totalLength": 80
},
{
"length": 80,
"quantity": 1,
"totalLength": 80
},
{
"length": 80,
"quantity": 1,
"totalLength": 80
},
{
"length": 80,
"quantity": 1,
"totalLength": 80
},
{
"length": 88.6,
"quantity": 1,
"totalLength": 88.6
},
{
"length": 88.6,
"quantity": 1,
"totalLength": 88.6
},
{
"length": 98.4,
"quantity": 1,
"totalLength": 98.4
},
{
"length": 98.4,
"quantity": 1,
"totalLength": 98.4
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 100,
"quantity": 1,
"totalLength": 100
},
{
"length": 120,
"quantity": 1,
"totalLength": 120
},
{
"length": 120,
"quantity": 1,
"totalLength": 120
},
{
"length": 150,
"quantity": 1,
"totalLength": 150
},
{
"length": 150,
"quantity": 1,
"totalLength": 150
},
{
"length": 150,
"quantity": 1,
"totalLength": 150
},
{
"length": 150,
"quantity": 1,
"totalLength": 150
},
{
"length": 150,
"quantity": 1,
"totalLength": 150
},
{
"length": 223.6,
"quantity": 1,
"totalLength": 223.6
}
],
"user_requirement": ""
}
]
}

View File

@@ -0,0 +1,55 @@
{
"request_no": "PR-20251014-009",
"job_no": "TK-MP-TEST-001",
"created_at": "2025-10-14T02:24:08.046686",
"materials": [
{
"material_id": 88864,
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "10\"",
"material_grade": "ASTM A182 F304",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88869,
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "12\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": [
{
"group_key": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304|10\"|undefined|ASTM A182 F304",
"material_ids": [
88864
],
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "10\"",
"material_grade": "ASTM A182 F304",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304|12\"|undefined|ASTM A182 F304",
"material_ids": [
88869
],
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "12\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
}
]
}

View File

@@ -0,0 +1,55 @@
{
"request_no": "PR-20251014-010",
"job_no": "TK-MP-TEST-001",
"created_at": "2025-10-14T02:24:14.814790",
"materials": [
{
"material_id": 90052,
"description": "ON/OFF VALVE, FLG, 600LB",
"category": "VALVE",
"size": "1\"",
"material_grade": "-",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 90053,
"description": "CHECK VALVE, LIFT, SW, 800LB",
"category": "VALVE",
"size": "1 1/2\"",
"material_grade": "-",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": [
{
"group_key": "ON/OFF VALVE, FLG, 600LB|1\"|undefined|-",
"material_ids": [
90052
],
"description": "ON/OFF VALVE, FLG, 600LB",
"category": "VALVE",
"size": "1\"",
"material_grade": "-",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "CHECK VALVE, LIFT, SW, 800LB|1 1/2\"|undefined|-",
"material_ids": [
90053
],
"description": "CHECK VALVE, LIFT, SW, 800LB",
"category": "VALVE",
"size": "1 1/2\"",
"material_grade": "-",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
}
]
}

View File

@@ -0,0 +1,32 @@
{
"request_no": "PR-20251014-011",
"job_no": "TK-MP-TEST-001",
"created_at": "2025-10-14T02:24:21.733349",
"materials": [
{
"material_id": 89216,
"description": "(4) 0.5, 75 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",
"category": "BOLT",
"size": "1\"",
"material_grade": "ASTM A193/A194 GR B7/2H",
"quantity": 51,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": [
{
"group_key": "(4) 0.5, 75 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV|1\"|undefined|ASTM A193/A194 GR B7/2H",
"material_ids": [
89216
],
"description": "(4) 0.5, 75 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",
"category": "BOLT",
"size": "1\"",
"material_grade": "ASTM A193/A194 GR B7/2H",
"quantity": 51,
"unit": "EA",
"user_requirement": ""
}
]
}

View File

@@ -0,0 +1,32 @@
{
"request_no": "PR-20251014-012",
"job_no": "TK-MP-TEST-001",
"created_at": "2025-10-14T02:42:08.351432",
"materials": [
{
"material_id": 89220,
"description": "(4) 0.5, 80 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",
"category": "BOLT",
"size": "1 1/2\"",
"material_grade": "ASTM A193/A194 GR B7/2H",
"quantity": 32,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": [
{
"group_key": "(4) 0.5, 80 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV|1 1/2\"|undefined|ASTM A193/A194 GR B7/2H",
"material_ids": [
89220
],
"description": "(4) 0.5, 80 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",
"category": "BOLT",
"size": "1 1/2\"",
"material_grade": "ASTM A193/A194 GR B7/2H",
"quantity": 32,
"unit": "EA",
"user_requirement": ""
}
]
}

View File

@@ -0,0 +1,32 @@
{
"request_no": "PR-20251014-013",
"job_no": "TK-MP-TEST-001",
"created_at": "2025-10-14T02:47:17.256790",
"materials": [
{
"material_id": 89465,
"description": "SWG, 150LB, H/F/I/O SS304/GRAPHITE/SS304/SS304, 4.5mm",
"category": "GASKET",
"size": "1/2\"",
"material_grade": "SS304",
"quantity": 44,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": [
{
"group_key": "SWG, 150LB, H/F/I/O SS304/GRAPHITE/SS304/SS304, 4.5mm|1/2\"|undefined|SS304",
"material_ids": [
89465
],
"description": "SWG, 150LB, H/F/I/O SS304/GRAPHITE/SS304/SS304, 4.5mm",
"category": "GASKET",
"size": "1/2\"",
"material_grade": "SS304",
"quantity": 44,
"unit": "EA",
"user_requirement": ""
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
{
"request_no": "PR-20251014-015",
"job_no": "TK-MP-TEST-001",
"created_at": "2025-10-14T02:54:15.899037",
"materials": [],
"grouped_materials": []
}

View File

@@ -0,0 +1,43 @@
{
"request_no": "PR-20251014-016",
"job_no": "TK-MP-TEST-001",
"created_at": "2025-10-14T02:54:33.149908",
"materials": [
{
"material_id": 88142,
"description": "HALF NIPPLE, SMLS, SCH 80S, ASTM A312 TP304 SW X NPT",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": "3.000",
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 88144,
"description": "HALF NIPPLE, SMLS, SCH 80S, ASTM A312 TP304 SW X NPT",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": "3.000",
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": [
{
"group_key": "HALF NIPPLE, SMLS, SCH 80S, ASTM A312 TP304 SW X NPT|1/2\"|undefined|ASTM A312 TP304",
"material_ids": [
88142,
88144
],
"description": "HALF NIPPLE, SMLS, SCH 80S, ASTM A312 TP304 SW X NPT",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": "3.000",
"unit": "EA",
"user_requirement": ""
}
]
}

View File

@@ -0,0 +1,132 @@
{
"request_no": "PR-20251014-017",
"job_no": "TK-MP-TEST-001",
"created_at": "2025-10-14T02:54:45.118843",
"materials": [
{
"material_id": 91515,
"description": "CLAMP CL-1",
"category": "SUPPORT",
"size": "1\"",
"material_grade": "-",
"quantity": "8.000",
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 91516,
"description": "CLAMP CL-1",
"category": "SUPPORT",
"size": "1\"",
"material_grade": "-",
"quantity": "8.000",
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 91517,
"description": "CLAMP CL-1",
"category": "SUPPORT",
"size": "1\"",
"material_grade": "-",
"quantity": "8.000",
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 91518,
"description": "CLAMP CL-1",
"category": "SUPPORT",
"size": "1\"",
"material_grade": "-",
"quantity": "8.000",
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 91552,
"description": "CLAMP CL-1",
"category": "SUPPORT",
"size": "1\"",
"material_grade": "-",
"quantity": "8.000",
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 91553,
"description": "CLAMP CL-1",
"category": "SUPPORT",
"size": "1\"",
"material_grade": "-",
"quantity": "8.000",
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 91554,
"description": "CLAMP CL-1",
"category": "SUPPORT",
"size": "1\"",
"material_grade": "-",
"quantity": "8.000",
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 91555,
"description": "CLAMP CL-1",
"category": "SUPPORT",
"size": "1\"",
"material_grade": "-",
"quantity": "8.000",
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 89471,
"description": "SWG, 150LB, H/F/I/O SS304/GRAPHITE/SS304/SS304, 4.5mm",
"category": "GASKET",
"size": "3/4\"",
"material_grade": "SS304",
"quantity": 18,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": [
{
"group_key": "CLAMP CL-1|1\"|undefined|-",
"material_ids": [
91515,
91516,
91517,
91518,
91552,
91553,
91554,
91555
],
"description": "CLAMP CL-1",
"category": "SUPPORT",
"size": "1\"",
"material_grade": "-",
"quantity": "8.000",
"unit": "EA",
"user_requirement": ""
},
{
"group_key": "SWG, 150LB, H/F/I/O SS304/GRAPHITE/SS304/SS304, 4.5mm|3/4\"|undefined|SS304",
"material_ids": [
89471
],
"description": "SWG, 150LB, H/F/I/O SS304/GRAPHITE/SS304/SS304, 4.5mm",
"category": "GASKET",
"size": "3/4\"",
"material_grade": "SS304",
"quantity": 18,
"unit": "EA",
"user_requirement": ""
}
]
}

View File

@@ -0,0 +1,109 @@
{
"request_no": "PR-20251014-018",
"job_no": "TK-MP-TEST-001",
"created_at": "2025-10-14T02:54:50.900910",
"materials": [
{
"material_id": 91515,
"description": "CLAMP CL-1",
"category": "SUPPORT",
"size": "1\"",
"material_grade": "-",
"quantity": "8.000",
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 91516,
"description": "CLAMP CL-1",
"category": "SUPPORT",
"size": "1\"",
"material_grade": "-",
"quantity": "8.000",
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 91517,
"description": "CLAMP CL-1",
"category": "SUPPORT",
"size": "1\"",
"material_grade": "-",
"quantity": "8.000",
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 91518,
"description": "CLAMP CL-1",
"category": "SUPPORT",
"size": "1\"",
"material_grade": "-",
"quantity": "8.000",
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 91552,
"description": "CLAMP CL-1",
"category": "SUPPORT",
"size": "1\"",
"material_grade": "-",
"quantity": "8.000",
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 91553,
"description": "CLAMP CL-1",
"category": "SUPPORT",
"size": "1\"",
"material_grade": "-",
"quantity": "8.000",
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 91554,
"description": "CLAMP CL-1",
"category": "SUPPORT",
"size": "1\"",
"material_grade": "-",
"quantity": "8.000",
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 91555,
"description": "CLAMP CL-1",
"category": "SUPPORT",
"size": "1\"",
"material_grade": "-",
"quantity": "8.000",
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": [
{
"group_key": "CLAMP CL-1|1\"|undefined|-",
"material_ids": [
91515,
91516,
91517,
91518,
91552,
91553,
91554,
91555
],
"description": "CLAMP CL-1",
"category": "SUPPORT",
"size": "1\"",
"material_grade": "-",
"quantity": "8.000",
"unit": "EA",
"user_requirement": ""
}
]
}

View File

@@ -0,0 +1,28 @@
-- users 테이블에 status 컬럼 추가 및 기존 데이터 마이그레이션
-- 1. status 컬럼 추가 (기본값은 'active')
ALTER TABLE users
ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'active';
-- 2. status 컬럼에 CHECK 제약 조건 추가
ALTER TABLE users
ADD CONSTRAINT users_status_check
CHECK (status IN ('pending', 'active', 'suspended', 'deleted'));
-- 3. 기존 데이터 마이그레이션
-- is_active가 false인 사용자는 'pending'으로
-- is_active가 true인 사용자는 'active'로
UPDATE users
SET status = CASE
WHEN is_active = FALSE THEN 'pending'
WHEN is_active = TRUE THEN 'active'
ELSE 'active'
END;
-- 4. status 컬럼에 인덱스 추가 (조회 성능 향상)
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
-- 5. 향후 is_active 컬럼은 deprecated로 간주
-- 하지만 하위 호환성을 위해 당분간 유지
COMMENT ON COLUMN users.status IS 'User account status: pending, active, suspended, deleted';
COMMENT ON COLUMN users.is_active IS 'DEPRECATED: Use status column instead. Kept for backward compatibility.';

View File

@@ -0,0 +1,135 @@
-- 엑셀 내보내기 이력 및 구매 상태 관리 테이블
-- 1. 엑셀 내보내기 이력 테이블
CREATE TABLE IF NOT EXISTS excel_export_history (
export_id SERIAL PRIMARY KEY,
file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
job_no VARCHAR(50) REFERENCES jobs(job_no),
exported_by INTEGER REFERENCES users(user_id),
export_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
export_type VARCHAR(50), -- 'full', 'category', 'filtered'
category VARCHAR(50), -- PIPE, FLANGE, VALVE 등
material_count INTEGER,
file_name VARCHAR(255),
notes TEXT,
-- 메타데이터
filters_applied JSONB, -- 적용된 필터 조건들
export_options JSONB -- 내보내기 옵션들
);
-- 2. 내보낸 자재 상세 (어떤 자재들이 내보내졌는지 추적)
CREATE TABLE IF NOT EXISTS exported_materials (
id SERIAL PRIMARY KEY,
export_id INTEGER REFERENCES excel_export_history(export_id) ON DELETE CASCADE,
material_id INTEGER REFERENCES materials(id),
purchase_status VARCHAR(50) DEFAULT 'pending', -- pending, requested, ordered, received, cancelled
purchase_request_no VARCHAR(100), -- 구매요청 번호
purchase_order_no VARCHAR(100), -- 구매주문 번호
requested_date TIMESTAMP,
ordered_date TIMESTAMP,
expected_date DATE,
received_date TIMESTAMP,
quantity_exported INTEGER, -- 내보낸 수량
quantity_ordered INTEGER, -- 주문 수량
quantity_received INTEGER, -- 입고 수량
unit_price DECIMAL(15, 2),
total_price DECIMAL(15, 2),
vendor_name VARCHAR(255),
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by INTEGER REFERENCES users(user_id)
);
-- 3. 구매 상태 이력 (상태 변경 추적)
CREATE TABLE IF NOT EXISTS purchase_status_history (
history_id SERIAL PRIMARY KEY,
exported_material_id INTEGER REFERENCES exported_materials(id) ON DELETE CASCADE,
material_id INTEGER REFERENCES materials(id),
previous_status VARCHAR(50),
new_status VARCHAR(50),
changed_by INTEGER REFERENCES users(user_id),
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
reason TEXT,
metadata JSONB -- 추가 정보 (예: 문서 번호, 승인자 등)
);
-- 4. 구매 문서 관리
CREATE TABLE IF NOT EXISTS purchase_documents (
document_id SERIAL PRIMARY KEY,
export_id INTEGER REFERENCES excel_export_history(export_id),
document_type VARCHAR(50), -- 'purchase_request', 'purchase_order', 'invoice', 'receipt'
document_no VARCHAR(100),
document_date DATE,
file_path VARCHAR(500),
uploaded_by INTEGER REFERENCES users(user_id),
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
notes TEXT
);
-- 인덱스 추가
CREATE INDEX IF NOT EXISTS idx_export_history_file_id ON excel_export_history(file_id);
CREATE INDEX IF NOT EXISTS idx_export_history_job_no ON excel_export_history(job_no);
CREATE INDEX IF NOT EXISTS idx_export_history_date ON excel_export_history(export_date);
CREATE INDEX IF NOT EXISTS idx_exported_materials_export_id ON exported_materials(export_id);
CREATE INDEX IF NOT EXISTS idx_exported_materials_material_id ON exported_materials(material_id);
CREATE INDEX IF NOT EXISTS idx_exported_materials_status ON exported_materials(purchase_status);
CREATE INDEX IF NOT EXISTS idx_exported_materials_pr_no ON exported_materials(purchase_request_no);
CREATE INDEX IF NOT EXISTS idx_exported_materials_po_no ON exported_materials(purchase_order_no);
CREATE INDEX IF NOT EXISTS idx_purchase_history_material ON purchase_status_history(material_id);
CREATE INDEX IF NOT EXISTS idx_purchase_history_date ON purchase_status_history(changed_at);
-- 뷰 생성: 구매 상태별 자재 현황
CREATE OR REPLACE VIEW v_purchase_status_summary AS
SELECT
em.purchase_status,
COUNT(DISTINCT em.material_id) as material_count,
COUNT(DISTINCT em.export_id) as export_count,
SUM(em.quantity_exported) as total_quantity_exported,
SUM(em.quantity_ordered) as total_quantity_ordered,
SUM(em.quantity_received) as total_quantity_received,
SUM(em.total_price) as total_amount,
MAX(em.updated_at) as last_updated
FROM exported_materials em
GROUP BY em.purchase_status;
-- 뷰 생성: 자재별 최신 구매 상태
CREATE OR REPLACE VIEW v_material_latest_purchase_status AS
SELECT DISTINCT ON (m.id)
m.id as material_id,
m.original_description,
m.classified_category,
em.purchase_status,
em.purchase_request_no,
em.purchase_order_no,
em.vendor_name,
em.expected_date,
em.quantity_ordered,
em.quantity_received,
em.updated_at as status_updated_at,
eeh.export_date as last_exported_date
FROM materials m
LEFT JOIN exported_materials em ON m.id = em.material_id
LEFT JOIN excel_export_history eeh ON em.export_id = eeh.export_id
ORDER BY m.id, em.updated_at DESC;
-- 트리거: updated_at 자동 업데이트
CREATE OR REPLACE FUNCTION update_exported_materials_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_exported_materials_updated_at_trigger
BEFORE UPDATE ON exported_materials
FOR EACH ROW
EXECUTE FUNCTION update_exported_materials_updated_at();
-- 코멘트 추가
COMMENT ON TABLE excel_export_history IS '엑셀 내보내기 이력 관리';
COMMENT ON TABLE exported_materials IS '내보낸 자재의 구매 상태 추적';
COMMENT ON TABLE purchase_status_history IS '구매 상태 변경 이력';
COMMENT ON TABLE purchase_documents IS '구매 관련 문서 관리';
COMMENT ON COLUMN exported_materials.purchase_status IS 'pending: 구매신청 전, requested: 구매신청, ordered: 구매주문, received: 입고완료, cancelled: 취소';

View File

@@ -0,0 +1,44 @@
-- 구매신청 관리 테이블
-- 구매신청 그룹 (같이 신청한 항목들의 묶음)
CREATE TABLE IF NOT EXISTS purchase_requests (
request_id SERIAL PRIMARY KEY,
request_no VARCHAR(50) UNIQUE, -- PR-20241014-001 형식
file_id INTEGER REFERENCES files(id),
job_no VARCHAR(50) REFERENCES jobs(job_no),
category VARCHAR(50),
material_count INTEGER,
excel_file_path VARCHAR(500), -- 저장된 엑셀 파일 경로
requested_by INTEGER REFERENCES users(user_id),
requested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(20) DEFAULT 'requested', -- requested, ordered, received
notes TEXT
);
-- 구매신청 자재 상세
CREATE TABLE IF NOT EXISTS purchase_request_items (
item_id SERIAL PRIMARY KEY,
request_id INTEGER REFERENCES purchase_requests(request_id) ON DELETE CASCADE,
material_id INTEGER REFERENCES materials(id),
quantity INTEGER,
unit VARCHAR(20),
user_requirement TEXT,
is_ordered BOOLEAN DEFAULT FALSE,
is_received BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 인덱스
CREATE INDEX IF NOT EXISTS idx_purchase_requests_file_id ON purchase_requests(file_id);
CREATE INDEX IF NOT EXISTS idx_purchase_requests_job_no ON purchase_requests(job_no);
CREATE INDEX IF NOT EXISTS idx_purchase_requests_status ON purchase_requests(status);
CREATE INDEX IF NOT EXISTS idx_purchase_request_items_request_id ON purchase_request_items(request_id);
CREATE INDEX IF NOT EXISTS idx_purchase_request_items_material_id ON purchase_request_items(material_id);
-- 뷰: 구매신청된 자재 ID 목록
CREATE OR REPLACE VIEW v_requested_material_ids AS
SELECT DISTINCT material_id
FROM purchase_request_items;
COMMENT ON TABLE purchase_requests IS '구매신청 그룹 관리';
COMMENT ON TABLE purchase_request_items IS '구매신청 자재 상세';

View File

@@ -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;

View File

@@ -5,6 +5,8 @@ import NewMaterialsPage from './pages/NewMaterialsPage';
import SystemSettingsPage from './pages/SystemSettingsPage';
import AccountSettingsPage from './pages/AccountSettingsPage';
import UserManagementPage from './pages/UserManagementPage';
import PurchaseBatchPage from './pages/PurchaseBatchPage';
import PurchaseRequestPage from './pages/PurchaseRequestPage';
import SystemLogsPage from './pages/SystemLogsPage';
import LogMonitoringPage from './pages/LogMonitoringPage';
import ErrorBoundary from './components/ErrorBoundary';
@@ -27,6 +29,20 @@ function App() {
const [newProjectCode, setNewProjectCode] = useState('');
const [newProjectName, setNewProjectName] = useState('');
const [newClientName, setNewClientName] = useState('');
const [pendingSignupCount, setPendingSignupCount] = useState(0);
// 승인 대기 중인 회원가입 수 조회
const loadPendingSignups = async () => {
try {
const response = await api.get('/auth/signup-requests');
// API 응답이 { requests: [...], count: ... } 형태
const pendingCount = response.data.count || 0;
setPendingSignupCount(pendingCount);
} catch (error) {
console.error('승인 대기 조회 실패:', error);
setPendingSignupCount(0);
}
};
// 프로젝트 목록 로드
const loadProjects = async () => {
@@ -143,11 +159,32 @@ function App() {
};
}, [showUserMenu]);
// 관리자인 경우 주기적으로 승인 대기 수 확인
useEffect(() => {
if ((user?.role === 'admin' || user?.role === 'system') && isAuthenticated) {
// 초기 로드
loadPendingSignups();
// 30초마다 확인
const interval = setInterval(() => {
loadPendingSignups();
}, 30000);
return () => clearInterval(interval);
}
}, [user?.role, isAuthenticated]);
// 로그인 성공 시 호출될 함수
const handleLoginSuccess = () => {
const userData = localStorage.getItem('user_data');
if (userData) {
setUser(JSON.parse(userData));
const parsedUser = JSON.parse(userData);
setUser(parsedUser);
// 관리자인 경우 승인 대기 수 확인
if (parsedUser?.role === 'admin' || parsedUser?.role === 'system') {
loadPendingSignups();
}
}
setIsAuthenticated(true);
};
@@ -173,8 +210,14 @@ function App() {
{
id: 'bom',
title: '📋 BOM 업로드 & 분류',
description: '엑셀 파일 업로드 → 자동 분류 → 검토 → 자재 확인 → 엑셀 내보내기',
description: '엑셀 파일 업로드 → 자동 분류 → 검토 → 자재 확인 → 구매신청 (엑셀 내보내기)',
color: '#4299e1'
},
{
id: 'purchase-request',
title: '📦 구매신청 관리',
description: '구매신청한 자재들을 그룹별로 조회하고 엑셀 재다운로드',
color: '#10b981'
}
];
};
@@ -183,15 +226,20 @@ function App() {
const getAdminFeatures = () => {
const features = [];
// 시스템 관리자 전용 기능
if (user?.role === 'system') {
console.log('getAdminFeatures - Current user:', user);
console.log('getAdminFeatures - User role:', user?.role);
console.log('getAdminFeatures - Pending count:', pendingSignupCount);
// 시스템 관리자 기능 (admin role이 시스템 관리자)
if (user?.role === 'admin') {
features.push(
{
id: 'user-management',
title: '👥 사용자 관리',
description: '계정 생성, 역할 변경, 사용자 삭제',
description: '계정 생성, 역할 변경, 회원가입 승인',
color: '#dc2626',
badge: '시스템 관리자'
badge: '시스템 관리자',
pendingCount: pendingSignupCount
},
{
id: 'system-logs',
@@ -203,8 +251,24 @@ function App() {
);
}
// 일반 관리자 기능
if (user?.role === 'manager') {
// 일반 관리자는 회원가입 승인만 가능
features.push(
{
id: 'user-management',
title: '👥 회원가입 승인',
description: '신규 회원가입 승인 및 거부',
color: '#dc2626',
badge: '관리자',
pendingCount: pendingSignupCount
}
);
}
// 관리자 이상 공통 기능
if (user?.role === 'admin' || user?.role === 'system') {
if (user?.role === 'admin' || user?.role === 'manager') {
features.push(
{
id: 'log-monitoring',
@@ -661,13 +725,12 @@ function App() {
)}
</div>
{/* 핵심 기능 */}
{/* 핵심 기능 - 프로젝트 선택 시만 표시 */}
{selectedProject && (
<>
<div style={{ marginBottom: '32px' }}>
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748', marginBottom: '16px' }}>
📋 BOM 관리 워크플로우
</h2>
<div style={{ marginBottom: '32px' }}>
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748', marginBottom: '16px' }}>
📋 BOM 관리 워크플로우
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
@@ -701,7 +764,10 @@ function App() {
{feature.description}
</p>
<button
onClick={() => navigateToPage(feature.id, { selectedProject })}
onClick={() => navigateToPage(feature.id, {
selectedProject,
jobNo: selectedProject?.official_project_code
})}
style={{
background: feature.color,
color: 'white',
@@ -720,8 +786,9 @@ function App() {
))}
</div>
</div>
)} {/* selectedProject 조건문 닫기 */}
{/* 관리자 기능 (있는 경우만) */}
{/* 관리자 기능 (프로젝트 선택과 무관하게 항상 표시) */}
{adminFeatures.length > 0 && (
<div style={{ marginBottom: '32px' }}>
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748', marginBottom: '16px' }}>
@@ -753,8 +820,29 @@ function App() {
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.07)';
}}
>
<h3 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', marginBottom: '12px' }}>
{feature.title}
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
marginBottom: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<span>{feature.title}</span>
{feature.id === 'user-management' && feature.pendingCount > 0 && (
<span style={{
background: '#ef4444',
color: 'white',
borderRadius: '12px',
padding: '2px 8px',
fontSize: '12px',
fontWeight: '700',
animation: 'pulse 2s ease-in-out infinite'
}}>
{feature.pendingCount} 대기
</span>
)}
</h3>
<p style={{ color: '#718096', marginBottom: '16px', fontSize: '14px' }}>
{feature.description}
@@ -853,8 +941,7 @@ function App() {
</div>
</div>
</div>
</>
)} {/* selectedProject 조건문 닫기 */}
)} {/* adminFeatures 조건문 닫기 */}
</div>
</div>
);
@@ -880,6 +967,25 @@ function App() {
filename={pageParams.filename}
/>
);
case 'purchase-batch':
return (
<PurchaseBatchPage
onNavigate={navigateToPage}
fileId={pageParams.file_id}
jobNo={pageParams.jobNo}
/>
);
case 'purchase-request':
return (
<PurchaseRequestPage
onNavigate={navigateToPage}
fileId={pageParams.file_id}
jobNo={pageParams.jobNo}
selectedProject={pageParams.selectedProject}
/>
);
case 'system-settings':
return (

View File

@@ -294,7 +294,7 @@
background: white;
margin: 16px 24px;
overflow-y: auto;
overflow-x: hidden;
overflow-x: auto; /* 좌우 스크롤 가능하도록 변경 */
max-height: calc(100vh - 220px);
border: 1px solid #d1d5db;
}
@@ -431,40 +431,40 @@
font-weight: 600;
}
/* U-BOLT 전용 헤더 - 8개 컬럼 */
.detailed-grid-header.ubolt-header {
grid-template-columns: 3% 11% 15% 10% 20% 12% 18% 10%;
/* SUPPORT 전용 헤더 - 8개 컬럼 */
.detailed-grid-header.support-header {
grid-template-columns: 3% 10% 12% 10% 25% 10% 18% 12%;
}
/* U-BOLT 전용 행 - 8개 컬럼 */
.detailed-material-row.ubolt-row {
grid-template-columns: 3% 11% 15% 10% 20% 12% 18% 10%;
/* SUPPORT 전용 행 - 8개 컬럼 */
.detailed-material-row.support-row {
grid-template-columns: 3% 10% 12% 10% 25% 10% 18% 12%;
}
/* U-BOLT 헤더 테두리 */
.detailed-grid-header.ubolt-header > div,
.detailed-grid-header.ubolt-header .filterable-header {
/* SUPPORT 헤더 테두리 */
.detailed-grid-header.support-header > div,
.detailed-grid-header.support-header .filterable-header {
border-right: 1px solid #d1d5db;
}
.detailed-grid-header.ubolt-header > div:last-child,
.detailed-grid-header.ubolt-header .filterable-header:last-child {
.detailed-grid-header.support-header > div:last-child,
.detailed-grid-header.support-header .filterable-header:last-child {
border-right: none;
}
/* U-BOLT 행 테두리 */
.detailed-material-row.ubolt-row .material-cell {
/* SUPPORT 행 테두리 */
.detailed-material-row.support-row .material-cell {
border-right: 1px solid #d1d5db;
}
.detailed-material-row.ubolt-row .material-cell:last-child {
.detailed-material-row.support-row .material-cell:last-child {
border-right: none;
}
/* U-BOLT 타입 배지 */
.type-badge.ubolt {
/* SUPPORT 타입 배지 */
.type-badge.support {
background: #059669;
color: white;
border: 2px solid #047857;
@@ -533,7 +533,7 @@
/* 플랜지 전용 헤더 - 10개 컬럼 */
.detailed-grid-header.flange-header {
grid-template-columns: 2% 8% 12% 8% 10% 10% 18% 10% 15% 6%;
grid-template-columns: 1.5% 8.5% 12% 8% 10% 10% 15% 8% 12% 11.5%;
}
@@ -550,7 +550,7 @@
/* 플랜지 전용 행 - 10개 컬럼 */
.detailed-material-row.flange-row {
grid-template-columns: 1.5% 8.5% 12% 8% 10% 10% 18% 10% 15% 6%;
grid-template-columns: 1.5% 8.5% 12% 8% 10% 10% 15% 8% 12% 11.5%;
}
@@ -565,7 +565,7 @@
/* 피팅 전용 헤더 - 10개 컬럼 */
.detailed-grid-header.fitting-header {
grid-template-columns: 2% 8% 20% 8% 8% 10% 18% 10% 15% 0%;
grid-template-columns: 2% 8.5% 16% 7.5% 7.5% 9% 15% 9% 13% 12%;
}
@@ -582,7 +582,7 @@
/* 피팅 전용 행 - 10개 컬럼 */
.detailed-material-row.fitting-row {
grid-template-columns: 2% 8% 20% 8% 8% 10% 18% 10% 15% 0%;
grid-template-columns: 2% 8.5% 16% 7.5% 7.5% 9% 15% 9% 13% 12%;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,388 @@
import React, { useState, useEffect } from 'react';
import api from '../api';
const PurchaseBatchPage = ({ onNavigate, fileId, jobNo }) => {
const [batches, setBatches] = useState([]);
const [selectedBatch, setSelectedBatch] = useState(null);
const [batchMaterials, setBatchMaterials] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState('all'); // all, pending, requested, ordered, received
const [message, setMessage] = useState({ type: '', text: '' });
useEffect(() => {
loadBatches();
}, [fileId, jobNo]);
const loadBatches = async () => {
setIsLoading(true);
try {
const params = {};
if (fileId) params.file_id = fileId;
if (jobNo) params.job_no = jobNo;
const response = await api.get('/export/batches', { params });
setBatches(response.data.batches || []);
} catch (error) {
console.error('Failed to load batches:', error);
setMessage({ type: 'error', text: '배치 목록 로드 실패' });
} finally {
setIsLoading(false);
}
};
const loadBatchMaterials = async (exportId) => {
setIsLoading(true);
try {
const response = await api.get(`/export/batch/${exportId}/materials`);
setBatchMaterials(response.data.materials || []);
} catch (error) {
console.error('Failed to load batch materials:', error);
setMessage({ type: 'error', text: '자재 목록 로드 실패' });
} finally {
setIsLoading(false);
}
};
const handleBatchSelect = (batch) => {
setSelectedBatch(batch);
loadBatchMaterials(batch.export_id);
};
const handleDownloadExcel = async (exportId, batchNo) => {
try {
const response = await api.get(`/export/batch/${exportId}/download`, {
responseType: 'blob'
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `batch_${batchNo}.xlsx`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
setMessage({ type: 'success', text: '엑셀 다운로드 완료' });
} catch (error) {
console.error('Failed to download excel:', error);
setMessage({ type: 'error', text: '엑셀 다운로드 실패' });
}
};
const handleBatchStatusUpdate = async (exportId, newStatus) => {
try {
const prNo = prompt('구매요청 번호 (PR)를 입력하세요:');
const response = await api.patch(`/export/batch/${exportId}/status`, {
status: newStatus,
purchase_request_no: prNo
});
if (response.data.success) {
setMessage({ type: 'success', text: response.data.message });
loadBatches();
if (selectedBatch?.export_id === exportId) {
loadBatchMaterials(exportId);
}
}
} catch (error) {
console.error('Failed to update batch status:', error);
setMessage({ type: 'error', text: '상태 업데이트 실패' });
}
};
const getStatusBadge = (status) => {
const styles = {
pending: { bg: '#FFFFE0', color: '#856404', text: '구매 전' },
requested: { bg: '#FFE4B5', color: '#8B4513', text: '구매신청' },
in_progress: { bg: '#ADD8E6', color: '#00008B', text: '진행중' },
ordered: { bg: '#87CEEB', color: '#4682B4', text: '발주완료' },
received: { bg: '#90EE90', color: '#228B22', text: '입고완료' },
completed: { bg: '#98FB98', color: '#006400', text: '완료' }
};
const style = styles[status] || styles.pending;
return (
<span style={{
background: style.bg,
color: style.color,
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600'
}}>
{style.text}
</span>
);
};
const filteredBatches = activeTab === 'all'
? batches
: batches.filter(b => b.batch_status === activeTab);
return (
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: '24px' }}>
<h1 style={{ fontSize: '24px', fontWeight: '600', marginBottom: '8px' }}>
구매 배치 관리
</h1>
<p style={{ color: '#6c757d' }}>
엑셀로 내보낸 자재들을 배치 단위로 관리합니다
</p>
</div>
{/* 메시지 */}
{message.text && (
<div style={{
padding: '12px',
marginBottom: '16px',
borderRadius: '8px',
background: message.type === 'error' ? '#fee' : '#e6ffe6',
color: message.type === 'error' ? '#dc3545' : '#28a745',
border: `1px solid ${message.type === 'error' ? '#fcc' : '#cfc'}`
}}>
{message.text}
</div>
)}
{/* 탭 네비게이션 */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '20px' }}>
{[
{ key: 'all', label: '전체' },
{ key: 'pending', label: '구매 전' },
{ key: 'requested', label: '구매신청' },
{ key: 'in_progress', label: '진행중' },
{ key: 'completed', label: '완료' }
].map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: '8px 16px',
borderRadius: '20px',
border: activeTab === tab.key ? '2px solid #007bff' : '1px solid #dee2e6',
background: activeTab === tab.key ? '#007bff' : 'white',
color: activeTab === tab.key ? 'white' : '#495057',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
{tab.label}
</button>
))}
</div>
{/* 메인 컨텐츠 */}
<div style={{ display: 'grid', gridTemplateColumns: '400px 1fr', gap: '24px' }}>
{/* 배치 목록 */}
<div style={{
background: 'white',
borderRadius: '12px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
overflow: 'hidden'
}}>
<div style={{
padding: '16px',
borderBottom: '1px solid #e9ecef',
background: '#f8f9fa'
}}>
<h2 style={{ fontSize: '16px', fontWeight: '600', margin: 0 }}>
배치 목록 ({filteredBatches.length})
</h2>
</div>
<div style={{ maxHeight: '600px', overflow: 'auto' }}>
{isLoading ? (
<div style={{ padding: '40px', textAlign: 'center' }}>
로딩중...
</div>
) : filteredBatches.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center', color: '#6c757d' }}>
배치가 없습니다
</div>
) : (
filteredBatches.map(batch => (
<div
key={batch.export_id}
onClick={() => handleBatchSelect(batch)}
style={{
padding: '16px',
borderBottom: '1px solid #f1f3f4',
cursor: 'pointer',
background: selectedBatch?.export_id === batch.export_id ? '#f0f8ff' : 'white',
transition: 'background 0.2s'
}}
onMouseEnter={(e) => {
if (selectedBatch?.export_id !== batch.export_id) {
e.currentTarget.style.background = '#f8f9fa';
}
}}
onMouseLeave={(e) => {
if (selectedBatch?.export_id !== batch.export_id) {
e.currentTarget.style.background = 'white';
}
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ fontWeight: '600', fontSize: '14px' }}>
{batch.batch_no}
</span>
{getStatusBadge(batch.batch_status)}
</div>
<div style={{ fontSize: '12px', color: '#6c757d', marginBottom: '4px' }}>
{batch.job_no} - {batch.job_name}
</div>
<div style={{ fontSize: '12px', color: '#6c757d', marginBottom: '8px' }}>
{batch.category || '전체'} | {batch.material_count} 자재
</div>
<div style={{ display: 'flex', gap: '8px', fontSize: '11px' }}>
<span style={{ background: '#e9ecef', padding: '2px 6px', borderRadius: '4px' }}>
대기: {batch.status_detail.pending}
</span>
<span style={{ background: '#fff3cd', padding: '2px 6px', borderRadius: '4px' }}>
신청: {batch.status_detail.requested}
</span>
<span style={{ background: '#cce5ff', padding: '2px 6px', borderRadius: '4px' }}>
발주: {batch.status_detail.ordered}
</span>
<span style={{ background: '#d4edda', padding: '2px 6px', borderRadius: '4px' }}>
입고: {batch.status_detail.received}
</span>
</div>
<div style={{ marginTop: '8px', fontSize: '11px', color: '#6c757d' }}>
{new Date(batch.export_date).toLocaleDateString()} | {batch.exported_by}
</div>
</div>
))
)}
</div>
</div>
{/* 선택된 배치 상세 */}
{selectedBatch ? (
<div style={{
background: 'white',
borderRadius: '12px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
overflow: 'hidden'
}}>
<div style={{
padding: '16px',
borderBottom: '1px solid #e9ecef',
background: '#f8f9fa'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ fontSize: '16px', fontWeight: '600', margin: 0 }}>
배치 {selectedBatch.batch_no}
</h2>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => handleDownloadExcel(selectedBatch.export_id, selectedBatch.batch_no)}
style={{
padding: '6px 12px',
background: '#28a745',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer'
}}
>
📥 엑셀 다운로드
</button>
<button
onClick={() => handleBatchStatusUpdate(selectedBatch.export_id, 'requested')}
style={{
padding: '6px 12px',
background: '#ffc107',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer'
}}
>
구매신청
</button>
</div>
</div>
</div>
<div style={{ padding: '16px' }}>
<div style={{ overflow: 'auto', maxHeight: '500px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f8f9fa' }}>
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>No</th>
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>카테고리</th>
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>자재 설명</th>
<th style={{ padding: '8px', textAlign: 'center', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>수량</th>
<th style={{ padding: '8px', textAlign: 'center', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>상태</th>
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>PR번호</th>
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>PO번호</th>
</tr>
</thead>
<tbody>
{batchMaterials.map((material, idx) => (
<tr key={material.exported_material_id} style={{ borderBottom: '1px solid #f1f3f4' }}>
<td style={{ padding: '8px', fontSize: '12px' }}>{idx + 1}</td>
<td style={{ padding: '8px', fontSize: '12px' }}>
<span style={{
background: '#e9ecef',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '11px'
}}>
{material.category}
</span>
</td>
<td style={{ padding: '8px', fontSize: '12px' }}>{material.description}</td>
<td style={{ padding: '8px', fontSize: '12px', textAlign: 'center' }}>
{material.quantity} {material.unit}
</td>
<td style={{ padding: '8px', textAlign: 'center' }}>
{getStatusBadge(material.purchase_status)}
</td>
<td style={{ padding: '8px', fontSize: '12px' }}>
{material.purchase_request_no || '-'}
</td>
<td style={{ padding: '8px', fontSize: '12px' }}>
{material.purchase_order_no || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
) : (
<div style={{
background: 'white',
borderRadius: '12px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px'
}}>
<div style={{ textAlign: 'center', color: '#6c757d' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📦</div>
<div style={{ fontSize: '16px' }}>배치를 선택하세요</div>
</div>
</div>
)}
</div>
</div>
);
};
export default PurchaseBatchPage;

View File

@@ -0,0 +1,211 @@
.purchase-request-page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
}
.back-btn {
background: none;
border: none;
color: #007bff;
cursor: pointer;
font-size: 14px;
padding: 0;
margin-bottom: 16px;
}
.back-btn:hover {
text-decoration: underline;
}
.page-header h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 8px 0;
}
.subtitle {
color: #6c757d;
margin: 0;
}
.main-content {
display: grid;
grid-template-columns: 400px 1fr;
gap: 24px;
}
/* 구매신청 목록 패널 */
.requests-panel {
background: white;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
.panel-header {
padding: 16px;
border-bottom: 1px solid #e9ecef;
background: #f8f9fa;
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-header h2 {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.requests-list {
max-height: 600px;
overflow-y: auto;
}
.request-card {
padding: 16px;
border-bottom: 1px solid #f1f3f4;
cursor: pointer;
transition: background 0.2s;
}
.request-card:hover {
background: #f8f9fa;
}
.request-card.selected {
background: #e7f3ff;
}
.request-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.request-no {
font-weight: 600;
font-size: 14px;
color: #007bff;
}
.request-date {
font-size: 12px;
color: #6c757d;
}
.request-info {
font-size: 13px;
color: #495057;
margin-bottom: 8px;
}
.material-count {
font-size: 12px;
color: #6c757d;
margin-top: 4px;
}
.request-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.requested-by {
font-size: 11px;
color: #6c757d;
}
.download-btn {
background: #28a745;
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
}
.download-btn:hover {
background: #218838;
}
/* 상세 패널 */
.details-panel {
background: white;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
.excel-btn {
background: #28a745;
color: white;
border: none;
border-radius: 6px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
.excel-btn:hover {
background: #218838;
}
.materials-table {
padding: 16px;
overflow: auto;
}
.materials-table table {
width: 100%;
border-collapse: collapse;
}
.materials-table th {
background: #f8f9fa;
padding: 8px;
text-align: left;
font-size: 12px;
font-weight: 600;
border-bottom: 2px solid #dee2e6;
}
.materials-table td {
padding: 8px;
font-size: 12px;
border-bottom: 1px solid #f1f3f4;
}
.category-badge {
background: #e9ecef;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
color: #6c757d;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.loading {
padding: 40px;
text-align: center;
color: #6c757d;
}

View File

@@ -0,0 +1,274 @@
import React, { useState, useEffect } from 'react';
import api from '../api';
import { exportMaterialsToExcel } from '../utils/excelExport';
import './PurchaseRequestPage.css';
const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) => {
const [requests, setRequests] = useState([]);
const [selectedRequest, setSelectedRequest] = useState(null);
const [requestMaterials, setRequestMaterials] = useState([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
loadRequests();
}, [fileId, jobNo]);
const loadRequests = async () => {
setIsLoading(true);
try {
const params = {};
if (fileId) params.file_id = fileId;
if (jobNo) params.job_no = jobNo;
const response = await api.get('/purchase-request/list', { params });
setRequests(response.data.requests || []);
} catch (error) {
console.error('Failed to load requests:', error);
} finally {
setIsLoading(false);
}
};
const loadRequestMaterials = async (requestId) => {
setIsLoading(true);
try {
const response = await api.get(`/purchase-request/${requestId}/materials`);
// 그룹화된 자재가 있으면 우선 표시, 없으면 개별 자재 표시
if (response.data.grouped_materials && response.data.grouped_materials.length > 0) {
setRequestMaterials(response.data.grouped_materials);
} else {
setRequestMaterials(response.data.materials || []);
}
} catch (error) {
console.error('Failed to load materials:', error);
} finally {
setIsLoading(false);
}
};
const handleRequestSelect = (request) => {
setSelectedRequest(request);
loadRequestMaterials(request.request_id);
};
const handleDownloadExcel = async (requestId, requestNo) => {
try {
// 서버에서 자재 데이터 가져오기
const response = await api.get(`/purchase-request/${requestId}/download-excel`);
if (response.data.success) {
const materials = response.data.materials;
const groupedMaterials = response.data.grouped_materials || [];
const jobNo = response.data.job_no;
// 사용자 요구사항 매핑
const userRequirements = {};
materials.forEach(material => {
if (material.user_requirement) {
userRequirements[material.material_id || material.id] = material.user_requirement;
}
});
// 그룹화된 자재가 있으면 그것을 사용, 없으면 원본 자재 사용
const dataToExport = groupedMaterials.length > 0 ? groupedMaterials : materials;
// 파일명 생성
const timestamp = new Date().toISOString().split('T')[0];
const fileName = `${jobNo}_${requestNo}_${timestamp}.xlsx`;
// 기존 엑셀 유틸리티 사용하여 엑셀 생성
// 그룹화된 데이터와 사용자 요구사항 전달
exportMaterialsToExcel(dataToExport, fileName, {
jobNo,
requestNo,
userRequirements
});
} else {
alert('데이터를 가져올 수 없습니다');
}
} catch (error) {
console.error('Failed to download excel:', error);
alert('엑셀 다운로드 실패');
}
};
return (
<div className="purchase-request-page">
<div className="page-header">
<button onClick={() => onNavigate('bom', { selectedProject })} className="back-btn">
BOM 관리로 돌아가기
</button>
<h1>구매신청 관리</h1>
<p className="subtitle">구매신청한 자재들을 그룹별로 관리합니다</p>
</div>
<div className="main-content">
{/* 구매신청 목록 */}
<div className="requests-panel">
<div className="panel-header">
<h2>구매신청 목록 ({requests.length})</h2>
</div>
<div className="requests-list">
{isLoading ? (
<div className="loading">로딩중...</div>
) : requests.length === 0 ? (
<div className="empty-state">구매신청이 없습니다</div>
) : (
requests.map(request => (
<div
key={request.request_id}
className={`request-card ${selectedRequest?.request_id === request.request_id ? 'selected' : ''}`}
onClick={() => handleRequestSelect(request)}
>
<div className="request-header">
<span className="request-no">{request.request_no}</span>
<span className="request-date">
{new Date(request.requested_at).toLocaleDateString()}
</span>
</div>
<div className="request-info">
<div>{request.job_no} - {request.job_name}</div>
<div className="material-count">
{request.category || '전체'} | {request.material_count} 자재
</div>
</div>
<div className="request-footer">
<span className="requested-by">{request.requested_by}</span>
<button
onClick={(e) => {
e.stopPropagation();
handleDownloadExcel(request.request_id, request.request_no);
}}
className="download-btn"
>
📥 엑셀
</button>
</div>
</div>
))
)}
</div>
</div>
{/* 선택된 구매신청 상세 */}
<div className="details-panel">
{selectedRequest ? (
<>
<div className="panel-header">
<h2>{selectedRequest.request_no}</h2>
<button
onClick={() => handleDownloadExcel(selectedRequest.request_id, selectedRequest.request_no)}
className="excel-btn"
>
📥 엑셀 다운로드
</button>
</div>
<div className="materials-table">
{/* 카테고리별로 그룹화하여 표시 */}
{(() => {
// 카테고리별로 자재 그룹화
const groupedByCategory = requestMaterials.reduce((acc, material) => {
const category = material.category || 'UNKNOWN';
if (!acc[category]) acc[category] = [];
acc[category].push(material);
return acc;
}, {});
return Object.entries(groupedByCategory).map(([category, materials]) => (
<div key={category} style={{ marginBottom: '30px' }}>
<h3 style={{
background: '#f0f0f0',
padding: '10px',
borderRadius: '4px',
fontSize: '14px',
fontWeight: 'bold'
}}>
{category} ({materials.length})
</h3>
<table>
<thead>
<tr>
<th>No</th>
<th>카테고리</th>
<th>자재 설명</th>
<th>크기</th>
{category === 'BOLT' ? <th>길이</th> : <th>스케줄</th>}
<th>재질</th>
<th>수량</th>
<th>사용자요구</th>
</tr>
</thead>
<tbody>
{materials.map((material, idx) => (
<tr key={material.item_id || `${category}-${idx}`}>
<td>{idx + 1}</td>
<td>
<span className="category-badge">
{material.category}
</span>
</td>
<td>{material.description}</td>
<td>{material.size || '-'}</td>
<td>{material.schedule || '-'}</td>
<td>{material.material_grade || '-'}</td>
<td>
{material.category === 'PIPE' ? (
<div>
<span style={{ fontWeight: 'bold' }}>
{(() => {
// 총 길이와 개수 계산
let totalLengthMm = material.total_length || 0;
let totalCount = 0;
if (material.pipe_lengths && material.pipe_lengths.length > 0) {
// pipe_lengths 배열에서 총 개수 계산
totalCount = material.pipe_lengths.reduce((sum, p) => sum + parseFloat(p.quantity || 0), 0);
} else if (material.material_ids && material.material_ids.length > 0) {
totalCount = material.material_ids.length;
if (!totalLengthMm) {
totalLengthMm = totalCount * 6000;
}
} else {
totalCount = parseFloat(material.quantity) || 1;
if (!totalLengthMm) {
totalLengthMm = totalCount * 6000;
}
}
// 6,000mm를 1본으로 계산
const pipeCount = Math.ceil(totalLengthMm / 6000);
// 형식: 2본(11,000mm/40개)
return `${pipeCount}본(${totalLengthMm.toLocaleString()}mm/${totalCount}개)`;
})()}
</span>
</div>
) : (
`${material.quantity} ${material.unit || 'EA'}`
)}
</td>
<td>{material.user_requirement || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
));
})()}
</div>
</>
) : (
<div className="empty-state">
<div className="empty-icon">📦</div>
<div>구매신청을 선택하세요</div>
</div>
)}
</div>
</div>
</div>
);
};
export default PurchaseRequestPage;

View File

@@ -4,6 +4,9 @@ import { reportError, logUserActionError } from '../utils/errorLogger';
const UserManagementPage = ({ onNavigate, user }) => {
const [users, setUsers] = useState([]);
const [pendingUsers, setPendingUsers] = useState([]);
const [suspendedUsers, setSuspendedUsers] = useState([]);
const [activeTab, setActiveTab] = useState('pending'); // 'pending', 'active', or 'suspended'
const [isLoading, setIsLoading] = useState(true);
const [message, setMessage] = useState({ type: '', text: '' });
const [showCreateModal, setShowCreateModal] = useState(false);
@@ -25,6 +28,8 @@ const UserManagementPage = ({ onNavigate, user }) => {
useEffect(() => {
loadUsers();
loadPendingUsers();
loadSuspendedUsers();
}, []);
const loadUsers = async () => {
@@ -46,6 +51,95 @@ const UserManagementPage = ({ onNavigate, user }) => {
}
};
const loadPendingUsers = async () => {
try {
const response = await api.get('/auth/signup-requests');
// API 응답에서 requests 배열을 가져옴
if (response.data.success && response.data.requests) {
setPendingUsers(response.data.requests);
} else {
setPendingUsers([]);
}
} catch (err) {
console.error('Load pending users error:', err);
// 에러는 무시하고 빈 배열로 설정
setPendingUsers([]);
}
};
const handleApproveUser = async (userId) => {
try {
const response = await api.post(`/auth/approve-signup/${userId}`);
if (response.data.success) {
setMessage({ type: 'success', text: '사용자가 승인되었습니다' });
loadPendingUsers();
loadUsers();
}
} catch (err) {
console.error('Approve user error:', err);
setMessage({ type: 'error', text: '승인 중 오류가 발생했습니다' });
}
};
const handleRejectUser = async (userId) => {
if (!window.confirm('정말로 이 가입 요청을 거부하시겠습니까?')) return;
try {
const response = await api.delete(`/auth/reject-signup/${userId}`);
if (response.data.success) {
setMessage({ type: 'success', text: '가입 요청이 거부되었습니다' });
loadPendingUsers();
}
} catch (err) {
console.error('Reject user error:', err);
setMessage({ type: 'error', text: '거부 중 오류가 발생했습니다' });
}
};
const loadSuspendedUsers = async () => {
try {
const response = await api.get('/auth/users/suspended');
if (response.data.success) {
setSuspendedUsers(response.data.users);
}
} catch (err) {
console.error('Load suspended users error:', err);
setSuspendedUsers([]);
}
};
const handleSuspendUser = async (userToSuspend) => {
if (!window.confirm(`정말로 ${userToSuspend.name} 사용자를 정지하시겠습니까?`)) return;
try {
const response = await api.patch(`/auth/users/${userToSuspend.user_id}/suspend`);
if (response.data.success) {
setMessage({ type: 'success', text: response.data.message });
loadUsers();
loadSuspendedUsers();
}
} catch (err) {
console.error('Suspend user error:', err);
setMessage({ type: 'error', text: '사용자 정지 중 오류가 발생했습니다' });
}
};
const handleReactivateUser = async (userToReactivate) => {
if (!window.confirm(`${userToReactivate.name} 사용자를 재활성화하시겠습니까?`)) return;
try {
const response = await api.patch(`/auth/users/${userToReactivate.user_id}/reactivate`);
if (response.data.success) {
setMessage({ type: 'success', text: response.data.message });
loadUsers();
loadSuspendedUsers();
}
} catch (err) {
console.error('Reactivate user error:', err);
setMessage({ type: 'error', text: '사용자 재활성화 중 오류가 발생했습니다' });
}
};
const handleCreateUser = async (e) => {
e.preventDefault();
@@ -256,22 +350,267 @@ const UserManagementPage = ({ onNavigate, user }) => {
</div>
)}
{/* 사용자 목록 */}
{/* 탭 네비게이션 */}
<div style={{
background: 'white',
borderRadius: '12px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
overflow: 'hidden'
display: 'flex',
gap: '8px',
marginBottom: '24px',
borderBottom: '2px solid #e9ecef',
paddingBottom: '0'
}}>
<button
onClick={() => setActiveTab('pending')}
style={{
background: activeTab === 'pending' ? 'white' : 'transparent',
color: activeTab === 'pending' ? '#007bff' : '#6c757d',
border: activeTab === 'pending' ? '2px solid #e9ecef' : '2px solid transparent',
borderBottom: activeTab === 'pending' ? '2px solid white' : '2px solid transparent',
borderRadius: '8px 8px 0 0',
padding: '12px 24px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
marginBottom: '-2px',
position: 'relative',
transition: 'all 0.2s'
}}
>
승인 대기
{pendingUsers.length > 0 && (
<span style={{
background: '#dc3545',
color: 'white',
borderRadius: '12px',
padding: '2px 6px',
fontSize: '11px',
marginLeft: '8px'
}}>
{pendingUsers.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('active')}
style={{
background: activeTab === 'active' ? 'white' : 'transparent',
color: activeTab === 'active' ? '#007bff' : '#6c757d',
border: activeTab === 'active' ? '2px solid #e9ecef' : '2px solid transparent',
borderBottom: activeTab === 'active' ? '2px solid white' : '2px solid transparent',
borderRadius: '8px 8px 0 0',
padding: '12px 24px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
marginBottom: '-2px',
transition: 'all 0.2s'
}}
>
등록된 사용자 ({users.length})
</button>
<button
onClick={() => setActiveTab('suspended')}
style={{
background: activeTab === 'suspended' ? 'white' : 'transparent',
color: activeTab === 'suspended' ? '#007bff' : '#6c757d',
border: activeTab === 'suspended' ? '2px solid #e9ecef' : '2px solid transparent',
borderBottom: activeTab === 'suspended' ? '2px solid white' : '2px solid transparent',
borderRadius: '8px 8px 0 0',
padding: '12px 24px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
marginBottom: '-2px',
transition: 'all 0.2s'
}}
>
정지된 사용자
{suspendedUsers.length > 0 && (
<span style={{
background: '#ffc107',
color: '#856404',
borderRadius: '12px',
padding: '2px 6px',
fontSize: '11px',
marginLeft: '8px'
}}>
{suspendedUsers.length}
</span>
)}
</button>
</div>
{/* 승인 대기 사용자 목록 */}
{activeTab === 'pending' && (
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e9ecef',
background: '#f8f9fa'
background: 'white',
borderRadius: '12px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
overflow: 'hidden'
}}>
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
등록된 사용자 ({users.length})
</h2>
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e9ecef',
background: '#fff5f5'
}}>
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
승인 대기 사용자 ({pendingUsers.length})
</h2>
</div>
{pendingUsers.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center' }}>
<div style={{ fontSize: '16px', color: '#6c757d' }}>승인 대기 중인 사용자가 없습니다</div>
</div>
) : (
<div style={{ overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f8f9fa' }}>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>사용자</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>역할</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>부서/직책</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>상태</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>가입일</th>
<th style={{ padding: '12px 16px', textAlign: 'center', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>관리</th>
</tr>
</thead>
<tbody>
{pendingUsers.map((userItem) => (
<tr key={userItem.user_id} style={{ borderBottom: '1px solid #f1f3f4' }}>
<td style={{ padding: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '50%',
background: '#e9ecef',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#6c757d',
fontSize: '16px',
fontWeight: '600'
}}>
{userItem.name ? userItem.name.charAt(0).toUpperCase() : '?'}
</div>
<div>
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
{userItem.name || '이름 없음'}
</div>
<div style={{ fontSize: '12px', color: '#6c757d' }}>
@{userItem.username}
</div>
{userItem.email && (
<div style={{ fontSize: '12px', color: '#6c757d' }}>
{userItem.email}
</div>
)}
</div>
</div>
</td>
<td style={{ padding: '16px' }}>
<span style={{
background: '#e9ecef',
color: '#495057',
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600'
}}>
{userItem.role || '사용자'}
</span>
</td>
<td style={{ padding: '16px' }}>
<div style={{ fontSize: '14px', color: '#495057' }}>
{userItem.department || '-'}
</div>
<div style={{ fontSize: '12px', color: '#6c757d' }}>
{userItem.position || '-'}
</div>
</td>
<td style={{ padding: '16px' }}>
<span style={{
background: '#fff3cd',
color: '#856404',
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600'
}}>
승인 대기
</span>
</td>
<td style={{ padding: '16px' }}>
<div style={{ fontSize: '12px', color: '#6c757d' }}>
{userItem.created_at ? new Date(userItem.created_at).toLocaleDateString() : '-'}
</div>
</td>
<td style={{ padding: '16px' }}>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
<button
onClick={() => handleApproveUser(userItem.user_id)}
style={{
background: '#28a745',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '6px 12px',
fontSize: '12px',
fontWeight: '600',
cursor: 'pointer',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.target.style.backgroundColor = '#218838'}
onMouseLeave={(e) => e.target.style.backgroundColor = '#28a745'}
>
승인
</button>
<button
onClick={() => handleRejectUser(userItem.user_id)}
style={{
background: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '6px 12px',
fontSize: '12px',
fontWeight: '600',
cursor: 'pointer',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.target.style.backgroundColor = '#c82333'}
onMouseLeave={(e) => e.target.style.backgroundColor = '#dc3545'}
>
거부
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* 등록된 사용자 목록 */}
{activeTab === 'active' && (
<div style={{
background: 'white',
borderRadius: '12px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
overflow: 'hidden'
}}>
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e9ecef',
background: '#f8f9fa'
}}>
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
등록된 사용자 ({users.length})
</h2>
</div>
{isLoading ? (
<div style={{ padding: '40px', textAlign: 'center' }}>
@@ -388,6 +727,26 @@ const UserManagementPage = ({ onNavigate, user }) => {
>
역할 변경
</button>
{userItem.user_id !== user?.user_id && (
<button
onClick={() => handleSuspendUser(userItem)}
style={{
background: '#ff9800',
color: 'white',
border: 'none',
borderRadius: '4px',
padding: '6px 12px',
fontSize: '12px',
cursor: 'pointer',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.target.style.backgroundColor = '#e68900'}
onMouseLeave={(e) => e.target.style.backgroundColor = '#ff9800'}
title="사용자 정지"
>
정지
</button>
)}
{userItem.user_id !== user?.user_id && (
<button
onClick={() => handleDeleteUser(userItem)}
@@ -417,7 +776,165 @@ const UserManagementPage = ({ onNavigate, user }) => {
</table>
</div>
)}
</div>
</div>
)}
{/* 정지된 사용자 목록 */}
{activeTab === 'suspended' && (
<div style={{
background: 'white',
borderRadius: '12px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
overflow: 'hidden'
}}>
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e9ecef',
background: '#fff8e1'
}}>
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
정지된 사용자 ({suspendedUsers.length})
</h2>
</div>
{suspendedUsers.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center' }}>
<div style={{ fontSize: '16px', color: '#6c757d' }}>정지된 사용자가 없습니다</div>
</div>
) : (
<div style={{ overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f8f9fa' }}>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>사용자</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>역할</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>부서/직책</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>상태</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>정지일</th>
<th style={{ padding: '12px 16px', textAlign: 'center', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>관리</th>
</tr>
</thead>
<tbody>
{suspendedUsers.map((userItem) => (
<tr key={userItem.user_id} style={{ borderBottom: '1px solid #f1f3f4' }}>
<td style={{ padding: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '50%',
background: '#ffc107',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '16px',
fontWeight: '600'
}}>
{userItem.name ? userItem.name.charAt(0).toUpperCase() : '?'}
</div>
<div>
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
{userItem.name || '이름 없음'}
</div>
<div style={{ fontSize: '12px', color: '#6c757d' }}>
@{userItem.username}
</div>
{userItem.email && (
<div style={{ fontSize: '12px', color: '#6c757d' }}>
{userItem.email}
</div>
)}
</div>
</div>
</td>
<td style={{ padding: '16px' }}>
<span style={{
background: getRoleBadgeColor(userItem.role).bg,
color: getRoleBadgeColor(userItem.role).color,
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600'
}}>
{getRoleDisplayName(userItem.role)}
</span>
</td>
<td style={{ padding: '16px' }}>
<div style={{ fontSize: '14px', color: '#495057' }}>
{userItem.department || '-'}
</div>
<div style={{ fontSize: '12px', color: '#6c757d' }}>
{userItem.position || '-'}
</div>
</td>
<td style={{ padding: '16px' }}>
<span style={{
background: '#fff3cd',
color: '#856404',
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600'
}}>
정지됨
</span>
</td>
<td style={{ padding: '16px' }}>
<div style={{ fontSize: '12px', color: '#6c757d' }}>
{userItem.updated_at ? new Date(userItem.updated_at).toLocaleDateString() : '-'}
</div>
</td>
<td style={{ padding: '16px' }}>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
<button
onClick={() => handleReactivateUser(userItem)}
style={{
background: '#28a745',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '6px 12px',
fontSize: '12px',
fontWeight: '600',
cursor: 'pointer',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.target.style.backgroundColor = '#218838'}
onMouseLeave={(e) => e.target.style.backgroundColor = '#28a745'}
>
재활성화
</button>
{userItem.user_id !== user?.user_id && (
<button
onClick={() => handleDeleteUser(userItem)}
style={{
background: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '6px 12px',
fontSize: '12px',
fontWeight: '600',
cursor: 'pointer',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.target.style.backgroundColor = '#c82333'}
onMouseLeave={(e) => e.target.style.backgroundColor = '#dc3545'}
>
삭제
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
{/* 사용자 생성 모달 */}

View File

@@ -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 || '-',