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

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

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

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

구매신청 관리:
- 엑셀 재다운로드 형식 개선 (BOM 페이지와 동일한 형식)
- 그룹화된 자재 정보 포함하여 저장 및 다운로드
2025-10-14 12:39:25 +09:00

592 lines
22 KiB
Python

"""
엑셀 내보내기 및 구매 배치 관리 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)}"
)