- 자재 리비전간 비교 기능 추가 (MaterialComparisonPage) - 버그 해결 필요 - 리비전간 추가 구매 필요 자재 분석 페이지 추가 (RevisionPurchasePage) - 자재 비교 결과 컴포넌트 구현 (MaterialComparisonResult) - 자재 비교 API 라우터 추가 (material_comparison.py) - 로직 개선 필요 - 자재 비교 시스템 데이터베이스 스키마 추가 - FileManager, FileUpload 컴포넌트 개선 - BOMManagerPage 제거 및 새로운 구조로 리팩토링 - 자재 분류기 및 스키마 개선 TODO: 자재 비교 알고리즘 정확도 향상 및 예외 처리 강화 필요
551 lines
20 KiB
Python
551 lines
20 KiB
Python
"""
|
|
자재 비교 및 발주 추적 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 |