""" 구매 관리 API - 구매 품목 생성/조회 - 구매 수량 계산 - 리비전 비교 """ from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy.orm import Session from sqlalchemy import text from typing import List, Optional from pydantic import BaseModel import json from datetime import datetime from ..database import get_db from ..services.purchase_calculator import ( generate_purchase_items_from_materials, save_purchase_items_to_db, calculate_pipe_purchase_quantity, calculate_standard_purchase_quantity ) router = APIRouter(prefix="/purchase", tags=["purchase"]) # Pydantic 모델 (최적화된 구조) class PurchaseItemMinimal(BaseModel): """구매 확정용 최소 필수 데이터""" item_code: str category: str specification: str size: str = "" material: str = "" bom_quantity: float calculated_qty: float unit: str = "EA" safety_factor: float = 1.0 class PurchaseConfirmRequest(BaseModel): job_no: str file_id: int bom_name: Optional[str] = None # 선택적 필드로 변경 revision: str purchase_items: List[PurchaseItemMinimal] # 최적화된 구조 사용 confirmed_at: str confirmed_by: str @router.get("/items/calculate") async def calculate_purchase_items( job_no: str = Query(..., description="Job 번호"), revision: str = Query("Rev.0", description="리비전"), file_id: Optional[int] = Query(None, description="파일 ID (선택사항)"), db: Session = Depends(get_db) ): """ 구매 품목 계산 (실시간) - 자재 데이터로부터 구매 품목 생성 - 수량 계산 (파이프 절단손실 포함) """ try: # 1. 파일 ID 조회 (job_no, revision으로) if not file_id: file_query = text(""" SELECT id FROM files WHERE job_no = :job_no AND revision = :revision AND is_active = TRUE ORDER BY updated_at DESC LIMIT 1 """) file_result = db.execute(file_query, {"job_no": job_no, "revision": revision}).fetchone() if not file_result: raise HTTPException(status_code=404, detail=f"Job {job_no} {revision}에 해당하는 파일을 찾을 수 없습니다") file_id = file_result[0] # 2. 구매 품목 생성 purchase_items = generate_purchase_items_from_materials(db, file_id, job_no, revision) return { "success": True, "job_no": job_no, "revision": revision, "file_id": file_id, "items": purchase_items, "total_items": len(purchase_items) } except Exception as e: raise HTTPException(status_code=500, detail=f"구매 품목 계산 실패: {str(e)}") @router.post("/confirm") async def confirm_purchase_quantities( request: PurchaseConfirmRequest, db: Session = Depends(get_db) ): """ 구매 수량 확정 - 계산된 구매 수량을 확정 상태로 저장 - 자재별 확정 수량 및 상태 업데이트 - 리비전 비교를 위한 기준 데이터 생성 """ try: # 1. 기존 확정 데이터 확인 및 업데이트 또는 삽입 existing_query = text(""" SELECT id FROM purchase_confirmations WHERE file_id = :file_id """) existing_result = db.execute(existing_query, {"file_id": request.file_id}).fetchone() if existing_result: # 기존 데이터 업데이트 confirmation_id = existing_result[0] update_query = text(""" UPDATE purchase_confirmations SET job_no = :job_no, bom_name = :bom_name, revision = :revision, confirmed_at = :confirmed_at, confirmed_by = :confirmed_by, is_active = TRUE, updated_at = CURRENT_TIMESTAMP WHERE id = :confirmation_id """) db.execute(update_query, { "confirmation_id": confirmation_id, "job_no": request.job_no, "bom_name": request.bom_name or f"{request.job_no}_{request.revision}", # 기본값 제공 "revision": request.revision, "confirmed_at": request.confirmed_at, "confirmed_by": request.confirmed_by }) # 기존 확정 품목들 삭제 delete_items_query = text(""" DELETE FROM confirmed_purchase_items WHERE confirmation_id = :confirmation_id """) db.execute(delete_items_query, {"confirmation_id": confirmation_id}) else: # 새로운 확정 데이터 삽입 confirm_query = text(""" INSERT INTO purchase_confirmations ( job_no, file_id, bom_name, revision, confirmed_at, confirmed_by, is_active, created_at ) VALUES ( :job_no, :file_id, :bom_name, :revision, :confirmed_at, :confirmed_by, TRUE, CURRENT_TIMESTAMP ) RETURNING id """) confirm_result = db.execute(confirm_query, { "job_no": request.job_no, "file_id": request.file_id, "bom_name": request.bom_name or f"{request.job_no}_{request.revision}", # 기본값 제공 "revision": request.revision, "confirmed_at": request.confirmed_at, "confirmed_by": request.confirmed_by }) confirmation_id = confirm_result.fetchone()[0] # 3. 확정된 구매 품목들 저장 saved_items = 0 for item in request.purchase_items: item_query = text(""" INSERT INTO confirmed_purchase_items ( confirmation_id, item_code, category, specification, size, material, bom_quantity, calculated_qty, unit, safety_factor, created_at ) VALUES ( :confirmation_id, :item_code, :category, :specification, :size, :material, :bom_quantity, :calculated_qty, :unit, :safety_factor, CURRENT_TIMESTAMP ) """) db.execute(item_query, { "confirmation_id": confirmation_id, "item_code": item.item_code or f"{item.category}-{saved_items+1}", "category": item.category, "specification": item.specification, "size": item.size or "", "material": item.material or "", "bom_quantity": item.bom_quantity, "calculated_qty": item.calculated_qty, "unit": item.unit, "safety_factor": item.safety_factor }) saved_items += 1 # 4. 파일 상태를 확정으로 업데이트 file_update_query = text(""" UPDATE files SET purchase_confirmed = TRUE, confirmed_at = :confirmed_at, confirmed_by = :confirmed_by, updated_at = CURRENT_TIMESTAMP WHERE id = :file_id """) db.execute(file_update_query, { "file_id": request.file_id, "confirmed_at": request.confirmed_at, "confirmed_by": request.confirmed_by }) db.commit() return { "success": True, "message": "구매 수량이 성공적으로 확정되었습니다", "confirmation_id": confirmation_id, "confirmed_items": saved_items, "job_no": request.job_no, "revision": request.revision, "confirmed_at": request.confirmed_at, "confirmed_by": request.confirmed_by } except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=f"구매 수량 확정 실패: {str(e)}") @router.post("/items/save") async def save_purchase_items( job_no: str, revision: str, file_id: int, db: Session = Depends(get_db) ): """ 구매 품목을 데이터베이스에 저장 """ try: # 1. 구매 품목 생성 purchase_items = generate_purchase_items_from_materials(db, file_id, job_no, revision) # 2. 데이터베이스에 저장 saved_ids = save_purchase_items_to_db(db, purchase_items) return { "success": True, "message": f"{len(saved_ids)}개 구매 품목이 저장되었습니다", "saved_items": len(saved_ids), "item_ids": saved_ids } except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=f"구매 품목 저장 실패: {str(e)}") @router.get("/items") async def get_purchase_items( job_no: str = Query(..., description="Job 번호"), revision: str = Query("Rev.0", description="리비전"), category: Optional[str] = Query(None, description="카테고리 필터"), db: Session = Depends(get_db) ): """ 저장된 구매 품목 조회 """ try: query = text(""" SELECT pi.*, COUNT(mpm.material_id) as material_count, SUM(m.quantity) as total_material_quantity FROM purchase_items pi LEFT JOIN material_purchase_mapping mpm ON pi.id = mpm.purchase_item_id LEFT JOIN materials m ON mpm.material_id = m.id WHERE pi.job_no = :job_no AND pi.revision = :revision AND pi.is_active = TRUE """) params = {"job_no": job_no, "revision": revision} if category: query = text(str(query) + " AND pi.category = :category") params["category"] = category query = text(str(query) + """ GROUP BY pi.id ORDER BY pi.category, pi.specification """) result = db.execute(query, params) items = result.fetchall() return { "success": True, "job_no": job_no, "revision": revision, "items": [dict(item) for item in items], "total_items": len(items) } except Exception as e: raise HTTPException(status_code=500, detail=f"구매 품목 조회 실패: {str(e)}") @router.patch("/items/{item_id}") async def update_purchase_item( item_id: int, safety_factor: Optional[float] = None, calculated_qty: Optional[float] = None, minimum_order_qty: Optional[float] = None, db: Session = Depends(get_db) ): """ 구매 품목 수정 (수량 조정) """ try: update_fields = [] params = {"item_id": item_id} if safety_factor is not None: update_fields.append("safety_factor = :safety_factor") params["safety_factor"] = safety_factor if calculated_qty is not None: update_fields.append("calculated_qty = :calculated_qty") params["calculated_qty"] = calculated_qty if minimum_order_qty is not None: update_fields.append("minimum_order_qty = :minimum_order_qty") params["minimum_order_qty"] = minimum_order_qty if not update_fields: raise HTTPException(status_code=400, detail="수정할 필드가 없습니다") update_fields.append("updated_at = CURRENT_TIMESTAMP") query = text(f""" UPDATE purchase_items SET {', '.join(update_fields)} WHERE id = :item_id RETURNING id, calculated_qty, safety_factor """) result = db.execute(query, params) updated_item = result.fetchone() if not updated_item: raise HTTPException(status_code=404, detail="구매 품목을 찾을 수 없습니다") db.commit() return { "success": True, "message": "구매 품목이 수정되었습니다", "item": dict(updated_item) } except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=f"구매 품목 수정 실패: {str(e)}") @router.get("/revision-diff") async def get_revision_diff( job_no: str = Query(..., description="Job 번호"), current_revision: str = Query(..., description="현재 리비전"), previous_revision: str = Query(..., description="이전 리비전"), db: Session = Depends(get_db) ): """ 리비전간 구매 수량 차이 계산 """ try: # 1. 이전 리비전 구매 품목 조회 prev_query = text(""" SELECT item_code, category, specification, calculated_qty, bom_quantity FROM purchase_items WHERE job_no = :job_no AND revision = :prev_revision AND is_active = TRUE """) prev_items = db.execute(prev_query, { "job_no": job_no, "prev_revision": previous_revision }).fetchall() # 2. 현재 리비전 구매 품목 조회 curr_query = text(""" SELECT item_code, category, specification, calculated_qty, bom_quantity FROM purchase_items WHERE job_no = :job_no AND revision = :curr_revision AND is_active = TRUE """) curr_items = db.execute(curr_query, { "job_no": job_no, "curr_revision": current_revision }).fetchall() # 3. 차이 계산 prev_dict = {item.item_code: dict(item) for item in prev_items} curr_dict = {item.item_code: dict(item) for item in curr_items} changes = [] added_items = 0 modified_items = 0 # 현재 리비전에서 추가되거나 변경된 항목 for item_code, curr_item in curr_dict.items(): if item_code not in prev_dict: # 새로 추가된 품목 changes.append({ "item_code": item_code, "change_type": "ADDED", "specification": curr_item["specification"], "previous_qty": 0, "current_qty": curr_item["calculated_qty"], "qty_diff": curr_item["calculated_qty"], "additional_needed": curr_item["calculated_qty"] }) added_items += 1 else: prev_item = prev_dict[item_code] qty_diff = curr_item["calculated_qty"] - prev_item["calculated_qty"] if abs(qty_diff) > 0.001: # 수량 변경 changes.append({ "item_code": item_code, "change_type": "MODIFIED", "specification": curr_item["specification"], "previous_qty": prev_item["calculated_qty"], "current_qty": curr_item["calculated_qty"], "qty_diff": qty_diff, "additional_needed": max(qty_diff, 0) # 증가한 경우만 추가 구매 }) modified_items += 1 # 삭제된 품목 (현재 리비전에 없는 항목) removed_items = 0 for item_code, prev_item in prev_dict.items(): if item_code not in curr_dict: changes.append({ "item_code": item_code, "change_type": "REMOVED", "specification": prev_item["specification"], "previous_qty": prev_item["calculated_qty"], "current_qty": 0, "qty_diff": -prev_item["calculated_qty"], "additional_needed": 0 }) removed_items += 1 # 요약 정보 total_additional_needed = sum( change["additional_needed"] for change in changes if change["additional_needed"] > 0 ) has_changes = len(changes) > 0 summary = f"추가: {added_items}개, 변경: {modified_items}개, 삭제: {removed_items}개" if total_additional_needed > 0: summary += f" (추가 구매 필요: {total_additional_needed:.1f})" return { "success": True, "job_no": job_no, "previous_revision": previous_revision, "current_revision": current_revision, "comparison": { "has_changes": has_changes, "summary": summary, "added_items": added_items, "modified_items": modified_items, "removed_items": removed_items, "total_additional_needed": total_additional_needed, "changes": changes } } except Exception as e: raise HTTPException(status_code=500, detail=f"리비전 비교 실패: {str(e)}") @router.post("/orders/create") async def create_purchase_order( job_no: str, revision: str, items: List[dict], supplier_name: Optional[str] = None, required_date: Optional[str] = None, db: Session = Depends(get_db) ): """ 구매 주문 생성 """ try: from datetime import datetime, date # 1. 주문 번호 생성 order_no = f"PO-{job_no}-{revision}-{datetime.now().strftime('%Y%m%d')}" # 2. 구매 주문 생성 order_query = text(""" INSERT INTO purchase_orders ( order_no, job_no, revision, status, order_date, required_date, supplier_name, created_by ) VALUES ( :order_no, :job_no, :revision, 'DRAFT', CURRENT_DATE, :required_date, :supplier_name, 'system' ) RETURNING id """) order_result = db.execute(order_query, { "order_no": order_no, "job_no": job_no, "revision": revision, "required_date": required_date, "supplier_name": supplier_name }) order_id = order_result.fetchone()[0] # 3. 주문 상세 항목 생성 total_amount = 0 for item in items: item_query = text(""" INSERT INTO purchase_order_items ( purchase_order_id, purchase_item_id, ordered_quantity, required_date ) VALUES ( :order_id, :item_id, :quantity, :required_date ) """) db.execute(item_query, { "order_id": order_id, "item_id": item["purchase_item_id"], "quantity": item["ordered_quantity"], "required_date": required_date }) db.commit() return { "success": True, "message": "구매 주문이 생성되었습니다", "order_no": order_no, "order_id": order_id, "items_count": len(items) } except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=f"구매 주문 생성 실패: {str(e)}") @router.get("/orders") async def get_purchase_orders( job_no: Optional[str] = Query(None, description="Job 번호"), status: Optional[str] = Query(None, description="주문 상태"), db: Session = Depends(get_db) ): """ 구매 주문 목록 조회 """ try: query = text(""" SELECT po.*, COUNT(poi.id) as items_count, SUM(poi.ordered_quantity) as total_quantity FROM purchase_orders po LEFT JOIN purchase_order_items poi ON po.id = poi.purchase_order_id WHERE 1=1 """) params = {} if job_no: query = text(str(query) + " AND po.job_no = :job_no") params["job_no"] = job_no if status: query = text(str(query) + " AND po.status = :status") params["status"] = status query = text(str(query) + """ GROUP BY po.id ORDER BY po.created_at DESC """) result = db.execute(query, params) orders = result.fetchall() return { "success": True, "orders": [dict(order) for order in orders], "total_orders": len(orders) } except Exception as e: raise HTTPException(status_code=500, detail=f"구매 주문 조회 실패: {str(e)}")