Compare commits
4 Commits
521446d56b
...
8f5330a008
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f5330a008 | ||
|
|
72126ef78d | ||
|
|
5a21ef8f6c | ||
|
|
e27020ae9b |
@@ -323,6 +323,75 @@ async def verify_token(
|
|||||||
|
|
||||||
|
|
||||||
# 관리자 전용 엔드포인트들
|
# 관리자 전용 엔드포인트들
|
||||||
|
@router.get("/users/suspended")
|
||||||
|
async def get_suspended_users(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
정지된 사용자 목록 조회 (관리자 전용)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
credentials: JWT 토큰
|
||||||
|
db: 데이터베이스 세션
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: 정지된 사용자 목록
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 토큰 검증 및 권한 확인
|
||||||
|
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||||
|
if payload['role'] not in ['admin', 'system']:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="관리자 이상의 권한이 필요합니다"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 정지된 사용자 조회
|
||||||
|
from sqlalchemy import text
|
||||||
|
query = text("""
|
||||||
|
SELECT
|
||||||
|
user_id, username, name, email, role, department, position,
|
||||||
|
phone, status, created_at, updated_at
|
||||||
|
FROM users
|
||||||
|
WHERE status = 'suspended'
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
results = db.execute(query).fetchall()
|
||||||
|
|
||||||
|
suspended_users = []
|
||||||
|
for row in results:
|
||||||
|
suspended_users.append({
|
||||||
|
"user_id": row.user_id,
|
||||||
|
"username": row.username,
|
||||||
|
"name": row.name,
|
||||||
|
"email": row.email,
|
||||||
|
"role": row.role,
|
||||||
|
"department": row.department,
|
||||||
|
"position": row.position,
|
||||||
|
"phone": row.phone,
|
||||||
|
"status": row.status,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"users": suspended_users,
|
||||||
|
"count": len(suspended_users)
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get suspended users: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="정지된 사용자 목록 조회 중 오류가 발생했습니다"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users")
|
@router.get("/users")
|
||||||
async def get_all_users(
|
async def get_all_users(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
@@ -371,6 +440,155 @@ async def get_all_users(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/users/{user_id}/suspend")
|
||||||
|
async def suspend_user(
|
||||||
|
user_id: int,
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
사용자 정지 (관리자 전용)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: 정지할 사용자 ID
|
||||||
|
credentials: JWT 토큰
|
||||||
|
db: 데이터베이스 세션
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: 정지 결과
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 토큰 검증 및 권한 확인
|
||||||
|
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||||
|
if payload['role'] not in ['system', 'admin']:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="관리자만 사용자를 정지할 수 있습니다"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 자기 자신 정지 방지
|
||||||
|
if payload['user_id'] == user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="자기 자신은 정지할 수 없습니다"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 사용자 정지
|
||||||
|
from sqlalchemy import text
|
||||||
|
update_query = text("""
|
||||||
|
UPDATE users
|
||||||
|
SET status = 'suspended',
|
||||||
|
is_active = FALSE,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = :user_id AND status = 'active'
|
||||||
|
RETURNING user_id, username, name, status
|
||||||
|
""")
|
||||||
|
|
||||||
|
result = db.execute(update_query, {"user_id": user_id}).fetchone()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="사용자를 찾을 수 없거나 이미 정지된 상태입니다"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"User {result.username} suspended by {payload['username']}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"{result.name} 사용자가 정지되었습니다",
|
||||||
|
"user": {
|
||||||
|
"user_id": result.user_id,
|
||||||
|
"username": result.username,
|
||||||
|
"name": result.name,
|
||||||
|
"status": result.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to suspend user {user_id}: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"사용자 정지 중 오류가 발생했습니다: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/users/{user_id}/reactivate")
|
||||||
|
async def reactivate_user(
|
||||||
|
user_id: int,
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
사용자 재활성화 (관리자 전용)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: 재활성화할 사용자 ID
|
||||||
|
credentials: JWT 토큰
|
||||||
|
db: 데이터베이스 세션
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: 재활성화 결과
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 토큰 검증 및 권한 확인
|
||||||
|
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||||
|
if payload['role'] not in ['system', 'admin']:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="관리자만 사용자를 재활성화할 수 있습니다"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 사용자 재활성화
|
||||||
|
from sqlalchemy import text
|
||||||
|
update_query = text("""
|
||||||
|
UPDATE users
|
||||||
|
SET status = 'active',
|
||||||
|
is_active = TRUE,
|
||||||
|
failed_login_attempts = 0,
|
||||||
|
locked_until = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = :user_id AND status = 'suspended'
|
||||||
|
RETURNING user_id, username, name, status
|
||||||
|
""")
|
||||||
|
|
||||||
|
result = db.execute(update_query, {"user_id": user_id}).fetchone()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="사용자를 찾을 수 없거나 정지 상태가 아닙니다"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"User {result.username} reactivated by {payload['username']}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"{result.name} 사용자가 재활성화되었습니다",
|
||||||
|
"user": {
|
||||||
|
"user_id": result.user_id,
|
||||||
|
"username": result.username,
|
||||||
|
"name": result.name,
|
||||||
|
"status": result.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to reactivate user {user_id}: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"사용자 재활성화 중 오류가 발생했습니다: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/users/{user_id}")
|
@router.delete("/users/{user_id}")
|
||||||
async def delete_user(
|
async def delete_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -391,10 +609,11 @@ async def delete_user(
|
|||||||
try:
|
try:
|
||||||
# 토큰 검증 및 권한 확인
|
# 토큰 검증 및 권한 확인
|
||||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||||
if payload['role'] != 'system':
|
# admin role도 사용자 삭제 가능하도록 수정
|
||||||
|
if payload['role'] not in ['system', 'admin']:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="사용자 삭제는 시스템 관리자만 가능합니다"
|
detail="사용자 삭제는 관리자만 가능합니다"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 자기 자신 삭제 방지
|
# 자기 자신 삭제 방지
|
||||||
@@ -404,7 +623,30 @@ async def delete_user(
|
|||||||
detail="자기 자신은 삭제할 수 없습니다"
|
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_repo = UserRepository(db)
|
||||||
user = user_repo.find_by_id(user_id)
|
user = user_repo.find_by_id(user_id)
|
||||||
|
|
||||||
@@ -414,15 +656,39 @@ async def delete_user(
|
|||||||
detail="해당 사용자를 찾을 수 없습니다"
|
detail="해당 사용자를 찾을 수 없습니다"
|
||||||
)
|
)
|
||||||
|
|
||||||
user_repo.delete_user(user)
|
if has_bom_data:
|
||||||
|
# BOM 데이터가 있으면 소프트 삭제 (status='deleted')
|
||||||
logger.info(f"User deleted by admin: {user.username} (deleted by: {payload['username']})")
|
soft_delete = text("""
|
||||||
|
UPDATE users
|
||||||
return {
|
SET status = 'deleted',
|
||||||
'success': True,
|
is_active = FALSE,
|
||||||
'message': '사용자가 삭제되었습니다',
|
updated_at = CURRENT_TIMESTAMP
|
||||||
'deleted_user_id': user_id
|
WHERE user_id = :user_id
|
||||||
}
|
RETURNING username, name
|
||||||
|
""")
|
||||||
|
result = db.execute(soft_delete, {"user_id": user_id}).fetchone()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"User soft-deleted (has BOM data): {result.username} (deleted by: {payload['username']})")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': f'{result.name} 사용자가 비활성화되었습니다 (BOM 데이터 보존)',
|
||||||
|
'soft_deleted': True,
|
||||||
|
'deleted_user_id': user_id
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# BOM 데이터가 없으면 완전 삭제
|
||||||
|
user_repo.delete_user(user)
|
||||||
|
|
||||||
|
logger.info(f"User hard-deleted (no BOM data): {user.username} (deleted by: {payload['username']})")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': '사용자가 완전히 삭제되었습니다',
|
||||||
|
'soft_deleted': False,
|
||||||
|
'deleted_user_id': user_id
|
||||||
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -62,14 +62,38 @@ class AuthService:
|
|||||||
message="아이디 또는 비밀번호가 올바르지 않습니다"
|
message="아이디 또는 비밀번호가 올바르지 않습니다"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 계정 활성화 상태 확인
|
# 계정 상태 확인 (새로운 status 체계)
|
||||||
if not user.is_active:
|
if hasattr(user, 'status'):
|
||||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_disabled')
|
if user.status == 'pending':
|
||||||
logger.warning(f"Login failed - account disabled: {username}")
|
await self._record_login_failure(user.user_id, ip_address, user_agent, 'pending_account')
|
||||||
raise TKMPException(
|
logger.warning(f"Login failed - pending account: {username}")
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
raise TKMPException(
|
||||||
message="비활성화된 계정입니다. 관리자에게 문의하세요"
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
)
|
message="계정 승인 대기 중입니다. 관리자 승인 후 이용 가능합니다"
|
||||||
|
)
|
||||||
|
elif user.status == 'suspended':
|
||||||
|
await self._record_login_failure(user.user_id, ip_address, user_agent, 'suspended_account')
|
||||||
|
logger.warning(f"Login failed - suspended account: {username}")
|
||||||
|
raise TKMPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
message="계정이 정지되었습니다. 관리자에게 문의하세요"
|
||||||
|
)
|
||||||
|
elif user.status == 'deleted':
|
||||||
|
await self._record_login_failure(user.user_id, ip_address, user_agent, 'deleted_account')
|
||||||
|
logger.warning(f"Login failed - deleted account: {username}")
|
||||||
|
raise TKMPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
message="삭제된 계정입니다"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 하위 호환성: status 필드가 없으면 is_active 사용
|
||||||
|
if not user.is_active:
|
||||||
|
await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_disabled')
|
||||||
|
logger.warning(f"Login failed - account disabled: {username}")
|
||||||
|
raise TKMPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
message="비활성화된 계정입니다. 관리자에게 문의하세요"
|
||||||
|
)
|
||||||
|
|
||||||
# 계정 잠금 상태 확인
|
# 계정 잠금 상태 확인
|
||||||
if user.is_locked():
|
if user.is_locked():
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ class User(Base):
|
|||||||
access_level = Column(String(20), default='worker', nullable=False) # 호환성 유지
|
access_level = Column(String(20), default='worker', nullable=False) # 호환성 유지
|
||||||
|
|
||||||
# 계정 상태 관리
|
# 계정 상태 관리
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False) # DEPRECATED: Use status instead
|
||||||
|
status = Column(String(20), default='active', nullable=False) # pending, active, suspended, deleted
|
||||||
failed_login_attempts = Column(Integer, default=0)
|
failed_login_attempts = Column(Integer, default=0)
|
||||||
locked_until = Column(DateTime, nullable=True)
|
locked_until = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
@@ -302,9 +303,15 @@ class UserRepository:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
def get_all_users(self, skip: int = 0, limit: int = 100) -> List[User]:
|
def get_all_users(self, skip: int = 0, limit: int = 100) -> List[User]:
|
||||||
"""모든 사용자 조회"""
|
"""활성 사용자만 조회 (status='active')"""
|
||||||
try:
|
try:
|
||||||
return self.db.query(User).offset(skip).limit(limit).all()
|
# status 필드가 있으면 status='active', 없으면 is_active=True (하위 호환성)
|
||||||
|
users = self.db.query(User)
|
||||||
|
if hasattr(User, 'status'):
|
||||||
|
users = users.filter(User.status == 'active')
|
||||||
|
else:
|
||||||
|
users = users.filter(User.is_active == True)
|
||||||
|
return users.offset(skip).limit(limit).all()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get all users: {str(e)}")
|
logger.error(f"Failed to get all users: {str(e)}")
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ async def signup_request(
|
|||||||
'position': signup_data.position,
|
'position': signup_data.position,
|
||||||
'phone': signup_data.phone,
|
'phone': signup_data.phone,
|
||||||
'role': 'user',
|
'role': 'user',
|
||||||
'is_active': False # 비활성 상태로 승인 대기 표시
|
'is_active': False, # 하위 호환성
|
||||||
|
'status': 'pending' # 새로운 status 체계: 승인 대기
|
||||||
})
|
})
|
||||||
|
|
||||||
# 가입 사유 저장 (notes 컬럼 활용)
|
# 가입 사유 저장 (notes 컬럼 활용)
|
||||||
@@ -130,13 +131,13 @@ async def get_signup_requests(
|
|||||||
detail="관리자만 접근 가능합니다"
|
detail="관리자만 접근 가능합니다"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 승인 대기 중인 사용자 조회 (is_active=False인 사용자)
|
# 승인 대기 중인 사용자 조회 (status='pending'인 사용자)
|
||||||
query = text("""
|
query = text("""
|
||||||
SELECT
|
SELECT
|
||||||
user_id as id, username, name, email, department, position,
|
user_id, username, name, email, department, position,
|
||||||
phone, notes, created_at
|
phone, created_at, role, is_active, status
|
||||||
FROM users
|
FROM users
|
||||||
WHERE is_active = FALSE
|
WHERE status = 'pending'
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -145,15 +146,18 @@ async def get_signup_requests(
|
|||||||
pending_users = []
|
pending_users = []
|
||||||
for row in results:
|
for row in results:
|
||||||
pending_users.append({
|
pending_users.append({
|
||||||
"id": row.id,
|
"user_id": row.user_id,
|
||||||
|
"id": row.user_id, # 호환성을 위해 둘 다 제공
|
||||||
"username": row.username,
|
"username": row.username,
|
||||||
"name": row.name,
|
"name": row.name,
|
||||||
"email": row.email,
|
"email": row.email,
|
||||||
"department": row.department,
|
"department": row.department,
|
||||||
"position": row.position,
|
"position": row.position,
|
||||||
"phone": row.phone,
|
"phone": row.phone,
|
||||||
"reason": row.notes,
|
"role": row.role,
|
||||||
"requested_at": row.created_at.isoformat() if row.created_at else None
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"requested_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"is_active": row.is_active
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -201,9 +205,10 @@ async def approve_signup(
|
|||||||
update_query = text("""
|
update_query = text("""
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET is_active = TRUE,
|
SET is_active = TRUE,
|
||||||
|
status = 'active',
|
||||||
access_level = :access_level,
|
access_level = :access_level,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE user_id = :user_id AND is_active = FALSE
|
WHERE user_id = :user_id AND status = 'pending'
|
||||||
RETURNING user_id as id, username, name
|
RETURNING user_id as id, username, name
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class SecuritySettings(BaseSettings):
|
|||||||
"""보안 설정"""
|
"""보안 설정"""
|
||||||
cors_origins: List[str] = Field(default=[], description="CORS 허용 도메인")
|
cors_origins: List[str] = Field(default=[], description="CORS 허용 도메인")
|
||||||
cors_methods: List[str] = Field(
|
cors_methods: List[str] = Field(
|
||||||
default=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
default=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
description="CORS 허용 메서드"
|
description="CORS 허용 메서드"
|
||||||
)
|
)
|
||||||
cors_headers: List[str] = Field(
|
cors_headers: List[str] = Field(
|
||||||
|
|||||||
@@ -97,6 +97,28 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("tubing 라우터를 찾을 수 없습니다")
|
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 라우터와 충돌 방지)
|
# 파일 관리 API 라우터 등록 (비활성화 - files 라우터와 충돌 방지)
|
||||||
# try:
|
# try:
|
||||||
# from .api import file_management
|
# from .api import file_management
|
||||||
|
|||||||
591
backend/app/routers/export_manager.py
Normal file
591
backend/app/routers/export_manager.py
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
"""
|
||||||
|
엑셀 내보내기 및 구매 배치 관리 API
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import openpyxl
|
||||||
|
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..auth.jwt_service import get_current_user
|
||||||
|
from ..utils.logger import logger
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/export", tags=["Export Management"])
|
||||||
|
|
||||||
|
# 엑셀 파일 저장 경로
|
||||||
|
EXPORT_DIR = "exports"
|
||||||
|
os.makedirs(EXPORT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def create_excel_from_materials(materials: List[Dict], batch_info: Dict) -> str:
|
||||||
|
"""
|
||||||
|
자재 목록으로 엑셀 파일 생성
|
||||||
|
"""
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = batch_info.get("category", "자재목록")
|
||||||
|
|
||||||
|
# 헤더 스타일
|
||||||
|
header_fill = PatternFill(start_color="B8E6FF", end_color="B8E6FF", fill_type="solid")
|
||||||
|
header_font = Font(bold=True, size=11)
|
||||||
|
header_alignment = Alignment(horizontal="center", vertical="center")
|
||||||
|
thin_border = Border(
|
||||||
|
left=Side(style='thin'),
|
||||||
|
right=Side(style='thin'),
|
||||||
|
top=Side(style='thin'),
|
||||||
|
bottom=Side(style='thin')
|
||||||
|
)
|
||||||
|
|
||||||
|
# 배치 정보 추가 (상단 3줄)
|
||||||
|
ws.merge_cells('A1:J1')
|
||||||
|
ws['A1'] = f"구매 배치: {batch_info.get('batch_no', 'N/A')}"
|
||||||
|
ws['A1'].font = Font(bold=True, size=14)
|
||||||
|
|
||||||
|
ws.merge_cells('A2:J2')
|
||||||
|
ws['A2'] = f"프로젝트: {batch_info.get('job_no', '')} - {batch_info.get('job_name', '')}"
|
||||||
|
|
||||||
|
ws.merge_cells('A3:J3')
|
||||||
|
ws['A3'] = f"내보낸 날짜: {batch_info.get('export_date', datetime.now().strftime('%Y-%m-%d %H:%M'))}"
|
||||||
|
|
||||||
|
# 빈 줄
|
||||||
|
ws.append([])
|
||||||
|
|
||||||
|
# 헤더 행
|
||||||
|
headers = [
|
||||||
|
"No.", "카테고리", "자재 설명", "크기", "스케줄/등급",
|
||||||
|
"재질", "수량", "단위", "추가요구", "사용자요구",
|
||||||
|
"구매상태", "PR번호", "PO번호", "공급업체", "예정일"
|
||||||
|
]
|
||||||
|
|
||||||
|
for col, header in enumerate(headers, 1):
|
||||||
|
cell = ws.cell(row=5, column=col, value=header)
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.font = header_font
|
||||||
|
cell.alignment = header_alignment
|
||||||
|
cell.border = thin_border
|
||||||
|
|
||||||
|
# 데이터 행
|
||||||
|
row_num = 6
|
||||||
|
for idx, material in enumerate(materials, 1):
|
||||||
|
row_data = [
|
||||||
|
idx,
|
||||||
|
material.get("category", ""),
|
||||||
|
material.get("description", ""),
|
||||||
|
material.get("size", ""),
|
||||||
|
material.get("schedule", ""),
|
||||||
|
material.get("material_grade", ""),
|
||||||
|
material.get("quantity", ""),
|
||||||
|
material.get("unit", ""),
|
||||||
|
material.get("additional_req", ""),
|
||||||
|
material.get("user_requirement", ""),
|
||||||
|
material.get("purchase_status", "pending"),
|
||||||
|
material.get("purchase_request_no", ""),
|
||||||
|
material.get("purchase_order_no", ""),
|
||||||
|
material.get("vendor_name", ""),
|
||||||
|
material.get("expected_date", "")
|
||||||
|
]
|
||||||
|
|
||||||
|
for col, value in enumerate(row_data, 1):
|
||||||
|
cell = ws.cell(row=row_num, column=col, value=value)
|
||||||
|
cell.border = thin_border
|
||||||
|
if col == 11: # 구매상태 컬럼
|
||||||
|
if value == "pending":
|
||||||
|
cell.fill = PatternFill(start_color="FFFFE0", end_color="FFFFE0", fill_type="solid")
|
||||||
|
elif value == "requested":
|
||||||
|
cell.fill = PatternFill(start_color="FFE4B5", end_color="FFE4B5", fill_type="solid")
|
||||||
|
elif value == "ordered":
|
||||||
|
cell.fill = PatternFill(start_color="ADD8E6", end_color="ADD8E6", fill_type="solid")
|
||||||
|
elif value == "received":
|
||||||
|
cell.fill = PatternFill(start_color="90EE90", end_color="90EE90", fill_type="solid")
|
||||||
|
|
||||||
|
row_num += 1
|
||||||
|
|
||||||
|
# 열 너비 자동 조정
|
||||||
|
for column in ws.columns:
|
||||||
|
max_length = 0
|
||||||
|
column_letter = get_column_letter(column[0].column)
|
||||||
|
for cell in column:
|
||||||
|
try:
|
||||||
|
if len(str(cell.value)) > max_length:
|
||||||
|
max_length = len(str(cell.value))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
adjusted_width = min(max_length + 2, 50)
|
||||||
|
ws.column_dimensions[column_letter].width = adjusted_width
|
||||||
|
|
||||||
|
# 파일 저장
|
||||||
|
file_name = f"batch_{batch_info.get('batch_no', uuid.uuid4().hex[:8])}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||||
|
file_path = os.path.join(EXPORT_DIR, file_name)
|
||||||
|
wb.save(file_path)
|
||||||
|
|
||||||
|
return file_name
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create-batch")
|
||||||
|
async def create_export_batch(
|
||||||
|
file_id: int,
|
||||||
|
job_no: Optional[str] = None,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
materials: List[Dict] = [],
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
엑셀 내보내기 배치 생성 (자재 그룹화)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 배치 번호 생성 (YYYYMMDD-XXX 형식)
|
||||||
|
batch_date = datetime.now().strftime('%Y%m%d')
|
||||||
|
|
||||||
|
# 오늘 생성된 배치 수 확인
|
||||||
|
count_query = text("""
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM excel_export_history
|
||||||
|
WHERE DATE(export_date) = CURRENT_DATE
|
||||||
|
""")
|
||||||
|
count_result = db.execute(count_query).fetchone()
|
||||||
|
batch_seq = (count_result.count + 1) if count_result else 1
|
||||||
|
batch_no = f"{batch_date}-{str(batch_seq).zfill(3)}"
|
||||||
|
|
||||||
|
# Job 정보 조회
|
||||||
|
job_name = ""
|
||||||
|
if job_no:
|
||||||
|
job_query = text("SELECT job_name FROM jobs WHERE job_no = :job_no")
|
||||||
|
job_result = db.execute(job_query, {"job_no": job_no}).fetchone()
|
||||||
|
if job_result:
|
||||||
|
job_name = job_result.job_name
|
||||||
|
|
||||||
|
# 배치 정보
|
||||||
|
batch_info = {
|
||||||
|
"batch_no": batch_no,
|
||||||
|
"job_no": job_no,
|
||||||
|
"job_name": job_name,
|
||||||
|
"category": category,
|
||||||
|
"export_date": datetime.now().strftime('%Y-%m-%d %H:%M')
|
||||||
|
}
|
||||||
|
|
||||||
|
# 엑셀 파일 생성
|
||||||
|
excel_file_name = create_excel_from_materials(materials, batch_info)
|
||||||
|
|
||||||
|
# 내보내기 이력 저장
|
||||||
|
insert_history = text("""
|
||||||
|
INSERT INTO excel_export_history (
|
||||||
|
file_id, job_no, exported_by, export_type,
|
||||||
|
category, material_count, file_name, notes
|
||||||
|
) VALUES (
|
||||||
|
:file_id, :job_no, :exported_by, :export_type,
|
||||||
|
:category, :material_count, :file_name, :notes
|
||||||
|
) RETURNING export_id
|
||||||
|
""")
|
||||||
|
|
||||||
|
result = db.execute(insert_history, {
|
||||||
|
"file_id": file_id,
|
||||||
|
"job_no": job_no,
|
||||||
|
"exported_by": current_user.get("user_id"),
|
||||||
|
"export_type": "batch",
|
||||||
|
"category": category,
|
||||||
|
"material_count": len(materials),
|
||||||
|
"file_name": excel_file_name,
|
||||||
|
"notes": f"배치번호: {batch_no}"
|
||||||
|
})
|
||||||
|
|
||||||
|
export_id = result.fetchone().export_id
|
||||||
|
|
||||||
|
# 자재별 내보내기 기록
|
||||||
|
material_ids = []
|
||||||
|
for material in materials:
|
||||||
|
material_id = material.get("id")
|
||||||
|
if material_id:
|
||||||
|
material_ids.append(material_id)
|
||||||
|
|
||||||
|
insert_material = text("""
|
||||||
|
INSERT INTO exported_materials (
|
||||||
|
export_id, material_id, purchase_status,
|
||||||
|
quantity_exported
|
||||||
|
) VALUES (
|
||||||
|
:export_id, :material_id, 'pending',
|
||||||
|
:quantity
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
db.execute(insert_material, {
|
||||||
|
"export_id": export_id,
|
||||||
|
"material_id": material_id,
|
||||||
|
"quantity": material.get("quantity", 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Export batch created: {batch_no} with {len(materials)} materials")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"batch_no": batch_no,
|
||||||
|
"export_id": export_id,
|
||||||
|
"file_name": excel_file_name,
|
||||||
|
"material_count": len(materials),
|
||||||
|
"message": f"배치 {batch_no}가 생성되었습니다"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to create export batch: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"배치 생성 실패: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/batches")
|
||||||
|
async def get_export_batches(
|
||||||
|
file_id: Optional[int] = None,
|
||||||
|
job_no: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
내보내기 배치 목록 조회
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = text("""
|
||||||
|
SELECT
|
||||||
|
eeh.export_id,
|
||||||
|
eeh.file_id,
|
||||||
|
eeh.job_no,
|
||||||
|
eeh.export_date,
|
||||||
|
eeh.category,
|
||||||
|
eeh.material_count,
|
||||||
|
eeh.file_name,
|
||||||
|
eeh.notes,
|
||||||
|
u.name as exported_by,
|
||||||
|
j.job_name,
|
||||||
|
f.original_filename,
|
||||||
|
-- 상태별 집계
|
||||||
|
COUNT(DISTINCT em.material_id) as total_materials,
|
||||||
|
COUNT(DISTINCT CASE WHEN em.purchase_status = 'pending' THEN em.material_id END) as pending_count,
|
||||||
|
COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) as requested_count,
|
||||||
|
COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) as ordered_count,
|
||||||
|
COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) as received_count,
|
||||||
|
-- 전체 상태 계산
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(DISTINCT em.material_id) = COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END)
|
||||||
|
THEN 'completed'
|
||||||
|
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) > 0
|
||||||
|
THEN 'in_progress'
|
||||||
|
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) > 0
|
||||||
|
THEN 'requested'
|
||||||
|
ELSE 'pending'
|
||||||
|
END as batch_status
|
||||||
|
FROM excel_export_history eeh
|
||||||
|
LEFT JOIN users u ON eeh.exported_by = u.user_id
|
||||||
|
LEFT JOIN jobs j ON eeh.job_no = j.job_no
|
||||||
|
LEFT JOIN files f ON eeh.file_id = f.id
|
||||||
|
LEFT JOIN exported_materials em ON eeh.export_id = em.export_id
|
||||||
|
WHERE eeh.export_type = 'batch'
|
||||||
|
AND (:file_id IS NULL OR eeh.file_id = :file_id)
|
||||||
|
AND (:job_no IS NULL OR eeh.job_no = :job_no)
|
||||||
|
GROUP BY
|
||||||
|
eeh.export_id, eeh.file_id, eeh.job_no, eeh.export_date,
|
||||||
|
eeh.category, eeh.material_count, eeh.file_name, eeh.notes,
|
||||||
|
u.name, j.job_name, f.original_filename
|
||||||
|
HAVING (:status IS NULL OR
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(DISTINCT em.material_id) = COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END)
|
||||||
|
THEN 'completed'
|
||||||
|
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) > 0
|
||||||
|
THEN 'in_progress'
|
||||||
|
WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) > 0
|
||||||
|
THEN 'requested'
|
||||||
|
ELSE 'pending'
|
||||||
|
END = :status)
|
||||||
|
ORDER BY eeh.export_date DESC
|
||||||
|
LIMIT :limit
|
||||||
|
""")
|
||||||
|
|
||||||
|
results = db.execute(query, {
|
||||||
|
"file_id": file_id,
|
||||||
|
"job_no": job_no,
|
||||||
|
"status": status,
|
||||||
|
"limit": limit
|
||||||
|
}).fetchall()
|
||||||
|
|
||||||
|
batches = []
|
||||||
|
for row in results:
|
||||||
|
# 배치 번호 추출 (notes에서)
|
||||||
|
batch_no = ""
|
||||||
|
if row.notes and "배치번호:" in row.notes:
|
||||||
|
batch_no = row.notes.split("배치번호:")[1].strip()
|
||||||
|
|
||||||
|
batches.append({
|
||||||
|
"export_id": row.export_id,
|
||||||
|
"batch_no": batch_no,
|
||||||
|
"file_id": row.file_id,
|
||||||
|
"job_no": row.job_no,
|
||||||
|
"job_name": row.job_name,
|
||||||
|
"export_date": row.export_date.isoformat() if row.export_date else None,
|
||||||
|
"category": row.category,
|
||||||
|
"material_count": row.total_materials,
|
||||||
|
"file_name": row.file_name,
|
||||||
|
"exported_by": row.exported_by,
|
||||||
|
"source_file": row.original_filename,
|
||||||
|
"batch_status": row.batch_status,
|
||||||
|
"status_detail": {
|
||||||
|
"pending": row.pending_count,
|
||||||
|
"requested": row.requested_count,
|
||||||
|
"ordered": row.ordered_count,
|
||||||
|
"received": row.received_count,
|
||||||
|
"total": row.total_materials
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"batches": batches,
|
||||||
|
"count": len(batches)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get export batches: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"배치 목록 조회 실패: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/batch/{export_id}/materials")
|
||||||
|
async def get_batch_materials(
|
||||||
|
export_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
배치에 포함된 자재 목록 조회
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = text("""
|
||||||
|
SELECT
|
||||||
|
em.id as exported_material_id,
|
||||||
|
em.material_id,
|
||||||
|
m.original_description,
|
||||||
|
m.classified_category,
|
||||||
|
m.size_inch,
|
||||||
|
m.schedule,
|
||||||
|
m.material_grade,
|
||||||
|
m.quantity,
|
||||||
|
m.unit,
|
||||||
|
em.purchase_status,
|
||||||
|
em.purchase_request_no,
|
||||||
|
em.purchase_order_no,
|
||||||
|
em.vendor_name,
|
||||||
|
em.expected_date,
|
||||||
|
em.quantity_ordered,
|
||||||
|
em.quantity_received,
|
||||||
|
em.unit_price,
|
||||||
|
em.total_price,
|
||||||
|
em.notes,
|
||||||
|
ur.requirement as user_requirement
|
||||||
|
FROM exported_materials em
|
||||||
|
JOIN materials m ON em.material_id = m.id
|
||||||
|
LEFT JOIN user_requirements ur ON m.id = ur.material_id
|
||||||
|
WHERE em.export_id = :export_id
|
||||||
|
ORDER BY m.classified_category, m.original_description
|
||||||
|
""")
|
||||||
|
|
||||||
|
results = db.execute(query, {"export_id": export_id}).fetchall()
|
||||||
|
|
||||||
|
materials = []
|
||||||
|
for row in results:
|
||||||
|
materials.append({
|
||||||
|
"exported_material_id": row.exported_material_id,
|
||||||
|
"material_id": row.material_id,
|
||||||
|
"description": row.original_description,
|
||||||
|
"category": row.classified_category,
|
||||||
|
"size": row.size_inch,
|
||||||
|
"schedule": row.schedule,
|
||||||
|
"material_grade": row.material_grade,
|
||||||
|
"quantity": row.quantity,
|
||||||
|
"unit": row.unit,
|
||||||
|
"user_requirement": row.user_requirement,
|
||||||
|
"purchase_status": row.purchase_status,
|
||||||
|
"purchase_request_no": row.purchase_request_no,
|
||||||
|
"purchase_order_no": row.purchase_order_no,
|
||||||
|
"vendor_name": row.vendor_name,
|
||||||
|
"expected_date": row.expected_date.isoformat() if row.expected_date else None,
|
||||||
|
"quantity_ordered": row.quantity_ordered,
|
||||||
|
"quantity_received": row.quantity_received,
|
||||||
|
"unit_price": float(row.unit_price) if row.unit_price else None,
|
||||||
|
"total_price": float(row.total_price) if row.total_price else None,
|
||||||
|
"notes": row.notes
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"materials": materials,
|
||||||
|
"count": len(materials)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get batch materials: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"배치 자재 조회 실패: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/batch/{export_id}/download")
|
||||||
|
async def download_batch_excel(
|
||||||
|
export_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
저장된 배치 엑셀 파일 다운로드
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 배치 정보 조회
|
||||||
|
query = text("""
|
||||||
|
SELECT file_name, notes
|
||||||
|
FROM excel_export_history
|
||||||
|
WHERE export_id = :export_id
|
||||||
|
""")
|
||||||
|
result = db.execute(query, {"export_id": export_id}).fetchone()
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="배치를 찾을 수 없습니다"
|
||||||
|
)
|
||||||
|
|
||||||
|
file_path = os.path.join(EXPORT_DIR, result.file_name)
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
# 파일이 없으면 재생성
|
||||||
|
materials = await get_batch_materials(export_id, current_user, db)
|
||||||
|
|
||||||
|
batch_no = ""
|
||||||
|
if result.notes and "배치번호:" in result.notes:
|
||||||
|
batch_no = result.notes.split("배치번호:")[1].strip()
|
||||||
|
|
||||||
|
batch_info = {
|
||||||
|
"batch_no": batch_no,
|
||||||
|
"export_date": datetime.now().strftime('%Y-%m-%d %H:%M')
|
||||||
|
}
|
||||||
|
|
||||||
|
file_name = create_excel_from_materials(materials["materials"], batch_info)
|
||||||
|
file_path = os.path.join(EXPORT_DIR, file_name)
|
||||||
|
|
||||||
|
# DB 업데이트
|
||||||
|
update_query = text("""
|
||||||
|
UPDATE excel_export_history
|
||||||
|
SET file_name = :file_name
|
||||||
|
WHERE export_id = :export_id
|
||||||
|
""")
|
||||||
|
db.execute(update_query, {
|
||||||
|
"file_name": file_name,
|
||||||
|
"export_id": export_id
|
||||||
|
})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=file_path,
|
||||||
|
filename=result.file_name,
|
||||||
|
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to download batch excel: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"엑셀 다운로드 실패: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/batch/{export_id}/status")
|
||||||
|
async def update_batch_status(
|
||||||
|
export_id: int,
|
||||||
|
status: str,
|
||||||
|
purchase_request_no: Optional[str] = None,
|
||||||
|
purchase_order_no: Optional[str] = None,
|
||||||
|
vendor_name: Optional[str] = None,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
배치 전체 상태 일괄 업데이트
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 배치의 모든 자재 상태 업데이트
|
||||||
|
update_query = text("""
|
||||||
|
UPDATE exported_materials
|
||||||
|
SET
|
||||||
|
purchase_status = :status,
|
||||||
|
purchase_request_no = COALESCE(:pr_no, purchase_request_no),
|
||||||
|
purchase_order_no = COALESCE(:po_no, purchase_order_no),
|
||||||
|
vendor_name = COALESCE(:vendor, vendor_name),
|
||||||
|
updated_by = :updated_by,
|
||||||
|
requested_date = CASE WHEN :status = 'requested' THEN CURRENT_TIMESTAMP ELSE requested_date END,
|
||||||
|
ordered_date = CASE WHEN :status = 'ordered' THEN CURRENT_TIMESTAMP ELSE ordered_date END,
|
||||||
|
received_date = CASE WHEN :status = 'received' THEN CURRENT_TIMESTAMP ELSE received_date END
|
||||||
|
WHERE export_id = :export_id
|
||||||
|
""")
|
||||||
|
|
||||||
|
result = db.execute(update_query, {
|
||||||
|
"export_id": export_id,
|
||||||
|
"status": status,
|
||||||
|
"pr_no": purchase_request_no,
|
||||||
|
"po_no": purchase_order_no,
|
||||||
|
"vendor": vendor_name,
|
||||||
|
"updated_by": current_user.get("user_id")
|
||||||
|
})
|
||||||
|
|
||||||
|
# 이력 기록
|
||||||
|
history_query = text("""
|
||||||
|
INSERT INTO purchase_status_history (
|
||||||
|
exported_material_id, material_id,
|
||||||
|
previous_status, new_status,
|
||||||
|
changed_by, reason
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
em.id, em.material_id,
|
||||||
|
em.purchase_status, :new_status,
|
||||||
|
:changed_by, :reason
|
||||||
|
FROM exported_materials em
|
||||||
|
WHERE em.export_id = :export_id
|
||||||
|
""")
|
||||||
|
|
||||||
|
db.execute(history_query, {
|
||||||
|
"export_id": export_id,
|
||||||
|
"new_status": status,
|
||||||
|
"changed_by": current_user.get("user_id"),
|
||||||
|
"reason": f"배치 일괄 업데이트"
|
||||||
|
})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Batch {export_id} status updated to {status}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"배치의 모든 자재가 {status} 상태로 변경되었습니다",
|
||||||
|
"updated_count": result.rowcount
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to update batch status: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"배치 상태 업데이트 실패: {str(e)}"
|
||||||
|
)
|
||||||
@@ -218,7 +218,8 @@ def parse_dataframe(df):
|
|||||||
mapped_columns[standard_col] = possible_name
|
mapped_columns[standard_col] = possible_name
|
||||||
break
|
break
|
||||||
|
|
||||||
# 로그 제거
|
print(f"📋 엑셀 컬럼 매핑 결과: {mapped_columns}")
|
||||||
|
print(f"📋 원본 컬럼명들: {list(df.columns)}")
|
||||||
|
|
||||||
materials = []
|
materials = []
|
||||||
for index, row in df.iterrows():
|
for index, row in df.iterrows():
|
||||||
@@ -262,16 +263,34 @@ def parse_dataframe(df):
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
length_value = None
|
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', '']:
|
if description and description not in ['nan', 'None', '']:
|
||||||
materials.append({
|
materials.append({
|
||||||
'original_description': description,
|
'original_description': description,
|
||||||
'quantity': quantity,
|
'quantity': quantity,
|
||||||
'unit': "EA",
|
'unit': "EA",
|
||||||
'size_spec': size_spec,
|
'size_spec': size_spec,
|
||||||
'main_nom': main_nom, # 추가
|
'main_nom': main_nom,
|
||||||
'red_nom': red_nom, # 추가
|
'red_nom': red_nom,
|
||||||
'material_grade': material_grade,
|
'material_grade': material_grade,
|
||||||
'length': length_value,
|
'length': length_value,
|
||||||
|
'dwg_name': dwg_name,
|
||||||
|
'line_num': line_num,
|
||||||
'line_number': index + 1,
|
'line_number': index + 1,
|
||||||
'row_number': index + 1
|
'row_number': index + 1
|
||||||
})
|
})
|
||||||
@@ -514,72 +533,67 @@ async def upload_file(
|
|||||||
})
|
})
|
||||||
materials_inserted += 1
|
materials_inserted += 1
|
||||||
|
|
||||||
# 리비전 업로드인 경우 차이분 계산
|
# 리비전 업로드의 경우 변경사항 추적
|
||||||
materials_diff = []
|
changed_materials_keys = set()
|
||||||
original_materials_to_classify = materials_to_classify.copy() # 원본 보존
|
new_materials_keys = set()
|
||||||
|
|
||||||
if parent_file_id is not None:
|
if parent_file_id is not None:
|
||||||
# 새 파일의 자재들을 수량별로 그룹화
|
print(f"🔄 리비전 업로드: 전체 {len(materials_to_classify)}개 자재 저장 (변경사항 추적 포함)")
|
||||||
new_materials_grouped = {}
|
|
||||||
for material_data in original_materials_to_classify:
|
# 이전 리비전의 자재 조회 (도면번호 기준)
|
||||||
description = material_data["original_description"]
|
prev_materials_query = text("""
|
||||||
size_spec = material_data["size_spec"]
|
SELECT original_description, size_spec, material_grade, main_nom,
|
||||||
quantity = float(material_data.get("quantity", 0))
|
drawing_name, line_no, quantity
|
||||||
|
FROM materials
|
||||||
material_key = f"{description}|{size_spec or ''}"
|
WHERE file_id = :parent_file_id
|
||||||
if material_key in new_materials_grouped:
|
""")
|
||||||
new_materials_grouped[material_key]["quantity"] += quantity
|
prev_materials_result = db.execute(prev_materials_query, {"parent_file_id": parent_file_id}).fetchall()
|
||||||
new_materials_grouped[material_key]["items"].append(material_data)
|
|
||||||
|
# 이전 자재를 딕셔너리로 변환 (도면번호 + 설명 + 크기 + 재질로 키 생성)
|
||||||
|
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 ''}"
|
||||||
else:
|
else:
|
||||||
new_materials_grouped[material_key] = {
|
key = f"{prev_mat.original_description}|{prev_mat.size_spec or ''}|{prev_mat.material_grade or ''}"
|
||||||
"quantity": quantity,
|
|
||||||
"items": [material_data],
|
|
||||||
"description": description,
|
|
||||||
"size_spec": size_spec
|
|
||||||
}
|
|
||||||
|
|
||||||
# 차이분 계산
|
|
||||||
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"]
|
|
||||||
|
|
||||||
# 디버깅: 첫 10개 키 매칭 상태 출력
|
prev_materials_dict[key] = {
|
||||||
if len(materials_diff) < 10:
|
"quantity": float(prev_mat.quantity) if prev_mat.quantity else 0,
|
||||||
print(f"🔑 키 매칭: '{material_key}' → 기존:{existing_quantity}, 신규:{new_quantity}")
|
"description": prev_mat.original_description
|
||||||
|
}
|
||||||
|
|
||||||
|
# 새 자재와 비교하여 변경사항 감지
|
||||||
|
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")
|
||||||
|
|
||||||
if new_quantity > existing_quantity:
|
if dwg_name:
|
||||||
# 증가분이 있는 경우
|
new_key = f"{dwg_name}|{desc}|{size_spec}|{material_grade}"
|
||||||
diff_quantity = new_quantity - existing_quantity
|
elif line_num:
|
||||||
print(f"차이분 발견: {new_data['description'][:50]}... (증가: {diff_quantity})")
|
new_key = f"{line_num}|{desc}|{size_spec}|{material_grade}"
|
||||||
|
else:
|
||||||
|
new_key = f"{desc}|{size_spec}|{material_grade}"
|
||||||
|
|
||||||
|
if new_key in prev_materials_dict:
|
||||||
|
# 기존에 있던 자재 - 수량 변경 확인
|
||||||
|
prev_qty = prev_materials_dict[new_key]["quantity"]
|
||||||
|
new_qty = float(material_data.get("quantity", 0))
|
||||||
|
|
||||||
# 증가분만큼 자재 데이터 생성
|
if abs(prev_qty - new_qty) > 0.001:
|
||||||
for item in new_data["items"]:
|
# 수량이 변경됨
|
||||||
if diff_quantity <= 0:
|
changed_materials_keys.add(new_key)
|
||||||
break
|
print(f"🔄 변경 감지: {desc[:40]}... (수량: {prev_qty} → {new_qty})")
|
||||||
|
|
||||||
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:
|
else:
|
||||||
print(f"수량 감소/동일: {new_data['description'][:50]}... (기존:{existing_quantity} 새:{new_quantity})")
|
# 새로 추가된 자재
|
||||||
|
new_materials_keys.add(new_key)
|
||||||
|
print(f"➕ 신규 감지: {desc[:40]}...")
|
||||||
|
|
||||||
# 차이분만 처리하도록 materials_to_classify 교체
|
print(f"📊 변경사항 요약: 변경 {len(changed_materials_keys)}개, 신규 {len(new_materials_keys)}개")
|
||||||
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:
|
else:
|
||||||
print(f"🆕 신규 업로드: 전체 {len(materials_to_classify)}개 분류 처리")
|
print(f"🆕 신규 업로드: 전체 {len(materials_to_classify)}개 분류 처리")
|
||||||
|
|
||||||
@@ -651,6 +665,18 @@ async def upload_file(
|
|||||||
elif material_type == "SUPPORT":
|
elif material_type == "SUPPORT":
|
||||||
from ..services.support_classifier import classify_support
|
from ..services.support_classifier import classify_support
|
||||||
classification_result = classify_support("", description, main_nom or "")
|
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:
|
else:
|
||||||
# UNKNOWN 처리
|
# UNKNOWN 처리
|
||||||
classification_result = {
|
classification_result = {
|
||||||
@@ -679,12 +705,14 @@ async def upload_file(
|
|||||||
INSERT INTO materials (
|
INSERT INTO materials (
|
||||||
file_id, original_description, quantity, unit, size_spec,
|
file_id, original_description, quantity, unit, size_spec,
|
||||||
main_nom, red_nom, material_grade, full_material_grade, line_number, row_number,
|
main_nom, red_nom, material_grade, full_material_grade, line_number, row_number,
|
||||||
classified_category, classification_confidence, is_verified, created_at
|
classified_category, classification_confidence, is_verified,
|
||||||
|
drawing_name, line_no, created_at
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
:file_id, :original_description, :quantity, :unit, :size_spec,
|
:file_id, :original_description, :quantity, :unit, :size_spec,
|
||||||
:main_nom, :red_nom, :material_grade, :full_material_grade, :line_number, :row_number,
|
:main_nom, :red_nom, :material_grade, :full_material_grade, :line_number, :row_number,
|
||||||
:classified_category, :classification_confidence, :is_verified, :created_at
|
:classified_category, :classification_confidence, :is_verified,
|
||||||
|
:drawing_name, :line_no, :created_at
|
||||||
)
|
)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""")
|
""")
|
||||||
@@ -695,6 +723,8 @@ async def upload_file(
|
|||||||
print(f" size_spec: '{material_data['size_spec']}'")
|
print(f" size_spec: '{material_data['size_spec']}'")
|
||||||
print(f" original_description: {material_data['original_description']}")
|
print(f" original_description: {material_data['original_description']}")
|
||||||
print(f" category: {classification_result.get('category', 'UNKNOWN')}")
|
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, {
|
material_result = db.execute(material_insert_query, {
|
||||||
"file_id": file_id,
|
"file_id": file_id,
|
||||||
@@ -702,8 +732,8 @@ async def upload_file(
|
|||||||
"quantity": material_data["quantity"],
|
"quantity": material_data["quantity"],
|
||||||
"unit": material_data["unit"],
|
"unit": material_data["unit"],
|
||||||
"size_spec": material_data["size_spec"],
|
"size_spec": material_data["size_spec"],
|
||||||
"main_nom": material_data.get("main_nom"), # 추가
|
"main_nom": material_data.get("main_nom"),
|
||||||
"red_nom": material_data.get("red_nom"), # 추가
|
"red_nom": material_data.get("red_nom"),
|
||||||
"material_grade": material_data["material_grade"],
|
"material_grade": material_data["material_grade"],
|
||||||
"full_material_grade": full_material_grade,
|
"full_material_grade": full_material_grade,
|
||||||
"line_number": material_data["line_number"],
|
"line_number": material_data["line_number"],
|
||||||
@@ -711,13 +741,45 @@ async def upload_file(
|
|||||||
"classified_category": classification_result.get("category", "UNKNOWN"),
|
"classified_category": classification_result.get("category", "UNKNOWN"),
|
||||||
"classification_confidence": classification_result.get("overall_confidence", 0.0),
|
"classification_confidence": classification_result.get("overall_confidence", 0.0),
|
||||||
"is_verified": False,
|
"is_verified": False,
|
||||||
|
"drawing_name": material_data.get("dwg_name"),
|
||||||
|
"line_no": material_data.get("line_num"),
|
||||||
"created_at": datetime.now()
|
"created_at": datetime.now()
|
||||||
})
|
})
|
||||||
|
|
||||||
material_id = material_result.fetchone()[0]
|
material_id = material_result.fetchone()[0]
|
||||||
materials_inserted += 1
|
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 분류 결과인 경우 상세 정보 저장
|
# PIPE 분류 결과인 경우 상세 정보 저장
|
||||||
if classification_result.get("category") == "PIPE":
|
if classification_result.get("category") == "PIPE":
|
||||||
@@ -1397,6 +1459,83 @@ async def upload_file(
|
|||||||
db.commit()
|
db.commit()
|
||||||
print(f"자재 저장 완료: {materials_inserted}개")
|
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:
|
try:
|
||||||
activity_logger = ActivityLogger(db)
|
activity_logger = ActivityLogger(db)
|
||||||
@@ -1416,25 +1555,136 @@ async def upload_file(
|
|||||||
print(f"활동 로그 기록 실패: {str(e)}")
|
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:
|
if parent_file_id is not None:
|
||||||
message = f"리비전 업로드 성공! {new_materials_count}개의 신규 자재가 추가되었습니다."
|
message = f"리비전 업로드 성공! {new_materials_count}개의 신규 자재가 추가되었습니다."
|
||||||
else:
|
else:
|
||||||
message = f"업로드 성공! {materials_inserted}개 자재가 분류되었습니다."
|
message = f"업로드 성공! {materials_inserted}개 자재가 분류되었습니다."
|
||||||
|
|
||||||
return {
|
response_data = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": message,
|
"message": message,
|
||||||
"original_filename": file.filename,
|
"original_filename": file.filename,
|
||||||
"file_id": file_id,
|
"file_id": file_id,
|
||||||
"materials_count": materials_inserted,
|
"materials_count": materials_inserted,
|
||||||
"saved_materials_count": materials_inserted,
|
"saved_materials_count": materials_inserted,
|
||||||
"new_materials_count": new_materials_count if parent_file_id is not None else None, # 신규 자재 수
|
"new_materials_count": new_materials_count if parent_file_id is not None else None,
|
||||||
"revision": revision, # 생성된 리비전 정보 추가
|
"revision": revision,
|
||||||
"uploaded_by": username, # 업로드한 사용자 정보 추가
|
"uploaded_by": username,
|
||||||
"parsed_count": parsed_count
|
"parsed_count": parsed_count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 누락된 도면 정보 추가
|
||||||
|
if missing_drawings_info:
|
||||||
|
response_data["missing_drawings"] = missing_drawings_info
|
||||||
|
|
||||||
|
return response_data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
@@ -1565,6 +1815,8 @@ async def get_materials(
|
|||||||
size_spec: Optional[str] = None,
|
size_spec: Optional[str] = None,
|
||||||
file_filter: Optional[str] = None,
|
file_filter: Optional[str] = None,
|
||||||
sort_by: Optional[str] = None,
|
sort_by: Optional[str] = None,
|
||||||
|
exclude_requested: bool = True, # 구매신청된 자재 제외 여부
|
||||||
|
group_by_spec: bool = False, # 같은 사양끼리 그룹화
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -1575,6 +1827,7 @@ async def get_materials(
|
|||||||
query = """
|
query = """
|
||||||
SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit,
|
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.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.created_at, m.classified_category, m.classification_confidence,
|
||||||
m.classification_details,
|
m.classification_details,
|
||||||
m.is_verified, m.verified_by, m.verified_at,
|
m.is_verified, m.verified_by, m.verified_at,
|
||||||
@@ -1612,6 +1865,8 @@ async def get_materials(
|
|||||||
FROM materials m
|
FROM materials m
|
||||||
LEFT JOIN files f ON m.file_id = f.id
|
LEFT JOIN files f ON m.file_id = f.id
|
||||||
LEFT JOIN projects p ON f.project_id = p.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_details pd ON m.id = pd.material_id
|
||||||
LEFT JOIN pipe_end_preparations pep ON m.id = pep.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
|
LEFT JOIN fitting_details fd ON m.id = fd.material_id
|
||||||
@@ -1625,6 +1880,11 @@ async def get_materials(
|
|||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
"""
|
"""
|
||||||
params = {}
|
params = {}
|
||||||
|
|
||||||
|
# 구매신청된 자재 제외
|
||||||
|
if exclude_requested:
|
||||||
|
query += " AND pri.material_id IS NULL"
|
||||||
|
|
||||||
if project_id:
|
if project_id:
|
||||||
query += " AND f.project_id = :project_id"
|
query += " AND f.project_id = :project_id"
|
||||||
params["project_id"] = project_id
|
params["project_id"] = project_id
|
||||||
@@ -1769,11 +2029,14 @@ async def get_materials(
|
|||||||
"quantity": float(m.quantity) if m.quantity else 0,
|
"quantity": float(m.quantity) if m.quantity else 0,
|
||||||
"unit": m.unit,
|
"unit": m.unit,
|
||||||
"size_spec": m.size_spec,
|
"size_spec": m.size_spec,
|
||||||
"main_nom": m.main_nom, # 추가
|
"main_nom": m.main_nom,
|
||||||
"red_nom": m.red_nom, # 추가
|
"red_nom": m.red_nom,
|
||||||
"material_grade": m.full_material_grade or enhanced_material_grade, # 전체 재질명 우선 사용
|
"material_grade": m.full_material_grade or enhanced_material_grade,
|
||||||
"original_material_grade": m.material_grade, # 원본 재질 정보도 보존
|
"original_material_grade": m.material_grade,
|
||||||
"full_material_grade": m.full_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',
|
||||||
"line_number": m.line_number,
|
"line_number": m.line_number,
|
||||||
"row_number": m.row_number,
|
"row_number": m.row_number,
|
||||||
# 구매수량 계산에서 분류된 정보를 우선 사용
|
# 구매수량 계산에서 분류된 정보를 우선 사용
|
||||||
@@ -2093,6 +2356,20 @@ async def get_materials(
|
|||||||
# 평균 단위 길이 계산
|
# 평균 단위 길이 계산
|
||||||
if group_info["total_quantity"] > 0:
|
if group_info["total_quantity"] > 0:
|
||||||
representative_pipe['pipe_details']['avg_length_mm'] = group_info["total_length_mm"] / group_info["total_quantity"]
|
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)
|
material_list.append(representative_pipe)
|
||||||
|
|
||||||
@@ -2297,7 +2574,8 @@ async def compare_revisions(
|
|||||||
# 기존 리비전 자재 조회
|
# 기존 리비전 자재 조회
|
||||||
old_materials_query = text("""
|
old_materials_query = text("""
|
||||||
SELECT m.original_description, m.quantity, m.unit, m.size_spec,
|
SELECT m.original_description, m.quantity, m.unit, m.size_spec,
|
||||||
m.material_grade, m.classified_category, m.classification_confidence
|
m.material_grade, m.classified_category, m.classification_confidence,
|
||||||
|
m.main_nom, m.red_nom, m.drawing_name, m.line_no
|
||||||
FROM materials m
|
FROM materials m
|
||||||
JOIN files f ON m.file_id = f.id
|
JOIN files f ON m.file_id = f.id
|
||||||
WHERE f.job_no = :job_no
|
WHERE f.job_no = :job_no
|
||||||
@@ -2315,7 +2593,8 @@ async def compare_revisions(
|
|||||||
# 새 리비전 자재 조회
|
# 새 리비전 자재 조회
|
||||||
new_materials_query = text("""
|
new_materials_query = text("""
|
||||||
SELECT m.original_description, m.quantity, m.unit, m.size_spec,
|
SELECT m.original_description, m.quantity, m.unit, m.size_spec,
|
||||||
m.material_grade, m.classified_category, m.classification_confidence
|
m.material_grade, m.classified_category, m.classification_confidence,
|
||||||
|
m.main_nom, m.red_nom, m.drawing_name, m.line_no
|
||||||
FROM materials m
|
FROM materials m
|
||||||
JOIN files f ON m.file_id = f.id
|
JOIN files f ON m.file_id = f.id
|
||||||
WHERE f.job_no = :job_no
|
WHERE f.job_no = :job_no
|
||||||
@@ -2330,9 +2609,17 @@ async def compare_revisions(
|
|||||||
})
|
})
|
||||||
new_materials = new_result.fetchall()
|
new_materials = new_result.fetchall()
|
||||||
|
|
||||||
# 자재 키 생성 함수 (전체 수량 기준)
|
# 자재 키 생성 함수 (도면번호 + 자재 정보)
|
||||||
def create_material_key(material):
|
def create_material_key(material):
|
||||||
return f"{material.original_description}|{material.size_spec or ''}|{material.material_grade or ''}"
|
# 도면번호가 있으면 도면번호 + 자재 설명으로 고유 키 생성
|
||||||
|
# (같은 도면에 여러 자재가 있을 수 있으므로)
|
||||||
|
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 ''}"
|
||||||
|
|
||||||
# 기존 자재를 딕셔너리로 변환 (수량 합산)
|
# 기존 자재를 딕셔너리로 변환 (수량 합산)
|
||||||
old_materials_dict = {}
|
old_materials_dict = {}
|
||||||
@@ -2349,7 +2636,11 @@ async def compare_revisions(
|
|||||||
"size_spec": material.size_spec,
|
"size_spec": material.size_spec,
|
||||||
"material_grade": material.material_grade,
|
"material_grade": material.material_grade,
|
||||||
"classified_category": material.classified_category,
|
"classified_category": material.classified_category,
|
||||||
"classification_confidence": material.classification_confidence
|
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
# 새 자재를 딕셔너리로 변환 (수량 합산)
|
# 새 자재를 딕셔너리로 변환 (수량 합산)
|
||||||
@@ -2367,7 +2658,11 @@ async def compare_revisions(
|
|||||||
"size_spec": material.size_spec,
|
"size_spec": material.size_spec,
|
||||||
"material_grade": material.material_grade,
|
"material_grade": material.material_grade,
|
||||||
"classified_category": material.classified_category,
|
"classified_category": material.classified_category,
|
||||||
"classification_confidence": material.classification_confidence
|
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
# 변경 사항 분석
|
# 변경 사항 분석
|
||||||
@@ -2396,22 +2691,68 @@ async def compare_revisions(
|
|||||||
"change_type": "added"
|
"change_type": "added"
|
||||||
})
|
})
|
||||||
elif old_item and new_item:
|
elif old_item and new_item:
|
||||||
# 수량 변경 확인 (전체 수량 기준)
|
# 변경 사항 감지: 수량, 재질, 크기, 카테고리 등
|
||||||
|
changes_detected = []
|
||||||
|
|
||||||
old_qty = old_item["quantity"]
|
old_qty = old_item["quantity"]
|
||||||
new_qty = new_item["quantity"]
|
new_qty = new_item["quantity"]
|
||||||
qty_diff = new_qty - old_qty
|
qty_diff = new_qty - old_qty
|
||||||
|
|
||||||
# 수량 차이가 있으면 변경된 것으로 간주 (소수점 오차 고려)
|
# 1. 수량 변경 확인
|
||||||
if abs(qty_diff) > 0.001:
|
if abs(qty_diff) > 0.001:
|
||||||
change_type = "quantity_increased" if qty_diff > 0 else "quantity_decreased"
|
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"
|
||||||
|
|
||||||
changed_items.append({
|
changed_items.append({
|
||||||
"key": key,
|
"key": key,
|
||||||
"old_item": old_item,
|
"old_item": old_item,
|
||||||
"new_item": new_item,
|
"new_item": new_item,
|
||||||
"quantity_change": qty_diff,
|
"changes": changes_detected,
|
||||||
"quantity_change_abs": abs(qty_diff),
|
|
||||||
"change_type": change_type,
|
"change_type": change_type,
|
||||||
"change_percentage": (qty_diff / old_qty * 100) if old_qty > 0 else 0
|
"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")
|
||||||
})
|
})
|
||||||
|
|
||||||
# 분류별 통계
|
# 분류별 통계
|
||||||
@@ -2429,6 +2770,40 @@ async def compare_revisions(
|
|||||||
removed_stats = calculate_category_stats(removed_items)
|
removed_stats = calculate_category_stats(removed_items)
|
||||||
changed_stats = calculate_category_stats(changed_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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"comparison": {
|
"comparison": {
|
||||||
@@ -2440,7 +2815,15 @@ async def compare_revisions(
|
|||||||
"added_count": len(added_items),
|
"added_count": len(added_items),
|
||||||
"removed_count": len(removed_items),
|
"removed_count": len(removed_items),
|
||||||
"changed_count": len(changed_items),
|
"changed_count": len(changed_items),
|
||||||
"total_changes": len(added_items) + len(removed_items) + 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
|
||||||
},
|
},
|
||||||
"changes": {
|
"changes": {
|
||||||
"added": added_items,
|
"added": added_items,
|
||||||
@@ -3230,4 +3613,91 @@ async def log_materials_view(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"조회 로그 기록 실패: {str(e)}")
|
logger.error(f"조회 로그 기록 실패: {str(e)}")
|
||||||
return {"message": "조회 로그 기록 실패"}
|
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)}")
|
||||||
577
backend/app/routers/purchase_request.py
Normal file
577
backend/app/routers/purchase_request.py
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
"""
|
||||||
|
구매신청 관리 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)
|
||||||
452
backend/app/routers/purchase_tracking.py
Normal file
452
backend/app/routers/purchase_tracking.py
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
"""
|
||||||
|
구매 추적 및 관리 API
|
||||||
|
엑셀 내보내기 이력 및 구매 상태 관리
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from datetime import datetime, date
|
||||||
|
import json
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..auth.jwt_service import get_current_user
|
||||||
|
from ..utils.logger import logger
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/purchase", tags=["Purchase Tracking"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/export-history")
|
||||||
|
async def create_export_history(
|
||||||
|
file_id: int,
|
||||||
|
job_no: Optional[str] = None,
|
||||||
|
export_type: str = "full",
|
||||||
|
category: Optional[str] = None,
|
||||||
|
material_ids: List[int] = [],
|
||||||
|
filters_applied: Optional[Dict] = None,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
엑셀 내보내기 이력 생성 및 자재 추적
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 내보내기 이력 생성
|
||||||
|
insert_history = text("""
|
||||||
|
INSERT INTO excel_export_history (
|
||||||
|
file_id, job_no, exported_by, export_type,
|
||||||
|
category, material_count, filters_applied
|
||||||
|
) VALUES (
|
||||||
|
:file_id, :job_no, :exported_by, :export_type,
|
||||||
|
:category, :material_count, :filters_applied
|
||||||
|
) RETURNING export_id
|
||||||
|
""")
|
||||||
|
|
||||||
|
result = db.execute(insert_history, {
|
||||||
|
"file_id": file_id,
|
||||||
|
"job_no": job_no,
|
||||||
|
"exported_by": current_user.get("user_id"),
|
||||||
|
"export_type": export_type,
|
||||||
|
"category": category,
|
||||||
|
"material_count": len(material_ids),
|
||||||
|
"filters_applied": json.dumps(filters_applied) if filters_applied else None
|
||||||
|
})
|
||||||
|
|
||||||
|
export_id = result.fetchone().export_id
|
||||||
|
|
||||||
|
# 내보낸 자재들 기록
|
||||||
|
if material_ids:
|
||||||
|
for material_id in material_ids:
|
||||||
|
insert_material = text("""
|
||||||
|
INSERT INTO exported_materials (
|
||||||
|
export_id, material_id, purchase_status
|
||||||
|
) VALUES (
|
||||||
|
:export_id, :material_id, 'pending'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
db.execute(insert_material, {
|
||||||
|
"export_id": export_id,
|
||||||
|
"material_id": material_id
|
||||||
|
})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Export history created: {export_id} with {len(material_ids)} materials")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"export_id": export_id,
|
||||||
|
"message": f"{len(material_ids)}개 자재의 내보내기 이력이 저장되었습니다"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to create export history: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"내보내기 이력 생성 실패: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export-history")
|
||||||
|
async def get_export_history(
|
||||||
|
file_id: Optional[int] = None,
|
||||||
|
job_no: Optional[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
엑셀 내보내기 이력 조회
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = text("""
|
||||||
|
SELECT
|
||||||
|
eeh.export_id,
|
||||||
|
eeh.file_id,
|
||||||
|
eeh.job_no,
|
||||||
|
eeh.export_date,
|
||||||
|
eeh.export_type,
|
||||||
|
eeh.category,
|
||||||
|
eeh.material_count,
|
||||||
|
u.name as exported_by_name,
|
||||||
|
f.original_filename,
|
||||||
|
COUNT(DISTINCT em.material_id) as actual_material_count,
|
||||||
|
COUNT(DISTINCT CASE WHEN em.purchase_status = 'pending' THEN em.material_id END) as pending_count,
|
||||||
|
COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) as requested_count,
|
||||||
|
COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) as ordered_count,
|
||||||
|
COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) as received_count
|
||||||
|
FROM excel_export_history eeh
|
||||||
|
LEFT JOIN users u ON eeh.exported_by = u.user_id
|
||||||
|
LEFT JOIN files f ON eeh.file_id = f.id
|
||||||
|
LEFT JOIN exported_materials em ON eeh.export_id = em.export_id
|
||||||
|
WHERE 1=1
|
||||||
|
AND (:file_id IS NULL OR eeh.file_id = :file_id)
|
||||||
|
AND (:job_no IS NULL OR eeh.job_no = :job_no)
|
||||||
|
GROUP BY
|
||||||
|
eeh.export_id, eeh.file_id, eeh.job_no, eeh.export_date,
|
||||||
|
eeh.export_type, eeh.category, eeh.material_count,
|
||||||
|
u.name, f.original_filename
|
||||||
|
ORDER BY eeh.export_date DESC
|
||||||
|
LIMIT :limit
|
||||||
|
""")
|
||||||
|
|
||||||
|
results = db.execute(query, {
|
||||||
|
"file_id": file_id,
|
||||||
|
"job_no": job_no,
|
||||||
|
"limit": limit
|
||||||
|
}).fetchall()
|
||||||
|
|
||||||
|
history = []
|
||||||
|
for row in results:
|
||||||
|
history.append({
|
||||||
|
"export_id": row.export_id,
|
||||||
|
"file_id": row.file_id,
|
||||||
|
"job_no": row.job_no,
|
||||||
|
"export_date": row.export_date.isoformat() if row.export_date else None,
|
||||||
|
"export_type": row.export_type,
|
||||||
|
"category": row.category,
|
||||||
|
"material_count": row.material_count,
|
||||||
|
"exported_by": row.exported_by_name,
|
||||||
|
"file_name": row.original_filename,
|
||||||
|
"status_summary": {
|
||||||
|
"total": row.actual_material_count,
|
||||||
|
"pending": row.pending_count,
|
||||||
|
"requested": row.requested_count,
|
||||||
|
"ordered": row.ordered_count,
|
||||||
|
"received": row.received_count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"history": history,
|
||||||
|
"count": len(history)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get export history: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"내보내기 이력 조회 실패: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/materials/status")
|
||||||
|
async def get_materials_by_status(
|
||||||
|
status: Optional[str] = None,
|
||||||
|
export_id: Optional[int] = None,
|
||||||
|
file_id: Optional[int] = None,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
구매 상태별 자재 목록 조회
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = text("""
|
||||||
|
SELECT
|
||||||
|
em.id as exported_material_id,
|
||||||
|
em.material_id,
|
||||||
|
m.original_description,
|
||||||
|
m.classified_category,
|
||||||
|
m.quantity,
|
||||||
|
m.unit,
|
||||||
|
em.purchase_status,
|
||||||
|
em.purchase_request_no,
|
||||||
|
em.purchase_order_no,
|
||||||
|
em.vendor_name,
|
||||||
|
em.expected_date,
|
||||||
|
em.quantity_ordered,
|
||||||
|
em.quantity_received,
|
||||||
|
em.unit_price,
|
||||||
|
em.total_price,
|
||||||
|
em.notes,
|
||||||
|
em.updated_at,
|
||||||
|
eeh.export_date,
|
||||||
|
f.original_filename as file_name,
|
||||||
|
j.job_no,
|
||||||
|
j.job_name
|
||||||
|
FROM exported_materials em
|
||||||
|
JOIN materials m ON em.material_id = m.id
|
||||||
|
JOIN excel_export_history eeh ON em.export_id = eeh.export_id
|
||||||
|
LEFT JOIN files f ON eeh.file_id = f.id
|
||||||
|
LEFT JOIN jobs j ON eeh.job_no = j.job_no
|
||||||
|
WHERE 1=1
|
||||||
|
AND (:status IS NULL OR em.purchase_status = :status)
|
||||||
|
AND (:export_id IS NULL OR em.export_id = :export_id)
|
||||||
|
AND (:file_id IS NULL OR eeh.file_id = :file_id)
|
||||||
|
ORDER BY em.updated_at DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
results = db.execute(query, {
|
||||||
|
"status": status,
|
||||||
|
"export_id": export_id,
|
||||||
|
"file_id": file_id
|
||||||
|
}).fetchall()
|
||||||
|
|
||||||
|
materials = []
|
||||||
|
for row in results:
|
||||||
|
materials.append({
|
||||||
|
"exported_material_id": row.exported_material_id,
|
||||||
|
"material_id": row.material_id,
|
||||||
|
"description": row.original_description,
|
||||||
|
"category": row.classified_category,
|
||||||
|
"quantity": row.quantity,
|
||||||
|
"unit": row.unit,
|
||||||
|
"purchase_status": row.purchase_status,
|
||||||
|
"purchase_request_no": row.purchase_request_no,
|
||||||
|
"purchase_order_no": row.purchase_order_no,
|
||||||
|
"vendor_name": row.vendor_name,
|
||||||
|
"expected_date": row.expected_date.isoformat() if row.expected_date else None,
|
||||||
|
"quantity_ordered": row.quantity_ordered,
|
||||||
|
"quantity_received": row.quantity_received,
|
||||||
|
"unit_price": float(row.unit_price) if row.unit_price else None,
|
||||||
|
"total_price": float(row.total_price) if row.total_price else None,
|
||||||
|
"notes": row.notes,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
"export_date": row.export_date.isoformat() if row.export_date else None,
|
||||||
|
"file_name": row.file_name,
|
||||||
|
"job_no": row.job_no,
|
||||||
|
"job_name": row.job_name
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"materials": materials,
|
||||||
|
"count": len(materials)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get materials by status: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"구매 상태별 자재 조회 실패: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/materials/{exported_material_id}/status")
|
||||||
|
async def update_purchase_status(
|
||||||
|
exported_material_id: int,
|
||||||
|
new_status: str,
|
||||||
|
purchase_request_no: Optional[str] = None,
|
||||||
|
purchase_order_no: Optional[str] = None,
|
||||||
|
vendor_name: Optional[str] = None,
|
||||||
|
expected_date: Optional[date] = None,
|
||||||
|
quantity_ordered: Optional[int] = None,
|
||||||
|
quantity_received: Optional[int] = None,
|
||||||
|
unit_price: Optional[float] = None,
|
||||||
|
notes: Optional[str] = None,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
자재 구매 상태 업데이트
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 현재 상태 조회
|
||||||
|
get_current = text("""
|
||||||
|
SELECT purchase_status, material_id
|
||||||
|
FROM exported_materials
|
||||||
|
WHERE id = :id
|
||||||
|
""")
|
||||||
|
current = db.execute(get_current, {"id": exported_material_id}).fetchone()
|
||||||
|
|
||||||
|
if not current:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="해당 자재를 찾을 수 없습니다"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 상태 업데이트
|
||||||
|
update_query = text("""
|
||||||
|
UPDATE exported_materials
|
||||||
|
SET
|
||||||
|
purchase_status = :new_status,
|
||||||
|
purchase_request_no = COALESCE(:pr_no, purchase_request_no),
|
||||||
|
purchase_order_no = COALESCE(:po_no, purchase_order_no),
|
||||||
|
vendor_name = COALESCE(:vendor, vendor_name),
|
||||||
|
expected_date = COALESCE(:expected_date, expected_date),
|
||||||
|
quantity_ordered = COALESCE(:qty_ordered, quantity_ordered),
|
||||||
|
quantity_received = COALESCE(:qty_received, quantity_received),
|
||||||
|
unit_price = COALESCE(:unit_price, unit_price),
|
||||||
|
total_price = CASE
|
||||||
|
WHEN :unit_price IS NOT NULL AND :qty_ordered IS NOT NULL
|
||||||
|
THEN :unit_price * :qty_ordered
|
||||||
|
WHEN :unit_price IS NOT NULL AND quantity_ordered IS NOT NULL
|
||||||
|
THEN :unit_price * quantity_ordered
|
||||||
|
ELSE total_price
|
||||||
|
END,
|
||||||
|
notes = COALESCE(:notes, notes),
|
||||||
|
updated_by = :updated_by,
|
||||||
|
requested_date = CASE WHEN :new_status = 'requested' THEN CURRENT_TIMESTAMP ELSE requested_date END,
|
||||||
|
ordered_date = CASE WHEN :new_status = 'ordered' THEN CURRENT_TIMESTAMP ELSE ordered_date END,
|
||||||
|
received_date = CASE WHEN :new_status = 'received' THEN CURRENT_TIMESTAMP ELSE received_date END
|
||||||
|
WHERE id = :id
|
||||||
|
""")
|
||||||
|
|
||||||
|
db.execute(update_query, {
|
||||||
|
"id": exported_material_id,
|
||||||
|
"new_status": new_status,
|
||||||
|
"pr_no": purchase_request_no,
|
||||||
|
"po_no": purchase_order_no,
|
||||||
|
"vendor": vendor_name,
|
||||||
|
"expected_date": expected_date,
|
||||||
|
"qty_ordered": quantity_ordered,
|
||||||
|
"qty_received": quantity_received,
|
||||||
|
"unit_price": unit_price,
|
||||||
|
"notes": notes,
|
||||||
|
"updated_by": current_user.get("user_id")
|
||||||
|
})
|
||||||
|
|
||||||
|
# 이력 기록
|
||||||
|
insert_history = text("""
|
||||||
|
INSERT INTO purchase_status_history (
|
||||||
|
exported_material_id, material_id,
|
||||||
|
previous_status, new_status,
|
||||||
|
changed_by, reason
|
||||||
|
) VALUES (
|
||||||
|
:em_id, :material_id,
|
||||||
|
:prev_status, :new_status,
|
||||||
|
:changed_by, :reason
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
db.execute(insert_history, {
|
||||||
|
"em_id": exported_material_id,
|
||||||
|
"material_id": current.material_id,
|
||||||
|
"prev_status": current.purchase_status,
|
||||||
|
"new_status": new_status,
|
||||||
|
"changed_by": current_user.get("user_id"),
|
||||||
|
"reason": notes
|
||||||
|
})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Purchase status updated: {exported_material_id} from {current.purchase_status} to {new_status}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"구매 상태가 {new_status}로 변경되었습니다"
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to update purchase status: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"구매 상태 업데이트 실패: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status-summary")
|
||||||
|
async def get_status_summary(
|
||||||
|
file_id: Optional[int] = None,
|
||||||
|
job_no: Optional[str] = None,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
구매 상태 요약 통계
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = text("""
|
||||||
|
SELECT
|
||||||
|
em.purchase_status,
|
||||||
|
COUNT(DISTINCT em.material_id) as material_count,
|
||||||
|
SUM(em.quantity_exported) as total_quantity,
|
||||||
|
SUM(em.total_price) as total_amount,
|
||||||
|
COUNT(DISTINCT em.export_id) as export_count
|
||||||
|
FROM exported_materials em
|
||||||
|
JOIN excel_export_history eeh ON em.export_id = eeh.export_id
|
||||||
|
WHERE 1=1
|
||||||
|
AND (:file_id IS NULL OR eeh.file_id = :file_id)
|
||||||
|
AND (:job_no IS NULL OR eeh.job_no = :job_no)
|
||||||
|
GROUP BY em.purchase_status
|
||||||
|
""")
|
||||||
|
|
||||||
|
results = db.execute(query, {
|
||||||
|
"file_id": file_id,
|
||||||
|
"job_no": job_no
|
||||||
|
}).fetchall()
|
||||||
|
|
||||||
|
summary = {}
|
||||||
|
total_materials = 0
|
||||||
|
total_amount = 0
|
||||||
|
|
||||||
|
for row in results:
|
||||||
|
summary[row.purchase_status] = {
|
||||||
|
"material_count": row.material_count,
|
||||||
|
"total_quantity": row.total_quantity,
|
||||||
|
"total_amount": float(row.total_amount) if row.total_amount else 0,
|
||||||
|
"export_count": row.export_count
|
||||||
|
}
|
||||||
|
total_materials += row.material_count
|
||||||
|
if row.total_amount:
|
||||||
|
total_amount += float(row.total_amount)
|
||||||
|
|
||||||
|
# 기본 상태들 추가 (없는 경우 0으로)
|
||||||
|
for status in ['pending', 'requested', 'ordered', 'received', 'cancelled']:
|
||||||
|
if status not in summary:
|
||||||
|
summary[status] = {
|
||||||
|
"material_count": 0,
|
||||||
|
"total_quantity": 0,
|
||||||
|
"total_amount": 0,
|
||||||
|
"export_count": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"summary": summary,
|
||||||
|
"total_materials": total_materials,
|
||||||
|
"total_amount": total_amount
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get status summary: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"구매 상태 요약 조회 실패: {str(e)}"
|
||||||
|
)
|
||||||
@@ -90,26 +90,37 @@ def classify_material_integrated(description: str, main_nom: str = "",
|
|||||||
desc_upper = description.upper()
|
desc_upper = description.upper()
|
||||||
|
|
||||||
# 최우선: SPECIAL 키워드 확인 (도면 업로드가 필요한 특수 자재)
|
# 최우선: SPECIAL 키워드 확인 (도면 업로드가 필요한 특수 자재)
|
||||||
special_keywords = ['SPECIAL', '스페셜', 'SPEC', 'SPL']
|
# SPECIAL이 포함된 경우 (단, SPECIFICATION은 제외)
|
||||||
for keyword in special_keywords:
|
if 'SPECIAL' in desc_upper and 'SPECIFICATION' not in desc_upper:
|
||||||
if keyword in desc_upper:
|
|
||||||
return {
|
|
||||||
"category": "SPECIAL",
|
|
||||||
"confidence": 1.0,
|
|
||||||
"evidence": [f"SPECIAL_KEYWORD: {keyword}"],
|
|
||||||
"classification_level": "LEVEL0_SPECIAL",
|
|
||||||
"reason": f"스페셜 키워드 발견: {keyword}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# U-BOLT 및 관련 부품 우선 확인 (BOLT 카테고리보다 먼저)
|
|
||||||
if ('U-BOLT' in desc_upper or 'U BOLT' in desc_upper or '유볼트' in desc_upper or
|
|
||||||
'URETHANE BLOCK' in desc_upper or 'BLOCK SHOE' in desc_upper or '우레탄' in desc_upper):
|
|
||||||
return {
|
return {
|
||||||
"category": "U_BOLT",
|
"category": "SPECIAL",
|
||||||
"confidence": 1.0,
|
"confidence": 1.0,
|
||||||
"evidence": ["U_BOLT_SYSTEM_KEYWORD"],
|
"evidence": ["SPECIAL_KEYWORD"],
|
||||||
"classification_level": "LEVEL0_U_BOLT",
|
"classification_level": "LEVEL0_SPECIAL",
|
||||||
"reason": "U-BOLT 시스템 키워드 발견"
|
"reason": "SPECIAL 키워드 발견"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 스페셜 관련 한글 키워드
|
||||||
|
if '스페셜' in desc_upper or 'SPL' in desc_upper:
|
||||||
|
return {
|
||||||
|
"category": "SPECIAL",
|
||||||
|
"confidence": 1.0,
|
||||||
|
"evidence": ["SPECIAL_KEYWORD"],
|
||||||
|
"classification_level": "LEVEL0_SPECIAL",
|
||||||
|
"reason": "스페셜 키워드 발견"
|
||||||
|
}
|
||||||
|
|
||||||
|
# SUPPORT 카테고리 우선 확인 (BOLT 카테고리보다 먼저)
|
||||||
|
# U-BOLT, CLAMP, URETHANE BLOCK 등
|
||||||
|
if ('U-BOLT' in desc_upper or 'U BOLT' in desc_upper or '유볼트' in desc_upper or
|
||||||
|
'URETHANE BLOCK' in desc_upper or 'BLOCK SHOE' in desc_upper or '우레탄' in desc_upper or
|
||||||
|
'CLAMP' in desc_upper or '클램프' in desc_upper or 'PIPE CLAMP' in desc_upper):
|
||||||
|
return {
|
||||||
|
"category": "SUPPORT",
|
||||||
|
"confidence": 1.0,
|
||||||
|
"evidence": ["SUPPORT_SYSTEM_KEYWORD"],
|
||||||
|
"classification_level": "LEVEL0_SUPPORT",
|
||||||
|
"reason": "SUPPORT 시스템 키워드 발견"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 쉼표로 구분된 각 부분을 별도로 체크 (예: "NIPPLE, SMLS, SCH 80")
|
# 쉼표로 구분된 각 부분을 별도로 체크 (예: "NIPPLE, SMLS, SCH 80")
|
||||||
|
|||||||
101
backend/exports/PR-20251014-001.json
Normal file
101
backend/exports/PR-20251014-001.json
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
101
backend/exports/PR-20251014-002.json
Normal file
101
backend/exports/PR-20251014-002.json
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"request_no": "PR-20251014-002",
|
||||||
|
"job_no": "TKG-25000P",
|
||||||
|
"created_at": "2025-10-14T06:54:44.585437",
|
||||||
|
"materials": [
|
||||||
|
{
|
||||||
|
"material_id": 5552,
|
||||||
|
"description": "SIGHT GLASS, FLG, 150LB",
|
||||||
|
"category": "FLANGE",
|
||||||
|
"size": "1\"",
|
||||||
|
"material_grade": "SS",
|
||||||
|
"quantity": 6,
|
||||||
|
"unit": "EA",
|
||||||
|
"user_requirement": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"material_id": 5558,
|
||||||
|
"description": "SIGHT GLASS, FLG, 150LB",
|
||||||
|
"category": "FLANGE",
|
||||||
|
"size": "1/2\"",
|
||||||
|
"material_grade": "SS",
|
||||||
|
"quantity": 5,
|
||||||
|
"unit": "EA",
|
||||||
|
"user_requirement": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"material_id": 5563,
|
||||||
|
"description": "SIGHT GLASS, FLG, 150LB",
|
||||||
|
"category": "FLANGE",
|
||||||
|
"size": "2\"",
|
||||||
|
"material_grade": "SS",
|
||||||
|
"quantity": 2,
|
||||||
|
"unit": "EA",
|
||||||
|
"user_requirement": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"material_id": 5566,
|
||||||
|
"description": "STRAINER, FLG, 150LB",
|
||||||
|
"category": "FLANGE",
|
||||||
|
"size": "2\"",
|
||||||
|
"material_grade": "-",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit": "EA",
|
||||||
|
"user_requirement": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grouped_materials": [
|
||||||
|
{
|
||||||
|
"group_key": "SIGHT GLASS, FLG, 150LB|1\"|undefined|SS",
|
||||||
|
"material_ids": [
|
||||||
|
5552
|
||||||
|
],
|
||||||
|
"description": "SIGHT GLASS, FLG, 150LB",
|
||||||
|
"category": "FLANGE",
|
||||||
|
"size": "1\"",
|
||||||
|
"material_grade": "SS",
|
||||||
|
"quantity": 6,
|
||||||
|
"unit": "EA",
|
||||||
|
"user_requirement": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group_key": "SIGHT GLASS, FLG, 150LB|1/2\"|undefined|SS",
|
||||||
|
"material_ids": [
|
||||||
|
5558
|
||||||
|
],
|
||||||
|
"description": "SIGHT GLASS, FLG, 150LB",
|
||||||
|
"category": "FLANGE",
|
||||||
|
"size": "1/2\"",
|
||||||
|
"material_grade": "SS",
|
||||||
|
"quantity": 5,
|
||||||
|
"unit": "EA",
|
||||||
|
"user_requirement": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group_key": "SIGHT GLASS, FLG, 150LB|2\"|undefined|SS",
|
||||||
|
"material_ids": [
|
||||||
|
5563
|
||||||
|
],
|
||||||
|
"description": "SIGHT GLASS, FLG, 150LB",
|
||||||
|
"category": "FLANGE",
|
||||||
|
"size": "2\"",
|
||||||
|
"material_grade": "SS",
|
||||||
|
"quantity": 2,
|
||||||
|
"unit": "EA",
|
||||||
|
"user_requirement": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group_key": "STRAINER, FLG, 150LB|2\"|undefined|-",
|
||||||
|
"material_ids": [
|
||||||
|
5566
|
||||||
|
],
|
||||||
|
"description": "STRAINER, FLG, 150LB",
|
||||||
|
"category": "FLANGE",
|
||||||
|
"size": "2\"",
|
||||||
|
"material_grade": "-",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit": "EA",
|
||||||
|
"user_requirement": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
28
backend/scripts/26_add_user_status_column.sql
Normal file
28
backend/scripts/26_add_user_status_column.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- users 테이블에 status 컬럼 추가 및 기존 데이터 마이그레이션
|
||||||
|
|
||||||
|
-- 1. status 컬럼 추가 (기본값은 'active')
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'active';
|
||||||
|
|
||||||
|
-- 2. status 컬럼에 CHECK 제약 조건 추가
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD CONSTRAINT users_status_check
|
||||||
|
CHECK (status IN ('pending', 'active', 'suspended', 'deleted'));
|
||||||
|
|
||||||
|
-- 3. 기존 데이터 마이그레이션
|
||||||
|
-- is_active가 false인 사용자는 'pending'으로
|
||||||
|
-- is_active가 true인 사용자는 'active'로
|
||||||
|
UPDATE users
|
||||||
|
SET status = CASE
|
||||||
|
WHEN is_active = FALSE THEN 'pending'
|
||||||
|
WHEN is_active = TRUE THEN 'active'
|
||||||
|
ELSE 'active'
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- 4. status 컬럼에 인덱스 추가 (조회 성능 향상)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
|
||||||
|
|
||||||
|
-- 5. 향후 is_active 컬럼은 deprecated로 간주
|
||||||
|
-- 하지만 하위 호환성을 위해 당분간 유지
|
||||||
|
COMMENT ON COLUMN users.status IS 'User account status: pending, active, suspended, deleted';
|
||||||
|
COMMENT ON COLUMN users.is_active IS 'DEPRECATED: Use status column instead. Kept for backward compatibility.';
|
||||||
135
backend/scripts/27_add_purchase_tracking.sql
Normal file
135
backend/scripts/27_add_purchase_tracking.sql
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
-- 엑셀 내보내기 이력 및 구매 상태 관리 테이블
|
||||||
|
|
||||||
|
-- 1. 엑셀 내보내기 이력 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS excel_export_history (
|
||||||
|
export_id SERIAL PRIMARY KEY,
|
||||||
|
file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
|
||||||
|
job_no VARCHAR(50) REFERENCES jobs(job_no),
|
||||||
|
exported_by INTEGER REFERENCES users(user_id),
|
||||||
|
export_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
export_type VARCHAR(50), -- 'full', 'category', 'filtered'
|
||||||
|
category VARCHAR(50), -- PIPE, FLANGE, VALVE 등
|
||||||
|
material_count INTEGER,
|
||||||
|
file_name VARCHAR(255),
|
||||||
|
notes TEXT,
|
||||||
|
-- 메타데이터
|
||||||
|
filters_applied JSONB, -- 적용된 필터 조건들
|
||||||
|
export_options JSONB -- 내보내기 옵션들
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. 내보낸 자재 상세 (어떤 자재들이 내보내졌는지 추적)
|
||||||
|
CREATE TABLE IF NOT EXISTS exported_materials (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
export_id INTEGER REFERENCES excel_export_history(export_id) ON DELETE CASCADE,
|
||||||
|
material_id INTEGER REFERENCES materials(id),
|
||||||
|
purchase_status VARCHAR(50) DEFAULT 'pending', -- pending, requested, ordered, received, cancelled
|
||||||
|
purchase_request_no VARCHAR(100), -- 구매요청 번호
|
||||||
|
purchase_order_no VARCHAR(100), -- 구매주문 번호
|
||||||
|
requested_date TIMESTAMP,
|
||||||
|
ordered_date TIMESTAMP,
|
||||||
|
expected_date DATE,
|
||||||
|
received_date TIMESTAMP,
|
||||||
|
quantity_exported INTEGER, -- 내보낸 수량
|
||||||
|
quantity_ordered INTEGER, -- 주문 수량
|
||||||
|
quantity_received INTEGER, -- 입고 수량
|
||||||
|
unit_price DECIMAL(15, 2),
|
||||||
|
total_price DECIMAL(15, 2),
|
||||||
|
vendor_name VARCHAR(255),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by INTEGER REFERENCES users(user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. 구매 상태 이력 (상태 변경 추적)
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_status_history (
|
||||||
|
history_id SERIAL PRIMARY KEY,
|
||||||
|
exported_material_id INTEGER REFERENCES exported_materials(id) ON DELETE CASCADE,
|
||||||
|
material_id INTEGER REFERENCES materials(id),
|
||||||
|
previous_status VARCHAR(50),
|
||||||
|
new_status VARCHAR(50),
|
||||||
|
changed_by INTEGER REFERENCES users(user_id),
|
||||||
|
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
reason TEXT,
|
||||||
|
metadata JSONB -- 추가 정보 (예: 문서 번호, 승인자 등)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. 구매 문서 관리
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_documents (
|
||||||
|
document_id SERIAL PRIMARY KEY,
|
||||||
|
export_id INTEGER REFERENCES excel_export_history(export_id),
|
||||||
|
document_type VARCHAR(50), -- 'purchase_request', 'purchase_order', 'invoice', 'receipt'
|
||||||
|
document_no VARCHAR(100),
|
||||||
|
document_date DATE,
|
||||||
|
file_path VARCHAR(500),
|
||||||
|
uploaded_by INTEGER REFERENCES users(user_id),
|
||||||
|
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 인덱스 추가
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_export_history_file_id ON excel_export_history(file_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_export_history_job_no ON excel_export_history(job_no);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_export_history_date ON excel_export_history(export_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exported_materials_export_id ON exported_materials(export_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exported_materials_material_id ON exported_materials(material_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exported_materials_status ON exported_materials(purchase_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exported_materials_pr_no ON exported_materials(purchase_request_no);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exported_materials_po_no ON exported_materials(purchase_order_no);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_history_material ON purchase_status_history(material_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_history_date ON purchase_status_history(changed_at);
|
||||||
|
|
||||||
|
-- 뷰 생성: 구매 상태별 자재 현황
|
||||||
|
CREATE OR REPLACE VIEW v_purchase_status_summary AS
|
||||||
|
SELECT
|
||||||
|
em.purchase_status,
|
||||||
|
COUNT(DISTINCT em.material_id) as material_count,
|
||||||
|
COUNT(DISTINCT em.export_id) as export_count,
|
||||||
|
SUM(em.quantity_exported) as total_quantity_exported,
|
||||||
|
SUM(em.quantity_ordered) as total_quantity_ordered,
|
||||||
|
SUM(em.quantity_received) as total_quantity_received,
|
||||||
|
SUM(em.total_price) as total_amount,
|
||||||
|
MAX(em.updated_at) as last_updated
|
||||||
|
FROM exported_materials em
|
||||||
|
GROUP BY em.purchase_status;
|
||||||
|
|
||||||
|
-- 뷰 생성: 자재별 최신 구매 상태
|
||||||
|
CREATE OR REPLACE VIEW v_material_latest_purchase_status AS
|
||||||
|
SELECT DISTINCT ON (m.id)
|
||||||
|
m.id as material_id,
|
||||||
|
m.original_description,
|
||||||
|
m.classified_category,
|
||||||
|
em.purchase_status,
|
||||||
|
em.purchase_request_no,
|
||||||
|
em.purchase_order_no,
|
||||||
|
em.vendor_name,
|
||||||
|
em.expected_date,
|
||||||
|
em.quantity_ordered,
|
||||||
|
em.quantity_received,
|
||||||
|
em.updated_at as status_updated_at,
|
||||||
|
eeh.export_date as last_exported_date
|
||||||
|
FROM materials m
|
||||||
|
LEFT JOIN exported_materials em ON m.id = em.material_id
|
||||||
|
LEFT JOIN excel_export_history eeh ON em.export_id = eeh.export_id
|
||||||
|
ORDER BY m.id, em.updated_at DESC;
|
||||||
|
|
||||||
|
-- 트리거: updated_at 자동 업데이트
|
||||||
|
CREATE OR REPLACE FUNCTION update_exported_materials_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER update_exported_materials_updated_at_trigger
|
||||||
|
BEFORE UPDATE ON exported_materials
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_exported_materials_updated_at();
|
||||||
|
|
||||||
|
-- 코멘트 추가
|
||||||
|
COMMENT ON TABLE excel_export_history IS '엑셀 내보내기 이력 관리';
|
||||||
|
COMMENT ON TABLE exported_materials IS '내보낸 자재의 구매 상태 추적';
|
||||||
|
COMMENT ON TABLE purchase_status_history IS '구매 상태 변경 이력';
|
||||||
|
COMMENT ON TABLE purchase_documents IS '구매 관련 문서 관리';
|
||||||
|
COMMENT ON COLUMN exported_materials.purchase_status IS 'pending: 구매신청 전, requested: 구매신청, ordered: 구매주문, received: 입고완료, cancelled: 취소';
|
||||||
44
backend/scripts/28_add_purchase_requests.sql
Normal file
44
backend/scripts/28_add_purchase_requests.sql
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
-- 구매신청 관리 테이블
|
||||||
|
|
||||||
|
-- 구매신청 그룹 (같이 신청한 항목들의 묶음)
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_requests (
|
||||||
|
request_id SERIAL PRIMARY KEY,
|
||||||
|
request_no VARCHAR(50) UNIQUE, -- PR-20241014-001 형식
|
||||||
|
file_id INTEGER REFERENCES files(id),
|
||||||
|
job_no VARCHAR(50) REFERENCES jobs(job_no),
|
||||||
|
category VARCHAR(50),
|
||||||
|
material_count INTEGER,
|
||||||
|
excel_file_path VARCHAR(500), -- 저장된 엑셀 파일 경로
|
||||||
|
requested_by INTEGER REFERENCES users(user_id),
|
||||||
|
requested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
status VARCHAR(20) DEFAULT 'requested', -- requested, ordered, received
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 구매신청 자재 상세
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_request_items (
|
||||||
|
item_id SERIAL PRIMARY KEY,
|
||||||
|
request_id INTEGER REFERENCES purchase_requests(request_id) ON DELETE CASCADE,
|
||||||
|
material_id INTEGER REFERENCES materials(id),
|
||||||
|
quantity INTEGER,
|
||||||
|
unit VARCHAR(20),
|
||||||
|
user_requirement TEXT,
|
||||||
|
is_ordered BOOLEAN DEFAULT FALSE,
|
||||||
|
is_received BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 인덱스
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_requests_file_id ON purchase_requests(file_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_requests_job_no ON purchase_requests(job_no);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_requests_status ON purchase_requests(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_request_items_request_id ON purchase_request_items(request_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_request_items_material_id ON purchase_request_items(material_id);
|
||||||
|
|
||||||
|
-- 뷰: 구매신청된 자재 ID 목록
|
||||||
|
CREATE OR REPLACE VIEW v_requested_material_ids AS
|
||||||
|
SELECT DISTINCT material_id
|
||||||
|
FROM purchase_request_items;
|
||||||
|
|
||||||
|
COMMENT ON TABLE purchase_requests IS '구매신청 그룹 관리';
|
||||||
|
COMMENT ON TABLE purchase_request_items IS '구매신청 자재 상세';
|
||||||
20
backend/scripts/29_add_revision_status.sql
Normal file
20
backend/scripts/29_add_revision_status.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- 리비전 관리 개선: 자재 상태 추적
|
||||||
|
-- 리비전 업로드 시 삭제된 자재의 상태를 추적
|
||||||
|
|
||||||
|
-- materials 테이블에 revision_status 컬럼 추가
|
||||||
|
ALTER TABLE materials ADD COLUMN IF NOT EXISTS revision_status VARCHAR(20) DEFAULT 'active';
|
||||||
|
-- 가능한 값: 'active', 'inventory', 'deleted_not_purchased', 'changed'
|
||||||
|
|
||||||
|
-- revision_status 설명:
|
||||||
|
-- 'active': 정상 활성 자재 (기본값)
|
||||||
|
-- 'inventory': 재고품 (구매신청 후 리비전에서 삭제됨 - 연노랑색 표시)
|
||||||
|
-- 'deleted_not_purchased': 구매신청 전 삭제됨 (숨김 처리)
|
||||||
|
-- 'changed': 변경된 자재 (추가 구매 필요)
|
||||||
|
|
||||||
|
-- 인덱스 추가 (성능 최적화)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_revision_status ON materials(revision_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_drawing_name ON materials(drawing_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_line_no ON materials(line_no);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN materials.revision_status IS '리비전 자재 상태: active(활성), inventory(재고품), deleted_not_purchased(삭제됨), changed(변경됨)';
|
||||||
|
|
||||||
@@ -71,6 +71,17 @@ body {
|
|||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 접근 거부 페이지 */
|
/* 접근 거부 페이지 */
|
||||||
.access-denied-container {
|
.access-denied-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import NewMaterialsPage from './pages/NewMaterialsPage';
|
|||||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||||
import AccountSettingsPage from './pages/AccountSettingsPage';
|
import AccountSettingsPage from './pages/AccountSettingsPage';
|
||||||
import UserManagementPage from './pages/UserManagementPage';
|
import UserManagementPage from './pages/UserManagementPage';
|
||||||
|
import PurchaseBatchPage from './pages/PurchaseBatchPage';
|
||||||
|
import PurchaseRequestPage from './pages/PurchaseRequestPage';
|
||||||
import SystemLogsPage from './pages/SystemLogsPage';
|
import SystemLogsPage from './pages/SystemLogsPage';
|
||||||
import LogMonitoringPage from './pages/LogMonitoringPage';
|
import LogMonitoringPage from './pages/LogMonitoringPage';
|
||||||
import ErrorBoundary from './components/ErrorBoundary';
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
@@ -27,6 +29,20 @@ function App() {
|
|||||||
const [newProjectCode, setNewProjectCode] = useState('');
|
const [newProjectCode, setNewProjectCode] = useState('');
|
||||||
const [newProjectName, setNewProjectName] = useState('');
|
const [newProjectName, setNewProjectName] = useState('');
|
||||||
const [newClientName, setNewClientName] = useState('');
|
const [newClientName, setNewClientName] = useState('');
|
||||||
|
const [pendingSignupCount, setPendingSignupCount] = useState(0);
|
||||||
|
|
||||||
|
// 승인 대기 중인 회원가입 수 조회
|
||||||
|
const loadPendingSignups = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/auth/signup-requests');
|
||||||
|
// API 응답이 { requests: [...], count: ... } 형태
|
||||||
|
const pendingCount = response.data.count || 0;
|
||||||
|
setPendingSignupCount(pendingCount);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('승인 대기 조회 실패:', error);
|
||||||
|
setPendingSignupCount(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 프로젝트 목록 로드
|
// 프로젝트 목록 로드
|
||||||
const loadProjects = async () => {
|
const loadProjects = async () => {
|
||||||
@@ -143,11 +159,32 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [showUserMenu]);
|
}, [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 handleLoginSuccess = () => {
|
||||||
const userData = localStorage.getItem('user_data');
|
const userData = localStorage.getItem('user_data');
|
||||||
if (userData) {
|
if (userData) {
|
||||||
setUser(JSON.parse(userData));
|
const parsedUser = JSON.parse(userData);
|
||||||
|
setUser(parsedUser);
|
||||||
|
|
||||||
|
// 관리자인 경우 승인 대기 수 확인
|
||||||
|
if (parsedUser?.role === 'admin' || parsedUser?.role === 'system') {
|
||||||
|
loadPendingSignups();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
};
|
};
|
||||||
@@ -173,8 +210,14 @@ function App() {
|
|||||||
{
|
{
|
||||||
id: 'bom',
|
id: 'bom',
|
||||||
title: '📋 BOM 업로드 & 분류',
|
title: '📋 BOM 업로드 & 분류',
|
||||||
description: '엑셀 파일 업로드 → 자동 분류 → 검토 → 자재 확인 → 엑셀 내보내기',
|
description: '엑셀 파일 업로드 → 자동 분류 → 검토 → 자재 확인 → 구매신청 (엑셀 내보내기)',
|
||||||
color: '#4299e1'
|
color: '#4299e1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'purchase-request',
|
||||||
|
title: '📦 구매신청 관리',
|
||||||
|
description: '구매신청한 자재들을 그룹별로 조회하고 엑셀 재다운로드',
|
||||||
|
color: '#10b981'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
@@ -183,15 +226,20 @@ function App() {
|
|||||||
const getAdminFeatures = () => {
|
const getAdminFeatures = () => {
|
||||||
const features = [];
|
const features = [];
|
||||||
|
|
||||||
// 시스템 관리자 전용 기능
|
console.log('getAdminFeatures - Current user:', user);
|
||||||
if (user?.role === 'system') {
|
console.log('getAdminFeatures - User role:', user?.role);
|
||||||
|
console.log('getAdminFeatures - Pending count:', pendingSignupCount);
|
||||||
|
|
||||||
|
// 시스템 관리자 기능 (admin role이 시스템 관리자)
|
||||||
|
if (user?.role === 'admin') {
|
||||||
features.push(
|
features.push(
|
||||||
{
|
{
|
||||||
id: 'user-management',
|
id: 'user-management',
|
||||||
title: '👥 사용자 관리',
|
title: '👥 사용자 관리',
|
||||||
description: '계정 생성, 역할 변경, 사용자 삭제',
|
description: '계정 생성, 역할 변경, 회원가입 승인',
|
||||||
color: '#dc2626',
|
color: '#dc2626',
|
||||||
badge: '시스템 관리자'
|
badge: '시스템 관리자',
|
||||||
|
pendingCount: pendingSignupCount
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'system-logs',
|
id: 'system-logs',
|
||||||
@@ -203,8 +251,24 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 일반 관리자 기능
|
||||||
|
if (user?.role === 'manager') {
|
||||||
|
// 일반 관리자는 회원가입 승인만 가능
|
||||||
|
features.push(
|
||||||
|
{
|
||||||
|
id: 'user-management',
|
||||||
|
title: '👥 회원가입 승인',
|
||||||
|
description: '신규 회원가입 승인 및 거부',
|
||||||
|
color: '#dc2626',
|
||||||
|
badge: '관리자',
|
||||||
|
pendingCount: pendingSignupCount
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 관리자 이상 공통 기능
|
// 관리자 이상 공통 기능
|
||||||
if (user?.role === 'admin' || user?.role === 'system') {
|
if (user?.role === 'admin' || user?.role === 'manager') {
|
||||||
|
|
||||||
features.push(
|
features.push(
|
||||||
{
|
{
|
||||||
id: 'log-monitoring',
|
id: 'log-monitoring',
|
||||||
@@ -661,13 +725,12 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 핵심 기능 */}
|
{/* 핵심 기능 - 프로젝트 선택 시만 표시 */}
|
||||||
{selectedProject && (
|
{selectedProject && (
|
||||||
<>
|
<div style={{ marginBottom: '32px' }}>
|
||||||
<div style={{ marginBottom: '32px' }}>
|
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748', marginBottom: '16px' }}>
|
||||||
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748', marginBottom: '16px' }}>
|
📋 BOM 관리 워크플로우
|
||||||
📋 BOM 관리 워크플로우
|
</h2>
|
||||||
</h2>
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||||
@@ -701,7 +764,10 @@ function App() {
|
|||||||
{feature.description}
|
{feature.description}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigateToPage(feature.id, { selectedProject })}
|
onClick={() => navigateToPage(feature.id, {
|
||||||
|
selectedProject,
|
||||||
|
jobNo: selectedProject?.official_project_code
|
||||||
|
})}
|
||||||
style={{
|
style={{
|
||||||
background: feature.color,
|
background: feature.color,
|
||||||
color: 'white',
|
color: 'white',
|
||||||
@@ -720,8 +786,9 @@ function App() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)} {/* selectedProject 조건문 닫기 */}
|
||||||
|
|
||||||
{/* 관리자 기능 (있는 경우만) */}
|
{/* 관리자 기능 (프로젝트 선택과 무관하게 항상 표시) */}
|
||||||
{adminFeatures.length > 0 && (
|
{adminFeatures.length > 0 && (
|
||||||
<div style={{ marginBottom: '32px' }}>
|
<div style={{ marginBottom: '32px' }}>
|
||||||
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748', marginBottom: '16px' }}>
|
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748', marginBottom: '16px' }}>
|
||||||
@@ -753,8 +820,29 @@ function App() {
|
|||||||
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.07)';
|
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.07)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', marginBottom: '12px' }}>
|
<h3 style={{
|
||||||
{feature.title}
|
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>
|
</h3>
|
||||||
<p style={{ color: '#718096', marginBottom: '16px', fontSize: '14px' }}>
|
<p style={{ color: '#718096', marginBottom: '16px', fontSize: '14px' }}>
|
||||||
{feature.description}
|
{feature.description}
|
||||||
@@ -853,8 +941,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
)} {/* adminFeatures 조건문 닫기 */}
|
||||||
)} {/* selectedProject 조건문 닫기 */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -880,6 +967,25 @@ function App() {
|
|||||||
filename={pageParams.filename}
|
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':
|
case 'system-settings':
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -299,6 +299,72 @@ const BOMWorkspacePage = ({ project, onNavigate, onBack }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.data?.success) {
|
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}개`);
|
alert(`리비전 ${response.data.revision} 업로드 성공!\n신규 자재: ${response.data.new_materials_count || 0}개`);
|
||||||
await loadFiles();
|
await loadFiles();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,7 +294,7 @@
|
|||||||
background: white;
|
background: white;
|
||||||
margin: 16px 24px;
|
margin: 16px 24px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: auto; /* 좌우 스크롤 가능하도록 변경 */
|
||||||
max-height: calc(100vh - 220px);
|
max-height: calc(100vh - 220px);
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
}
|
}
|
||||||
@@ -431,40 +431,40 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* U-BOLT 전용 헤더 - 8개 컬럼 */
|
/* SUPPORT 전용 헤더 - 8개 컬럼 */
|
||||||
.detailed-grid-header.ubolt-header {
|
.detailed-grid-header.support-header {
|
||||||
grid-template-columns: 3% 11% 15% 10% 20% 12% 18% 10%;
|
grid-template-columns: 3% 10% 12% 10% 25% 10% 18% 12%;
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* U-BOLT 전용 행 - 8개 컬럼 */
|
/* SUPPORT 전용 행 - 8개 컬럼 */
|
||||||
.detailed-material-row.ubolt-row {
|
.detailed-material-row.support-row {
|
||||||
grid-template-columns: 3% 11% 15% 10% 20% 12% 18% 10%;
|
grid-template-columns: 3% 10% 12% 10% 25% 10% 18% 12%;
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* U-BOLT 헤더 테두리 */
|
/* SUPPORT 헤더 테두리 */
|
||||||
.detailed-grid-header.ubolt-header > div,
|
.detailed-grid-header.support-header > div,
|
||||||
.detailed-grid-header.ubolt-header .filterable-header {
|
.detailed-grid-header.support-header .filterable-header {
|
||||||
border-right: 1px solid #d1d5db;
|
border-right: 1px solid #d1d5db;
|
||||||
}
|
}
|
||||||
.detailed-grid-header.ubolt-header > div:last-child,
|
.detailed-grid-header.support-header > div:last-child,
|
||||||
.detailed-grid-header.ubolt-header .filterable-header:last-child {
|
.detailed-grid-header.support-header .filterable-header:last-child {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* U-BOLT 행 테두리 */
|
/* SUPPORT 행 테두리 */
|
||||||
.detailed-material-row.ubolt-row .material-cell {
|
.detailed-material-row.support-row .material-cell {
|
||||||
border-right: 1px solid #d1d5db;
|
border-right: 1px solid #d1d5db;
|
||||||
}
|
}
|
||||||
.detailed-material-row.ubolt-row .material-cell:last-child {
|
.detailed-material-row.support-row .material-cell:last-child {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* U-BOLT 타입 배지 */
|
/* SUPPORT 타입 배지 */
|
||||||
.type-badge.ubolt {
|
.type-badge.support {
|
||||||
background: #059669;
|
background: #059669;
|
||||||
color: white;
|
color: white;
|
||||||
border: 2px solid #047857;
|
border: 2px solid #047857;
|
||||||
@@ -533,7 +533,7 @@
|
|||||||
|
|
||||||
/* 플랜지 전용 헤더 - 10개 컬럼 */
|
/* 플랜지 전용 헤더 - 10개 컬럼 */
|
||||||
.detailed-grid-header.flange-header {
|
.detailed-grid-header.flange-header {
|
||||||
grid-template-columns: 2% 8% 12% 8% 10% 10% 18% 10% 15% 6%;
|
grid-template-columns: 1.5% 8.5% 12% 8% 10% 10% 15% 8% 12% 11.5%;
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -550,7 +550,7 @@
|
|||||||
|
|
||||||
/* 플랜지 전용 행 - 10개 컬럼 */
|
/* 플랜지 전용 행 - 10개 컬럼 */
|
||||||
.detailed-material-row.flange-row {
|
.detailed-material-row.flange-row {
|
||||||
grid-template-columns: 1.5% 8.5% 12% 8% 10% 10% 18% 10% 15% 6%;
|
grid-template-columns: 1.5% 8.5% 12% 8% 10% 10% 15% 8% 12% 11.5%;
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -565,7 +565,7 @@
|
|||||||
|
|
||||||
/* 피팅 전용 헤더 - 10개 컬럼 */
|
/* 피팅 전용 헤더 - 10개 컬럼 */
|
||||||
.detailed-grid-header.fitting-header {
|
.detailed-grid-header.fitting-header {
|
||||||
grid-template-columns: 2% 8% 20% 8% 8% 10% 18% 10% 15% 0%;
|
grid-template-columns: 2% 8.5% 16% 7.5% 7.5% 9% 15% 9% 13% 12%;
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -582,7 +582,7 @@
|
|||||||
|
|
||||||
/* 피팅 전용 행 - 10개 컬럼 */
|
/* 피팅 전용 행 - 10개 컬럼 */
|
||||||
.detailed-material-row.fitting-row {
|
.detailed-material-row.fitting-row {
|
||||||
grid-template-columns: 2% 8% 20% 8% 8% 10% 18% 10% 15% 0%;
|
grid-template-columns: 2% 8.5% 16% 7.5% 7.5% 9% 15% 9% 13% 12%;
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1168,4 +1168,24 @@
|
|||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #9ca3af;
|
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
388
frontend/src/pages/PurchaseBatchPage.jsx
Normal file
388
frontend/src/pages/PurchaseBatchPage.jsx
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
const PurchaseBatchPage = ({ onNavigate, fileId, jobNo }) => {
|
||||||
|
const [batches, setBatches] = useState([]);
|
||||||
|
const [selectedBatch, setSelectedBatch] = useState(null);
|
||||||
|
const [batchMaterials, setBatchMaterials] = useState([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState('all'); // all, pending, requested, ordered, received
|
||||||
|
const [message, setMessage] = useState({ type: '', text: '' });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBatches();
|
||||||
|
}, [fileId, jobNo]);
|
||||||
|
|
||||||
|
const loadBatches = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const params = {};
|
||||||
|
if (fileId) params.file_id = fileId;
|
||||||
|
if (jobNo) params.job_no = jobNo;
|
||||||
|
|
||||||
|
const response = await api.get('/export/batches', { params });
|
||||||
|
setBatches(response.data.batches || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load batches:', error);
|
||||||
|
setMessage({ type: 'error', text: '배치 목록 로드 실패' });
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadBatchMaterials = async (exportId) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/export/batch/${exportId}/materials`);
|
||||||
|
setBatchMaterials(response.data.materials || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load batch materials:', error);
|
||||||
|
setMessage({ type: 'error', text: '자재 목록 로드 실패' });
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchSelect = (batch) => {
|
||||||
|
setSelectedBatch(batch);
|
||||||
|
loadBatchMaterials(batch.export_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadExcel = async (exportId, batchNo) => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/export/batch/${exportId}/download`, {
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', `batch_${batchNo}.xlsx`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
setMessage({ type: 'success', text: '엑셀 다운로드 완료' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download excel:', error);
|
||||||
|
setMessage({ type: 'error', text: '엑셀 다운로드 실패' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchStatusUpdate = async (exportId, newStatus) => {
|
||||||
|
try {
|
||||||
|
const prNo = prompt('구매요청 번호 (PR)를 입력하세요:');
|
||||||
|
const response = await api.patch(`/export/batch/${exportId}/status`, {
|
||||||
|
status: newStatus,
|
||||||
|
purchase_request_no: prNo
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
setMessage({ type: 'success', text: response.data.message });
|
||||||
|
loadBatches();
|
||||||
|
if (selectedBatch?.export_id === exportId) {
|
||||||
|
loadBatchMaterials(exportId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update batch status:', error);
|
||||||
|
setMessage({ type: 'error', text: '상태 업데이트 실패' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status) => {
|
||||||
|
const styles = {
|
||||||
|
pending: { bg: '#FFFFE0', color: '#856404', text: '구매 전' },
|
||||||
|
requested: { bg: '#FFE4B5', color: '#8B4513', text: '구매신청' },
|
||||||
|
in_progress: { bg: '#ADD8E6', color: '#00008B', text: '진행중' },
|
||||||
|
ordered: { bg: '#87CEEB', color: '#4682B4', text: '발주완료' },
|
||||||
|
received: { bg: '#90EE90', color: '#228B22', text: '입고완료' },
|
||||||
|
completed: { bg: '#98FB98', color: '#006400', text: '완료' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const style = styles[status] || styles.pending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
background: style.bg,
|
||||||
|
color: style.color,
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
|
{style.text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredBatches = activeTab === 'all'
|
||||||
|
? batches
|
||||||
|
: batches.filter(b => b.batch_status === activeTab);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<h1 style={{ fontSize: '24px', fontWeight: '600', marginBottom: '8px' }}>
|
||||||
|
구매 배치 관리
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: '#6c757d' }}>
|
||||||
|
엑셀로 내보낸 자재들을 배치 단위로 관리합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메시지 */}
|
||||||
|
{message.text && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: message.type === 'error' ? '#fee' : '#e6ffe6',
|
||||||
|
color: message.type === 'error' ? '#dc3545' : '#28a745',
|
||||||
|
border: `1px solid ${message.type === 'error' ? '#fcc' : '#cfc'}`
|
||||||
|
}}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 탭 네비게이션 */}
|
||||||
|
<div style={{ display: 'flex', gap: '12px', marginBottom: '20px' }}>
|
||||||
|
{[
|
||||||
|
{ key: 'all', label: '전체' },
|
||||||
|
{ key: 'pending', label: '구매 전' },
|
||||||
|
{ key: 'requested', label: '구매신청' },
|
||||||
|
{ key: 'in_progress', label: '진행중' },
|
||||||
|
{ key: 'completed', label: '완료' }
|
||||||
|
].map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '20px',
|
||||||
|
border: activeTab === tab.key ? '2px solid #007bff' : '1px solid #dee2e6',
|
||||||
|
background: activeTab === tab.key ? '#007bff' : 'white',
|
||||||
|
color: activeTab === tab.key ? 'white' : '#495057',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 컨텐츠 */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '400px 1fr', gap: '24px' }}>
|
||||||
|
{/* 배치 목록 */}
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: '1px solid #e9ecef',
|
||||||
|
background: '#f8f9fa'
|
||||||
|
}}>
|
||||||
|
<h2 style={{ fontSize: '16px', fontWeight: '600', margin: 0 }}>
|
||||||
|
배치 목록 ({filteredBatches.length})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ maxHeight: '600px', overflow: 'auto' }}>
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||||
|
로딩중...
|
||||||
|
</div>
|
||||||
|
) : filteredBatches.length === 0 ? (
|
||||||
|
<div style={{ padding: '40px', textAlign: 'center', color: '#6c757d' }}>
|
||||||
|
배치가 없습니다
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredBatches.map(batch => (
|
||||||
|
<div
|
||||||
|
key={batch.export_id}
|
||||||
|
onClick={() => handleBatchSelect(batch)}
|
||||||
|
style={{
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: '1px solid #f1f3f4',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: selectedBatch?.export_id === batch.export_id ? '#f0f8ff' : 'white',
|
||||||
|
transition: 'background 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (selectedBatch?.export_id !== batch.export_id) {
|
||||||
|
e.currentTarget.style.background = '#f8f9fa';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (selectedBatch?.export_id !== batch.export_id) {
|
||||||
|
e.currentTarget.style.background = 'white';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||||
|
<span style={{ fontWeight: '600', fontSize: '14px' }}>
|
||||||
|
{batch.batch_no}
|
||||||
|
</span>
|
||||||
|
{getStatusBadge(batch.batch_status)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: '12px', color: '#6c757d', marginBottom: '4px' }}>
|
||||||
|
{batch.job_no} - {batch.job_name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: '12px', color: '#6c757d', marginBottom: '8px' }}>
|
||||||
|
{batch.category || '전체'} | {batch.material_count}개 자재
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '8px', fontSize: '11px' }}>
|
||||||
|
<span style={{ background: '#e9ecef', padding: '2px 6px', borderRadius: '4px' }}>
|
||||||
|
대기: {batch.status_detail.pending}
|
||||||
|
</span>
|
||||||
|
<span style={{ background: '#fff3cd', padding: '2px 6px', borderRadius: '4px' }}>
|
||||||
|
신청: {batch.status_detail.requested}
|
||||||
|
</span>
|
||||||
|
<span style={{ background: '#cce5ff', padding: '2px 6px', borderRadius: '4px' }}>
|
||||||
|
발주: {batch.status_detail.ordered}
|
||||||
|
</span>
|
||||||
|
<span style={{ background: '#d4edda', padding: '2px 6px', borderRadius: '4px' }}>
|
||||||
|
입고: {batch.status_detail.received}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '8px', fontSize: '11px', color: '#6c757d' }}>
|
||||||
|
{new Date(batch.export_date).toLocaleDateString()} | {batch.exported_by}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선택된 배치 상세 */}
|
||||||
|
{selectedBatch ? (
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: '1px solid #e9ecef',
|
||||||
|
background: '#f8f9fa'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h2 style={{ fontSize: '16px', fontWeight: '600', margin: 0 }}>
|
||||||
|
배치 {selectedBatch.batch_no}
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownloadExcel(selectedBatch.export_id, selectedBatch.batch_no)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
background: '#28a745',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📥 엑셀 다운로드
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleBatchStatusUpdate(selectedBatch.export_id, 'requested')}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
background: '#ffc107',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
구매신청
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '16px' }}>
|
||||||
|
<div style={{ overflow: 'auto', maxHeight: '500px' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#f8f9fa' }}>
|
||||||
|
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>No</th>
|
||||||
|
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>카테고리</th>
|
||||||
|
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>자재 설명</th>
|
||||||
|
<th style={{ padding: '8px', textAlign: 'center', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>수량</th>
|
||||||
|
<th style={{ padding: '8px', textAlign: 'center', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>상태</th>
|
||||||
|
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>PR번호</th>
|
||||||
|
<th style={{ padding: '8px', textAlign: 'left', fontSize: '12px', borderBottom: '2px solid #dee2e6' }}>PO번호</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{batchMaterials.map((material, idx) => (
|
||||||
|
<tr key={material.exported_material_id} style={{ borderBottom: '1px solid #f1f3f4' }}>
|
||||||
|
<td style={{ padding: '8px', fontSize: '12px' }}>{idx + 1}</td>
|
||||||
|
<td style={{ padding: '8px', fontSize: '12px' }}>
|
||||||
|
<span style={{
|
||||||
|
background: '#e9ecef',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '11px'
|
||||||
|
}}>
|
||||||
|
{material.category}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '8px', fontSize: '12px' }}>{material.description}</td>
|
||||||
|
<td style={{ padding: '8px', fontSize: '12px', textAlign: 'center' }}>
|
||||||
|
{material.quantity} {material.unit}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '8px', textAlign: 'center' }}>
|
||||||
|
{getStatusBadge(material.purchase_status)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '8px', fontSize: '12px' }}>
|
||||||
|
{material.purchase_request_no || '-'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '8px', fontSize: '12px' }}>
|
||||||
|
{material.purchase_order_no || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '400px'
|
||||||
|
}}>
|
||||||
|
<div style={{ textAlign: 'center', color: '#6c757d' }}>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📦</div>
|
||||||
|
<div style={{ fontSize: '16px' }}>배치를 선택하세요</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PurchaseBatchPage;
|
||||||
211
frontend/src/pages/PurchaseRequestPage.css
Normal file
211
frontend/src/pages/PurchaseRequestPage.css
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
.purchase-request-page {
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #007bff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #6c757d;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 400px 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 구매신청 목록 패널 */
|
||||||
|
.requests-panel {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
background: #f8f9fa;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requests-list {
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #f1f3f4;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card.selected {
|
||||||
|
background: #e7f3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-no {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #495057;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requested-by {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 상세 패널 */
|
||||||
|
.details-panel {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excel-btn {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excel-btn:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materials-table {
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materials-table table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materials-table th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materials-table td {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-bottom: 1px solid #f1f3f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-badge {
|
||||||
|
background: #e9ecef;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 400px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
263
frontend/src/pages/PurchaseRequestPage.jsx
Normal file
263
frontend/src/pages/PurchaseRequestPage.jsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
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,6 +4,9 @@ import { reportError, logUserActionError } from '../utils/errorLogger';
|
|||||||
|
|
||||||
const UserManagementPage = ({ onNavigate, user }) => {
|
const UserManagementPage = ({ onNavigate, user }) => {
|
||||||
const [users, setUsers] = useState([]);
|
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 [isLoading, setIsLoading] = useState(true);
|
||||||
const [message, setMessage] = useState({ type: '', text: '' });
|
const [message, setMessage] = useState({ type: '', text: '' });
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
@@ -25,6 +28,8 @@ const UserManagementPage = ({ onNavigate, user }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
|
loadPendingUsers();
|
||||||
|
loadSuspendedUsers();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
@@ -46,6 +51,95 @@ const UserManagementPage = ({ onNavigate, user }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadPendingUsers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/auth/signup-requests');
|
||||||
|
// API 응답에서 requests 배열을 가져옴
|
||||||
|
if (response.data.success && response.data.requests) {
|
||||||
|
setPendingUsers(response.data.requests);
|
||||||
|
} else {
|
||||||
|
setPendingUsers([]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Load pending users error:', err);
|
||||||
|
// 에러는 무시하고 빈 배열로 설정
|
||||||
|
setPendingUsers([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApproveUser = async (userId) => {
|
||||||
|
try {
|
||||||
|
const response = await api.post(`/auth/approve-signup/${userId}`);
|
||||||
|
if (response.data.success) {
|
||||||
|
setMessage({ type: 'success', text: '사용자가 승인되었습니다' });
|
||||||
|
loadPendingUsers();
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Approve user error:', err);
|
||||||
|
setMessage({ type: 'error', text: '승인 중 오류가 발생했습니다' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectUser = async (userId) => {
|
||||||
|
if (!window.confirm('정말로 이 가입 요청을 거부하시겠습니까?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.delete(`/auth/reject-signup/${userId}`);
|
||||||
|
if (response.data.success) {
|
||||||
|
setMessage({ type: 'success', text: '가입 요청이 거부되었습니다' });
|
||||||
|
loadPendingUsers();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Reject user error:', err);
|
||||||
|
setMessage({ type: 'error', text: '거부 중 오류가 발생했습니다' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSuspendedUsers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/auth/users/suspended');
|
||||||
|
if (response.data.success) {
|
||||||
|
setSuspendedUsers(response.data.users);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Load suspended users error:', err);
|
||||||
|
setSuspendedUsers([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuspendUser = async (userToSuspend) => {
|
||||||
|
if (!window.confirm(`정말로 ${userToSuspend.name} 사용자를 정지하시겠습니까?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.patch(`/auth/users/${userToSuspend.user_id}/suspend`);
|
||||||
|
if (response.data.success) {
|
||||||
|
setMessage({ type: 'success', text: response.data.message });
|
||||||
|
loadUsers();
|
||||||
|
loadSuspendedUsers();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Suspend user error:', err);
|
||||||
|
setMessage({ type: 'error', text: '사용자 정지 중 오류가 발생했습니다' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReactivateUser = async (userToReactivate) => {
|
||||||
|
if (!window.confirm(`${userToReactivate.name} 사용자를 재활성화하시겠습니까?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.patch(`/auth/users/${userToReactivate.user_id}/reactivate`);
|
||||||
|
if (response.data.success) {
|
||||||
|
setMessage({ type: 'success', text: response.data.message });
|
||||||
|
loadUsers();
|
||||||
|
loadSuspendedUsers();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Reactivate user error:', err);
|
||||||
|
setMessage({ type: 'error', text: '사용자 재활성화 중 오류가 발생했습니다' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateUser = async (e) => {
|
const handleCreateUser = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -256,22 +350,267 @@ const UserManagementPage = ({ onNavigate, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 사용자 목록 */}
|
{/* 탭 네비게이션 */}
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'white',
|
display: 'flex',
|
||||||
borderRadius: '12px',
|
gap: '8px',
|
||||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
marginBottom: '24px',
|
||||||
overflow: 'hidden'
|
borderBottom: '2px solid #e9ecef',
|
||||||
|
paddingBottom: '0'
|
||||||
}}>
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('pending')}
|
||||||
|
style={{
|
||||||
|
background: activeTab === 'pending' ? 'white' : 'transparent',
|
||||||
|
color: activeTab === 'pending' ? '#007bff' : '#6c757d',
|
||||||
|
border: activeTab === 'pending' ? '2px solid #e9ecef' : '2px solid transparent',
|
||||||
|
borderBottom: activeTab === 'pending' ? '2px solid white' : '2px solid transparent',
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
padding: '12px 24px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: '-2px',
|
||||||
|
position: 'relative',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
승인 대기
|
||||||
|
{pendingUsers.length > 0 && (
|
||||||
|
<span style={{
|
||||||
|
background: '#dc3545',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
marginLeft: '8px'
|
||||||
|
}}>
|
||||||
|
{pendingUsers.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('active')}
|
||||||
|
style={{
|
||||||
|
background: activeTab === 'active' ? 'white' : 'transparent',
|
||||||
|
color: activeTab === 'active' ? '#007bff' : '#6c757d',
|
||||||
|
border: activeTab === 'active' ? '2px solid #e9ecef' : '2px solid transparent',
|
||||||
|
borderBottom: activeTab === 'active' ? '2px solid white' : '2px solid transparent',
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
padding: '12px 24px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: '-2px',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
등록된 사용자 ({users.length}명)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('suspended')}
|
||||||
|
style={{
|
||||||
|
background: activeTab === 'suspended' ? 'white' : 'transparent',
|
||||||
|
color: activeTab === 'suspended' ? '#007bff' : '#6c757d',
|
||||||
|
border: activeTab === 'suspended' ? '2px solid #e9ecef' : '2px solid transparent',
|
||||||
|
borderBottom: activeTab === 'suspended' ? '2px solid white' : '2px solid transparent',
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
padding: '12px 24px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: '-2px',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
정지된 사용자
|
||||||
|
{suspendedUsers.length > 0 && (
|
||||||
|
<span style={{
|
||||||
|
background: '#ffc107',
|
||||||
|
color: '#856404',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
marginLeft: '8px'
|
||||||
|
}}>
|
||||||
|
{suspendedUsers.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 승인 대기 사용자 목록 */}
|
||||||
|
{activeTab === 'pending' && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '20px 24px',
|
background: 'white',
|
||||||
borderBottom: '1px solid #e9ecef',
|
borderRadius: '12px',
|
||||||
background: '#f8f9fa'
|
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||||
|
overflow: 'hidden'
|
||||||
}}>
|
}}>
|
||||||
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
|
<div style={{
|
||||||
등록된 사용자 ({users.length}명)
|
padding: '20px 24px',
|
||||||
</h2>
|
borderBottom: '1px solid #e9ecef',
|
||||||
|
background: '#fff5f5'
|
||||||
|
}}>
|
||||||
|
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
|
||||||
|
승인 대기 사용자 ({pendingUsers.length}명)
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pendingUsers.length === 0 ? (
|
||||||
|
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '16px', color: '#6c757d' }}>승인 대기 중인 사용자가 없습니다</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflow: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#f8f9fa' }}>
|
||||||
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>사용자</th>
|
||||||
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>역할</th>
|
||||||
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>부서/직책</th>
|
||||||
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>상태</th>
|
||||||
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>가입일</th>
|
||||||
|
<th style={{ padding: '12px 16px', textAlign: 'center', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pendingUsers.map((userItem) => (
|
||||||
|
<tr key={userItem.user_id} style={{ borderBottom: '1px solid #f1f3f4' }}>
|
||||||
|
<td style={{ padding: '16px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#e9ecef',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#6c757d',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
|
{userItem.name ? userItem.name.charAt(0).toUpperCase() : '?'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||||
|
{userItem.name || '이름 없음'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||||
|
@{userItem.username}
|
||||||
|
</div>
|
||||||
|
{userItem.email && (
|
||||||
|
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||||
|
{userItem.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px' }}>
|
||||||
|
<span style={{
|
||||||
|
background: '#e9ecef',
|
||||||
|
color: '#495057',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
|
{userItem.role || '사용자'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px' }}>
|
||||||
|
<div style={{ fontSize: '14px', color: '#495057' }}>
|
||||||
|
{userItem.department || '-'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||||
|
{userItem.position || '-'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px' }}>
|
||||||
|
<span style={{
|
||||||
|
background: '#fff3cd',
|
||||||
|
color: '#856404',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
|
승인 대기
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||||
|
{userItem.created_at ? new Date(userItem.created_at).toLocaleDateString() : '-'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleApproveUser(userItem.user_id)}
|
||||||
|
style={{
|
||||||
|
background: '#28a745',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.target.style.backgroundColor = '#218838'}
|
||||||
|
onMouseLeave={(e) => e.target.style.backgroundColor = '#28a745'}
|
||||||
|
>
|
||||||
|
승인
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRejectUser(userItem.user_id)}
|
||||||
|
style={{
|
||||||
|
background: '#dc3545',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.target.style.backgroundColor = '#c82333'}
|
||||||
|
onMouseLeave={(e) => e.target.style.backgroundColor = '#dc3545'}
|
||||||
|
>
|
||||||
|
거부
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</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 ? (
|
{isLoading ? (
|
||||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||||
@@ -388,6 +727,26 @@ const UserManagementPage = ({ onNavigate, user }) => {
|
|||||||
>
|
>
|
||||||
역할 변경
|
역할 변경
|
||||||
</button>
|
</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 && (
|
{userItem.user_id !== user?.user_id && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteUser(userItem)}
|
onClick={() => handleDeleteUser(userItem)}
|
||||||
@@ -417,7 +776,165 @@ const UserManagementPage = ({ onNavigate, user }) => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 정지된 사용자 목록 */}
|
||||||
|
{activeTab === 'suspended' && (
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
padding: '20px 24px',
|
||||||
|
borderBottom: '1px solid #e9ecef',
|
||||||
|
background: '#fff8e1'
|
||||||
|
}}>
|
||||||
|
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
|
||||||
|
정지된 사용자 ({suspendedUsers.length}명)
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{suspendedUsers.length === 0 ? (
|
||||||
|
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '16px', color: '#6c757d' }}>정지된 사용자가 없습니다</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflow: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#f8f9fa' }}>
|
||||||
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>사용자</th>
|
||||||
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>역할</th>
|
||||||
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>부서/직책</th>
|
||||||
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>상태</th>
|
||||||
|
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>정지일</th>
|
||||||
|
<th style={{ padding: '12px 16px', textAlign: 'center', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{suspendedUsers.map((userItem) => (
|
||||||
|
<tr key={userItem.user_id} style={{ borderBottom: '1px solid #f1f3f4' }}>
|
||||||
|
<td style={{ padding: '16px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#ffc107',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
|
{userItem.name ? userItem.name.charAt(0).toUpperCase() : '?'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||||
|
{userItem.name || '이름 없음'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||||
|
@{userItem.username}
|
||||||
|
</div>
|
||||||
|
{userItem.email && (
|
||||||
|
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||||
|
{userItem.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px' }}>
|
||||||
|
<span style={{
|
||||||
|
background: getRoleBadgeColor(userItem.role).bg,
|
||||||
|
color: getRoleBadgeColor(userItem.role).color,
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
|
{getRoleDisplayName(userItem.role)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px' }}>
|
||||||
|
<div style={{ fontSize: '14px', color: '#495057' }}>
|
||||||
|
{userItem.department || '-'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||||
|
{userItem.position || '-'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px' }}>
|
||||||
|
<span style={{
|
||||||
|
background: '#fff3cd',
|
||||||
|
color: '#856404',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
|
정지됨
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||||
|
{userItem.updated_at ? new Date(userItem.updated_at).toLocaleDateString() : '-'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '16px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleReactivateUser(userItem)}
|
||||||
|
style={{
|
||||||
|
background: '#28a745',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.target.style.backgroundColor = '#218838'}
|
||||||
|
onMouseLeave={(e) => e.target.style.backgroundColor = '#28a745'}
|
||||||
|
>
|
||||||
|
재활성화
|
||||||
|
</button>
|
||||||
|
{userItem.user_id !== user?.user_id && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUser(userItem)}
|
||||||
|
style={{
|
||||||
|
background: '#dc3545',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.target.style.backgroundColor = '#c82333'}
|
||||||
|
onMouseLeave={(e) => e.target.style.backgroundColor = '#dc3545'}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 사용자 생성 모달 */}
|
{/* 사용자 생성 모달 */}
|
||||||
|
|||||||
@@ -150,14 +150,43 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
|||||||
// 구매 수량 계산
|
// 구매 수량 계산
|
||||||
const purchaseInfo = calculatePurchaseQuantity(material);
|
const purchaseInfo = calculatePurchaseQuantity(material);
|
||||||
|
|
||||||
// 품목명 생성 (카테고리별 상세 처리)
|
// 변수 선언 (먼저 선언)
|
||||||
let itemName = '';
|
let itemName = '';
|
||||||
|
let detailInfo = '';
|
||||||
|
let gasketMaterial = '';
|
||||||
|
let gasketThickness = '';
|
||||||
if (category === 'PIPE') {
|
if (category === 'PIPE') {
|
||||||
itemName = material.pipe_details?.manufacturing_method || 'PIPE';
|
itemName = material.pipe_details?.manufacturing_method || 'PIPE';
|
||||||
} else if (category === 'FITTING') {
|
} else if (category === 'FITTING') {
|
||||||
itemName = material.fitting_details?.fitting_type || 'FITTING';
|
itemName = material.fitting_details?.fitting_type || 'FITTING';
|
||||||
} else if (category === 'FLANGE') {
|
} else if (category === 'FLANGE') {
|
||||||
|
// 플랜지는 품목명만 간단하게 (상세내역에 타입 정보)
|
||||||
itemName = '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') {
|
} else if (category === 'VALVE') {
|
||||||
itemName = 'VALVE';
|
itemName = 'VALVE';
|
||||||
} else if (category === 'GASKET') {
|
} else if (category === 'GASKET') {
|
||||||
@@ -189,6 +218,18 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
|||||||
}
|
}
|
||||||
} else if (category === 'BOLT') {
|
} else if (category === 'BOLT') {
|
||||||
itemName = '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 {
|
} else {
|
||||||
itemName = category || 'UNKNOWN';
|
itemName = category || 'UNKNOWN';
|
||||||
}
|
}
|
||||||
@@ -315,10 +356,7 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리별 상세 정보 추출
|
// 카테고리별 상세 정보 추출 (이미 위에서 선언됨)
|
||||||
let detailInfo = '';
|
|
||||||
let gasketMaterial = '';
|
|
||||||
let gasketThickness = '';
|
|
||||||
|
|
||||||
if (category === 'BOLT') {
|
if (category === 'BOLT') {
|
||||||
// 볼트의 경우 표면처리 정보 추출
|
// 볼트의 경우 표면처리 정보 추출
|
||||||
@@ -392,11 +430,26 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
|||||||
detailInfo = otherDetails.join(', ');
|
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 = {
|
const base = {
|
||||||
'TAGNO': '', // 비워둠
|
'TAGNO': '', // 비워둠
|
||||||
'품목명': itemName,
|
'품목명': itemName,
|
||||||
'수량': purchaseInfo.purchaseQuantity || material.quantity || 0,
|
'수량': quantity,
|
||||||
'통화구분': 'KRW', // 기본값
|
'통화구분': 'KRW', // 기본값
|
||||||
'단가': 1, // 일괄 1로 설정
|
'단가': 1, // 일괄 1로 설정
|
||||||
'크기': material.size_spec || '-',
|
'크기': material.size_spec || '-',
|
||||||
@@ -467,19 +520,27 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
|||||||
*/
|
*/
|
||||||
export const exportMaterialsToExcel = (materials, filename, additionalInfo = {}) => {
|
export const exportMaterialsToExcel = (materials, filename, additionalInfo = {}) => {
|
||||||
try {
|
try {
|
||||||
|
console.log('🔧 exportMaterialsToExcel 시작:', materials.length, '개 자재');
|
||||||
|
|
||||||
// 카테고리별로 그룹화
|
// 카테고리별로 그룹화
|
||||||
const categoryGroups = groupMaterialsByCategory(materials);
|
const categoryGroups = groupMaterialsByCategory(materials);
|
||||||
|
console.log('📁 카테고리별 그룹:', Object.keys(categoryGroups).map(k => `${k}: ${categoryGroups[k].length}개`));
|
||||||
|
|
||||||
// 전체 자재 합치기 (먼저 계산)
|
// 전체 자재 합치기 (먼저 계산)
|
||||||
const consolidatedMaterials = consolidateMaterials(materials);
|
const consolidatedMaterials = consolidateMaterials(materials);
|
||||||
|
console.log('📦 합쳐진 자재:', consolidatedMaterials.length, '개');
|
||||||
|
|
||||||
// 새 워크북 생성
|
// 새 워크북 생성
|
||||||
const workbook = XLSX.utils.book_new();
|
const workbook = XLSX.utils.book_new();
|
||||||
|
|
||||||
// 카테고리별 시트 생성 (합쳐진 자재)
|
// 카테고리별 시트 생성 (합쳐진 자재)
|
||||||
Object.entries(categoryGroups).forEach(([category, items]) => {
|
Object.entries(categoryGroups).forEach(([category, items]) => {
|
||||||
|
console.log(`📄 ${category} 시트 생성 중... (${items.length}개 자재)`);
|
||||||
const consolidatedItems = consolidateMaterials(items);
|
const consolidatedItems = consolidateMaterials(items);
|
||||||
|
console.log(` → 합쳐진 결과: ${consolidatedItems.length}개`);
|
||||||
|
|
||||||
const formattedItems = consolidatedItems.map(material => formatMaterialForExcel(material));
|
const formattedItems = consolidatedItems.map(material => formatMaterialForExcel(material));
|
||||||
|
console.log(` → 포맷 완료: ${formattedItems.length}개`);
|
||||||
|
|
||||||
if (formattedItems.length > 0) {
|
if (formattedItems.length > 0) {
|
||||||
const categorySheet = XLSX.utils.json_to_sheet(formattedItems);
|
const categorySheet = XLSX.utils.json_to_sheet(formattedItems);
|
||||||
|
|||||||
Reference in New Issue
Block a user