Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 파이프 수량 계산 로직 수정 (단관 개수가 아닌 실제 길이 기반 계산) - UI 전면 개편 (DevonThink 스타일의 간결하고 세련된 디자인) - 자재별 그룹핑 로직 개선: * 플랜지: 동일 사양별 그룹핑, WN 스케줄 표시, ORIFICE 풀네임 표시 * 피팅: 상세 타입 표시 (니플 길이, 엘보 각도/연결, 티 타입, 리듀서 타입 등) * 밸브: 동일 사양별 그룹핑, 타입/연결방식/압력 표시 * 볼트: 크기/재질/길이별 그룹핑 (8SET → 개별 집계) * 가스켓: 동일 사양별 그룹핑, 재질/상세내역/두께 분리 표시 * UNKNOWN: 원본 설명 전체 표시, 동일 항목 그룹핑 - 전체 카테고리 버튼 제거 (표시 복잡도 감소) - 카테고리별 동적 컬럼 헤더 및 레이아웃 적용
585 lines
21 KiB
Python
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)}") |