Files
TK-BOM-Project/backend/app/routers/purchase.py
Hyungi Ahn 28431ee490 feat: 구매 수량 계산 시스템 구현
🧮 구매 수량 계산 로직:
- PIPE: 절단 손실(3mm/절단) + 6M 단위 올림 계산
- 일반 자재: 여유율 + 최소 주문 수량 적용
- 자재별 차별화된 여유율 (VALVE 50%, BOLT 20% 등)

🛒 구매 관리 API:
- /purchase/items/calculate: 실시간 구매 수량 계산
- /purchase/items/save: 구매 품목 DB 저장
- /purchase/revision-diff: 리비전간 차이 계산
- /purchase/orders/create: 구매 주문 생성

🧪 테스트 검증:
- PIPE 절단 손실 계산: 25,000mm → 5본 (정확)
- 여유율 적용: VALVE 2개 → 3개 (50% 예비)
- 최소 주문: BOLT 24개 → 50개 (박스 단위)

📱 프론트엔드:
- PurchaseConfirmationPage 라우팅 추가
- 구매확정 버튼 → 구매 페이지 이동
2025-07-18 13:18:13 +09:00

428 lines
15 KiB
Python

"""
구매 관리 API
- 구매 품목 생성/조회
- 구매 수량 계산
- 리비전 비교
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import List, Optional
import json
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"])
@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 created_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("/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)}")