Compare commits
1 Commits
8f5330a008
...
521446d56b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
521446d56b |
@@ -323,75 +323,6 @@ 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,
|
||||
@@ -440,155 +371,6 @@ 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,
|
||||
@@ -609,11 +391,10 @@ async def delete_user(
|
||||
try:
|
||||
# 토큰 검증 및 권한 확인
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
# admin role도 사용자 삭제 가능하도록 수정
|
||||
if payload['role'] not in ['system', 'admin']:
|
||||
if payload['role'] != 'system':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="사용자 삭제는 관리자만 가능합니다"
|
||||
detail="사용자 삭제는 시스템 관리자만 가능합니다"
|
||||
)
|
||||
|
||||
# 자기 자신 삭제 방지
|
||||
@@ -623,30 +404,7 @@ 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)
|
||||
|
||||
@@ -656,39 +414,15 @@ async def delete_user(
|
||||
detail="해당 사용자를 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -62,38 +62,14 @@ class AuthService:
|
||||
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 not user.is_active:
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_disabled')
|
||||
logger.warning(f"Login failed - account disabled: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
message="비활성화된 계정입니다. 관리자에게 문의하세요"
|
||||
)
|
||||
|
||||
# 계정 잠금 상태 확인
|
||||
if user.is_locked():
|
||||
|
||||
@@ -32,8 +32,7 @@ class User(Base):
|
||||
access_level = Column(String(20), default='worker', 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
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
failed_login_attempts = Column(Integer, default=0)
|
||||
locked_until = Column(DateTime, nullable=True)
|
||||
|
||||
@@ -303,15 +302,9 @@ class UserRepository:
|
||||
raise
|
||||
|
||||
def get_all_users(self, skip: int = 0, limit: int = 100) -> List[User]:
|
||||
"""활성 사용자만 조회 (status='active')"""
|
||||
"""모든 사용자 조회"""
|
||||
try:
|
||||
# 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()
|
||||
return self.db.query(User).offset(skip).limit(limit).all()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get all users: {str(e)}")
|
||||
return []
|
||||
|
||||
@@ -83,8 +83,7 @@ async def signup_request(
|
||||
'position': signup_data.position,
|
||||
'phone': signup_data.phone,
|
||||
'role': 'user',
|
||||
'is_active': False, # 하위 호환성
|
||||
'status': 'pending' # 새로운 status 체계: 승인 대기
|
||||
'is_active': False # 비활성 상태로 승인 대기 표시
|
||||
})
|
||||
|
||||
# 가입 사유 저장 (notes 컬럼 활용)
|
||||
@@ -131,13 +130,13 @@ async def get_signup_requests(
|
||||
detail="관리자만 접근 가능합니다"
|
||||
)
|
||||
|
||||
# 승인 대기 중인 사용자 조회 (status='pending'인 사용자)
|
||||
# 승인 대기 중인 사용자 조회 (is_active=False인 사용자)
|
||||
query = text("""
|
||||
SELECT
|
||||
user_id, username, name, email, department, position,
|
||||
phone, created_at, role, is_active, status
|
||||
user_id as id, username, name, email, department, position,
|
||||
phone, notes, created_at
|
||||
FROM users
|
||||
WHERE status = 'pending'
|
||||
WHERE is_active = FALSE
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
|
||||
@@ -146,18 +145,15 @@ async def get_signup_requests(
|
||||
pending_users = []
|
||||
for row in results:
|
||||
pending_users.append({
|
||||
"user_id": row.user_id,
|
||||
"id": row.user_id, # 호환성을 위해 둘 다 제공
|
||||
"id": row.id,
|
||||
"username": row.username,
|
||||
"name": row.name,
|
||||
"email": row.email,
|
||||
"department": row.department,
|
||||
"position": row.position,
|
||||
"phone": row.phone,
|
||||
"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
|
||||
"reason": row.notes,
|
||||
"requested_at": row.created_at.isoformat() if row.created_at else None
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -205,10 +201,9 @@ 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 status = 'pending'
|
||||
WHERE user_id = :user_id AND is_active = FALSE
|
||||
RETURNING user_id as id, username, name
|
||||
""")
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class SecuritySettings(BaseSettings):
|
||||
"""보안 설정"""
|
||||
cors_origins: List[str] = Field(default=[], description="CORS 허용 도메인")
|
||||
cors_methods: List[str] = Field(
|
||||
default=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
default=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
description="CORS 허용 메서드"
|
||||
)
|
||||
cors_headers: List[str] = Field(
|
||||
|
||||
@@ -97,28 +97,6 @@ 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
|
||||
|
||||
@@ -1,591 +0,0 @@
|
||||
"""
|
||||
엑셀 내보내기 및 구매 배치 관리 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
import uuid
|
||||
|
||||
from ..database import get_db
|
||||
from ..auth.jwt_service import get_current_user
|
||||
from ..utils.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/export", tags=["Export Management"])
|
||||
|
||||
# 엑셀 파일 저장 경로
|
||||
EXPORT_DIR = "exports"
|
||||
os.makedirs(EXPORT_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def create_excel_from_materials(materials: List[Dict], batch_info: Dict) -> str:
|
||||
"""
|
||||
자재 목록으로 엑셀 파일 생성
|
||||
"""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = batch_info.get("category", "자재목록")
|
||||
|
||||
# 헤더 스타일
|
||||
header_fill = PatternFill(start_color="B8E6FF", end_color="B8E6FF", fill_type="solid")
|
||||
header_font = Font(bold=True, size=11)
|
||||
header_alignment = Alignment(horizontal="center", vertical="center")
|
||||
thin_border = Border(
|
||||
left=Side(style='thin'),
|
||||
right=Side(style='thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
|
||||
# 배치 정보 추가 (상단 3줄)
|
||||
ws.merge_cells('A1:J1')
|
||||
ws['A1'] = f"구매 배치: {batch_info.get('batch_no', 'N/A')}"
|
||||
ws['A1'].font = Font(bold=True, size=14)
|
||||
|
||||
ws.merge_cells('A2:J2')
|
||||
ws['A2'] = f"프로젝트: {batch_info.get('job_no', '')} - {batch_info.get('job_name', '')}"
|
||||
|
||||
ws.merge_cells('A3:J3')
|
||||
ws['A3'] = f"내보낸 날짜: {batch_info.get('export_date', datetime.now().strftime('%Y-%m-%d %H:%M'))}"
|
||||
|
||||
# 빈 줄
|
||||
ws.append([])
|
||||
|
||||
# 헤더 행
|
||||
headers = [
|
||||
"No.", "카테고리", "자재 설명", "크기", "스케줄/등급",
|
||||
"재질", "수량", "단위", "추가요구", "사용자요구",
|
||||
"구매상태", "PR번호", "PO번호", "공급업체", "예정일"
|
||||
]
|
||||
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=5, column=col, value=header)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = header_alignment
|
||||
cell.border = thin_border
|
||||
|
||||
# 데이터 행
|
||||
row_num = 6
|
||||
for idx, material in enumerate(materials, 1):
|
||||
row_data = [
|
||||
idx,
|
||||
material.get("category", ""),
|
||||
material.get("description", ""),
|
||||
material.get("size", ""),
|
||||
material.get("schedule", ""),
|
||||
material.get("material_grade", ""),
|
||||
material.get("quantity", ""),
|
||||
material.get("unit", ""),
|
||||
material.get("additional_req", ""),
|
||||
material.get("user_requirement", ""),
|
||||
material.get("purchase_status", "pending"),
|
||||
material.get("purchase_request_no", ""),
|
||||
material.get("purchase_order_no", ""),
|
||||
material.get("vendor_name", ""),
|
||||
material.get("expected_date", "")
|
||||
]
|
||||
|
||||
for col, value in enumerate(row_data, 1):
|
||||
cell = ws.cell(row=row_num, column=col, value=value)
|
||||
cell.border = thin_border
|
||||
if col == 11: # 구매상태 컬럼
|
||||
if value == "pending":
|
||||
cell.fill = PatternFill(start_color="FFFFE0", end_color="FFFFE0", fill_type="solid")
|
||||
elif value == "requested":
|
||||
cell.fill = PatternFill(start_color="FFE4B5", end_color="FFE4B5", fill_type="solid")
|
||||
elif value == "ordered":
|
||||
cell.fill = PatternFill(start_color="ADD8E6", end_color="ADD8E6", fill_type="solid")
|
||||
elif value == "received":
|
||||
cell.fill = PatternFill(start_color="90EE90", end_color="90EE90", fill_type="solid")
|
||||
|
||||
row_num += 1
|
||||
|
||||
# 열 너비 자동 조정
|
||||
for column in ws.columns:
|
||||
max_length = 0
|
||||
column_letter = get_column_letter(column[0].column)
|
||||
for cell in column:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except:
|
||||
pass
|
||||
adjusted_width = min(max_length + 2, 50)
|
||||
ws.column_dimensions[column_letter].width = adjusted_width
|
||||
|
||||
# 파일 저장
|
||||
file_name = f"batch_{batch_info.get('batch_no', uuid.uuid4().hex[:8])}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
file_path = os.path.join(EXPORT_DIR, file_name)
|
||||
wb.save(file_path)
|
||||
|
||||
return file_name
|
||||
|
||||
|
||||
@router.post("/create-batch")
|
||||
async def create_export_batch(
|
||||
file_id: int,
|
||||
job_no: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
materials: List[Dict] = [],
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
엑셀 내보내기 배치 생성 (자재 그룹화)
|
||||
"""
|
||||
try:
|
||||
# 배치 번호 생성 (YYYYMMDD-XXX 형식)
|
||||
batch_date = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
# 오늘 생성된 배치 수 확인
|
||||
count_query = text("""
|
||||
SELECT COUNT(*) as count
|
||||
FROM excel_export_history
|
||||
WHERE DATE(export_date) = CURRENT_DATE
|
||||
""")
|
||||
count_result = db.execute(count_query).fetchone()
|
||||
batch_seq = (count_result.count + 1) if count_result else 1
|
||||
batch_no = f"{batch_date}-{str(batch_seq).zfill(3)}"
|
||||
|
||||
# Job 정보 조회
|
||||
job_name = ""
|
||||
if job_no:
|
||||
job_query = text("SELECT job_name FROM jobs WHERE job_no = :job_no")
|
||||
job_result = db.execute(job_query, {"job_no": job_no}).fetchone()
|
||||
if job_result:
|
||||
job_name = job_result.job_name
|
||||
|
||||
# 배치 정보
|
||||
batch_info = {
|
||||
"batch_no": batch_no,
|
||||
"job_no": job_no,
|
||||
"job_name": job_name,
|
||||
"category": category,
|
||||
"export_date": datetime.now().strftime('%Y-%m-%d %H:%M')
|
||||
}
|
||||
|
||||
# 엑셀 파일 생성
|
||||
excel_file_name = create_excel_from_materials(materials, batch_info)
|
||||
|
||||
# 내보내기 이력 저장
|
||||
insert_history = text("""
|
||||
INSERT INTO excel_export_history (
|
||||
file_id, job_no, exported_by, export_type,
|
||||
category, material_count, file_name, notes
|
||||
) VALUES (
|
||||
:file_id, :job_no, :exported_by, :export_type,
|
||||
:category, :material_count, :file_name, :notes
|
||||
) RETURNING export_id
|
||||
""")
|
||||
|
||||
result = db.execute(insert_history, {
|
||||
"file_id": file_id,
|
||||
"job_no": job_no,
|
||||
"exported_by": current_user.get("user_id"),
|
||||
"export_type": "batch",
|
||||
"category": category,
|
||||
"material_count": len(materials),
|
||||
"file_name": excel_file_name,
|
||||
"notes": f"배치번호: {batch_no}"
|
||||
})
|
||||
|
||||
export_id = result.fetchone().export_id
|
||||
|
||||
# 자재별 내보내기 기록
|
||||
material_ids = []
|
||||
for material in materials:
|
||||
material_id = material.get("id")
|
||||
if material_id:
|
||||
material_ids.append(material_id)
|
||||
|
||||
insert_material = text("""
|
||||
INSERT INTO exported_materials (
|
||||
export_id, material_id, purchase_status,
|
||||
quantity_exported
|
||||
) VALUES (
|
||||
:export_id, :material_id, 'pending',
|
||||
:quantity
|
||||
)
|
||||
""")
|
||||
|
||||
db.execute(insert_material, {
|
||||
"export_id": export_id,
|
||||
"material_id": material_id,
|
||||
"quantity": material.get("quantity", 0)
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Export batch created: {batch_no} with {len(materials)} materials")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"batch_no": batch_no,
|
||||
"export_id": export_id,
|
||||
"file_name": excel_file_name,
|
||||
"material_count": len(materials),
|
||||
"message": f"배치 {batch_no}가 생성되었습니다"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to create export batch: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"배치 생성 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/batches")
|
||||
async def get_export_batches(
|
||||
file_id: Optional[int] = None,
|
||||
job_no: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
내보내기 배치 목록 조회
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
eeh.export_id,
|
||||
eeh.file_id,
|
||||
eeh.job_no,
|
||||
eeh.export_date,
|
||||
eeh.category,
|
||||
eeh.material_count,
|
||||
eeh.file_name,
|
||||
eeh.notes,
|
||||
u.name as exported_by,
|
||||
j.job_name,
|
||||
f.original_filename,
|
||||
-- 상태별 집계
|
||||
COUNT(DISTINCT em.material_id) as total_materials,
|
||||
COUNT(DISTINCT CASE WHEN em.purchase_status = 'pending' THEN em.material_id END) as pending_count,
|
||||
COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) as requested_count,
|
||||
COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) as ordered_count,
|
||||
COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) as received_count,
|
||||
-- 전체 상태 계산
|
||||
CASE
|
||||
WHEN COUNT(DISTINCT em.material_id) = COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END)
|
||||
THEN 'completed'
|
||||
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) > 0
|
||||
THEN 'in_progress'
|
||||
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) > 0
|
||||
THEN 'requested'
|
||||
ELSE 'pending'
|
||||
END as batch_status
|
||||
FROM excel_export_history eeh
|
||||
LEFT JOIN users u ON eeh.exported_by = u.user_id
|
||||
LEFT JOIN jobs j ON eeh.job_no = j.job_no
|
||||
LEFT JOIN files f ON eeh.file_id = f.id
|
||||
LEFT JOIN exported_materials em ON eeh.export_id = em.export_id
|
||||
WHERE eeh.export_type = 'batch'
|
||||
AND (:file_id IS NULL OR eeh.file_id = :file_id)
|
||||
AND (:job_no IS NULL OR eeh.job_no = :job_no)
|
||||
GROUP BY
|
||||
eeh.export_id, eeh.file_id, eeh.job_no, eeh.export_date,
|
||||
eeh.category, eeh.material_count, eeh.file_name, eeh.notes,
|
||||
u.name, j.job_name, f.original_filename
|
||||
HAVING (:status IS NULL OR
|
||||
CASE
|
||||
WHEN COUNT(DISTINCT em.material_id) = COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END)
|
||||
THEN 'completed'
|
||||
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) > 0
|
||||
THEN 'in_progress'
|
||||
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) > 0
|
||||
THEN 'requested'
|
||||
ELSE 'pending'
|
||||
END = :status)
|
||||
ORDER BY eeh.export_date DESC
|
||||
LIMIT :limit
|
||||
""")
|
||||
|
||||
results = db.execute(query, {
|
||||
"file_id": file_id,
|
||||
"job_no": job_no,
|
||||
"status": status,
|
||||
"limit": limit
|
||||
}).fetchall()
|
||||
|
||||
batches = []
|
||||
for row in results:
|
||||
# 배치 번호 추출 (notes에서)
|
||||
batch_no = ""
|
||||
if row.notes and "배치번호:" in row.notes:
|
||||
batch_no = row.notes.split("배치번호:")[1].strip()
|
||||
|
||||
batches.append({
|
||||
"export_id": row.export_id,
|
||||
"batch_no": batch_no,
|
||||
"file_id": row.file_id,
|
||||
"job_no": row.job_no,
|
||||
"job_name": row.job_name,
|
||||
"export_date": row.export_date.isoformat() if row.export_date else None,
|
||||
"category": row.category,
|
||||
"material_count": row.total_materials,
|
||||
"file_name": row.file_name,
|
||||
"exported_by": row.exported_by,
|
||||
"source_file": row.original_filename,
|
||||
"batch_status": row.batch_status,
|
||||
"status_detail": {
|
||||
"pending": row.pending_count,
|
||||
"requested": row.requested_count,
|
||||
"ordered": row.ordered_count,
|
||||
"received": row.received_count,
|
||||
"total": row.total_materials
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"batches": batches,
|
||||
"count": len(batches)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get export batches: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"배치 목록 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/batch/{export_id}/materials")
|
||||
async def get_batch_materials(
|
||||
export_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
배치에 포함된 자재 목록 조회
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
em.id as exported_material_id,
|
||||
em.material_id,
|
||||
m.original_description,
|
||||
m.classified_category,
|
||||
m.size_inch,
|
||||
m.schedule,
|
||||
m.material_grade,
|
||||
m.quantity,
|
||||
m.unit,
|
||||
em.purchase_status,
|
||||
em.purchase_request_no,
|
||||
em.purchase_order_no,
|
||||
em.vendor_name,
|
||||
em.expected_date,
|
||||
em.quantity_ordered,
|
||||
em.quantity_received,
|
||||
em.unit_price,
|
||||
em.total_price,
|
||||
em.notes,
|
||||
ur.requirement as user_requirement
|
||||
FROM exported_materials em
|
||||
JOIN materials m ON em.material_id = m.id
|
||||
LEFT JOIN user_requirements ur ON m.id = ur.material_id
|
||||
WHERE em.export_id = :export_id
|
||||
ORDER BY m.classified_category, m.original_description
|
||||
""")
|
||||
|
||||
results = db.execute(query, {"export_id": export_id}).fetchall()
|
||||
|
||||
materials = []
|
||||
for row in results:
|
||||
materials.append({
|
||||
"exported_material_id": row.exported_material_id,
|
||||
"material_id": row.material_id,
|
||||
"description": row.original_description,
|
||||
"category": row.classified_category,
|
||||
"size": row.size_inch,
|
||||
"schedule": row.schedule,
|
||||
"material_grade": row.material_grade,
|
||||
"quantity": row.quantity,
|
||||
"unit": row.unit,
|
||||
"user_requirement": row.user_requirement,
|
||||
"purchase_status": row.purchase_status,
|
||||
"purchase_request_no": row.purchase_request_no,
|
||||
"purchase_order_no": row.purchase_order_no,
|
||||
"vendor_name": row.vendor_name,
|
||||
"expected_date": row.expected_date.isoformat() if row.expected_date else None,
|
||||
"quantity_ordered": row.quantity_ordered,
|
||||
"quantity_received": row.quantity_received,
|
||||
"unit_price": float(row.unit_price) if row.unit_price else None,
|
||||
"total_price": float(row.total_price) if row.total_price else None,
|
||||
"notes": row.notes
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"materials": materials,
|
||||
"count": len(materials)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get batch materials: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"배치 자재 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/batch/{export_id}/download")
|
||||
async def download_batch_excel(
|
||||
export_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
저장된 배치 엑셀 파일 다운로드
|
||||
"""
|
||||
try:
|
||||
# 배치 정보 조회
|
||||
query = text("""
|
||||
SELECT file_name, notes
|
||||
FROM excel_export_history
|
||||
WHERE export_id = :export_id
|
||||
""")
|
||||
result = db.execute(query, {"export_id": export_id}).fetchone()
|
||||
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="배치를 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
file_path = os.path.join(EXPORT_DIR, result.file_name)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
# 파일이 없으면 재생성
|
||||
materials = await get_batch_materials(export_id, current_user, db)
|
||||
|
||||
batch_no = ""
|
||||
if result.notes and "배치번호:" in result.notes:
|
||||
batch_no = result.notes.split("배치번호:")[1].strip()
|
||||
|
||||
batch_info = {
|
||||
"batch_no": batch_no,
|
||||
"export_date": datetime.now().strftime('%Y-%m-%d %H:%M')
|
||||
}
|
||||
|
||||
file_name = create_excel_from_materials(materials["materials"], batch_info)
|
||||
file_path = os.path.join(EXPORT_DIR, file_name)
|
||||
|
||||
# DB 업데이트
|
||||
update_query = text("""
|
||||
UPDATE excel_export_history
|
||||
SET file_name = :file_name
|
||||
WHERE export_id = :export_id
|
||||
""")
|
||||
db.execute(update_query, {
|
||||
"file_name": file_name,
|
||||
"export_id": export_id
|
||||
})
|
||||
db.commit()
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=result.file_name,
|
||||
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download batch excel: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"엑셀 다운로드 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/batch/{export_id}/status")
|
||||
async def update_batch_status(
|
||||
export_id: int,
|
||||
status: str,
|
||||
purchase_request_no: Optional[str] = None,
|
||||
purchase_order_no: Optional[str] = None,
|
||||
vendor_name: Optional[str] = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
배치 전체 상태 일괄 업데이트
|
||||
"""
|
||||
try:
|
||||
# 배치의 모든 자재 상태 업데이트
|
||||
update_query = text("""
|
||||
UPDATE exported_materials
|
||||
SET
|
||||
purchase_status = :status,
|
||||
purchase_request_no = COALESCE(:pr_no, purchase_request_no),
|
||||
purchase_order_no = COALESCE(:po_no, purchase_order_no),
|
||||
vendor_name = COALESCE(:vendor, vendor_name),
|
||||
updated_by = :updated_by,
|
||||
requested_date = CASE WHEN :status = 'requested' THEN CURRENT_TIMESTAMP ELSE requested_date END,
|
||||
ordered_date = CASE WHEN :status = 'ordered' THEN CURRENT_TIMESTAMP ELSE ordered_date END,
|
||||
received_date = CASE WHEN :status = 'received' THEN CURRENT_TIMESTAMP ELSE received_date END
|
||||
WHERE export_id = :export_id
|
||||
""")
|
||||
|
||||
result = db.execute(update_query, {
|
||||
"export_id": export_id,
|
||||
"status": status,
|
||||
"pr_no": purchase_request_no,
|
||||
"po_no": purchase_order_no,
|
||||
"vendor": vendor_name,
|
||||
"updated_by": current_user.get("user_id")
|
||||
})
|
||||
|
||||
# 이력 기록
|
||||
history_query = text("""
|
||||
INSERT INTO purchase_status_history (
|
||||
exported_material_id, material_id,
|
||||
previous_status, new_status,
|
||||
changed_by, reason
|
||||
)
|
||||
SELECT
|
||||
em.id, em.material_id,
|
||||
em.purchase_status, :new_status,
|
||||
:changed_by, :reason
|
||||
FROM exported_materials em
|
||||
WHERE em.export_id = :export_id
|
||||
""")
|
||||
|
||||
db.execute(history_query, {
|
||||
"export_id": export_id,
|
||||
"new_status": status,
|
||||
"changed_by": current_user.get("user_id"),
|
||||
"reason": f"배치 일괄 업데이트"
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Batch {export_id} status updated to {status}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"배치의 모든 자재가 {status} 상태로 변경되었습니다",
|
||||
"updated_count": result.rowcount
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to update batch status: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"배치 상태 업데이트 실패: {str(e)}"
|
||||
)
|
||||
@@ -218,8 +218,7 @@ 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():
|
||||
@@ -263,34 +262,16 @@ 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
|
||||
})
|
||||
@@ -533,67 +514,72 @@ async def upload_file(
|
||||
})
|
||||
materials_inserted += 1
|
||||
|
||||
# 리비전 업로드의 경우 변경사항 추적
|
||||
changed_materials_keys = set()
|
||||
new_materials_keys = set()
|
||||
# 리비전 업로드인 경우 차이분 계산
|
||||
materials_diff = []
|
||||
original_materials_to_classify = materials_to_classify.copy() # 원본 보존
|
||||
|
||||
if parent_file_id is not None:
|
||||
print(f"🔄 리비전 업로드: 전체 {len(materials_to_classify)}개 자재 저장 (변경사항 추적 포함)")
|
||||
|
||||
# 이전 리비전의 자재 조회 (도면번호 기준)
|
||||
prev_materials_query = text("""
|
||||
SELECT original_description, size_spec, material_grade, main_nom,
|
||||
drawing_name, line_no, quantity
|
||||
FROM materials
|
||||
WHERE file_id = :parent_file_id
|
||||
""")
|
||||
prev_materials_result = db.execute(prev_materials_query, {"parent_file_id": parent_file_id}).fetchall()
|
||||
|
||||
# 이전 자재를 딕셔너리로 변환 (도면번호 + 설명 + 크기 + 재질로 키 생성)
|
||||
prev_materials_dict = {}
|
||||
for prev_mat in prev_materials_result:
|
||||
if prev_mat.drawing_name:
|
||||
key = f"{prev_mat.drawing_name}|{prev_mat.original_description}|{prev_mat.size_spec or ''}|{prev_mat.material_grade or ''}"
|
||||
elif prev_mat.line_no:
|
||||
key = f"{prev_mat.line_no}|{prev_mat.original_description}|{prev_mat.size_spec or ''}|{prev_mat.material_grade or ''}"
|
||||
# 새 파일의 자재들을 수량별로 그룹화
|
||||
new_materials_grouped = {}
|
||||
for material_data in original_materials_to_classify:
|
||||
description = material_data["original_description"]
|
||||
size_spec = material_data["size_spec"]
|
||||
quantity = float(material_data.get("quantity", 0))
|
||||
|
||||
material_key = f"{description}|{size_spec or ''}"
|
||||
if material_key in new_materials_grouped:
|
||||
new_materials_grouped[material_key]["quantity"] += quantity
|
||||
new_materials_grouped[material_key]["items"].append(material_data)
|
||||
else:
|
||||
key = f"{prev_mat.original_description}|{prev_mat.size_spec or ''}|{prev_mat.material_grade or ''}"
|
||||
|
||||
prev_materials_dict[key] = {
|
||||
"quantity": float(prev_mat.quantity) if prev_mat.quantity else 0,
|
||||
"description": prev_mat.original_description
|
||||
}
|
||||
new_materials_grouped[material_key] = {
|
||||
"quantity": quantity,
|
||||
"items": [material_data],
|
||||
"description": description,
|
||||
"size_spec": size_spec
|
||||
}
|
||||
|
||||
# 새 자재와 비교하여 변경사항 감지
|
||||
for material_data in materials_to_classify:
|
||||
desc = material_data["original_description"]
|
||||
size_spec = material_data.get("size_spec", "")
|
||||
material_grade = material_data.get("material_grade", "")
|
||||
dwg_name = material_data.get("dwg_name")
|
||||
line_num = material_data.get("line_num")
|
||||
# 차이분 계산
|
||||
print(f"🔍 차이분 계산 시작: 신규 {len(new_materials_grouped)}개 vs 기존 {len(existing_materials_with_quantity)}개")
|
||||
|
||||
for material_key, new_data in new_materials_grouped.items():
|
||||
existing_quantity = existing_materials_with_quantity.get(material_key, 0)
|
||||
new_quantity = new_data["quantity"]
|
||||
|
||||
if dwg_name:
|
||||
new_key = f"{dwg_name}|{desc}|{size_spec}|{material_grade}"
|
||||
elif line_num:
|
||||
new_key = f"{line_num}|{desc}|{size_spec}|{material_grade}"
|
||||
else:
|
||||
new_key = f"{desc}|{size_spec}|{material_grade}"
|
||||
# 디버깅: 첫 10개 키 매칭 상태 출력
|
||||
if len(materials_diff) < 10:
|
||||
print(f"🔑 키 매칭: '{material_key}' → 기존:{existing_quantity}, 신규:{new_quantity}")
|
||||
|
||||
if new_key in prev_materials_dict:
|
||||
# 기존에 있던 자재 - 수량 변경 확인
|
||||
prev_qty = prev_materials_dict[new_key]["quantity"]
|
||||
new_qty = float(material_data.get("quantity", 0))
|
||||
if new_quantity > existing_quantity:
|
||||
# 증가분이 있는 경우
|
||||
diff_quantity = new_quantity - existing_quantity
|
||||
print(f"차이분 발견: {new_data['description'][:50]}... (증가: {diff_quantity})")
|
||||
|
||||
if abs(prev_qty - new_qty) > 0.001:
|
||||
# 수량이 변경됨
|
||||
changed_materials_keys.add(new_key)
|
||||
print(f"🔄 변경 감지: {desc[:40]}... (수량: {prev_qty} → {new_qty})")
|
||||
# 증가분만큼 자재 데이터 생성
|
||||
for item in new_data["items"]:
|
||||
if diff_quantity <= 0:
|
||||
break
|
||||
|
||||
item_quantity = float(item.get("quantity", 0))
|
||||
if item_quantity <= diff_quantity:
|
||||
# 이 아이템 전체를 포함
|
||||
materials_diff.append(item)
|
||||
diff_quantity -= item_quantity
|
||||
new_materials_count += 1
|
||||
else:
|
||||
# 이 아이템의 일부만 포함
|
||||
item_copy = item.copy()
|
||||
item_copy["quantity"] = diff_quantity
|
||||
materials_diff.append(item_copy)
|
||||
new_materials_count += 1
|
||||
break
|
||||
else:
|
||||
# 새로 추가된 자재
|
||||
new_materials_keys.add(new_key)
|
||||
print(f"➕ 신규 감지: {desc[:40]}...")
|
||||
print(f"수량 감소/동일: {new_data['description'][:50]}... (기존:{existing_quantity} 새:{new_quantity})")
|
||||
|
||||
print(f"📊 변경사항 요약: 변경 {len(changed_materials_keys)}개, 신규 {len(new_materials_keys)}개")
|
||||
# 차이분만 처리하도록 materials_to_classify 교체
|
||||
materials_to_classify = materials_diff
|
||||
print(f"차이분 자재 개수: {len(materials_to_classify)}")
|
||||
print(f"🔄 리비전 업로드: 차이분 {len(materials_diff)}개 라인 아이템 분류 처리")
|
||||
print(f"📊 차이분 요약: {len(new_materials_grouped)}개 자재 그룹 → {len(materials_diff)}개 라인 아이템")
|
||||
else:
|
||||
print(f"🆕 신규 업로드: 전체 {len(materials_to_classify)}개 분류 처리")
|
||||
|
||||
@@ -665,18 +651,6 @@ 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 = {
|
||||
@@ -705,14 +679,12 @@ 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,
|
||||
drawing_name, line_no, created_at
|
||||
classified_category, classification_confidence, is_verified, 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,
|
||||
:drawing_name, :line_no, :created_at
|
||||
:classified_category, :classification_confidence, :is_verified, :created_at
|
||||
)
|
||||
RETURNING id
|
||||
""")
|
||||
@@ -723,8 +695,6 @@ async def upload_file(
|
||||
print(f" size_spec: '{material_data['size_spec']}'")
|
||||
print(f" original_description: {material_data['original_description']}")
|
||||
print(f" category: {classification_result.get('category', 'UNKNOWN')}")
|
||||
print(f" drawing_name: {material_data.get('dwg_name')}")
|
||||
print(f" line_no: {material_data.get('line_num')}")
|
||||
|
||||
material_result = db.execute(material_insert_query, {
|
||||
"file_id": file_id,
|
||||
@@ -732,8 +702,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"],
|
||||
@@ -741,45 +711,13 @@ 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()
|
||||
})
|
||||
|
||||
material_id = material_result.fetchone()[0]
|
||||
materials_inserted += 1
|
||||
|
||||
# 리비전 업로드인 경우 변경/신규 자재 표시 및 구매신청 정보 상속
|
||||
if parent_file_id is not None:
|
||||
# 현재 자재의 키 생성
|
||||
dwg_name = material_data.get("dwg_name")
|
||||
line_num = material_data.get("line_num")
|
||||
|
||||
if dwg_name:
|
||||
current_key = f"{dwg_name}|{description}|{size_spec}|{material_data.get('material_grade', '')}"
|
||||
elif line_num:
|
||||
current_key = f"{line_num}|{description}|{size_spec}|{material_data.get('material_grade', '')}"
|
||||
else:
|
||||
current_key = f"{description}|{size_spec}|{material_data.get('material_grade', '')}"
|
||||
|
||||
# 변경 또는 신규 자재에 상태 표시
|
||||
if current_key in changed_materials_keys:
|
||||
# 변경된 자재
|
||||
update_status_query = text("""
|
||||
UPDATE materials SET revision_status = 'changed'
|
||||
WHERE id = :material_id
|
||||
""")
|
||||
db.execute(update_status_query, {"material_id": material_id})
|
||||
elif current_key in new_materials_keys:
|
||||
# 신규 자재 (추가됨)
|
||||
update_status_query = text("""
|
||||
UPDATE materials SET revision_status = 'active'
|
||||
WHERE id = :material_id
|
||||
""")
|
||||
db.execute(update_status_query, {"material_id": material_id})
|
||||
|
||||
# 구매신청 정보 상속은 전체 자재 저장 후 일괄 처리하도록 변경
|
||||
# (개별 처리는 비효율적이고 수량 계산이 복잡함)
|
||||
# 리비전 업로드인 경우 신규 자재 카운트는 이미 위에서 처리됨
|
||||
|
||||
# PIPE 분류 결과인 경우 상세 정보 저장
|
||||
if classification_result.get("category") == "PIPE":
|
||||
@@ -1459,83 +1397,6 @@ async def upload_file(
|
||||
db.commit()
|
||||
print(f"자재 저장 완료: {materials_inserted}개")
|
||||
|
||||
# 리비전 업로드인 경우 구매신청 정보 수량 기반 상속
|
||||
if parent_file_id is not None:
|
||||
try:
|
||||
print(f"🔄 구매신청 정보 상속 처리 시작...")
|
||||
|
||||
# 1. 이전 리비전에서 그룹별 구매신청 수량 집계
|
||||
prev_purchase_summary = text("""
|
||||
SELECT
|
||||
m.original_description,
|
||||
m.size_spec,
|
||||
m.material_grade,
|
||||
m.drawing_name,
|
||||
COUNT(DISTINCT pri.material_id) as purchased_count,
|
||||
SUM(pri.quantity) as total_purchased_qty,
|
||||
MIN(pri.request_id) as request_id
|
||||
FROM materials m
|
||||
JOIN purchase_request_items pri ON m.id = pri.material_id
|
||||
WHERE m.file_id = :parent_file_id
|
||||
GROUP BY m.original_description, m.size_spec, m.material_grade, m.drawing_name
|
||||
""")
|
||||
|
||||
prev_purchases = db.execute(prev_purchase_summary, {"parent_file_id": parent_file_id}).fetchall()
|
||||
|
||||
# 2. 새 리비전에서 같은 그룹의 자재에 수량만큼 구매신청 상속
|
||||
for prev_purchase in prev_purchases:
|
||||
purchased_count = prev_purchase.purchased_count
|
||||
|
||||
# 새 리비전에서 같은 그룹의 자재 조회 (순서대로)
|
||||
new_group_materials = text("""
|
||||
SELECT id, quantity
|
||||
FROM materials
|
||||
WHERE file_id = :file_id
|
||||
AND original_description = :description
|
||||
AND COALESCE(size_spec, '') = :size_spec
|
||||
AND COALESCE(material_grade, '') = :material_grade
|
||||
AND COALESCE(drawing_name, '') = :drawing_name
|
||||
ORDER BY id
|
||||
LIMIT :limit
|
||||
""")
|
||||
|
||||
new_materials = db.execute(new_group_materials, {
|
||||
"file_id": file_id,
|
||||
"description": prev_purchase.original_description,
|
||||
"size_spec": prev_purchase.size_spec or '',
|
||||
"material_grade": prev_purchase.material_grade or '',
|
||||
"drawing_name": prev_purchase.drawing_name or '',
|
||||
"limit": purchased_count
|
||||
}).fetchall()
|
||||
|
||||
# 구매신청 수량만큼만 상속
|
||||
for new_mat in new_materials:
|
||||
inherit_query = text("""
|
||||
INSERT INTO purchase_request_items (
|
||||
request_id, material_id, quantity, unit, user_requirement
|
||||
) VALUES (
|
||||
:request_id, :material_id, :quantity, 'EA', ''
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
""")
|
||||
db.execute(inherit_query, {
|
||||
"request_id": prev_purchase.request_id,
|
||||
"material_id": new_mat.id,
|
||||
"quantity": new_mat.quantity
|
||||
})
|
||||
|
||||
inherited_count = len(new_materials)
|
||||
if inherited_count > 0:
|
||||
print(f" ✅ {prev_purchase.original_description[:30]}... (도면: {prev_purchase.drawing_name or 'N/A'}) → {inherited_count}/{purchased_count}개 상속")
|
||||
|
||||
db.commit()
|
||||
print(f"✅ 구매신청 정보 상속 완료")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 구매신청 정보 상속 실패: {str(e)}")
|
||||
db.rollback()
|
||||
# 상속 실패는 업로드 성공에 영향 없음
|
||||
|
||||
# 활동 로그 기록
|
||||
try:
|
||||
activity_logger = ActivityLogger(db)
|
||||
@@ -1555,136 +1416,25 @@ async def upload_file(
|
||||
print(f"활동 로그 기록 실패: {str(e)}")
|
||||
# 로그 실패는 업로드 성공에 영향을 주지 않음
|
||||
|
||||
# 리비전 업로드인 경우 누락된 도면 감지 및 구매신청 여부 확인
|
||||
missing_drawings_info = None
|
||||
has_previous_purchase = False
|
||||
|
||||
if parent_file_id is not None:
|
||||
try:
|
||||
# 이전 리비전의 구매신청 여부 확인
|
||||
purchase_check_query = text("""
|
||||
SELECT COUNT(*) as purchase_count
|
||||
FROM purchase_request_items pri
|
||||
JOIN materials m ON pri.material_id = m.id
|
||||
WHERE m.file_id = :parent_file_id
|
||||
""")
|
||||
purchase_result = db.execute(purchase_check_query, {"parent_file_id": parent_file_id}).fetchone()
|
||||
has_previous_purchase = purchase_result.purchase_count > 0
|
||||
|
||||
print(f"📦 이전 리비전 구매신청 여부: {has_previous_purchase} ({purchase_result.purchase_count}개 자재)")
|
||||
print(f"📂 parent_file_id: {parent_file_id}, new file_id: {file_id}")
|
||||
|
||||
# 이전 리비전의 도면 목록 조회
|
||||
prev_drawings_query = text("""
|
||||
SELECT DISTINCT drawing_name, line_no, COUNT(*) as material_count
|
||||
FROM materials
|
||||
WHERE file_id = :parent_file_id
|
||||
AND (drawing_name IS NOT NULL OR line_no IS NOT NULL)
|
||||
GROUP BY drawing_name, line_no
|
||||
""")
|
||||
prev_drawings_result = db.execute(prev_drawings_query, {"parent_file_id": parent_file_id}).fetchall()
|
||||
print(f"📋 이전 리비전 도면 수: {len(prev_drawings_result)}")
|
||||
|
||||
# 새 리비전의 도면 목록 조회
|
||||
new_drawings_query = text("""
|
||||
SELECT DISTINCT drawing_name, line_no
|
||||
FROM materials
|
||||
WHERE file_id = :file_id
|
||||
AND (drawing_name IS NOT NULL OR line_no IS NOT NULL)
|
||||
""")
|
||||
new_drawings_result = db.execute(new_drawings_query, {"file_id": file_id}).fetchall()
|
||||
print(f"📋 새 리비전 도면 수: {len(new_drawings_result)}")
|
||||
|
||||
prev_drawings = set()
|
||||
for row in prev_drawings_result:
|
||||
if row.drawing_name:
|
||||
prev_drawings.add(row.drawing_name)
|
||||
elif row.line_no:
|
||||
prev_drawings.add(row.line_no)
|
||||
|
||||
new_drawings = set()
|
||||
for row in new_drawings_result:
|
||||
if row.drawing_name:
|
||||
new_drawings.add(row.drawing_name)
|
||||
elif row.line_no:
|
||||
new_drawings.add(row.line_no)
|
||||
|
||||
missing_drawings = prev_drawings - new_drawings
|
||||
|
||||
print(f"📊 이전 도면: {list(prev_drawings)[:5]}")
|
||||
print(f"📊 새 도면: {list(new_drawings)[:5]}")
|
||||
print(f"❌ 누락 도면: {len(missing_drawings)}개")
|
||||
|
||||
if missing_drawings:
|
||||
# 누락된 도면의 자재 상세 정보
|
||||
missing_materials = []
|
||||
for row in prev_drawings_result:
|
||||
drawing = row.drawing_name or row.line_no
|
||||
if drawing in missing_drawings:
|
||||
missing_materials.append({
|
||||
"drawing_name": drawing,
|
||||
"material_count": row.material_count
|
||||
})
|
||||
|
||||
missing_drawings_info = {
|
||||
"drawings": list(missing_drawings),
|
||||
"materials": missing_materials,
|
||||
"count": len(missing_drawings),
|
||||
"requires_confirmation": True,
|
||||
"has_previous_purchase": has_previous_purchase,
|
||||
"action": "mark_as_inventory" if has_previous_purchase else "hide_materials"
|
||||
}
|
||||
|
||||
# 누락된 도면의 자재 상태 업데이트
|
||||
if missing_drawings:
|
||||
for drawing in missing_drawings:
|
||||
status_to_set = 'inventory' if has_previous_purchase else 'deleted_not_purchased'
|
||||
|
||||
update_status_query = text("""
|
||||
UPDATE materials
|
||||
SET revision_status = :status
|
||||
WHERE file_id = :parent_file_id
|
||||
AND (drawing_name = :drawing OR line_no = :drawing)
|
||||
""")
|
||||
|
||||
db.execute(update_status_query, {
|
||||
"status": status_to_set,
|
||||
"parent_file_id": parent_file_id,
|
||||
"drawing": drawing
|
||||
})
|
||||
|
||||
db.commit()
|
||||
print(f"✅ 누락 도면 자재 상태 업데이트: {len(missing_drawings)}개 도면 → {status_to_set}")
|
||||
except Exception as e:
|
||||
print(f"누락 도면 감지 실패: {str(e)}")
|
||||
db.rollback()
|
||||
# 감지 실패는 업로드 성공에 영향 없음
|
||||
|
||||
# 리비전 업로드인 경우 메시지 다르게 표시
|
||||
if parent_file_id is not None:
|
||||
message = f"리비전 업로드 성공! {new_materials_count}개의 신규 자재가 추가되었습니다."
|
||||
else:
|
||||
message = f"업로드 성공! {materials_inserted}개 자재가 분류되었습니다."
|
||||
|
||||
response_data = {
|
||||
return {
|
||||
"success": True,
|
||||
"message": message,
|
||||
"original_filename": file.filename,
|
||||
"file_id": file_id,
|
||||
"materials_count": materials_inserted,
|
||||
"saved_materials_count": materials_inserted,
|
||||
"new_materials_count": new_materials_count if parent_file_id is not None else None,
|
||||
"revision": revision,
|
||||
"uploaded_by": username,
|
||||
"new_materials_count": new_materials_count if parent_file_id is not None else None, # 신규 자재 수
|
||||
"revision": revision, # 생성된 리비전 정보 추가
|
||||
"uploaded_by": username, # 업로드한 사용자 정보 추가
|
||||
"parsed_count": parsed_count
|
||||
}
|
||||
|
||||
# 누락된 도면 정보 추가
|
||||
if missing_drawings_info:
|
||||
response_data["missing_drawings"] = missing_drawings_info
|
||||
|
||||
return response_data
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
if os.path.exists(file_path):
|
||||
@@ -1815,8 +1565,6 @@ 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)
|
||||
):
|
||||
"""
|
||||
@@ -1827,7 +1575,6 @@ 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.revision_status,
|
||||
m.created_at, m.classified_category, m.classification_confidence,
|
||||
m.classification_details,
|
||||
m.is_verified, m.verified_by, m.verified_at,
|
||||
@@ -1865,8 +1612,6 @@ 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
|
||||
@@ -1880,11 +1625,6 @@ 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
|
||||
@@ -2029,14 +1769,11 @@ 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,
|
||||
"drawing_name": m.drawing_name,
|
||||
"line_no": m.line_no,
|
||||
"revision_status": m.revision_status or 'active',
|
||||
"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, # 전체 재질명
|
||||
"line_number": m.line_number,
|
||||
"row_number": m.row_number,
|
||||
# 구매수량 계산에서 분류된 정보를 우선 사용
|
||||
@@ -2356,20 +2093,6 @@ 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)
|
||||
|
||||
@@ -2574,8 +2297,7 @@ async def compare_revisions(
|
||||
# 기존 리비전 자재 조회
|
||||
old_materials_query = text("""
|
||||
SELECT m.original_description, m.quantity, m.unit, m.size_spec,
|
||||
m.material_grade, m.classified_category, m.classification_confidence,
|
||||
m.main_nom, m.red_nom, m.drawing_name, m.line_no
|
||||
m.material_grade, m.classified_category, m.classification_confidence
|
||||
FROM materials m
|
||||
JOIN files f ON m.file_id = f.id
|
||||
WHERE f.job_no = :job_no
|
||||
@@ -2593,8 +2315,7 @@ async def compare_revisions(
|
||||
# 새 리비전 자재 조회
|
||||
new_materials_query = text("""
|
||||
SELECT m.original_description, m.quantity, m.unit, m.size_spec,
|
||||
m.material_grade, m.classified_category, m.classification_confidence,
|
||||
m.main_nom, m.red_nom, m.drawing_name, m.line_no
|
||||
m.material_grade, m.classified_category, m.classification_confidence
|
||||
FROM materials m
|
||||
JOIN files f ON m.file_id = f.id
|
||||
WHERE f.job_no = :job_no
|
||||
@@ -2609,17 +2330,9 @@ async def compare_revisions(
|
||||
})
|
||||
new_materials = new_result.fetchall()
|
||||
|
||||
# 자재 키 생성 함수 (도면번호 + 자재 정보)
|
||||
# 자재 키 생성 함수 (전체 수량 기준)
|
||||
def create_material_key(material):
|
||||
# 도면번호가 있으면 도면번호 + 자재 설명으로 고유 키 생성
|
||||
# (같은 도면에 여러 자재가 있을 수 있으므로)
|
||||
if hasattr(material, 'drawing_name') and material.drawing_name:
|
||||
return f"{material.drawing_name}|{material.original_description}|{material.size_spec or ''}|{material.material_grade or ''}"
|
||||
elif hasattr(material, 'line_no') and material.line_no:
|
||||
return f"{material.line_no}|{material.original_description}|{material.size_spec or ''}|{material.material_grade or ''}"
|
||||
else:
|
||||
# 도면번호 없으면 기존 방식 (설명 + 크기 + 재질)
|
||||
return f"{material.original_description}|{material.size_spec or ''}|{material.material_grade or ''}"
|
||||
return f"{material.original_description}|{material.size_spec or ''}|{material.material_grade or ''}"
|
||||
|
||||
# 기존 자재를 딕셔너리로 변환 (수량 합산)
|
||||
old_materials_dict = {}
|
||||
@@ -2636,11 +2349,7 @@ async def compare_revisions(
|
||||
"size_spec": material.size_spec,
|
||||
"material_grade": material.material_grade,
|
||||
"classified_category": material.classified_category,
|
||||
"classification_confidence": material.classification_confidence,
|
||||
"main_nom": material.main_nom,
|
||||
"red_nom": material.red_nom,
|
||||
"drawing_name": material.drawing_name,
|
||||
"line_no": material.line_no
|
||||
"classification_confidence": material.classification_confidence
|
||||
}
|
||||
|
||||
# 새 자재를 딕셔너리로 변환 (수량 합산)
|
||||
@@ -2658,11 +2367,7 @@ async def compare_revisions(
|
||||
"size_spec": material.size_spec,
|
||||
"material_grade": material.material_grade,
|
||||
"classified_category": material.classified_category,
|
||||
"classification_confidence": material.classification_confidence,
|
||||
"main_nom": material.main_nom,
|
||||
"red_nom": material.red_nom,
|
||||
"drawing_name": material.drawing_name,
|
||||
"line_no": material.line_no
|
||||
"classification_confidence": material.classification_confidence
|
||||
}
|
||||
|
||||
# 변경 사항 분석
|
||||
@@ -2691,68 +2396,22 @@ async def compare_revisions(
|
||||
"change_type": "added"
|
||||
})
|
||||
elif old_item and new_item:
|
||||
# 변경 사항 감지: 수량, 재질, 크기, 카테고리 등
|
||||
changes_detected = []
|
||||
|
||||
# 수량 변경 확인 (전체 수량 기준)
|
||||
old_qty = old_item["quantity"]
|
||||
new_qty = new_item["quantity"]
|
||||
qty_diff = new_qty - old_qty
|
||||
|
||||
# 1. 수량 변경 확인
|
||||
# 수량 차이가 있으면 변경된 것으로 간주 (소수점 오차 고려)
|
||||
if abs(qty_diff) > 0.001:
|
||||
changes_detected.append({
|
||||
"type": "quantity",
|
||||
"old_value": old_qty,
|
||||
"new_value": new_qty,
|
||||
"diff": qty_diff
|
||||
})
|
||||
|
||||
# 2. 재질 변경 확인
|
||||
if old_item.get("material_grade") != new_item.get("material_grade"):
|
||||
changes_detected.append({
|
||||
"type": "material",
|
||||
"old_value": old_item.get("material_grade", "-"),
|
||||
"new_value": new_item.get("material_grade", "-")
|
||||
})
|
||||
|
||||
# 3. 크기 변경 확인
|
||||
if old_item.get("main_nom") != new_item.get("main_nom") or old_item.get("size_spec") != new_item.get("size_spec"):
|
||||
changes_detected.append({
|
||||
"type": "size",
|
||||
"old_value": old_item.get("main_nom") or old_item.get("size_spec", "-"),
|
||||
"new_value": new_item.get("main_nom") or new_item.get("size_spec", "-")
|
||||
})
|
||||
|
||||
# 4. 카테고리 변경 확인 (자재 종류가 바뀜)
|
||||
if old_item.get("classified_category") != new_item.get("classified_category"):
|
||||
changes_detected.append({
|
||||
"type": "category",
|
||||
"old_value": old_item.get("classified_category", "-"),
|
||||
"new_value": new_item.get("classified_category", "-")
|
||||
})
|
||||
|
||||
# 변경사항이 있으면 changed_items에 추가
|
||||
if changes_detected:
|
||||
# 변경 유형 결정
|
||||
has_qty_change = any(c["type"] == "quantity" for c in changes_detected)
|
||||
has_spec_change = any(c["type"] in ["material", "size", "category"] for c in changes_detected)
|
||||
|
||||
if has_spec_change:
|
||||
change_type = "specification_changed" # 자재 사양 변경
|
||||
elif has_qty_change:
|
||||
change_type = "quantity_changed" # 수량만 변경
|
||||
else:
|
||||
change_type = "modified"
|
||||
|
||||
change_type = "quantity_increased" if qty_diff > 0 else "quantity_decreased"
|
||||
changed_items.append({
|
||||
"key": key,
|
||||
"old_item": old_item,
|
||||
"new_item": new_item,
|
||||
"changes": changes_detected,
|
||||
"quantity_change": qty_diff,
|
||||
"quantity_change_abs": abs(qty_diff),
|
||||
"change_type": change_type,
|
||||
"quantity_change": qty_diff if has_qty_change else 0,
|
||||
"drawing_name": new_item.get("drawing_name") or old_item.get("drawing_name"),
|
||||
"line_no": new_item.get("line_no") or old_item.get("line_no")
|
||||
"change_percentage": (qty_diff / old_qty * 100) if old_qty > 0 else 0
|
||||
})
|
||||
|
||||
# 분류별 통계
|
||||
@@ -2770,40 +2429,6 @@ async def compare_revisions(
|
||||
removed_stats = calculate_category_stats(removed_items)
|
||||
changed_stats = calculate_category_stats(changed_items)
|
||||
|
||||
# 누락된 도면 감지
|
||||
old_drawings = set()
|
||||
new_drawings = set()
|
||||
|
||||
for material in old_materials:
|
||||
if hasattr(material, 'drawing_name') and material.drawing_name:
|
||||
old_drawings.add(material.drawing_name)
|
||||
elif hasattr(material, 'line_no') and material.line_no:
|
||||
old_drawings.add(material.line_no)
|
||||
|
||||
for material in new_materials:
|
||||
if hasattr(material, 'drawing_name') and material.drawing_name:
|
||||
new_drawings.add(material.drawing_name)
|
||||
elif hasattr(material, 'line_no') and material.line_no:
|
||||
new_drawings.add(material.line_no)
|
||||
|
||||
missing_drawings = old_drawings - new_drawings # 이전에는 있었는데 새 파일에 없는 도면
|
||||
new_only_drawings = new_drawings - old_drawings # 새로 추가된 도면
|
||||
|
||||
# 누락된 도면의 자재 목록
|
||||
missing_drawing_materials = []
|
||||
if missing_drawings:
|
||||
for material in old_materials:
|
||||
drawing = getattr(material, 'drawing_name', None) or getattr(material, 'line_no', None)
|
||||
if drawing in missing_drawings:
|
||||
missing_drawing_materials.append({
|
||||
"drawing_name": drawing,
|
||||
"description": material.original_description,
|
||||
"quantity": float(material.quantity) if material.quantity else 0,
|
||||
"category": material.classified_category,
|
||||
"size": material.main_nom or material.size_spec,
|
||||
"material_grade": material.material_grade
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"comparison": {
|
||||
@@ -2815,15 +2440,7 @@ async def compare_revisions(
|
||||
"added_count": len(added_items),
|
||||
"removed_count": len(removed_items),
|
||||
"changed_count": len(changed_items),
|
||||
"total_changes": len(added_items) + len(removed_items) + len(changed_items),
|
||||
"missing_drawings_count": len(missing_drawings),
|
||||
"new_drawings_count": len(new_only_drawings)
|
||||
},
|
||||
"missing_drawings": {
|
||||
"drawings": list(missing_drawings),
|
||||
"materials": missing_drawing_materials,
|
||||
"count": len(missing_drawings),
|
||||
"requires_confirmation": len(missing_drawings) > 0
|
||||
"total_changes": len(added_items) + len(removed_items) + len(changed_items)
|
||||
},
|
||||
"changes": {
|
||||
"added": added_items,
|
||||
@@ -3613,91 +3230,4 @@ async def log_materials_view(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"조회 로그 기록 실패: {str(e)}")
|
||||
return {"message": "조회 로그 기록 실패"}
|
||||
|
||||
|
||||
@router.post("/{file_id}/process-missing-drawings")
|
||||
async def process_missing_drawings(
|
||||
file_id: int,
|
||||
action: str = "delete",
|
||||
drawings: List[str] = [],
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
누락된 도면 처리
|
||||
- action='delete': 누락된 도면의 이전 리비전 자재 삭제 처리
|
||||
- action='keep': 누락된 도면의 이전 리비전 자재 유지
|
||||
"""
|
||||
try:
|
||||
# 현재 파일 정보 확인
|
||||
from pydantic import BaseModel
|
||||
|
||||
class MissingDrawingsRequest(BaseModel):
|
||||
action: str
|
||||
drawings: List[str]
|
||||
|
||||
# parent_file_id 조회는 files 테이블에 없을 수 있으므로 revision으로 판단
|
||||
file_query = text("""
|
||||
SELECT f.id, f.job_no, f.revision, f.original_filename
|
||||
FROM files f
|
||||
WHERE f.id = :file_id
|
||||
""")
|
||||
file_result = db.execute(file_query, {"file_id": file_id}).fetchone()
|
||||
|
||||
if not file_result:
|
||||
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
||||
|
||||
# 이전 리비전 파일 찾기
|
||||
prev_revision_query = text("""
|
||||
SELECT id FROM files
|
||||
WHERE job_no = :job_no
|
||||
AND original_filename = :filename
|
||||
AND revision < :current_revision
|
||||
ORDER BY revision DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
prev_result = db.execute(prev_revision_query, {
|
||||
"job_no": file_result.job_no,
|
||||
"filename": file_result.original_filename,
|
||||
"current_revision": file_result.revision
|
||||
}).fetchone()
|
||||
|
||||
if not prev_result:
|
||||
raise HTTPException(status_code=400, detail="이전 리비전을 찾을 수 없습니다")
|
||||
|
||||
parent_file_id = prev_result.id
|
||||
|
||||
if action == "delete":
|
||||
# 누락된 도면의 자재를 deleted_not_purchased 상태로 변경
|
||||
for drawing in drawings:
|
||||
update_query = text("""
|
||||
UPDATE materials
|
||||
SET revision_status = 'deleted_not_purchased'
|
||||
WHERE file_id = :parent_file_id
|
||||
AND (drawing_name = :drawing OR line_no = :drawing)
|
||||
""")
|
||||
db.execute(update_query, {
|
||||
"parent_file_id": parent_file_id,
|
||||
"drawing": drawing
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"{len(drawings)}개 도면의 자재가 삭제 처리되었습니다",
|
||||
"action": "deleted",
|
||||
"drawings_count": len(drawings)
|
||||
}
|
||||
else:
|
||||
# keep - 이미 처리됨 (inventory 또는 active 상태 유지)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "누락된 도면의 자재가 유지됩니다",
|
||||
"action": "kept",
|
||||
"drawings_count": len(drawings)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"도면 처리 실패: {str(e)}")
|
||||
return {"message": "조회 로그 기록 실패"}
|
||||
@@ -1,577 +0,0 @@
|
||||
"""
|
||||
구매신청 관리 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"
|
||||
excel_filename = f"{request_no}.xlsx"
|
||||
json_path = os.path.join(EXCEL_DIR, json_filename)
|
||||
excel_path = os.path.join(EXCEL_DIR, excel_filename)
|
||||
|
||||
# JSON 저장
|
||||
save_materials_data(
|
||||
request_data.materials_data,
|
||||
json_path,
|
||||
request_no,
|
||||
request_data.job_no,
|
||||
request_data.grouped_materials # 그룹화 정보 추가
|
||||
)
|
||||
|
||||
# 엑셀 파일 생성 및 저장
|
||||
create_excel_file(
|
||||
request_data.grouped_materials or request_data.materials_data,
|
||||
excel_path,
|
||||
request_no,
|
||||
request_data.job_no
|
||||
)
|
||||
|
||||
# 구매신청 레코드 생성
|
||||
insert_request = text("""
|
||||
INSERT INTO purchase_requests (
|
||||
request_no, file_id, job_no, category,
|
||||
material_count, excel_file_path, requested_by
|
||||
) VALUES (
|
||||
:request_no, :file_id, :job_no, :category,
|
||||
:material_count, :excel_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": excel_filename, # 엑셀 파일명 저장 (JSON 대신)
|
||||
"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.main_nom,
|
||||
m.red_nom,
|
||||
m.schedule,
|
||||
m.material_grade,
|
||||
m.full_material_grade,
|
||||
m.quantity as original_quantity,
|
||||
m.unit as original_unit,
|
||||
m.classification_details,
|
||||
pd.outer_diameter, pd.schedule as pipe_schedule, pd.material_spec, pd.manufacturing_method,
|
||||
pd.end_preparation, pd.length_mm,
|
||||
fd.fitting_type, fd.fitting_subtype, fd.connection_method as fitting_connection,
|
||||
fd.pressure_rating as fitting_pressure, fd.schedule as fitting_schedule,
|
||||
fld.flange_type, fld.facing_type,
|
||||
fld.pressure_rating as flange_pressure,
|
||||
gd.gasket_type, gd.gasket_subtype, gd.material_type as gasket_material,
|
||||
gd.filler_material, gd.pressure_rating as gasket_pressure, gd.thickness as gasket_thickness,
|
||||
bd.bolt_type, bd.material_standard as bolt_material, bd.length as bolt_length
|
||||
FROM purchase_request_items pri
|
||||
JOIN materials m ON pri.material_id = m.id
|
||||
LEFT JOIN pipe_details pd ON m.id = pd.material_id
|
||||
LEFT JOIN fitting_details fd ON m.id = fd.material_id
|
||||
LEFT JOIN flange_details fld ON m.id = fld.material_id
|
||||
LEFT JOIN gasket_details gd ON m.id = gd.material_id
|
||||
LEFT JOIN bolt_details bd ON m.id = bd.material_id
|
||||
WHERE pri.request_id = :request_id
|
||||
ORDER BY m.classified_category, m.original_description
|
||||
""")
|
||||
|
||||
results = db.execute(query, {"request_id": request_id}).fetchall()
|
||||
|
||||
materials = []
|
||||
for row in results:
|
||||
# quantity를 정수로 변환 (소수점 제거)
|
||||
qty = row.requested_quantity or row.original_quantity
|
||||
try:
|
||||
qty_int = int(float(qty)) if qty else 0
|
||||
except (ValueError, TypeError):
|
||||
qty_int = 0
|
||||
|
||||
# BOM 페이지와 동일한 형식으로 데이터 구성
|
||||
material_dict = {
|
||||
"item_id": row.item_id,
|
||||
"material_id": row.material_id,
|
||||
"id": row.material_id,
|
||||
"original_description": row.original_description,
|
||||
"classified_category": row.classified_category,
|
||||
"size_spec": row.size_spec,
|
||||
"size_inch": row.main_nom,
|
||||
"main_nom": row.main_nom,
|
||||
"red_nom": row.red_nom,
|
||||
"schedule": row.schedule,
|
||||
"material_grade": row.material_grade,
|
||||
"full_material_grade": row.full_material_grade,
|
||||
"quantity": qty_int,
|
||||
"unit": row.requested_unit or row.original_unit,
|
||||
"user_requirement": row.user_requirement,
|
||||
"is_ordered": row.is_ordered,
|
||||
"is_received": row.is_received,
|
||||
"classification_details": row.classification_details
|
||||
}
|
||||
|
||||
# 카테고리별 상세 정보 추가
|
||||
if row.classified_category == 'PIPE' and row.manufacturing_method:
|
||||
material_dict["pipe_details"] = {
|
||||
"manufacturing_method": row.manufacturing_method,
|
||||
"schedule": row.pipe_schedule,
|
||||
"material_spec": row.material_spec,
|
||||
"end_preparation": row.end_preparation,
|
||||
"length_mm": row.length_mm
|
||||
}
|
||||
elif row.classified_category == 'FITTING' and row.fitting_type:
|
||||
material_dict["fitting_details"] = {
|
||||
"fitting_type": row.fitting_type,
|
||||
"fitting_subtype": row.fitting_subtype,
|
||||
"connection_method": row.fitting_connection,
|
||||
"pressure_rating": row.fitting_pressure,
|
||||
"schedule": row.fitting_schedule
|
||||
}
|
||||
elif row.classified_category == 'FLANGE' and row.flange_type:
|
||||
material_dict["flange_details"] = {
|
||||
"flange_type": row.flange_type,
|
||||
"facing_type": row.facing_type,
|
||||
"pressure_rating": row.flange_pressure
|
||||
}
|
||||
elif row.classified_category == 'GASKET' and row.gasket_type:
|
||||
material_dict["gasket_details"] = {
|
||||
"gasket_type": row.gasket_type,
|
||||
"gasket_subtype": row.gasket_subtype,
|
||||
"material_type": row.gasket_material,
|
||||
"filler_material": row.filler_material,
|
||||
"pressure_rating": row.gasket_pressure,
|
||||
"thickness": row.gasket_thickness
|
||||
}
|
||||
elif row.classified_category == 'BOLT' and row.bolt_type:
|
||||
material_dict["bolt_details"] = {
|
||||
"bolt_type": row.bolt_type,
|
||||
"material_standard": row.bolt_material,
|
||||
"length": row.bolt_length
|
||||
}
|
||||
|
||||
materials.append(material_dict)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"materials": materials,
|
||||
"grouped_materials": grouped_materials, # 그룹화 정보 추가
|
||||
"count": len(grouped_materials) if grouped_materials else len(materials)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get request materials: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=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)
|
||||
):
|
||||
"""
|
||||
구매신청 엑셀 파일 직접 다운로드 (BOM 페이지에서 생성한 파일 그대로)
|
||||
"""
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
try:
|
||||
# 구매신청 정보 조회
|
||||
query = text("""
|
||||
SELECT request_no, excel_file_path, job_no
|
||||
FROM purchase_requests
|
||||
WHERE request_id = :request_id
|
||||
""")
|
||||
result = db.execute(query, {"request_id": request_id}).fetchone()
|
||||
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="구매신청을 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
excel_file_path = os.path.join(EXCEL_DIR, result.excel_file_path)
|
||||
|
||||
if not os.path.exists(excel_file_path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="엑셀 파일을 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
# 엑셀 파일 직접 다운로드
|
||||
return FileResponse(
|
||||
path=excel_file_path,
|
||||
filename=f"{result.job_no}_{result.request_no}.xlsx",
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download request excel: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=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으로 저장 (프론트엔드에서 동일한 엑셀 포맷으로 생성하기 위해)
|
||||
"""
|
||||
# 수량을 정수로 변환하여 저장
|
||||
cleaned_materials = []
|
||||
for material in materials_data:
|
||||
cleaned_material = material.copy()
|
||||
if 'quantity' in cleaned_material:
|
||||
try:
|
||||
cleaned_material['quantity'] = int(float(cleaned_material['quantity']))
|
||||
except (ValueError, TypeError):
|
||||
cleaned_material['quantity'] = 0
|
||||
cleaned_materials.append(cleaned_material)
|
||||
|
||||
# 그룹화된 자재도 수량 정수 변환
|
||||
cleaned_grouped = []
|
||||
if grouped_materials:
|
||||
for group in grouped_materials:
|
||||
cleaned_group = group.copy()
|
||||
if 'quantity' in cleaned_group:
|
||||
try:
|
||||
cleaned_group['quantity'] = int(float(cleaned_group['quantity']))
|
||||
except (ValueError, TypeError):
|
||||
cleaned_group['quantity'] = 0
|
||||
cleaned_grouped.append(cleaned_group)
|
||||
|
||||
data_to_save = {
|
||||
"request_no": request_no,
|
||||
"job_no": job_no,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"materials": cleaned_materials,
|
||||
"grouped_materials": cleaned_grouped or []
|
||||
}
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data_to_save, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def create_excel_file(materials_data: List[Dict], file_path: str, request_no: str, job_no: str):
|
||||
"""
|
||||
자재 데이터로 엑셀 파일 생성 (BOM 페이지와 동일한 형식)
|
||||
"""
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
|
||||
# 새 워크북 생성
|
||||
wb = openpyxl.Workbook()
|
||||
wb.remove(wb.active) # 기본 시트 제거
|
||||
|
||||
# 카테고리별 그룹화
|
||||
category_groups = {}
|
||||
for material in materials_data:
|
||||
category = material.get('category', 'UNKNOWN')
|
||||
if category not in category_groups:
|
||||
category_groups[category] = []
|
||||
category_groups[category].append(material)
|
||||
|
||||
# 각 카테고리별 시트 생성
|
||||
for category, items in category_groups.items():
|
||||
if not items:
|
||||
continue
|
||||
|
||||
ws = wb.create_sheet(title=category)
|
||||
|
||||
# 헤더 정의
|
||||
headers = ['TAGNO', '품목명', '수량', '통화구분', '단가', '크기', '압력등급', '스케줄',
|
||||
'재질', '상세내역', '사용자요구', '관리항목1', '관리항목7', '관리항목8',
|
||||
'관리항목9', '관리항목10', '납기일(YYYY-MM-DD)']
|
||||
|
||||
# 헤더 작성
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col, value=header)
|
||||
cell.font = Font(bold=True, color="000000", size=12, name="맑은 고딕")
|
||||
cell.fill = PatternFill(start_color="B3D9FF", end_color="B3D9FF", fill_type="solid")
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
cell.border = Border(
|
||||
top=Side(style="thin", color="666666"),
|
||||
bottom=Side(style="thin", color="666666"),
|
||||
left=Side(style="thin", color="666666"),
|
||||
right=Side(style="thin", color="666666")
|
||||
)
|
||||
|
||||
# 데이터 작성
|
||||
for row_idx, material in enumerate(items, 2):
|
||||
data = [
|
||||
'', # TAGNO
|
||||
category, # 품목명
|
||||
material.get('quantity', 0), # 수량
|
||||
'KRW', # 통화구분
|
||||
1, # 단가
|
||||
material.get('size', '-'), # 크기
|
||||
'-', # 압력등급 (추후 개선)
|
||||
material.get('schedule', '-'), # 스케줄
|
||||
material.get('material_grade', '-'), # 재질
|
||||
'-', # 상세내역 (추후 개선)
|
||||
material.get('user_requirement', ''), # 사용자요구
|
||||
'', '', '', '', '', # 관리항목들
|
||||
datetime.now().strftime('%Y-%m-%d') # 납기일
|
||||
]
|
||||
|
||||
for col, value in enumerate(data, 1):
|
||||
ws.cell(row=row_idx, column=col, value=value)
|
||||
|
||||
# 컬럼 너비 자동 조정
|
||||
for column in ws.columns:
|
||||
max_length = 0
|
||||
column_letter = column[0].column_letter
|
||||
for cell in column:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except:
|
||||
pass
|
||||
adjusted_width = min(max(max_length + 2, 10), 50)
|
||||
ws.column_dimensions[column_letter].width = adjusted_width
|
||||
|
||||
# 파일 저장
|
||||
wb.save(file_path)
|
||||
@@ -1,452 +0,0 @@
|
||||
"""
|
||||
구매 추적 및 관리 API
|
||||
엑셀 내보내기 이력 및 구매 상태 관리
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, date
|
||||
import json
|
||||
|
||||
from ..database import get_db
|
||||
from ..auth.jwt_service import get_current_user
|
||||
from ..utils.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/purchase", tags=["Purchase Tracking"])
|
||||
|
||||
|
||||
@router.post("/export-history")
|
||||
async def create_export_history(
|
||||
file_id: int,
|
||||
job_no: Optional[str] = None,
|
||||
export_type: str = "full",
|
||||
category: Optional[str] = None,
|
||||
material_ids: List[int] = [],
|
||||
filters_applied: Optional[Dict] = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
엑셀 내보내기 이력 생성 및 자재 추적
|
||||
"""
|
||||
try:
|
||||
# 내보내기 이력 생성
|
||||
insert_history = text("""
|
||||
INSERT INTO excel_export_history (
|
||||
file_id, job_no, exported_by, export_type,
|
||||
category, material_count, filters_applied
|
||||
) VALUES (
|
||||
:file_id, :job_no, :exported_by, :export_type,
|
||||
:category, :material_count, :filters_applied
|
||||
) RETURNING export_id
|
||||
""")
|
||||
|
||||
result = db.execute(insert_history, {
|
||||
"file_id": file_id,
|
||||
"job_no": job_no,
|
||||
"exported_by": current_user.get("user_id"),
|
||||
"export_type": export_type,
|
||||
"category": category,
|
||||
"material_count": len(material_ids),
|
||||
"filters_applied": json.dumps(filters_applied) if filters_applied else None
|
||||
})
|
||||
|
||||
export_id = result.fetchone().export_id
|
||||
|
||||
# 내보낸 자재들 기록
|
||||
if material_ids:
|
||||
for material_id in material_ids:
|
||||
insert_material = text("""
|
||||
INSERT INTO exported_materials (
|
||||
export_id, material_id, purchase_status
|
||||
) VALUES (
|
||||
:export_id, :material_id, 'pending'
|
||||
)
|
||||
""")
|
||||
db.execute(insert_material, {
|
||||
"export_id": export_id,
|
||||
"material_id": material_id
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Export history created: {export_id} with {len(material_ids)} materials")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"export_id": export_id,
|
||||
"message": f"{len(material_ids)}개 자재의 내보내기 이력이 저장되었습니다"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to create export history: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"내보내기 이력 생성 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export-history")
|
||||
async def get_export_history(
|
||||
file_id: Optional[int] = None,
|
||||
job_no: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
엑셀 내보내기 이력 조회
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
eeh.export_id,
|
||||
eeh.file_id,
|
||||
eeh.job_no,
|
||||
eeh.export_date,
|
||||
eeh.export_type,
|
||||
eeh.category,
|
||||
eeh.material_count,
|
||||
u.name as exported_by_name,
|
||||
f.original_filename,
|
||||
COUNT(DISTINCT em.material_id) as actual_material_count,
|
||||
COUNT(DISTINCT CASE WHEN em.purchase_status = 'pending' THEN em.material_id END) as pending_count,
|
||||
COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) as requested_count,
|
||||
COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) as ordered_count,
|
||||
COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) as received_count
|
||||
FROM excel_export_history eeh
|
||||
LEFT JOIN users u ON eeh.exported_by = u.user_id
|
||||
LEFT JOIN files f ON eeh.file_id = f.id
|
||||
LEFT JOIN exported_materials em ON eeh.export_id = em.export_id
|
||||
WHERE 1=1
|
||||
AND (:file_id IS NULL OR eeh.file_id = :file_id)
|
||||
AND (:job_no IS NULL OR eeh.job_no = :job_no)
|
||||
GROUP BY
|
||||
eeh.export_id, eeh.file_id, eeh.job_no, eeh.export_date,
|
||||
eeh.export_type, eeh.category, eeh.material_count,
|
||||
u.name, f.original_filename
|
||||
ORDER BY eeh.export_date DESC
|
||||
LIMIT :limit
|
||||
""")
|
||||
|
||||
results = db.execute(query, {
|
||||
"file_id": file_id,
|
||||
"job_no": job_no,
|
||||
"limit": limit
|
||||
}).fetchall()
|
||||
|
||||
history = []
|
||||
for row in results:
|
||||
history.append({
|
||||
"export_id": row.export_id,
|
||||
"file_id": row.file_id,
|
||||
"job_no": row.job_no,
|
||||
"export_date": row.export_date.isoformat() if row.export_date else None,
|
||||
"export_type": row.export_type,
|
||||
"category": row.category,
|
||||
"material_count": row.material_count,
|
||||
"exported_by": row.exported_by_name,
|
||||
"file_name": row.original_filename,
|
||||
"status_summary": {
|
||||
"total": row.actual_material_count,
|
||||
"pending": row.pending_count,
|
||||
"requested": row.requested_count,
|
||||
"ordered": row.ordered_count,
|
||||
"received": row.received_count
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"history": history,
|
||||
"count": len(history)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get export history: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"내보내기 이력 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/materials/status")
|
||||
async def get_materials_by_status(
|
||||
status: Optional[str] = None,
|
||||
export_id: Optional[int] = None,
|
||||
file_id: Optional[int] = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
구매 상태별 자재 목록 조회
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
em.id as exported_material_id,
|
||||
em.material_id,
|
||||
m.original_description,
|
||||
m.classified_category,
|
||||
m.quantity,
|
||||
m.unit,
|
||||
em.purchase_status,
|
||||
em.purchase_request_no,
|
||||
em.purchase_order_no,
|
||||
em.vendor_name,
|
||||
em.expected_date,
|
||||
em.quantity_ordered,
|
||||
em.quantity_received,
|
||||
em.unit_price,
|
||||
em.total_price,
|
||||
em.notes,
|
||||
em.updated_at,
|
||||
eeh.export_date,
|
||||
f.original_filename as file_name,
|
||||
j.job_no,
|
||||
j.job_name
|
||||
FROM exported_materials em
|
||||
JOIN materials m ON em.material_id = m.id
|
||||
JOIN excel_export_history eeh ON em.export_id = eeh.export_id
|
||||
LEFT JOIN files f ON eeh.file_id = f.id
|
||||
LEFT JOIN jobs j ON eeh.job_no = j.job_no
|
||||
WHERE 1=1
|
||||
AND (:status IS NULL OR em.purchase_status = :status)
|
||||
AND (:export_id IS NULL OR em.export_id = :export_id)
|
||||
AND (:file_id IS NULL OR eeh.file_id = :file_id)
|
||||
ORDER BY em.updated_at DESC
|
||||
""")
|
||||
|
||||
results = db.execute(query, {
|
||||
"status": status,
|
||||
"export_id": export_id,
|
||||
"file_id": file_id
|
||||
}).fetchall()
|
||||
|
||||
materials = []
|
||||
for row in results:
|
||||
materials.append({
|
||||
"exported_material_id": row.exported_material_id,
|
||||
"material_id": row.material_id,
|
||||
"description": row.original_description,
|
||||
"category": row.classified_category,
|
||||
"quantity": row.quantity,
|
||||
"unit": row.unit,
|
||||
"purchase_status": row.purchase_status,
|
||||
"purchase_request_no": row.purchase_request_no,
|
||||
"purchase_order_no": row.purchase_order_no,
|
||||
"vendor_name": row.vendor_name,
|
||||
"expected_date": row.expected_date.isoformat() if row.expected_date else None,
|
||||
"quantity_ordered": row.quantity_ordered,
|
||||
"quantity_received": row.quantity_received,
|
||||
"unit_price": float(row.unit_price) if row.unit_price else None,
|
||||
"total_price": float(row.total_price) if row.total_price else None,
|
||||
"notes": row.notes,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
"export_date": row.export_date.isoformat() if row.export_date else None,
|
||||
"file_name": row.file_name,
|
||||
"job_no": row.job_no,
|
||||
"job_name": row.job_name
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"materials": materials,
|
||||
"count": len(materials)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get materials by status: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"구매 상태별 자재 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/materials/{exported_material_id}/status")
|
||||
async def update_purchase_status(
|
||||
exported_material_id: int,
|
||||
new_status: str,
|
||||
purchase_request_no: Optional[str] = None,
|
||||
purchase_order_no: Optional[str] = None,
|
||||
vendor_name: Optional[str] = None,
|
||||
expected_date: Optional[date] = None,
|
||||
quantity_ordered: Optional[int] = None,
|
||||
quantity_received: Optional[int] = None,
|
||||
unit_price: Optional[float] = None,
|
||||
notes: Optional[str] = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
자재 구매 상태 업데이트
|
||||
"""
|
||||
try:
|
||||
# 현재 상태 조회
|
||||
get_current = text("""
|
||||
SELECT purchase_status, material_id
|
||||
FROM exported_materials
|
||||
WHERE id = :id
|
||||
""")
|
||||
current = db.execute(get_current, {"id": exported_material_id}).fetchone()
|
||||
|
||||
if not current:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="해당 자재를 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
# 상태 업데이트
|
||||
update_query = text("""
|
||||
UPDATE exported_materials
|
||||
SET
|
||||
purchase_status = :new_status,
|
||||
purchase_request_no = COALESCE(:pr_no, purchase_request_no),
|
||||
purchase_order_no = COALESCE(:po_no, purchase_order_no),
|
||||
vendor_name = COALESCE(:vendor, vendor_name),
|
||||
expected_date = COALESCE(:expected_date, expected_date),
|
||||
quantity_ordered = COALESCE(:qty_ordered, quantity_ordered),
|
||||
quantity_received = COALESCE(:qty_received, quantity_received),
|
||||
unit_price = COALESCE(:unit_price, unit_price),
|
||||
total_price = CASE
|
||||
WHEN :unit_price IS NOT NULL AND :qty_ordered IS NOT NULL
|
||||
THEN :unit_price * :qty_ordered
|
||||
WHEN :unit_price IS NOT NULL AND quantity_ordered IS NOT NULL
|
||||
THEN :unit_price * quantity_ordered
|
||||
ELSE total_price
|
||||
END,
|
||||
notes = COALESCE(:notes, notes),
|
||||
updated_by = :updated_by,
|
||||
requested_date = CASE WHEN :new_status = 'requested' THEN CURRENT_TIMESTAMP ELSE requested_date END,
|
||||
ordered_date = CASE WHEN :new_status = 'ordered' THEN CURRENT_TIMESTAMP ELSE ordered_date END,
|
||||
received_date = CASE WHEN :new_status = 'received' THEN CURRENT_TIMESTAMP ELSE received_date END
|
||||
WHERE id = :id
|
||||
""")
|
||||
|
||||
db.execute(update_query, {
|
||||
"id": exported_material_id,
|
||||
"new_status": new_status,
|
||||
"pr_no": purchase_request_no,
|
||||
"po_no": purchase_order_no,
|
||||
"vendor": vendor_name,
|
||||
"expected_date": expected_date,
|
||||
"qty_ordered": quantity_ordered,
|
||||
"qty_received": quantity_received,
|
||||
"unit_price": unit_price,
|
||||
"notes": notes,
|
||||
"updated_by": current_user.get("user_id")
|
||||
})
|
||||
|
||||
# 이력 기록
|
||||
insert_history = text("""
|
||||
INSERT INTO purchase_status_history (
|
||||
exported_material_id, material_id,
|
||||
previous_status, new_status,
|
||||
changed_by, reason
|
||||
) VALUES (
|
||||
:em_id, :material_id,
|
||||
:prev_status, :new_status,
|
||||
:changed_by, :reason
|
||||
)
|
||||
""")
|
||||
|
||||
db.execute(insert_history, {
|
||||
"em_id": exported_material_id,
|
||||
"material_id": current.material_id,
|
||||
"prev_status": current.purchase_status,
|
||||
"new_status": new_status,
|
||||
"changed_by": current_user.get("user_id"),
|
||||
"reason": notes
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Purchase status updated: {exported_material_id} from {current.purchase_status} to {new_status}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"구매 상태가 {new_status}로 변경되었습니다"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to update purchase status: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"구매 상태 업데이트 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status-summary")
|
||||
async def get_status_summary(
|
||||
file_id: Optional[int] = None,
|
||||
job_no: Optional[str] = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
구매 상태 요약 통계
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
em.purchase_status,
|
||||
COUNT(DISTINCT em.material_id) as material_count,
|
||||
SUM(em.quantity_exported) as total_quantity,
|
||||
SUM(em.total_price) as total_amount,
|
||||
COUNT(DISTINCT em.export_id) as export_count
|
||||
FROM exported_materials em
|
||||
JOIN excel_export_history eeh ON em.export_id = eeh.export_id
|
||||
WHERE 1=1
|
||||
AND (:file_id IS NULL OR eeh.file_id = :file_id)
|
||||
AND (:job_no IS NULL OR eeh.job_no = :job_no)
|
||||
GROUP BY em.purchase_status
|
||||
""")
|
||||
|
||||
results = db.execute(query, {
|
||||
"file_id": file_id,
|
||||
"job_no": job_no
|
||||
}).fetchall()
|
||||
|
||||
summary = {}
|
||||
total_materials = 0
|
||||
total_amount = 0
|
||||
|
||||
for row in results:
|
||||
summary[row.purchase_status] = {
|
||||
"material_count": row.material_count,
|
||||
"total_quantity": row.total_quantity,
|
||||
"total_amount": float(row.total_amount) if row.total_amount else 0,
|
||||
"export_count": row.export_count
|
||||
}
|
||||
total_materials += row.material_count
|
||||
if row.total_amount:
|
||||
total_amount += float(row.total_amount)
|
||||
|
||||
# 기본 상태들 추가 (없는 경우 0으로)
|
||||
for status in ['pending', 'requested', 'ordered', 'received', 'cancelled']:
|
||||
if status not in summary:
|
||||
summary[status] = {
|
||||
"material_count": 0,
|
||||
"total_quantity": 0,
|
||||
"total_amount": 0,
|
||||
"export_count": 0
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"summary": summary,
|
||||
"total_materials": total_materials,
|
||||
"total_amount": total_amount
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get status summary: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"구매 상태 요약 조회 실패: {str(e)}"
|
||||
)
|
||||
@@ -90,37 +90,26 @@ def classify_material_integrated(description: str, main_nom: str = "",
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 최우선: SPECIAL 키워드 확인 (도면 업로드가 필요한 특수 자재)
|
||||
# SPECIAL이 포함된 경우 (단, SPECIFICATION은 제외)
|
||||
if 'SPECIAL' in desc_upper and 'SPECIFICATION' not in desc_upper:
|
||||
return {
|
||||
"category": "SPECIAL",
|
||||
"confidence": 1.0,
|
||||
"evidence": ["SPECIAL_KEYWORD"],
|
||||
"classification_level": "LEVEL0_SPECIAL",
|
||||
"reason": "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}"
|
||||
}
|
||||
|
||||
# 스페셜 관련 한글 키워드
|
||||
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 등
|
||||
# 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 or
|
||||
'CLAMP' in desc_upper or '클램프' in desc_upper or 'PIPE CLAMP' in desc_upper):
|
||||
'URETHANE BLOCK' in desc_upper or 'BLOCK SHOE' in desc_upper or '우레탄' in desc_upper):
|
||||
return {
|
||||
"category": "SUPPORT",
|
||||
"category": "U_BOLT",
|
||||
"confidence": 1.0,
|
||||
"evidence": ["SUPPORT_SYSTEM_KEYWORD"],
|
||||
"classification_level": "LEVEL0_SUPPORT",
|
||||
"reason": "SUPPORT 시스템 키워드 발견"
|
||||
"evidence": ["U_BOLT_SYSTEM_KEYWORD"],
|
||||
"classification_level": "LEVEL0_U_BOLT",
|
||||
"reason": "U-BOLT 시스템 키워드 발견"
|
||||
}
|
||||
|
||||
# 쉼표로 구분된 각 부분을 별도로 체크 (예: "NIPPLE, SMLS, SCH 80")
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
{
|
||||
"request_no": "PR-20251014-001",
|
||||
"job_no": "테스트용",
|
||||
"created_at": "2025-10-14T06:47:25.065166",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 2013,
|
||||
"description": "SIGHT GLASS, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "1\"",
|
||||
"material_grade": "SS",
|
||||
"quantity": 6,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 2019,
|
||||
"description": "SIGHT GLASS, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "SS",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 2024,
|
||||
"description": "SIGHT GLASS, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "SS",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 2027,
|
||||
"description": "STRAINER, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "-",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": [
|
||||
{
|
||||
"group_key": "SIGHT GLASS, FLG, 150LB|1\"|undefined|SS",
|
||||
"material_ids": [
|
||||
2013
|
||||
],
|
||||
"description": "SIGHT GLASS, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "1\"",
|
||||
"material_grade": "SS",
|
||||
"quantity": 6,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "SIGHT GLASS, FLG, 150LB|1/2\"|undefined|SS",
|
||||
"material_ids": [
|
||||
2019
|
||||
],
|
||||
"description": "SIGHT GLASS, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "SS",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "SIGHT GLASS, FLG, 150LB|2\"|undefined|SS",
|
||||
"material_ids": [
|
||||
2024
|
||||
],
|
||||
"description": "SIGHT GLASS, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "SS",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "STRAINER, FLG, 150LB|2\"|undefined|-",
|
||||
"material_ids": [
|
||||
2027
|
||||
],
|
||||
"description": "STRAINER, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "-",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
{
|
||||
"request_no": "PR-20251014-002",
|
||||
"job_no": "TKG-25000P",
|
||||
"created_at": "2025-10-14T06:54:44.585437",
|
||||
"materials": [
|
||||
{
|
||||
"material_id": 5552,
|
||||
"description": "SIGHT GLASS, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "1\"",
|
||||
"material_grade": "SS",
|
||||
"quantity": 6,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5558,
|
||||
"description": "SIGHT GLASS, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "SS",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5563,
|
||||
"description": "SIGHT GLASS, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "SS",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"material_id": 5566,
|
||||
"description": "STRAINER, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "-",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
],
|
||||
"grouped_materials": [
|
||||
{
|
||||
"group_key": "SIGHT GLASS, FLG, 150LB|1\"|undefined|SS",
|
||||
"material_ids": [
|
||||
5552
|
||||
],
|
||||
"description": "SIGHT GLASS, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "1\"",
|
||||
"material_grade": "SS",
|
||||
"quantity": 6,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "SIGHT GLASS, FLG, 150LB|1/2\"|undefined|SS",
|
||||
"material_ids": [
|
||||
5558
|
||||
],
|
||||
"description": "SIGHT GLASS, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "1/2\"",
|
||||
"material_grade": "SS",
|
||||
"quantity": 5,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "SIGHT GLASS, FLG, 150LB|2\"|undefined|SS",
|
||||
"material_ids": [
|
||||
5563
|
||||
],
|
||||
"description": "SIGHT GLASS, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "SS",
|
||||
"quantity": 2,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
},
|
||||
{
|
||||
"group_key": "STRAINER, FLG, 150LB|2\"|undefined|-",
|
||||
"material_ids": [
|
||||
5566
|
||||
],
|
||||
"description": "STRAINER, FLG, 150LB",
|
||||
"category": "FLANGE",
|
||||
"size": "2\"",
|
||||
"material_grade": "-",
|
||||
"quantity": 1,
|
||||
"unit": "EA",
|
||||
"user_requirement": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
-- 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.';
|
||||
@@ -1,135 +0,0 @@
|
||||
-- 엑셀 내보내기 이력 및 구매 상태 관리 테이블
|
||||
|
||||
-- 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: 취소';
|
||||
@@ -1,44 +0,0 @@
|
||||
-- 구매신청 관리 테이블
|
||||
|
||||
-- 구매신청 그룹 (같이 신청한 항목들의 묶음)
|
||||
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 '구매신청 자재 상세';
|
||||
@@ -1,20 +0,0 @@
|
||||
-- 리비전 관리 개선: 자재 상태 추적
|
||||
-- 리비전 업로드 시 삭제된 자재의 상태를 추적
|
||||
|
||||
-- materials 테이블에 revision_status 컬럼 추가
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS revision_status VARCHAR(20) DEFAULT 'active';
|
||||
-- 가능한 값: 'active', 'inventory', 'deleted_not_purchased', 'changed'
|
||||
|
||||
-- revision_status 설명:
|
||||
-- 'active': 정상 활성 자재 (기본값)
|
||||
-- 'inventory': 재고품 (구매신청 후 리비전에서 삭제됨 - 연노랑색 표시)
|
||||
-- 'deleted_not_purchased': 구매신청 전 삭제됨 (숨김 처리)
|
||||
-- 'changed': 변경된 자재 (추가 구매 필요)
|
||||
|
||||
-- 인덱스 추가 (성능 최적화)
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_revision_status ON materials(revision_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_drawing_name ON materials(drawing_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_line_no ON materials(line_no);
|
||||
|
||||
COMMENT ON COLUMN materials.revision_status IS '리비전 자재 상태: active(활성), inventory(재고품), deleted_not_purchased(삭제됨), changed(변경됨)';
|
||||
|
||||
@@ -5,8 +5,6 @@ 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';
|
||||
@@ -35,12 +33,11 @@ function App() {
|
||||
const loadPendingSignups = async () => {
|
||||
try {
|
||||
const response = await api.get('/auth/signup-requests');
|
||||
// API 응답이 { requests: [...], count: ... } 형태
|
||||
const pendingCount = response.data.count || 0;
|
||||
setPendingSignupCount(pendingCount);
|
||||
if (response.data && response.data.requests) {
|
||||
setPendingSignupCount(response.data.count || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('승인 대기 조회 실패:', error);
|
||||
setPendingSignupCount(0);
|
||||
// 에러 무시 (관리자가 아니면 403)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,8 +117,18 @@ function App() {
|
||||
|
||||
if (token && userData) {
|
||||
setIsAuthenticated(true);
|
||||
setUser(JSON.parse(userData));
|
||||
const userObj = JSON.parse(userData);
|
||||
setUser(userObj);
|
||||
loadProjects(); // 프로젝트 목록 로드
|
||||
|
||||
// 관리자인 경우 승인 대기 수 조회
|
||||
if (userObj.role === 'admin' || userObj.role === 'system') {
|
||||
loadPendingSignups();
|
||||
|
||||
// 30초마다 갱신
|
||||
const interval = setInterval(loadPendingSignups, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
@@ -159,32 +166,11 @@ 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) {
|
||||
const parsedUser = JSON.parse(userData);
|
||||
setUser(parsedUser);
|
||||
|
||||
// 관리자인 경우 승인 대기 수 확인
|
||||
if (parsedUser?.role === 'admin' || parsedUser?.role === 'system') {
|
||||
loadPendingSignups();
|
||||
}
|
||||
setUser(JSON.parse(userData));
|
||||
}
|
||||
setIsAuthenticated(true);
|
||||
};
|
||||
@@ -210,14 +196,8 @@ function App() {
|
||||
{
|
||||
id: 'bom',
|
||||
title: '📋 BOM 업로드 & 분류',
|
||||
description: '엑셀 파일 업로드 → 자동 분류 → 검토 → 자재 확인 → 구매신청 (엑셀 내보내기)',
|
||||
description: '엑셀 파일 업로드 → 자동 분류 → 검토 → 자재 확인 → 엑셀 내보내기',
|
||||
color: '#4299e1'
|
||||
},
|
||||
{
|
||||
id: 'purchase-request',
|
||||
title: '📦 구매신청 관리',
|
||||
description: '구매신청한 자재들을 그룹별로 조회하고 엑셀 재다운로드',
|
||||
color: '#10b981'
|
||||
}
|
||||
];
|
||||
};
|
||||
@@ -226,20 +206,15 @@ function App() {
|
||||
const getAdminFeatures = () => {
|
||||
const features = [];
|
||||
|
||||
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') {
|
||||
// 시스템 관리자 전용 기능
|
||||
if (user?.role === 'system') {
|
||||
features.push(
|
||||
{
|
||||
id: 'user-management',
|
||||
title: '👥 사용자 관리',
|
||||
description: '계정 생성, 역할 변경, 회원가입 승인',
|
||||
description: '계정 생성, 역할 변경, 사용자 삭제',
|
||||
color: '#dc2626',
|
||||
badge: '시스템 관리자',
|
||||
pendingCount: pendingSignupCount
|
||||
badge: '시스템 관리자'
|
||||
},
|
||||
{
|
||||
id: 'system-logs',
|
||||
@@ -251,24 +226,8 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 관리자 기능
|
||||
if (user?.role === 'manager') {
|
||||
// 일반 관리자는 회원가입 승인만 가능
|
||||
features.push(
|
||||
{
|
||||
id: 'user-management',
|
||||
title: '👥 회원가입 승인',
|
||||
description: '신규 회원가입 승인 및 거부',
|
||||
color: '#dc2626',
|
||||
badge: '관리자',
|
||||
pendingCount: pendingSignupCount
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 관리자 이상 공통 기능
|
||||
if (user?.role === 'admin' || user?.role === 'manager') {
|
||||
|
||||
if (user?.role === 'admin' || user?.role === 'system') {
|
||||
features.push(
|
||||
{
|
||||
id: 'log-monitoring',
|
||||
@@ -311,11 +270,65 @@ function App() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 사용자 메뉴 */}
|
||||
<div className="user-menu-container" style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
style={{
|
||||
{/* 알람 및 사용자 메뉴 */}
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
{/* 회원가입 승인 알람 (관리자 전용) */}
|
||||
{(user?.role === 'admin' || user?.role === 'system') && pendingSignupCount > 0 && (
|
||||
<button
|
||||
onClick={() => navigateToPage('user-management')}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
background: '#fef3c7',
|
||||
border: '2px solid #f59e0b',
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px',
|
||||
transition: 'all 0.2s ease',
|
||||
animation: 'pulse 2s ease-in-out infinite'
|
||||
}}
|
||||
title={`회원가입 승인 대기: ${pendingSignupCount}명`}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = '#fde68a';
|
||||
e.target.style.transform = 'scale(1.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = '#fef3c7';
|
||||
e.target.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
🔔
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
right: '-4px',
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '11px',
|
||||
fontWeight: '700',
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)'
|
||||
}}>
|
||||
{pendingSignupCount}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 사용자 메뉴 */}
|
||||
<div className="user-menu-container" style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
@@ -725,12 +738,13 @@ 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))',
|
||||
@@ -764,10 +778,7 @@ function App() {
|
||||
{feature.description}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigateToPage(feature.id, {
|
||||
selectedProject,
|
||||
jobNo: selectedProject?.official_project_code
|
||||
})}
|
||||
onClick={() => navigateToPage(feature.id, { selectedProject })}
|
||||
style={{
|
||||
background: feature.color,
|
||||
color: 'white',
|
||||
@@ -786,9 +797,8 @@ function App() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)} {/* selectedProject 조건문 닫기 */}
|
||||
|
||||
{/* 관리자 기능 (프로젝트 선택과 무관하게 항상 표시) */}
|
||||
{/* 관리자 기능 (있는 경우만) */}
|
||||
{adminFeatures.length > 0 && (
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748', marginBottom: '16px' }}>
|
||||
@@ -820,29 +830,8 @@ 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',
|
||||
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 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', marginBottom: '12px' }}>
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p style={{ color: '#718096', marginBottom: '16px', fontSize: '14px' }}>
|
||||
{feature.description}
|
||||
@@ -941,7 +930,8 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)} {/* adminFeatures 조건문 닫기 */}
|
||||
</>
|
||||
)} {/* selectedProject 조건문 닫기 */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -967,25 +957,6 @@ 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 (
|
||||
|
||||
@@ -299,72 +299,6 @@ const BOMWorkspacePage = ({ project, onNavigate, onBack }) => {
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
// 누락된 도면 확인
|
||||
if (response.data.missing_drawings && response.data.missing_drawings.requires_confirmation) {
|
||||
const missingDrawings = response.data.missing_drawings.drawings || [];
|
||||
const materialCount = response.data.missing_drawings.materials?.length || 0;
|
||||
const hasPreviousPurchase = response.data.missing_drawings.has_previous_purchase;
|
||||
const fileId = response.data.file_id;
|
||||
|
||||
// 사용자 선택을 위한 프롬프트 메시지
|
||||
let alertMessage = `⚠️ 리비전 업로드 확인\n\n` +
|
||||
`다음 도면이 새 파일에 없습니다:\n` +
|
||||
`${missingDrawings.slice(0, 5).join('\n')}` +
|
||||
`${missingDrawings.length > 5 ? `\n...외 ${missingDrawings.length - 5}개` : ''}\n\n` +
|
||||
`관련 자재: ${materialCount}개\n\n`;
|
||||
|
||||
if (hasPreviousPurchase) {
|
||||
// 케이스 1: 이미 구매신청된 경우
|
||||
alertMessage += `✅ 이전 리비전에서 구매신청이 진행되었습니다.\n\n` +
|
||||
`다음 중 선택하세요:\n\n` +
|
||||
`1️⃣ "일부만 업로드" - 변경된 도면만 업로드, 기존 도면 유지\n` +
|
||||
` → 누락된 도면의 자재는 "재고품"으로 표시 (연노랑색)\n\n` +
|
||||
`2️⃣ "도면 삭제됨" - 누락된 도면 완전 삭제\n` +
|
||||
` → 해당 자재 제거 및 재고품 처리\n\n` +
|
||||
`3️⃣ "취소" - 업로드 취소\n\n` +
|
||||
`숫자를 입력하세요 (1, 2, 3):`;
|
||||
} else {
|
||||
// 케이스 2: 구매신청 전인 경우
|
||||
alertMessage += `⚠️ 아직 구매신청이 진행되지 않았습니다.\n\n` +
|
||||
`다음 중 선택하세요:\n\n` +
|
||||
`1️⃣ "일부만 업로드" - 변경된 도면만 업로드, 기존 도면 유지\n` +
|
||||
` → 누락된 도면의 자재는 그대로 유지\n\n` +
|
||||
`2️⃣ "도면 삭제됨" - 누락된 도면 완전 삭제\n` +
|
||||
` → 해당 자재 완전 제거 (숨김 처리)\n\n` +
|
||||
`3️⃣ "취소" - 업로드 취소\n\n` +
|
||||
`숫자를 입력하세요 (1, 2, 3):`;
|
||||
}
|
||||
|
||||
const userChoice = prompt(alertMessage);
|
||||
|
||||
if (userChoice === '3' || userChoice === null) {
|
||||
// 취소 선택
|
||||
await api.delete(`/files/${fileId}`);
|
||||
alert('업로드가 취소되었습니다.');
|
||||
return;
|
||||
} else if (userChoice === '2') {
|
||||
// 도면 삭제됨 - 백엔드에 삭제 처리 요청
|
||||
try {
|
||||
await api.post(`/files/${fileId}/process-missing-drawings`, {
|
||||
action: 'delete',
|
||||
drawings: missingDrawings
|
||||
});
|
||||
alert(`✅ ${missingDrawings.length}개 도면이 삭제 처리되었습니다.`);
|
||||
} catch (err) {
|
||||
console.error('도면 삭제 처리 실패:', err);
|
||||
alert('도면 삭제 처리에 실패했습니다.');
|
||||
}
|
||||
} else if (userChoice === '1') {
|
||||
// 일부만 업로드 - 이미 처리됨 (기본 동작)
|
||||
alert(`✅ 일부 업로드로 처리되었습니다.\n누락된 ${missingDrawings.length}개 도면은 기존 상태를 유지합니다.`);
|
||||
} else {
|
||||
// 잘못된 입력
|
||||
await api.delete(`/files/${fileId}`);
|
||||
alert('잘못된 입력입니다. 업로드가 취소되었습니다.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
alert(`리비전 ${response.data.revision} 업로드 성공!\n신규 자재: ${response.data.new_materials_count || 0}개`);
|
||||
await loadFiles();
|
||||
}
|
||||
|
||||
@@ -294,7 +294,7 @@
|
||||
background: white;
|
||||
margin: 16px 24px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto; /* 좌우 스크롤 가능하도록 변경 */
|
||||
overflow-x: hidden;
|
||||
max-height: calc(100vh - 220px);
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
@@ -431,40 +431,40 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* SUPPORT 전용 헤더 - 8개 컬럼 */
|
||||
.detailed-grid-header.support-header {
|
||||
grid-template-columns: 3% 10% 12% 10% 25% 10% 18% 12%;
|
||||
/* U-BOLT 전용 헤더 - 8개 컬럼 */
|
||||
.detailed-grid-header.ubolt-header {
|
||||
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 전용 행 - 8개 컬럼 */
|
||||
.detailed-material-row.ubolt-row {
|
||||
grid-template-columns: 3% 11% 15% 10% 20% 12% 18% 10%;
|
||||
|
||||
|
||||
}
|
||||
|
||||
/* SUPPORT 헤더 테두리 */
|
||||
.detailed-grid-header.support-header > div,
|
||||
.detailed-grid-header.support-header .filterable-header {
|
||||
/* U-BOLT 헤더 테두리 */
|
||||
.detailed-grid-header.ubolt-header > div,
|
||||
.detailed-grid-header.ubolt-header .filterable-header {
|
||||
border-right: 1px solid #d1d5db;
|
||||
}
|
||||
.detailed-grid-header.support-header > div:last-child,
|
||||
.detailed-grid-header.support-header .filterable-header:last-child {
|
||||
.detailed-grid-header.ubolt-header > div:last-child,
|
||||
.detailed-grid-header.ubolt-header .filterable-header:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* SUPPORT 행 테두리 */
|
||||
.detailed-material-row.support-row .material-cell {
|
||||
/* U-BOLT 행 테두리 */
|
||||
.detailed-material-row.ubolt-row .material-cell {
|
||||
border-right: 1px solid #d1d5db;
|
||||
}
|
||||
.detailed-material-row.support-row .material-cell:last-child {
|
||||
.detailed-material-row.ubolt-row .material-cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* SUPPORT 타입 배지 */
|
||||
.type-badge.support {
|
||||
/* U-BOLT 타입 배지 */
|
||||
.type-badge.ubolt {
|
||||
background: #059669;
|
||||
color: white;
|
||||
border: 2px solid #047857;
|
||||
@@ -533,7 +533,7 @@
|
||||
|
||||
/* 플랜지 전용 헤더 - 10개 컬럼 */
|
||||
.detailed-grid-header.flange-header {
|
||||
grid-template-columns: 1.5% 8.5% 12% 8% 10% 10% 15% 8% 12% 11.5%;
|
||||
grid-template-columns: 2% 8% 12% 8% 10% 10% 18% 10% 15% 6%;
|
||||
|
||||
|
||||
}
|
||||
@@ -550,7 +550,7 @@
|
||||
|
||||
/* 플랜지 전용 행 - 10개 컬럼 */
|
||||
.detailed-material-row.flange-row {
|
||||
grid-template-columns: 1.5% 8.5% 12% 8% 10% 10% 15% 8% 12% 11.5%;
|
||||
grid-template-columns: 1.5% 8.5% 12% 8% 10% 10% 18% 10% 15% 6%;
|
||||
|
||||
|
||||
}
|
||||
@@ -565,7 +565,7 @@
|
||||
|
||||
/* 피팅 전용 헤더 - 10개 컬럼 */
|
||||
.detailed-grid-header.fitting-header {
|
||||
grid-template-columns: 2% 8.5% 16% 7.5% 7.5% 9% 15% 9% 13% 12%;
|
||||
grid-template-columns: 2% 8% 20% 8% 8% 10% 18% 10% 15% 0%;
|
||||
|
||||
|
||||
}
|
||||
@@ -582,7 +582,7 @@
|
||||
|
||||
/* 피팅 전용 행 - 10개 컬럼 */
|
||||
.detailed-material-row.fitting-row {
|
||||
grid-template-columns: 2% 8.5% 16% 7.5% 7.5% 9% 15% 9% 13% 12%;
|
||||
grid-template-columns: 2% 8% 20% 8% 8% 10% 18% 10% 15% 0%;
|
||||
|
||||
|
||||
}
|
||||
@@ -1168,24 +1168,4 @@
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
/* ================================
|
||||
리비전 상태별 스타일
|
||||
================================ */
|
||||
|
||||
/* 재고품 스타일 (구매신청 후 리비전에서 삭제됨) */
|
||||
.detailed-material-row.inventory {
|
||||
background-color: #fef3c7 !important; /* 연노랑색 배경 */
|
||||
border-left: 4px solid #f59e0b !important; /* 주황색 왼쪽 테두리 */
|
||||
}
|
||||
|
||||
/* 삭제된 자재 (구매신청 전) - 숨김 처리 */
|
||||
.detailed-material-row.deleted-not-purchased {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 변경된 자재 (추가 구매 필요) */
|
||||
.detailed-material-row.changed {
|
||||
border-left: 4px solid #3b82f6 !important; /* 파란색 왼쪽 테두리 */
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,388 +0,0 @@
|
||||
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;
|
||||
@@ -1,211 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
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 {
|
||||
console.log('📥 엑셀 다운로드 시작:', requestId, requestNo);
|
||||
|
||||
// 서버에서 생성된 엑셀 파일 직접 다운로드 (BOM 페이지와 동일한 파일)
|
||||
const response = await api.get(`/purchase-request/${requestId}/download-excel`, {
|
||||
responseType: 'blob' // 파일 다운로드용
|
||||
});
|
||||
|
||||
// 파일 다운로드 처리
|
||||
const blob = new Blob([response.data], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${requestNo}_재다운로드.xlsx`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
console.log('✅ 엑셀 파일 다운로드 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 엑셀 다운로드 실패:', error);
|
||||
alert('엑셀 다운로드 실패: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="purchase-request-page">
|
||||
<div className="page-header">
|
||||
<button onClick={() => onNavigate('bom', { selectedProject })} className="back-btn">
|
||||
← BOM 관리로 돌아가기
|
||||
</button>
|
||||
<h1>구매신청 관리</h1>
|
||||
<p className="subtitle">구매신청한 자재들을 그룹별로 관리합니다</p>
|
||||
</div>
|
||||
|
||||
<div className="main-content">
|
||||
{/* 구매신청 목록 */}
|
||||
<div className="requests-panel">
|
||||
<div className="panel-header">
|
||||
<h2>구매신청 목록 ({requests.length})</h2>
|
||||
</div>
|
||||
|
||||
<div className="requests-list">
|
||||
{isLoading ? (
|
||||
<div className="loading">로딩중...</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="empty-state">구매신청이 없습니다</div>
|
||||
) : (
|
||||
requests.map(request => (
|
||||
<div
|
||||
key={request.request_id}
|
||||
className={`request-card ${selectedRequest?.request_id === request.request_id ? 'selected' : ''}`}
|
||||
onClick={() => handleRequestSelect(request)}
|
||||
>
|
||||
<div className="request-header">
|
||||
<span className="request-no">{request.request_no}</span>
|
||||
<span className="request-date">
|
||||
{new Date(request.requested_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="request-info">
|
||||
<div>{request.job_no} - {request.job_name}</div>
|
||||
<div className="material-count">
|
||||
{request.category || '전체'} | {request.material_count}개 자재
|
||||
</div>
|
||||
</div>
|
||||
<div className="request-footer">
|
||||
<span className="requested-by">{request.requested_by}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownloadExcel(request.request_id, request.request_no);
|
||||
}}
|
||||
className="download-btn"
|
||||
>
|
||||
📥 엑셀
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 구매신청 상세 */}
|
||||
<div className="details-panel">
|
||||
{selectedRequest ? (
|
||||
<>
|
||||
<div className="panel-header">
|
||||
<h2>{selectedRequest.request_no}</h2>
|
||||
<button
|
||||
onClick={() => handleDownloadExcel(selectedRequest.request_id, selectedRequest.request_no)}
|
||||
className="excel-btn"
|
||||
>
|
||||
📥 엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="materials-table">
|
||||
{/* 카테고리별로 그룹화하여 표시 */}
|
||||
{(() => {
|
||||
// 카테고리별로 자재 그룹화
|
||||
const groupedByCategory = requestMaterials.reduce((acc, material) => {
|
||||
const category = material.category || 'UNKNOWN';
|
||||
if (!acc[category]) acc[category] = [];
|
||||
acc[category].push(material);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.entries(groupedByCategory).map(([category, materials]) => (
|
||||
<div key={category} style={{ marginBottom: '30px' }}>
|
||||
<h3 style={{
|
||||
background: '#f0f0f0',
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{category} ({materials.length}개)
|
||||
</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>No</th>
|
||||
<th>카테고리</th>
|
||||
<th>자재 설명</th>
|
||||
<th>크기</th>
|
||||
{category === 'BOLT' ? <th>길이</th> : <th>스케줄</th>}
|
||||
<th>재질</th>
|
||||
<th>수량</th>
|
||||
<th>사용자요구</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{materials.map((material, idx) => (
|
||||
<tr key={material.item_id || `${category}-${idx}`}>
|
||||
<td>{idx + 1}</td>
|
||||
<td>
|
||||
<span className="category-badge">
|
||||
{material.category}
|
||||
</span>
|
||||
</td>
|
||||
<td>{material.description}</td>
|
||||
<td>{material.size || '-'}</td>
|
||||
<td>{material.schedule || '-'}</td>
|
||||
<td>{material.material_grade || '-'}</td>
|
||||
<td>
|
||||
{material.category === 'PIPE' ? (
|
||||
<div>
|
||||
<span style={{ fontWeight: 'bold' }}>
|
||||
{(() => {
|
||||
// 총 길이와 개수 계산
|
||||
let totalLengthMm = material.total_length || 0;
|
||||
let totalCount = 0;
|
||||
|
||||
if (material.pipe_lengths && material.pipe_lengths.length > 0) {
|
||||
// pipe_lengths 배열에서 총 개수 계산
|
||||
totalCount = material.pipe_lengths.reduce((sum, p) => sum + parseFloat(p.quantity || 0), 0);
|
||||
} else if (material.material_ids && material.material_ids.length > 0) {
|
||||
totalCount = material.material_ids.length;
|
||||
if (!totalLengthMm) {
|
||||
totalLengthMm = totalCount * 6000;
|
||||
}
|
||||
} else {
|
||||
totalCount = parseFloat(material.quantity) || 1;
|
||||
if (!totalLengthMm) {
|
||||
totalLengthMm = totalCount * 6000;
|
||||
}
|
||||
}
|
||||
|
||||
// 6,000mm를 1본으로 계산
|
||||
const pipeCount = Math.ceil(totalLengthMm / 6000);
|
||||
|
||||
// 형식: 2본(11,000mm/40개)
|
||||
return `${pipeCount}본(${totalLengthMm.toLocaleString()}mm/${totalCount}개)`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
`${material.quantity} ${material.unit || 'EA'}`
|
||||
)}
|
||||
</td>
|
||||
<td>{material.user_requirement || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">📦</div>
|
||||
<div>구매신청을 선택하세요</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseRequestPage;
|
||||
@@ -4,9 +4,6 @@ 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);
|
||||
@@ -28,8 +25,6 @@ const UserManagementPage = ({ onNavigate, user }) => {
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
loadPendingUsers();
|
||||
loadSuspendedUsers();
|
||||
}, []);
|
||||
|
||||
const loadUsers = async () => {
|
||||
@@ -51,95 +46,6 @@ 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();
|
||||
|
||||
@@ -350,267 +256,22 @@ const UserManagementPage = ({ onNavigate, user }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
{/* 사용자 목록 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
marginBottom: '24px',
|
||||
borderBottom: '2px solid #e9ecef',
|
||||
paddingBottom: '0'
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<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={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden'
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
background: '#f8f9fa'
|
||||
}}>
|
||||
<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>
|
||||
)}
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
|
||||
등록된 사용자 ({users.length}명)
|
||||
</h2>
|
||||
</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' }}>
|
||||
@@ -727,26 +388,6 @@ 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)}
|
||||
@@ -776,165 +417,7 @@ const UserManagementPage = ({ onNavigate, user }) => {
|
||||
</table>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* 사용자 생성 모달 */}
|
||||
|
||||
@@ -150,43 +150,14 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
// 구매 수량 계산
|
||||
const purchaseInfo = calculatePurchaseQuantity(material);
|
||||
|
||||
// 변수 선언 (먼저 선언)
|
||||
// 품목명 생성 (카테고리별 상세 처리)
|
||||
let itemName = '';
|
||||
let detailInfo = '';
|
||||
let gasketMaterial = '';
|
||||
let gasketThickness = '';
|
||||
if (category === 'PIPE') {
|
||||
itemName = material.pipe_details?.manufacturing_method || 'PIPE';
|
||||
} else if (category === 'FITTING') {
|
||||
itemName = material.fitting_details?.fitting_type || 'FITTING';
|
||||
} else if (category === 'FLANGE') {
|
||||
// 플랜지는 품목명만 간단하게 (상세내역에 타입 정보)
|
||||
itemName = 'FLANGE';
|
||||
|
||||
// 특수 플랜지는 구분
|
||||
const desc = cleanDescription.toUpperCase();
|
||||
if (desc.includes('ORIFICE')) {
|
||||
itemName = 'ORIFICE FLANGE';
|
||||
} else if (desc.includes('SPECTACLE')) {
|
||||
itemName = 'SPECTACLE BLIND';
|
||||
} else if (desc.includes('PADDLE')) {
|
||||
itemName = 'PADDLE BLIND';
|
||||
} else if (desc.includes('SPACER')) {
|
||||
itemName = 'SPACER';
|
||||
} else if (desc.includes('BLIND')) {
|
||||
itemName = 'BLIND FLANGE';
|
||||
}
|
||||
|
||||
// 상세내역에 플랜지 타입 정보 저장 (줄임말 사용)
|
||||
if (material.flange_details && material.flange_details.flange_type) {
|
||||
detailInfo = material.flange_details.flange_type; // WN RF, SO RF 등
|
||||
} else {
|
||||
// description에서 추출 (전체 이름 그대로 사용)
|
||||
const flangeTypeMatch = cleanDescription.match(/FLG\s+([^,]+?)(?=\s*SCH|\s*,\s*\d+LB|$)/i);
|
||||
if (flangeTypeMatch) {
|
||||
detailInfo = flangeTypeMatch[1].trim(); // WELD NECK RF 등 그대로
|
||||
}
|
||||
}
|
||||
} else if (category === 'VALVE') {
|
||||
itemName = 'VALVE';
|
||||
} else if (category === 'GASKET') {
|
||||
@@ -218,18 +189,6 @@ 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';
|
||||
}
|
||||
@@ -356,7 +315,10 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 카테고리별 상세 정보 추출 (이미 위에서 선언됨)
|
||||
// 카테고리별 상세 정보 추출
|
||||
let detailInfo = '';
|
||||
let gasketMaterial = '';
|
||||
let gasketThickness = '';
|
||||
|
||||
if (category === 'BOLT') {
|
||||
// 볼트의 경우 표면처리 정보 추출
|
||||
@@ -430,26 +392,11 @@ 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,
|
||||
'수량': quantity,
|
||||
'수량': purchaseInfo.purchaseQuantity || material.quantity || 0,
|
||||
'통화구분': 'KRW', // 기본값
|
||||
'단가': 1, // 일괄 1로 설정
|
||||
'크기': material.size_spec || '-',
|
||||
@@ -520,27 +467,19 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
*/
|
||||
export const exportMaterialsToExcel = (materials, filename, additionalInfo = {}) => {
|
||||
try {
|
||||
console.log('🔧 exportMaterialsToExcel 시작:', materials.length, '개 자재');
|
||||
|
||||
// 카테고리별로 그룹화
|
||||
const categoryGroups = groupMaterialsByCategory(materials);
|
||||
console.log('📁 카테고리별 그룹:', Object.keys(categoryGroups).map(k => `${k}: ${categoryGroups[k].length}개`));
|
||||
|
||||
// 전체 자재 합치기 (먼저 계산)
|
||||
const consolidatedMaterials = consolidateMaterials(materials);
|
||||
console.log('📦 합쳐진 자재:', consolidatedMaterials.length, '개');
|
||||
|
||||
// 새 워크북 생성
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
// 카테고리별 시트 생성 (합쳐진 자재)
|
||||
Object.entries(categoryGroups).forEach(([category, items]) => {
|
||||
console.log(`📄 ${category} 시트 생성 중... (${items.length}개 자재)`);
|
||||
const consolidatedItems = consolidateMaterials(items);
|
||||
console.log(` → 합쳐진 결과: ${consolidatedItems.length}개`);
|
||||
|
||||
const formattedItems = consolidatedItems.map(material => formatMaterialForExcel(material));
|
||||
console.log(` → 포맷 완료: ${formattedItems.length}개`);
|
||||
|
||||
if (formattedItems.length > 0) {
|
||||
const categorySheet = XLSX.utils.json_to_sheet(formattedItems);
|
||||
|
||||
Reference in New Issue
Block a user