Files
TK-BOM-Project/backend/app/routers/purchase.py
Hyungi Ahn 83b90ef05c
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
feat: 자재 관리 페이지 대규모 개선
- 파이프 수량 계산 로직 수정 (단관 개수가 아닌 실제 길이 기반 계산)
- UI 전면 개편 (DevonThink 스타일의 간결하고 세련된 디자인)
- 자재별 그룹핑 로직 개선:
  * 플랜지: 동일 사양별 그룹핑, WN 스케줄 표시, ORIFICE 풀네임 표시
  * 피팅: 상세 타입 표시 (니플 길이, 엘보 각도/연결, 티 타입, 리듀서 타입 등)
  * 밸브: 동일 사양별 그룹핑, 타입/연결방식/압력 표시
  * 볼트: 크기/재질/길이별 그룹핑 (8SET → 개별 집계)
  * 가스켓: 동일 사양별 그룹핑, 재질/상세내역/두께 분리 표시
  * UNKNOWN: 원본 설명 전체 표시, 동일 항목 그룹핑
- 전체 카테고리 버튼 제거 (표시 복잡도 감소)
- 카테고리별 동적 컬럼 헤더 및 레이아웃 적용
2025-09-09 09:24:45 +09:00

585 lines
21 KiB
Python

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