Files
TK-BOM-Project/backend/app/routers/purchase_tracking.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

453 lines
16 KiB
Python

"""
구매 추적 및 관리 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)}"
)