""" 자재 비교 및 발주 추적 API - 리비전간 자재 비교 - 추가 발주 필요량 계산 - 발주 상태 관리 """ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from sqlalchemy import text import json from typing import List, Optional, Dict from datetime import datetime from ..database import get_db router = APIRouter(prefix="/materials", tags=["material-comparison"]) @router.post("/compare-revisions") async def compare_material_revisions( job_no: str, current_revision: str, previous_revision: Optional[str] = None, save_result: bool = True, db: Session = Depends(get_db) ): """ 리비전간 자재 비교 및 추가 발주 필요량 계산 - 해시 기반 고성능 비교 - 누적 재고 고려한 실제 구매 필요량 계산 """ try: # 1. 파일 정보 조회 current_file = await get_file_by_revision(db, job_no, current_revision) if not current_file: raise HTTPException(status_code=404, detail=f"{job_no} {current_revision} 파일을 찾을 수 없습니다") # 2. 이전 리비전 자동 탐지 if not previous_revision: previous_revision = await get_previous_revision(db, job_no, current_revision) previous_file = None if previous_revision: previous_file = await get_file_by_revision(db, job_no, previous_revision) # 3. 자재 비교 실행 comparison_result = await perform_material_comparison( db, current_file, previous_file, job_no ) # 4. 결과 저장 (선택사항) comparison_id = None if save_result and previous_file and previous_revision: comparison_id = await save_comparison_result( db, job_no, current_revision, previous_revision, current_file["id"], previous_file["id"], comparison_result ) return { "success": True, "job_no": job_no, "current_revision": current_revision, "previous_revision": previous_revision, "comparison_id": comparison_id, "summary": comparison_result["summary"], "new_items": comparison_result["new_items"], "modified_items": comparison_result["modified_items"], "removed_items": comparison_result["removed_items"], "purchase_summary": comparison_result["purchase_summary"] } except Exception as e: raise HTTPException(status_code=500, detail=f"자재 비교 실패: {str(e)}") @router.get("/comparison-history") async def get_comparison_history( job_no: str = Query(..., description="Job 번호"), limit: int = Query(10, ge=1, le=50), db: Session = Depends(get_db) ): """ 자재 비교 이력 조회 """ try: query = text(""" SELECT id, current_revision, previous_revision, new_items_count, modified_items_count, removed_items_count, upload_date, created_by FROM material_revisions_comparison WHERE job_no = :job_no ORDER BY upload_date DESC LIMIT :limit """) result = db.execute(query, {"job_no": job_no, "limit": limit}) comparisons = result.fetchall() return { "success": True, "job_no": job_no, "comparisons": [ { "id": comp[0], "current_revision": comp[1], "previous_revision": comp[2], "new_items_count": comp[3], "modified_items_count": comp[4], "removed_items_count": comp[5], "upload_date": comp[6], "created_by": comp[7] } for comp in comparisons ] } except Exception as e: raise HTTPException(status_code=500, detail=f"비교 이력 조회 실패: {str(e)}") @router.get("/inventory-status") async def get_material_inventory_status( job_no: str = Query(..., description="Job 번호"), material_hash: Optional[str] = Query(None, description="특정 자재 해시"), db: Session = Depends(get_db) ): """ 자재별 누적 재고 현황 조회 """ try: # 임시로 빈 결과 반환 (추후 개선) return { "success": True, "job_no": job_no, "inventory": [] } except Exception as e: raise HTTPException(status_code=500, detail=f"재고 현황 조회 실패: {str(e)}") @router.post("/confirm-purchase") async def confirm_material_purchase( job_no: str, revision: str, confirmations: List[Dict], confirmed_by: str = "system", db: Session = Depends(get_db) ): """ 자재 발주 확정 처리 confirmations = [ { "material_hash": "abc123", "confirmed_quantity": 100, "supplier_name": "ABC공급업체", "unit_price": 1000 } ] """ try: confirmed_items = [] for confirmation in confirmations: # 발주 추적 테이블에 저장/업데이트 upsert_query = text(""" INSERT INTO material_purchase_tracking ( job_no, material_hash, revision, description, size_spec, unit, bom_quantity, calculated_quantity, confirmed_quantity, purchase_status, supplier_name, unit_price, total_price, confirmed_by, confirmed_at ) SELECT :job_no, m.material_hash, :revision, m.original_description, m.size_spec, m.unit, m.quantity, :calculated_qty, :confirmed_qty, 'CONFIRMED', :supplier_name, :unit_price, :total_price, :confirmed_by, CURRENT_TIMESTAMP FROM materials m WHERE m.material_hash = :material_hash AND m.file_id = ( SELECT id FROM files WHERE job_no = :job_no AND revision = :revision ORDER BY upload_date DESC LIMIT 1 ) LIMIT 1 ON CONFLICT (job_no, material_hash, revision) DO UPDATE SET confirmed_quantity = :confirmed_qty, purchase_status = 'CONFIRMED', supplier_name = :supplier_name, unit_price = :unit_price, total_price = :total_price, confirmed_by = :confirmed_by, confirmed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP RETURNING id, description, confirmed_quantity """) calculated_qty = confirmation.get("calculated_quantity", confirmation["confirmed_quantity"]) total_price = confirmation["confirmed_quantity"] * confirmation.get("unit_price", 0) result = db.execute(upsert_query, { "job_no": job_no, "revision": revision, "material_hash": confirmation["material_hash"], "calculated_qty": calculated_qty, "confirmed_qty": confirmation["confirmed_quantity"], "supplier_name": confirmation.get("supplier_name", ""), "unit_price": confirmation.get("unit_price", 0), "total_price": total_price, "confirmed_by": confirmed_by }) confirmed_item = result.fetchone() if confirmed_item: confirmed_items.append({ "id": confirmed_item[0], "material_hash": confirmed_item[1], "confirmed_quantity": confirmed_item[2], "supplier_name": confirmed_item[3], "unit_price": confirmed_item[4], "total_price": confirmed_item[5] }) db.commit() return { "success": True, "message": f"{len(confirmed_items)}개 자재 발주가 확정되었습니다", "confirmed_items": confirmed_items, "job_no": job_no, "revision": revision } except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=f"발주 확정 실패: {str(e)}") @router.get("/purchase-status") async def get_purchase_status( job_no: str = Query(..., description="Job 번호"), revision: Optional[str] = Query(None, description="리비전 (전체 조회시 생략)"), status: Optional[str] = Query(None, description="발주 상태 필터"), db: Session = Depends(get_db) ): """ 발주 상태 조회 """ try: where_conditions = ["job_no = :job_no"] params = {"job_no": job_no} if revision: where_conditions.append("revision = :revision") params["revision"] = revision if status: where_conditions.append("purchase_status = :status") params["status"] = status query = text(f""" SELECT material_hash, revision, description, size_spec, unit, bom_quantity, calculated_quantity, confirmed_quantity, purchase_status, supplier_name, unit_price, total_price, order_date, delivery_date, confirmed_by, confirmed_at FROM material_purchase_tracking WHERE {' AND '.join(where_conditions)} ORDER BY revision DESC, description """) result = db.execute(query, params) purchases = result.fetchall() # 상태별 요약 status_summary = {} total_amount = 0 for purchase in purchases: status_key = purchase.purchase_status if status_key not in status_summary: status_summary[status_key] = {"count": 0, "total_amount": 0} status_summary[status_key]["count"] += 1 status_summary[status_key]["total_amount"] += purchase.total_price or 0 total_amount += purchase.total_price or 0 return { "success": True, "job_no": job_no, "revision": revision, "purchases": [purchase._asdict() if hasattr(purchase, '_asdict') else dict(zip(purchase.keys(), purchase)) for purchase in purchases], "summary": { "total_items": len(purchases), "total_amount": total_amount, "status_breakdown": status_summary } } except Exception as e: raise HTTPException(status_code=500, detail=f"발주 상태 조회 실패: {str(e)}") # ========== 헬퍼 함수들 ========== async def get_file_by_revision(db: Session, job_no: str, revision: str) -> Optional[Dict]: """리비전으로 파일 정보 조회""" query = text(""" SELECT id, original_filename, revision, upload_date FROM files WHERE job_no = :job_no AND revision = :revision AND is_active = TRUE ORDER BY upload_date DESC LIMIT 1 """) result = db.execute(query, {"job_no": job_no, "revision": revision}) file_row = result.fetchone() if file_row: return { "id": file_row[0], "original_filename": file_row[1], "revision": file_row[2], "upload_date": file_row[3] } return None async def get_previous_revision(db: Session, job_no: str, current_revision: str) -> Optional[str]: """이전 리비전 자동 탐지""" query = text(""" SELECT revision FROM files WHERE job_no = :job_no AND revision < :current_revision AND is_active = TRUE ORDER BY revision DESC LIMIT 1 """) result = db.execute(query, {"job_no": job_no, "current_revision": current_revision}) prev_row = result.fetchone() if prev_row is not None: return prev_row[0] return None async def perform_material_comparison( db: Session, current_file: Dict, previous_file: Optional[Dict], job_no: str ) -> Dict: """ 핵심 자재 비교 로직 - 해시 기반 고성능 비교 - 누적 재고 고려한 실제 구매 필요량 계산 """ # 1. 현재 리비전 자재 목록 (해시별로 그룹화) current_materials = await get_materials_by_hash(db, current_file["id"]) # 2. 이전 리비전 자재 목록 previous_materials = {} if previous_file: previous_materials = await get_materials_by_hash(db, previous_file["id"]) # 3. 현재까지의 누적 재고 조회 current_inventory = await get_current_inventory(db, job_no) # 4. 비교 실행 new_items = [] modified_items = [] removed_items = [] purchase_summary = { "additional_purchase_needed": 0, "total_new_items": 0, "total_increased_items": 0 } # 신규/변경 항목 찾기 for material_hash, current_item in current_materials.items(): current_qty = current_item["quantity"] available_stock = current_inventory.get(material_hash, 0) if material_hash not in previous_materials: # 완전히 새로운 항목 additional_needed = max(current_qty - available_stock, 0) new_items.append({ "material_hash": material_hash, "description": current_item["description"], "size_spec": current_item["size_spec"], "material_grade": current_item["material_grade"], "quantity": current_qty, "available_stock": available_stock, "additional_needed": additional_needed }) purchase_summary["additional_purchase_needed"] += additional_needed purchase_summary["total_new_items"] += 1 else: # 기존 항목 - 수량 변경 체크 previous_qty = previous_materials[material_hash]["quantity"] qty_diff = current_qty - previous_qty if qty_diff != 0: additional_needed = max(current_qty - available_stock, 0) modified_items.append({ "material_hash": material_hash, "description": current_item["description"], "size_spec": current_item["size_spec"], "previous_quantity": previous_qty, "current_quantity": current_qty, "quantity_diff": qty_diff, "available_stock": available_stock, "additional_needed": additional_needed }) if additional_needed > 0: purchase_summary["additional_purchase_needed"] += additional_needed purchase_summary["total_increased_items"] += 1 # 삭제된 항목 찾기 for material_hash, previous_item in previous_materials.items(): if material_hash not in current_materials: removed_items.append({ "material_hash": material_hash, "description": previous_item["description"], "size_spec": previous_item["size_spec"], "quantity": previous_item["quantity"] }) return { "summary": { "total_current_items": len(current_materials), "total_previous_items": len(previous_materials), "new_items_count": len(new_items), "modified_items_count": len(modified_items), "removed_items_count": len(removed_items) }, "new_items": new_items, "modified_items": modified_items, "removed_items": removed_items, "purchase_summary": purchase_summary } async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]: """파일의 자재를 해시별로 그룹화하여 조회""" import hashlib query = text(""" SELECT original_description, size_spec, material_grade, SUM(quantity) as quantity, classified_category FROM materials WHERE file_id = :file_id GROUP BY original_description, size_spec, material_grade, classified_category """) result = db.execute(query, {"file_id": file_id}) materials = result.fetchall() materials_dict = {} for mat in materials: # 자재 해시 생성 (description + size_spec + material_grade) hash_source = f"{mat[0] or ''}|{mat[1] or ''}|{mat[2] or ''}" material_hash = hashlib.md5(hash_source.encode()).hexdigest() materials_dict[material_hash] = { "material_hash": material_hash, "original_description": mat[0], "size_spec": mat[1], "material_grade": mat[2], "quantity": float(mat[3]) if mat[3] else 0.0, "classified_category": mat[4] } return materials_dict async def get_current_inventory(db: Session, job_no: str) -> Dict[str, float]: """현재까지의 누적 재고량 조회""" query = text(""" SELECT material_hash, available_stock FROM material_inventory_status WHERE job_no = :job_no """) result = db.execute(query, {"job_no": job_no}) inventory = result.fetchall() return {inv.material_hash: float(inv.available_stock or 0) for inv in inventory} async def save_comparison_result( db: Session, job_no: str, current_revision: str, previous_revision: str, current_file_id: int, previous_file_id: int, comparison_result: Dict ) -> int: """비교 결과를 데이터베이스에 저장""" # 메인 비교 레코드 저장 insert_query = text(""" INSERT INTO material_revisions_comparison ( job_no, current_revision, previous_revision, current_file_id, previous_file_id, total_current_items, total_previous_items, new_items_count, modified_items_count, removed_items_count, comparison_details, created_by ) VALUES ( :job_no, :current_revision, :previous_revision, :current_file_id, :previous_file_id, :total_current_items, :total_previous_items, :new_items_count, :modified_items_count, :removed_items_count, :comparison_details, 'system' ) ON CONFLICT (job_no, current_revision, previous_revision) DO UPDATE SET total_current_items = :total_current_items, total_previous_items = :total_previous_items, new_items_count = :new_items_count, modified_items_count = :modified_items_count, removed_items_count = :removed_items_count, comparison_details = :comparison_details, upload_date = CURRENT_TIMESTAMP RETURNING id """) import json summary = comparison_result["summary"] result = db.execute(insert_query, { "job_no": job_no, "current_revision": current_revision, "previous_revision": previous_revision, "current_file_id": current_file_id, "previous_file_id": previous_file_id, "total_current_items": summary["total_current_items"], "total_previous_items": summary["total_previous_items"], "new_items_count": summary["new_items_count"], "modified_items_count": summary["modified_items_count"], "removed_items_count": summary["removed_items_count"], "comparison_details": json.dumps(comparison_result, ensure_ascii=False) }) comparison_id = result.fetchone()[0] db.commit() return comparison_id