Files
TK-BOM-Project/backend/app/routers/material_comparison.py
Hyungi Ahn 2d178f8161 파이프 길이 계산 및 엑셀 내보내기 버그 수정
- 자재 비교에서 파이프 길이 합산 로직 수정
- 프론트엔드에서 혼란스러운 '평균단위' 표시 제거
- 파이프 변경사항에 실제 이전/현재 총길이 표시
- 엑셀 내보내기에서 '초기화되지 않은 변수' 오류 수정
- 리비전 비교에서 파이프 길이 변화 계산 개선
2025-07-23 08:12:19 +09:00

643 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
자재 비교 및 발주 추적 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
# TODO: 저장 기능 활성화
# 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"]
}
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]:
"""이전 리비전 자동 탐지 - 숫자 기반 비교"""
# 현재 리비전의 숫자 추출
try:
current_rev_num = int(current_revision.replace("Rev.", ""))
except (ValueError, AttributeError):
current_rev_num = 0
query = text("""
SELECT revision
FROM files
WHERE job_no = :job_no AND is_active = TRUE
ORDER BY revision DESC
""")
result = db.execute(query, {"job_no": job_no})
revisions = result.fetchall()
# 현재 리비전보다 낮은 리비전 중 가장 높은 것 찾기
previous_revision = None
highest_prev_num = -1
for row in revisions:
rev = row[0]
try:
rev_num = int(rev.replace("Rev.", ""))
if rev_num < current_rev_num and rev_num > highest_prev_num:
highest_prev_num = rev_num
previous_revision = rev
except (ValueError, AttributeError):
continue
return previous_revision
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. 비교 실행
new_items = []
modified_items = []
removed_items = []
# 신규/변경 항목 찾기
for material_hash, current_item in current_materials.items():
current_qty = current_item["quantity"]
if material_hash not in previous_materials:
# 완전히 새로운 항목
new_item = {
"material_hash": material_hash,
"description": current_item["description"],
"size_spec": current_item["size_spec"],
"material_grade": current_item["material_grade"],
"quantity": current_qty,
"category": current_item["category"],
"unit": current_item["unit"]
}
# 파이프인 경우 pipe_details 정보 포함
if current_item.get("pipe_details"):
new_item["pipe_details"] = current_item["pipe_details"]
new_items.append(new_item)
else:
# 기존 항목 - 수량 변경 체크
previous_qty = previous_materials[material_hash]["quantity"]
qty_change = current_qty - previous_qty
if qty_change != 0:
modified_item = {
"material_hash": material_hash,
"description": current_item["description"],
"size_spec": current_item["size_spec"],
"material_grade": current_item["material_grade"],
"previous_quantity": previous_qty,
"current_quantity": current_qty,
"quantity_change": qty_change,
"category": current_item["category"],
"unit": current_item["unit"]
}
# 파이프인 경우 이전/현재 pipe_details 모두 포함
if current_item.get("pipe_details"):
modified_item["pipe_details"] = current_item["pipe_details"]
# 이전 리비전 pipe_details도 포함
previous_item = previous_materials[material_hash]
if previous_item.get("pipe_details"):
modified_item["previous_pipe_details"] = previous_item["pipe_details"]
# 실제 길이 변화 계산 (현재 총길이 - 이전 총길이)
if current_item.get("pipe_details"):
current_total = current_item["pipe_details"]["total_length_mm"]
previous_total = previous_item["pipe_details"]["total_length_mm"]
length_change = current_total - previous_total
modified_item["length_change"] = length_change
print(f"🔢 실제 길이 변화: {current_item['description'][:50]} - 이전:{previous_total:.0f}mm → 현재:{current_total:.0f}mm (변화:{length_change:+.0f}mm)")
modified_items.append(modified_item)
# 삭제된 항목 찾기
for material_hash, previous_item in previous_materials.items():
if material_hash not in current_materials:
removed_item = {
"material_hash": material_hash,
"description": previous_item["description"],
"size_spec": previous_item["size_spec"],
"material_grade": previous_item["material_grade"],
"quantity": previous_item["quantity"],
"category": previous_item["category"],
"unit": previous_item["unit"]
}
# 파이프인 경우 pipe_details 정보 포함
if previous_item.get("pipe_details"):
removed_item["pipe_details"] = previous_item["pipe_details"]
removed_items.append(removed_item)
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
}
async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
"""파일의 자재를 해시별로 그룹화하여 조회"""
import hashlib
print(f"🚨🚨🚨 get_materials_by_hash 호출됨! file_id={file_id} 🚨🚨🚨")
query = text("""
SELECT
m.id,
m.original_description,
m.size_spec,
m.material_grade,
m.quantity,
m.classified_category,
m.unit,
pd.length_mm,
m.line_number
FROM materials m
LEFT JOIN pipe_details pd ON m.id = pd.material_id
WHERE m.file_id = :file_id
ORDER BY m.line_number
""")
result = db.execute(query, {"file_id": file_id})
materials = result.fetchall()
print(f"🔍 쿼리 결과 개수: {len(materials)}")
if len(materials) > 0:
print(f"🔍 첫 번째 자료 샘플: {materials[0]}")
else:
print(f"❌ 자료가 없음! file_id={file_id}")
# 🔄 같은 파이프들을 Python에서 올바르게 그룹핑
materials_dict = {}
for mat in materials:
# 자재 해시 생성 (description + size_spec + material_grade)
hash_source = f"{mat[1] or ''}|{mat[2] or ''}|{mat[3] or ''}"
material_hash = hashlib.md5(hash_source.encode()).hexdigest()
print(f"📝 개별 자재: {mat[1][:50]}... ({mat[2]}) - 수량: {mat[4]}, 길이: {mat[7]}mm")
if material_hash in materials_dict:
# 🔄 기존 항목에 수량 합계
existing = materials_dict[material_hash]
existing["quantity"] += float(mat[4]) if mat[4] else 0.0
existing["line_number"] += f", {mat[8]}" if mat[8] else ""
# 파이프인 경우 길이 정보 합산
if mat[5] == 'PIPE' and mat[7] is not None:
if "pipe_details" in existing:
# 총길이 합산: 기존 총길이 + (현재 수량 × 현재 길이)
current_total = existing["pipe_details"]["total_length_mm"]
current_count = existing["pipe_details"]["pipe_count"]
new_length = float(mat[4]) * float(mat[7]) # 수량 × 단위길이
existing["pipe_details"]["total_length_mm"] = current_total + new_length
existing["pipe_details"]["pipe_count"] = current_count + float(mat[4])
# 평균 단위 길이 재계산
total_length = existing["pipe_details"]["total_length_mm"]
total_count = existing["pipe_details"]["pipe_count"]
existing["pipe_details"]["length_mm"] = total_length / total_count
print(f"🔄 파이프 합산: {mat[1]} ({mat[2]}) - 총길이: {total_length}mm, 총개수: {total_count}개, 평균: {total_length/total_count:.1f}mm")
else:
# 첫 파이프 정보 설정
pipe_length = float(mat[4]) * float(mat[7])
existing["pipe_details"] = {
"length_mm": float(mat[7]),
"total_length_mm": pipe_length,
"pipe_count": float(mat[4])
}
else:
# 🆕 새 항목 생성
material_data = {
"material_hash": material_hash,
"description": mat[1], # original_description
"size_spec": mat[2],
"material_grade": mat[3],
"quantity": float(mat[4]) if mat[4] else 0.0,
"category": mat[5], # classified_category
"unit": mat[6] or 'EA',
"line_number": str(mat[8]) if mat[8] else ''
}
# 파이프인 경우 pipe_details 정보 추가
if mat[5] == 'PIPE' and mat[7] is not None:
pipe_length = float(mat[4]) * float(mat[7]) # 수량 × 단위길이
material_data["pipe_details"] = {
"length_mm": float(mat[7]), # 단위 길이
"total_length_mm": pipe_length, # 총 길이
"pipe_count": float(mat[4]) # 파이프 개수
}
print(f"🆕 파이프 신규: {mat[1]} ({mat[2]}) - 단위: {mat[7]}mm, 총길이: {pipe_length}mm")
materials_dict[material_hash] = material_data
# 파이프 데이터가 포함되었는지 확인
pipe_count = sum(1 for data in materials_dict.values() if data.get('category') == 'PIPE')
pipe_with_details = sum(1 for data in materials_dict.values()
if data.get('category') == 'PIPE' and 'pipe_details' in data)
print(f"🔍 반환 결과: 총 {len(materials_dict)}개 자재, 파이프 {pipe_count}개, pipe_details 있는 파이프 {pipe_with_details}")
# 첫 번째 파이프 데이터 샘플 출력
for hash_key, data in materials_dict.items():
if data.get('category') == 'PIPE':
print(f"🔍 파이프 샘플: {data}")
break
return materials_dict
async def get_current_inventory(db: Session, job_no: str) -> Dict[str, float]:
"""현재까지의 누적 재고량 조회 - 임시로 빈 딕셔너리 반환"""
# TODO: 실제 재고 시스템 구현 후 활성화
return {}
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