diff --git a/backend/app/main.py b/backend/app/main.py
index 72ffbb6..8015b0c 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -43,6 +43,12 @@ try:
except ImportError:
print("purchase 라우터를 찾을 수 없습니다")
+try:
+ from .routers import material_comparison
+ app.include_router(material_comparison.router, tags=["material-comparison"])
+except ImportError:
+ print("material_comparison 라우터를 찾을 수 없습니다")
+
# 파일 목록 조회 API
@app.get("/files")
async def get_files(
@@ -94,12 +100,13 @@ async def get_files(
"name": f.original_filename,
"job_no": f.job_no, # job_no 사용
"bom_name": f.bom_name or f.original_filename, # 실제 bom_name 값 사용, 없으면 파일명
+ "revision": f.revision or "Rev.0", # 실제 리비전 또는 기본값
+ "parsed_count": f.parsed_count or 0, # 파싱된 자재 수
"bom_type": f.file_type or "unknown", # file_type을 BOM 종류로 사용
"status": "active" if f.is_active else "inactive", # is_active 상태
"file_size": f.file_size,
"created_at": f.upload_date,
"upload_date": f.upload_date,
- "revision": f.revision or "Rev.0", # 실제 리비전 또는 기본값
"description": f"파일: {f.original_filename}"
}
for f in files
diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py
index e6e9360..7854879 100644
--- a/backend/app/routers/files.py
+++ b/backend/app/routers/files.py
@@ -169,9 +169,18 @@ def parse_file_data(file_path):
async def upload_file(
file: UploadFile = File(...),
job_no: str = Form(...),
- revision: str = Form("Rev.0"),
+ revision: str = Form("Rev.0"), # 기본값은 Rev.0 (새 BOM)
+ parent_file_id: Optional[int] = Form(None), # 리비전 업로드 시 부모 파일 ID
+ bom_name: Optional[str] = Form(None), # BOM 이름 (사용자 입력)
db: Session = Depends(get_db)
):
+ print(f"📥 업로드 요청 받음:")
+ print(f" - 파일명: {file.filename}")
+ print(f" - job_no: {job_no}")
+ print(f" - revision: {revision}")
+ print(f" - parent_file_id: {parent_file_id}")
+ print(f" - bom_name: {bom_name}")
+ print(f" - parent_file_id 타입: {type(parent_file_id)}")
if not validate_file_extension(file.filename):
raise HTTPException(
status_code=400,
@@ -198,11 +207,67 @@ async def upload_file(
parsed_count = len(materials_data)
print(f"파싱 완료: {parsed_count}개 자재")
+ # 리비전 업로드인 경우만 자동 리비전 생성
+ if parent_file_id is not None:
+ print(f"리비전 업로드 모드: parent_file_id = {parent_file_id}")
+ # 부모 파일의 정보 조회
+ parent_query = text("""
+ SELECT original_filename, revision, bom_name FROM files
+ WHERE id = :parent_file_id AND job_no = :job_no
+ """)
+
+ parent_result = db.execute(parent_query, {
+ "parent_file_id": parent_file_id,
+ "job_no": job_no
+ })
+ parent_file = parent_result.fetchone()
+
+ if not parent_file:
+ raise HTTPException(status_code=404, detail="부모 파일을 찾을 수 없습니다.")
+
+ # 해당 BOM의 최신 리비전 확인 (bom_name 기준)
+ bom_name_to_use = parent_file[2] or parent_file[0] # bom_name 우선, 없으면 original_filename
+ latest_revision_query = text("""
+ SELECT revision FROM files
+ WHERE job_no = :job_no
+ AND (bom_name = :bom_name OR (bom_name IS NULL AND original_filename = :bom_name))
+ ORDER BY revision DESC
+ LIMIT 1
+ """)
+
+ latest_result = db.execute(latest_revision_query, {
+ "job_no": job_no,
+ "bom_name": bom_name_to_use
+ })
+ latest_revision = latest_result.fetchone()
+
+ if latest_revision:
+ latest_rev = latest_revision[0]
+ if latest_rev.startswith("Rev."):
+ try:
+ rev_num = int(latest_rev.replace("Rev.", ""))
+ revision = f"Rev.{rev_num + 1}"
+ except ValueError:
+ revision = "Rev.1"
+ else:
+ revision = "Rev.1"
+
+ print(f"리비전 업로드: {latest_rev} → {revision}")
+ else:
+ revision = "Rev.1"
+ print(f"첫 번째 리비전: {revision}")
+
+ # 파일명을 부모와 동일하게 유지
+ file.filename = parent_file[0]
+ else:
+ # 일반 업로드 (새 BOM)
+ print(f"일반 업로드 모드: 새 BOM 파일 (Rev.0)")
+
# 파일 정보 저장
print("DB 저장 시작")
file_insert_query = text("""
- INSERT INTO files (filename, original_filename, file_path, job_no, revision, description, file_size, parsed_count, is_active)
- VALUES (:filename, :original_filename, :file_path, :job_no, :revision, :description, :file_size, :parsed_count, :is_active)
+ INSERT INTO files (filename, original_filename, file_path, job_no, revision, bom_name, description, file_size, parsed_count, is_active)
+ VALUES (:filename, :original_filename, :file_path, :job_no, :revision, :bom_name, :description, :file_size, :parsed_count, :is_active)
RETURNING id
""")
@@ -212,6 +277,7 @@ async def upload_file(
"file_path": str(file_path),
"job_no": job_no,
"revision": revision,
+ "bom_name": bom_name or file.filename, # bom_name 우선, 없으면 파일명
"description": f"BOM 파일 - {parsed_count}개 자재",
"file_size": file.size,
"parsed_count": parsed_count,
@@ -434,8 +500,28 @@ async def upload_file(
else:
end_prep = str(end_prep_info) if end_prep_info else ""
- # 재질 정보 - 이미 정제된 material_grade 사용
- material_spec = material_data.get("material_grade", "")
+ # 재질 정보 - 분류 결과에서 상세 정보 추출
+ material_grade_from_classifier = ""
+ if isinstance(material_info, dict):
+ material_grade_from_classifier = material_info.get("grade", "")
+
+ # 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
+ if material_grade_from_classifier and material_grade_from_classifier != "UNKNOWN":
+ material_spec = material_grade_from_classifier
+
+ # materials 테이블의 material_grade도 업데이트
+ db.execute(text("""
+ UPDATE materials
+ SET material_grade = :new_material_grade
+ WHERE id = :material_id
+ """), {
+ "new_material_grade": material_grade_from_classifier,
+ "material_id": material_id
+ })
+ print(f"PIPE material_grade 업데이트: {material_grade_from_classifier}")
+ else:
+ # 기존 파싱 결과 사용
+ material_spec = material_data.get("material_grade", "")
# 제조방법 추출
manufacturing_method = ""
@@ -488,6 +574,18 @@ async def upload_file(
material_standard = material_info.get("standard", "")
material_grade = material_info.get("grade", "")
+ # 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
+ if material_grade and material_grade != "UNKNOWN":
+ db.execute(text("""
+ UPDATE materials
+ SET material_grade = :new_material_grade
+ WHERE id = :material_id
+ """), {
+ "new_material_grade": material_grade,
+ "material_id": material_id
+ })
+ print(f"FITTING material_grade 업데이트: {material_grade}")
+
# main_size와 reduced_size
main_size = material_data.get("main_nom") or material_data.get("size_spec", "")
reduced_size = material_data.get("red_nom", "")
@@ -572,6 +670,18 @@ async def upload_file(
if isinstance(material_info, dict):
material_standard = material_info.get("standard", "")
material_grade = material_info.get("grade", "")
+
+ # 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
+ if material_grade and material_grade != "UNKNOWN":
+ db.execute(text("""
+ UPDATE materials
+ SET material_grade = :new_material_grade
+ WHERE id = :material_id
+ """), {
+ "new_material_grade": material_grade,
+ "material_id": material_id
+ })
+ print(f"FLANGE material_grade 업데이트: {material_grade}")
# 사이즈 정보
size_inches = material_data.get("main_nom") or material_data.get("size_spec", "")
@@ -718,6 +828,18 @@ async def upload_file(
if isinstance(material_info, dict):
material_standard = material_info.get("standard", "")
material_grade = material_info.get("grade", "")
+
+ # 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
+ if material_grade and material_grade != "UNKNOWN":
+ db.execute(text("""
+ UPDATE materials
+ SET material_grade = :new_material_grade
+ WHERE id = :material_id
+ """), {
+ "new_material_grade": material_grade,
+ "material_id": material_id
+ })
+ print(f"BOLT material_grade 업데이트: {material_grade}")
# 압력 등급 (150LB 등)
pressure_rating = ""
@@ -821,6 +943,18 @@ async def upload_file(
body_material = material_info.get("grade", "")
# 트림 재질은 일반적으로 바디와 동일하거나 별도 명시
trim_material = body_material
+
+ # 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
+ if body_material and body_material != "UNKNOWN":
+ db.execute(text("""
+ UPDATE materials
+ SET material_grade = :new_material_grade
+ WHERE id = :material_id
+ """), {
+ "new_material_grade": body_material,
+ "material_id": material_id
+ })
+ print(f"VALVE material_grade 업데이트: {body_material}")
# 사이즈 정보
size_inches = material_data.get("main_nom") or material_data.get("size_spec", "")
@@ -879,6 +1013,8 @@ async def upload_file(
"original_filename": file.filename,
"file_id": file_id,
"materials_count": materials_inserted,
+ "saved_materials_count": materials_inserted,
+ "revision": revision, # 생성된 리비전 정보 추가
"parsed_count": parsed_count
}
diff --git a/backend/app/routers/material_comparison.py b/backend/app/routers/material_comparison.py
new file mode 100644
index 0000000..949d761
--- /dev/null
+++ b/backend/app/routers/material_comparison.py
@@ -0,0 +1,551 @@
+"""
+자재 비교 및 발주 추적 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
\ No newline at end of file
diff --git a/backend/app/services/material_classifier.py b/backend/app/services/material_classifier.py
index daaf4ab..804a6fb 100644
--- a/backend/app/services/material_classifier.py
+++ b/backend/app/services/material_classifier.py
@@ -131,13 +131,16 @@ def check_astm_standard(description: str, standard: str, standard_data: Dict) ->
for pattern in standard_data["patterns"]:
match = re.search(pattern, description)
if match:
+ grade_code = match.group(1) if match.groups() else ""
+ full_grade = f"ASTM {standard}" + (f" {grade_code}" if grade_code else "")
+
return {
"standard": f"ASTM {standard}",
- "grade": f"ASTM {standard}",
- "material_type": determine_material_type(standard, ""),
+ "grade": full_grade,
+ "material_type": determine_material_type(standard, grade_code),
"manufacturing": standard_data.get("manufacturing", "UNKNOWN"),
"confidence": 0.9,
- "evidence": [f"ASTM_{standard}: Direct Match"]
+ "evidence": [f"ASTM_{standard}: {grade_code if grade_code else 'Direct Match'}"]
}
# 하위 분류가 있는 경우 (A182, A234 등)
@@ -149,9 +152,27 @@ def check_astm_standard(description: str, standard: str, standard_data: Dict) ->
grade_code = match.group(1) if match.groups() else ""
grade_info = subtype_data["grades"].get(grade_code, {})
+ # A312의 경우 TP304 형태로 전체 grade 표시
+ if standard == "A312" and grade_code and not grade_code.startswith("TP"):
+ full_grade = f"ASTM {standard} TP{grade_code}"
+ elif grade_code.startswith("TP"):
+ full_grade = f"ASTM {standard} {grade_code}"
+ # A403의 경우 WP304 형태로 전체 grade 표시
+ elif standard == "A403" and grade_code and not grade_code.startswith("WP"):
+ full_grade = f"ASTM {standard} WP{grade_code}"
+ elif grade_code.startswith("WP"):
+ full_grade = f"ASTM {standard} {grade_code}"
+ # A420의 경우 WPL3 형태로 전체 grade 표시
+ elif standard == "A420" and grade_code and not grade_code.startswith("WPL"):
+ full_grade = f"ASTM {standard} WPL{grade_code}"
+ elif grade_code.startswith("WPL"):
+ full_grade = f"ASTM {standard} {grade_code}"
+ else:
+ full_grade = f"ASTM {standard} {grade_code}" if grade_code else f"ASTM {standard}"
+
return {
"standard": f"ASTM {standard}",
- "grade": f"ASTM {standard} {grade_code}",
+ "grade": full_grade,
"material_type": determine_material_type(standard, grade_code),
"manufacturing": subtype_data.get("manufacturing", "UNKNOWN"),
"composition": grade_info.get("composition", ""),
@@ -275,7 +296,7 @@ def get_manufacturing_method_from_material(material_result: Dict) -> str:
# 직접 매핑
if 'A182' in material_standard or 'A105' in material_standard:
return 'FORGED'
- elif 'A234' in material_standard or 'A403' in material_standard:
+ elif 'A234' in material_standard or 'A403' in material_standard or 'A420' in material_standard:
return 'WELDED_FABRICATED'
elif 'A216' in material_standard or 'A351' in material_standard:
return 'CAST'
diff --git a/backend/app/services/materials_schema.py b/backend/app/services/materials_schema.py
index b4ba7b0..95fe00e 100644
--- a/backend/app/services/materials_schema.py
+++ b/backend/app/services/materials_schema.py
@@ -165,7 +165,11 @@ MATERIAL_STANDARDS = {
"patterns": [
r"ASTM\s+A403\s+(?:GR\s*)?WP\s*(\d{3}[LH]*)",
r"A403\s+(?:GR\s*)?WP\s*(\d{3}[LH]*)",
- r"ASME\s+SA403\s+(?:GR\s*)?WP\s*(\d{3}[LH]*)"
+ r"ASME\s+SA403\s+(?:GR\s*)?WP\s*(\d{3}[LH]*)",
+ r"ASTM\s+A403\s+(WP\d{3}[LH]*)",
+ r"A403\s+(WP\d{3}[LH]*)",
+ r"(WP\d{3}[LH]*)\s+A403",
+ r"(WP\d{3}[LH]*)"
],
"grades": {
"WP304": {
@@ -191,6 +195,37 @@ MATERIAL_STANDARDS = {
},
"manufacturing": "WELDED_FABRICATED"
}
+ },
+ "A420": {
+ "low_temp_carbon": {
+ "patterns": [
+ r"ASTM\s+A420\s+(?:GR\s*)?WPL\s*(\d+)",
+ r"A420\s+(?:GR\s*)?WPL\s*(\d+)",
+ r"ASME\s+SA420\s+(?:GR\s*)?WPL\s*(\d+)",
+ r"ASTM\s+A420\s+(WPL\d+)",
+ r"A420\s+(WPL\d+)",
+ r"(WPL\d+)\s+A420",
+ r"(WPL\d+)"
+ ],
+ "grades": {
+ "WPL1": {
+ "composition": "탄소강",
+ "temp_min": "-29°C",
+ "applications": "저온용 피팅"
+ },
+ "WPL3": {
+ "composition": "3.5Ni",
+ "temp_min": "-46°C",
+ "applications": "저온용 피팅"
+ },
+ "WPL6": {
+ "composition": "탄소강",
+ "temp_min": "-46°C",
+ "applications": "저온용 피팅"
+ }
+ },
+ "manufacturing": "WELDED_FABRICATED"
+ }
}
},
@@ -289,7 +324,11 @@ MATERIAL_STANDARDS = {
"A312": {
"patterns": [
r"ASTM\s+A312\s+TP\s*(\d{3}[LH]*)",
- r"A312\s+TP\s*(\d{3}[LH]*)"
+ r"A312\s+TP\s*(\d{3}[LH]*)",
+ r"ASTM\s+A312\s+(TP\d{3}[LH]*)",
+ r"A312\s+(TP\d{3}[LH]*)",
+ r"(TP\d{3}[LH]*)\s+A312",
+ r"(TP\d{3}[LH]*)"
],
"grades": {
"TP304": {
@@ -310,6 +349,31 @@ MATERIAL_STANDARDS = {
}
},
"manufacturing": "SEAMLESS"
+ },
+ "A333": {
+ "patterns": [
+ r"ASTM\s+A333\s+(?:GR\s*)?(\d+)",
+ r"A333\s+(?:GR\s*)?(\d+)",
+ r"ASME\s+SA333\s+(?:GR\s*)?(\d+)"
+ ],
+ "grades": {
+ "1": {
+ "composition": "탄소강",
+ "temp_min": "-29°C",
+ "applications": "저온용 배관"
+ },
+ "3": {
+ "composition": "3.5Ni",
+ "temp_min": "-46°C",
+ "applications": "저온용 배관"
+ },
+ "6": {
+ "composition": "탄소강",
+ "temp_min": "-46°C",
+ "applications": "저온용 배관"
+ }
+ },
+ "manufacturing": "SEAMLESS"
}
}
},
diff --git a/backend/scripts/10_add_material_comparison_system.sql b/backend/scripts/10_add_material_comparison_system.sql
new file mode 100644
index 0000000..9099d62
--- /dev/null
+++ b/backend/scripts/10_add_material_comparison_system.sql
@@ -0,0 +1,243 @@
+-- 자재 비교 및 발주 추적 시스템
+-- 실행일: 2025.01.22
+
+-- ================================
+-- 1. materials 테이블에 해시 컬럼 추가
+-- ================================
+
+-- 자재 비교를 위한 해시 컬럼 추가
+ALTER TABLE materials ADD COLUMN IF NOT EXISTS material_hash VARCHAR(32);
+ALTER TABLE materials ADD COLUMN IF NOT EXISTS normalized_description TEXT;
+
+-- 해시 인덱스 추가 (성능 최적화)
+CREATE INDEX IF NOT EXISTS idx_materials_hash ON materials(material_hash);
+CREATE INDEX IF NOT EXISTS idx_materials_file_hash ON materials(file_id, material_hash);
+
+-- ================================
+-- 2. 자재 비교 결과 저장 테이블
+-- ================================
+
+CREATE TABLE IF NOT EXISTS material_revisions_comparison (
+ id SERIAL PRIMARY KEY,
+
+ -- 비교 기본 정보
+ job_no VARCHAR(50) NOT NULL,
+ current_revision VARCHAR(20) NOT NULL,
+ previous_revision VARCHAR(20) NOT NULL,
+ current_file_id INTEGER NOT NULL,
+ previous_file_id INTEGER NOT NULL,
+
+ -- 비교 결과 요약
+ total_current_items INTEGER DEFAULT 0,
+ total_previous_items INTEGER DEFAULT 0,
+ new_items_count INTEGER DEFAULT 0,
+ modified_items_count INTEGER DEFAULT 0,
+ removed_items_count INTEGER DEFAULT 0,
+ unchanged_items_count INTEGER DEFAULT 0,
+
+ -- 상세 결과 (JSON)
+ comparison_details JSONB,
+
+ -- 관리 정보
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ created_by VARCHAR(100),
+
+ -- 외래키
+ FOREIGN KEY (current_file_id) REFERENCES files(id),
+ FOREIGN KEY (previous_file_id) REFERENCES files(id),
+
+ -- 유니크 제약 (같은 비교는 한 번만)
+ UNIQUE(job_no, current_revision, previous_revision)
+);
+
+-- ================================
+-- 3. 개별 자재 비교 상세 테이블
+-- ================================
+
+CREATE TABLE IF NOT EXISTS material_comparison_details (
+ id SERIAL PRIMARY KEY,
+
+ comparison_id INTEGER NOT NULL,
+ material_hash VARCHAR(32) NOT NULL,
+
+ -- 비교 타입
+ change_type VARCHAR(20) NOT NULL, -- 'NEW', 'MODIFIED', 'REMOVED', 'UNCHANGED'
+
+ -- 자재 정보
+ description TEXT NOT NULL,
+ size_spec VARCHAR(100),
+ material_grade VARCHAR(100),
+
+ -- 수량 비교
+ previous_quantity DECIMAL(10,3) DEFAULT 0,
+ current_quantity DECIMAL(10,3) DEFAULT 0,
+ quantity_diff DECIMAL(10,3) DEFAULT 0,
+
+ -- 추가 구매 필요량 (핵심!)
+ additional_purchase_needed DECIMAL(10,3) DEFAULT 0,
+
+ -- 분류 정보
+ classified_category VARCHAR(50),
+ classification_confidence DECIMAL(3,2),
+
+ -- 관리 정보
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
+ -- 외래키
+ FOREIGN KEY (comparison_id) REFERENCES material_revisions_comparison(id) ON DELETE CASCADE
+);
+
+-- ================================
+-- 4. 발주 추적 테이블 (실제 발주 관리)
+-- ================================
+
+CREATE TABLE IF NOT EXISTS material_purchase_tracking (
+ id SERIAL PRIMARY KEY,
+
+ -- 연결 정보
+ job_no VARCHAR(50) NOT NULL,
+ material_hash VARCHAR(32) NOT NULL,
+ revision VARCHAR(20) NOT NULL,
+
+ -- 자재 정보
+ description TEXT NOT NULL,
+ size_spec VARCHAR(100),
+ unit VARCHAR(10) DEFAULT 'EA',
+
+ -- 수량 정보
+ bom_quantity DECIMAL(10,3) NOT NULL, -- BOM상 필요 수량
+ safety_margin DECIMAL(3,2) DEFAULT 1.10, -- 여유율 (10%)
+ calculated_quantity DECIMAL(10,3) NOT NULL, -- 계산된 구매 수량
+
+ -- 발주 상태
+ purchase_status VARCHAR(20) DEFAULT 'PENDING', -- 'PENDING', 'CONFIRMED', 'ORDERED', 'RECEIVED'
+ confirmed_quantity DECIMAL(10,3) DEFAULT 0, -- 확정된 발주 수량
+ ordered_quantity DECIMAL(10,3) DEFAULT 0, -- 실제 주문 수량
+ received_quantity DECIMAL(10,3) DEFAULT 0, -- 입고 수량
+
+ -- 발주 정보
+ purchase_order_no VARCHAR(100),
+ supplier_name VARCHAR(200),
+ unit_price DECIMAL(10,2),
+ total_price DECIMAL(12,2),
+ order_date DATE,
+ delivery_date DATE,
+
+ -- 관리 정보
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ confirmed_by VARCHAR(100),
+ confirmed_at TIMESTAMP,
+
+ -- 외래키
+ FOREIGN KEY (job_no) REFERENCES jobs(job_no),
+
+ -- 유니크 제약
+ UNIQUE(job_no, material_hash, revision)
+);
+
+-- ================================
+-- 5. 누적 재고 현황 뷰
+-- ================================
+
+CREATE OR REPLACE VIEW material_inventory_status AS
+SELECT
+ mpt.job_no,
+ mpt.material_hash,
+ mpt.description,
+ mpt.size_spec,
+ mpt.unit,
+
+ -- 누적 수량
+ SUM(mpt.confirmed_quantity) as total_confirmed,
+ SUM(mpt.ordered_quantity) as total_ordered,
+ SUM(mpt.received_quantity) as total_received,
+
+ -- 현재 가용 재고
+ SUM(mpt.received_quantity) as available_stock,
+
+ -- 최신 리비전 정보
+ MAX(mpt.revision) as latest_revision,
+ MAX(mpt.updated_at) as last_updated
+
+FROM material_purchase_tracking mpt
+WHERE mpt.purchase_status != 'CANCELLED'
+GROUP BY mpt.job_no, mpt.material_hash, mpt.description, mpt.size_spec, mpt.unit;
+
+-- ================================
+-- 6. 인덱스 생성
+-- ================================
+
+-- material_revisions_comparison 인덱스
+CREATE INDEX IF NOT EXISTS idx_material_revisions_job ON material_revisions_comparison(job_no);
+CREATE INDEX IF NOT EXISTS idx_material_revisions_current ON material_revisions_comparison(current_revision);
+
+-- material_comparison_details 인덱스
+CREATE INDEX IF NOT EXISTS idx_material_comparison_hash ON material_comparison_details(material_hash);
+CREATE INDEX IF NOT EXISTS idx_material_comparison_type ON material_comparison_details(change_type);
+
+-- material_purchase_tracking 인덱스
+CREATE INDEX IF NOT EXISTS idx_purchase_tracking_job_hash ON material_purchase_tracking(job_no, material_hash);
+CREATE INDEX IF NOT EXISTS idx_purchase_tracking_status ON material_purchase_tracking(purchase_status);
+CREATE INDEX IF NOT EXISTS idx_purchase_tracking_revision ON material_purchase_tracking(revision);
+
+-- ================================
+-- 7. 해시 생성 함수 (PostgreSQL)
+-- ================================
+
+CREATE OR REPLACE FUNCTION generate_material_hash(
+ description TEXT,
+ size_spec TEXT DEFAULT '',
+ material_grade TEXT DEFAULT ''
+) RETURNS VARCHAR(32) AS $$
+BEGIN
+ -- 정규화: 대소문자 통일, 공백 정리
+ description := UPPER(TRIM(REGEXP_REPLACE(description, '\s+', ' ', 'g')));
+ size_spec := UPPER(TRIM(COALESCE(size_spec, '')));
+ material_grade := UPPER(TRIM(COALESCE(material_grade, '')));
+
+ -- MD5 해시 생성 (32자리)
+ RETURN MD5(description || '|' || size_spec || '|' || material_grade);
+END;
+$$ LANGUAGE plpgsql;
+
+-- ================================
+-- 8. 기존 데이터에 해시 생성 (배치 처리)
+-- ================================
+
+-- 기존 materials 데이터에 해시 추가
+UPDATE materials
+SET
+ material_hash = generate_material_hash(original_description, size_spec, material_grade),
+ normalized_description = UPPER(TRIM(REGEXP_REPLACE(original_description, '\s+', ' ', 'g')))
+WHERE material_hash IS NULL;
+
+-- ================================
+-- 9. 트리거 생성 (자동 해시 생성)
+-- ================================
+
+CREATE OR REPLACE FUNCTION auto_generate_material_hash()
+RETURNS TRIGGER AS $$
+BEGIN
+ -- 새로 삽입되거나 업데이트될 때 자동으로 해시 생성
+ NEW.material_hash := generate_material_hash(
+ NEW.original_description,
+ NEW.size_spec,
+ NEW.material_grade
+ );
+ NEW.normalized_description := UPPER(TRIM(REGEXP_REPLACE(NEW.original_description, '\s+', ' ', 'g')));
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_auto_material_hash
+ BEFORE INSERT OR UPDATE ON materials
+ FOR EACH ROW
+ EXECUTE FUNCTION auto_generate_material_hash();
+
+-- ================================
+-- 10. 완료 메시지
+-- ================================
+
+SELECT 'Material comparison and purchase tracking system created successfully!' as status;
\ No newline at end of file
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index dcc0528..b3285af 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,20 +1,24 @@
import React from 'react';
-import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
+import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import ProjectSelectionPage from './pages/ProjectSelectionPage';
-import BOMManagerPage from './pages/BOMManagerPage';
import MaterialsPage from './pages/MaterialsPage';
import BOMStatusPage from './pages/BOMStatusPage';
import PurchaseConfirmationPage from './pages/PurchaseConfirmationPage';
+import MaterialComparisonPage from './pages/MaterialComparisonPage';
+import RevisionPurchasePage from './pages/RevisionPurchasePage';
function App() {
return (
} />
- } />
- } />
+ {/* BOM 관리는 /bom-status로 통일 */}
+ } />
} />
+ } />
} />
+ } />
+ } />
);
diff --git a/frontend/src/api.js b/frontend/src/api.js
index ac7cbd2..92835d5 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -131,4 +131,45 @@ export function generateSpoolIdentifier(dwgName, areaNumber, spoolNumber) {
area_number: areaNumber,
spool_number: spoolNumber
});
+}
+
+// 자재 비교 관련 API
+export function compareMaterialRevisions(jobNo, currentRevision, previousRevision = null, saveResult = true) {
+ return api.post('/materials/compare-revisions', null, {
+ params: {
+ job_no: jobNo,
+ current_revision: currentRevision,
+ previous_revision: previousRevision,
+ save_result: saveResult
+ }
+ });
+}
+
+export function getMaterialComparisonHistory(jobNo, limit = 10) {
+ return api.get('/materials/comparison-history', {
+ params: { job_no: jobNo, limit }
+ });
+}
+
+export function getMaterialInventoryStatus(jobNo, materialHash = null) {
+ return api.get('/materials/inventory-status', {
+ params: { job_no: jobNo, material_hash: materialHash }
+ });
+}
+
+export function confirmMaterialPurchase(jobNo, revision, confirmations, confirmedBy = 'user') {
+ return api.post('/materials/confirm-purchase', null, {
+ params: {
+ job_no: jobNo,
+ revision: revision,
+ confirmed_by: confirmedBy
+ },
+ data: confirmations
+ });
+}
+
+export function getMaterialPurchaseStatus(jobNo, revision = null, status = null) {
+ return api.get('/materials/purchase-status', {
+ params: { job_no: jobNo, revision, status }
+ });
}
\ No newline at end of file
diff --git a/frontend/src/components/FileManager.jsx b/frontend/src/components/FileManager.jsx
index 4f7b039..210724c 100644
--- a/frontend/src/components/FileManager.jsx
+++ b/frontend/src/components/FileManager.jsx
@@ -34,15 +34,19 @@ import {
FileUpload,
Warning,
CheckCircle,
- Error
+ Error,
+ Update
} from '@mui/icons-material';
-import { fetchFiles, deleteFile } from '../api';
+import { fetchFiles, deleteFile, uploadFile } from '../api';
import Toast from './Toast';
function FileManager({ selectedProject }) {
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [deleteDialog, setDeleteDialog] = useState({ open: false, file: null });
+ const [revisionDialog, setRevisionDialog] = useState({ open: false, file: null });
+ const [revisionFile, setRevisionFile] = useState(null);
+ const [uploading, setUploading] = useState(false);
const [toast, setToast] = useState({ open: false, message: '', type: 'info' });
const [filter, setFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
@@ -131,6 +135,57 @@ function FileManager({ selectedProject }) {
}
};
+ const handleRevisionUpload = async () => {
+ if (!revisionFile || !revisionDialog.file) return;
+
+ setUploading(true);
+ try {
+ const formData = new FormData();
+ formData.append('file', revisionFile);
+ formData.append('job_no', selectedProject.job_no);
+ formData.append('parent_file_id', revisionDialog.file.id);
+ formData.append('bom_name', revisionDialog.file.bom_name || revisionDialog.file.original_filename);
+
+ console.log('🔄 리비전 업로드 FormData:', {
+ fileName: revisionFile.name,
+ jobNo: selectedProject.job_no,
+ parentFileId: revisionDialog.file.id,
+ parentFileIdType: typeof revisionDialog.file.id,
+ baseFileName: revisionDialog.file.original_filename,
+ bomName: revisionDialog.file.bom_name || revisionDialog.file.original_filename,
+ fullFileObject: revisionDialog.file
+ });
+
+ const response = await uploadFile(formData);
+
+ if (response.data.success) {
+ setToast({
+ open: true,
+ message: `리비전 업로드 성공! ${response.data.revision}`,
+ type: 'success'
+ });
+ setRevisionDialog({ open: false, file: null });
+ setRevisionFile(null);
+ fetchFilesList(); // 목록 새로고침
+ } else {
+ setToast({
+ open: true,
+ message: response.data.message || '리비전 업로드에 실패했습니다.',
+ type: 'error'
+ });
+ }
+ } catch (error) {
+ console.error('리비전 업로드 실패:', error);
+ setToast({
+ open: true,
+ message: '리비전 업로드에 실패했습니다.',
+ type: 'error'
+ });
+ } finally {
+ setUploading(false);
+ }
+ };
+
const getStatusColor = (status) => {
switch (status) {
case 'completed':
@@ -396,6 +451,17 @@ function FileManager({ selectedProject }) {
>
+ {
+ console.log('🔄 리비전 버튼 클릭, 파일 정보:', file);
+ setRevisionDialog({ open: true, file });
+ }}
+ >
+
+
+
+ {/* 리비전 업로드 다이얼로그 */}
+
);
}
diff --git a/frontend/src/components/FileUpload.jsx b/frontend/src/components/FileUpload.jsx
index e455f2d..c756298 100644
--- a/frontend/src/components/FileUpload.jsx
+++ b/frontend/src/components/FileUpload.jsx
@@ -29,16 +29,19 @@ import {
Description,
AutoAwesome,
Category,
- Science
+ Science,
+ Compare
} from '@mui/icons-material';
import { useDropzone } from 'react-dropzone';
import { uploadFile as uploadFileApi, fetchMaterialsSummary } from '../api';
import Toast from './Toast';
+import { useNavigate } from 'react-router-dom';
function FileUpload({ selectedProject, onUploadSuccess }) {
console.log('=== FileUpload 컴포넌트 렌더링 ===');
console.log('selectedProject:', selectedProject);
+ const navigate = useNavigate();
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadResult, setUploadResult] = useState(null);
@@ -131,7 +134,7 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
const formData = new FormData();
formData.append('file', file);
formData.append('job_no', selectedProject.job_no);
- formData.append('revision', 'Rev.0');
+ formData.append('revision', 'Rev.0'); // 새 BOM은 항상 Rev.0
formData.append('bom_name', file.name); // BOM 이름으로 파일명 사용
formData.append('bom_type', 'excel'); // 파일 타입
formData.append('description', ''); // 설명 (빈 문자열)
@@ -139,7 +142,7 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
console.log('FormData 내용:', {
fileName: file.name,
jobNo: selectedProject.job_no,
- revision: 'Rev.0',
+ revision: 'Rev.0', // 새 BOM은 항상 Rev.0
bomName: file.name,
bomType: 'excel'
});
@@ -458,7 +461,7 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
)}
-
+
+
+ {uploadResult?.revision && uploadResult.revision !== 'Rev.0' && uploadResult.revision !== undefined && (
+
+ )}
+
+
+
+ );
+
+ return (
+
+ {renderSummaryCard()}
+
+
+ 0 && (
+ }
+ onClick={handleOpenConfirmDialog}
+ disabled={loading}
+ >
+ 선택 항목 발주 확정 ({selectedItems.size}개)
+
+ )
+ }
+ />
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+ {selectedTab === 0 && renderNewItemsTable()}
+ {selectedTab === 1 && renderModifiedItemsTable()}
+ {selectedTab === 2 && renderRemovedItemsTable()}
+
+
+
+
+ {renderConfirmDialog()}
+
+ );
+};
+
+export default MaterialComparisonResult;
\ No newline at end of file
diff --git a/frontend/src/pages/BOMManagerPage.jsx b/frontend/src/pages/BOMManagerPage.jsx
deleted file mode 100644
index 6e75592..0000000
--- a/frontend/src/pages/BOMManagerPage.jsx
+++ /dev/null
@@ -1,219 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import { Box, Typography, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, TextField, CircularProgress, Alert, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
-import { useSearchParams, useNavigate } from 'react-router-dom';
-import { fetchFiles, uploadFile, deleteFile } from '../api';
-
-const BOMManagerPage = () => {
- const [files, setFiles] = useState([]);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState('');
- const [uploading, setUploading] = useState(false);
- const [file, setFile] = useState(null);
- const [filename, setFilename] = useState('');
- const [revisionDialogOpen, setRevisionDialogOpen] = useState(false);
- const [revisionTarget, setRevisionTarget] = useState(null);
- const [revisionFile, setRevisionFile] = useState(null);
- const [searchParams] = useSearchParams();
- const jobNo = searchParams.get('job_no');
- const jobName = searchParams.get('job_name');
- const navigate = useNavigate();
-
- // 파일 목록 불러오기
- const loadFiles = async () => {
- setLoading(true);
- setError('');
- try {
- const response = await fetchFiles({ job_no: jobNo });
- if (Array.isArray(response.data)) {
- setFiles(response.data);
- } else {
- setFiles([]);
- }
- } catch (e) {
- setError('파일 목록을 불러오지 못했습니다.');
- console.error('파일 목록 로드 에러:', e);
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- if (jobNo) loadFiles();
- // eslint-disable-next-line
- }, [jobNo]);
-
- // 파일 업로드 핸들러
- const handleUpload = async (e) => {
- e.preventDefault();
- if (!file || !filename) return;
- setUploading(true);
- setError('');
- try {
- const formData = new FormData();
- formData.append('file', file);
- formData.append('job_no', jobNo);
- formData.append('bom_name', filename);
-
- const response = await uploadFile(formData);
- if (response.data.success) {
- setFile(null);
- setFilename('');
- loadFiles(); // 파일 목록 새로고침
- alert(`업로드 성공: ${response.data.materials_count}개 자재가 분류되었습니다.`);
- } else {
- throw new Error(response.data.error || '업로드 실패');
- }
- } catch (e) {
- setError(`파일 업로드에 실패했습니다: ${e.message}`);
- console.error('업로드 에러:', e);
- } finally {
- setUploading(false);
- }
- };
-
- // 리비전 업로드 핸들러
- const handleRevisionUpload = async () => {
- if (!revisionFile || !revisionTarget) return;
- setUploading(true);
- setError('');
- try {
- const formData = new FormData();
- formData.append('file', revisionFile);
- formData.append('job_no', jobNo);
- formData.append('bom_name', revisionTarget.original_filename);
- formData.append('parent_bom_id', revisionTarget.id);
-
- const response = await uploadFile(formData);
- if (response.data.success) {
- setRevisionDialogOpen(false);
- setRevisionFile(null);
- setRevisionTarget(null);
- loadFiles();
- alert(`리비전 업로드 성공: ${response.data.revision}`);
- } else {
- throw new Error(response.data.error || '리비전 업로드 실패');
- }
- } catch (e) {
- setError(`리비전 업로드에 실패했습니다: ${e.message}`);
- console.error('리비전 업로드 에러:', e);
- } finally {
- setUploading(false);
- }
- };
-
- // 파일 삭제 핸들러
- const handleDelete = async (fileId, filename) => {
- if (!confirm(`정말로 "${filename}" 파일을 삭제하시겠습니까?`)) return;
-
- try {
- const response = await deleteFile(fileId);
- if (response.data.success) {
- loadFiles();
- alert('파일이 삭제되었습니다.');
- } else {
- throw new Error(response.data.error || '삭제 실패');
- }
- } catch (e) {
- setError(`파일 삭제에 실패했습니다: ${e.message}`);
- console.error('삭제 에러:', e);
- }
- };
-
- // 자재확인 페이지로 이동
- const handleViewMaterials = (file) => {
- navigate(`/materials?file_id=${file.id}&job_no=${jobNo}&filename=${encodeURIComponent(file.original_filename)}`);
- };
-
- return (
-
- navigate('/')} sx={{ mb: 2 }}>
- ← 프로젝트 선택
-
-
- {jobNo && jobName && `${jobNo} (${jobName})`}
-
- {/* BOM 업로드 폼 */}
-
- {error && {error}}
- {loading && }
- {/* 파일 목록 리스트 */}
-
-
-
-
- 도면명
- 리비전
- 세부내역
- 리비전
- 삭제
-
-
-
- {files.map(file => (
-
- {file.original_filename}
- {file.revision}
-
- handleViewMaterials(file)}>
- 자재확인
-
-
-
- { setRevisionTarget(file); setRevisionDialogOpen(true); }}>
- 리비전
-
-
-
- handleDelete(file.id, file.original_filename)}>
- 삭제
-
-
-
- ))}
-
-
-
- {/* 리비전 업로드 다이얼로그 */}
-
-
- );
-};
-
-export default BOMManagerPage;
\ No newline at end of file
diff --git a/frontend/src/pages/BOMStatusPage.jsx b/frontend/src/pages/BOMStatusPage.jsx
index b576def..ba8365a 100644
--- a/frontend/src/pages/BOMStatusPage.jsx
+++ b/frontend/src/pages/BOMStatusPage.jsx
@@ -137,7 +137,7 @@ const BOMStatusPage = () => {
formData.append('bom_name', revisionDialog.bomName);
formData.append('bom_type', 'excel');
formData.append('description', '');
- formData.append('parent_bom_id', revisionDialog.parentId);
+ formData.append('parent_file_id', revisionDialog.parentId);
const response = await uploadFileApi(formData);
@@ -189,7 +189,7 @@ const BOMStatusPage = () => {
navigate('/')} sx={{ mb: 2 }}>
← 뒤로가기
- BOM 업로드 및 현황
+ 📊 BOM 관리 시스템
{jobNo && jobName && (
{jobNo} - {jobName}
@@ -257,30 +257,46 @@ const BOMStatusPage = () => {
{Object.entries(groupFilesByBOM()).map(([bomKey, bomFiles]) => (
bomFiles.map((file, index) => (
{file.bom_name || bomKey}
{index === 0 && bomFiles.length > 1 && (
-
+
(최신 리비전)
)}
+ {index > 0 && (
+
+ (이전 버전)
+
+ )}
+
+
+
+ {file.filename || file.original_filename}
+
- {file.filename || file.original_filename}
{file.revision || 'Rev.0'}
- {file.parsed_count || '-'}
- {file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'}
+
+ {file.parsed_count || 0}개
+
+
+
+
+ {file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'}
+
{
리비전
)}
+ {file.revision !== 'Rev.0' && index < 3 && (
+ <>
+ navigate(`/material-comparison?job_no=${jobNo}&revision=${file.revision}&filename=${encodeURIComponent(file.original_filename)}`)}
+ sx={{ mr: 1 }}
+ >
+ 비교
+
+ navigate(`/revision-purchase?job_no=${jobNo}¤t_revision=${file.revision}&bom_name=${encodeURIComponent(file.bom_name || bomKey)}`)}
+ sx={{ mr: 1 }}
+ >
+ 구매 필요
+
+ >
+ )}
{
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const [loading, setLoading] = useState(true);
+ const [confirmLoading, setConfirmLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [comparisonResult, setComparisonResult] = useState(null);
+
+ // URL 파라미터에서 정보 추출
+ const jobNo = searchParams.get('job_no');
+ const currentRevision = searchParams.get('revision');
+ const previousRevision = searchParams.get('prev_revision');
+ const filename = searchParams.get('filename');
+
+ useEffect(() => {
+ if (jobNo && currentRevision) {
+ loadComparison();
+ } else {
+ setError('필수 파라미터가 누락되었습니다 (job_no, revision)');
+ setLoading(false);
+ }
+ }, [jobNo, currentRevision, previousRevision]);
+
+ const loadComparison = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ console.log('자재 비교 실행:', { jobNo, currentRevision, previousRevision });
+
+ const result = await compareMaterialRevisions(
+ jobNo,
+ currentRevision,
+ previousRevision,
+ true // 결과 저장
+ );
+
+ console.log('비교 결과:', result);
+ setComparisonResult(result);
+
+ } catch (err) {
+ console.error('자재 비교 실패:', err);
+ setError(err.response?.data?.detail || err.message || '자재 비교 중 오류가 발생했습니다');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleConfirmPurchase = async (confirmations) => {
+ try {
+ setConfirmLoading(true);
+
+ console.log('발주 확정 실행:', { jobNo, currentRevision, confirmations });
+
+ const result = await confirmMaterialPurchase(
+ jobNo,
+ currentRevision,
+ confirmations,
+ 'user'
+ );
+
+ console.log('발주 확정 결과:', result);
+
+ // 성공 메시지 표시 후 비교 결과 새로고침
+ alert(`${result.confirmed_items?.length || confirmations.length}개 항목의 발주가 확정되었습니다!`);
+
+ // 비교 결과 새로고침 (재고 상태가 변경되었을 수 있음)
+ await loadComparison();
+
+ } catch (err) {
+ console.error('발주 확정 실패:', err);
+ alert('발주 확정 중 오류가 발생했습니다: ' + (err.response?.data?.detail || err.message));
+ } finally {
+ setConfirmLoading(false);
+ }
+ };
+
+ const handleRefresh = () => {
+ loadComparison();
+ };
+
+ const handleGoBack = () => {
+ // 이전 페이지로 이동 (대부분 파일 업로드 완료 페이지)
+ if (jobNo) {
+ navigate(`/materials?job_no=${jobNo}`);
+ } else {
+ navigate(-1);
+ }
+ };
+
+ const renderHeader = () => (
+
+
+ navigate('/jobs')}
+ sx={{ textDecoration: 'none' }}
+ >
+ 프로젝트 목록
+
+ navigate(`/materials?job_no=${jobNo}`)}
+ sx={{ textDecoration: 'none' }}
+ >
+ {jobNo}
+
+
+ 자재 비교
+
+
+
+
+
+
+ 자재 리비전 비교
+
+
+ {filename && `파일: ${filename}`}
+
+ {previousRevision ?
+ `${previousRevision} → ${currentRevision} 비교` :
+ `${currentRevision} (이전 리비전 없음)`
+ }
+
+
+
+
+ }
+ onClick={handleRefresh}
+ disabled={loading}
+ >
+ 새로고침
+
+ }
+ onClick={handleGoBack}
+ >
+ 돌아가기
+
+
+
+
+ );
+
+ if (loading) {
+ return (
+
+ {renderHeader()}
+
+
+
+
+ 자재 비교 중...
+
+
+ 리비전간 차이점을 분석하고 있습니다
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {renderHeader()}
+
+
+ 자재 비교 실패
+
+
+ {error}
+
+
+ }
+ onClick={handleRefresh}
+ >
+ 다시 시도
+
+
+ );
+ }
+
+ return (
+
+ {renderHeader()}
+
+ {comparisonResult ? (
+
+ ) : (
+
+ 비교 결과가 없습니다.
+
+ )}
+
+ );
+};
+
+export default MaterialComparisonPage;
\ No newline at end of file
diff --git a/frontend/src/pages/MaterialsPage.jsx b/frontend/src/pages/MaterialsPage.jsx
index f30abbb..0752909 100644
--- a/frontend/src/pages/MaterialsPage.jsx
+++ b/frontend/src/pages/MaterialsPage.jsx
@@ -16,11 +16,17 @@ import {
Alert,
CircularProgress,
Chip,
- Divider
+ Divider,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+ Grid
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ShoppingCart from '@mui/icons-material/ShoppingCart';
-import { api } from '../api';
+import { Compare as CompareIcon } from '@mui/icons-material';
+import { api, fetchFiles } from '../api';
const MaterialsPage = () => {
const [materials, setMaterials] = useState([]);
@@ -28,23 +34,63 @@ const MaterialsPage = () => {
const [error, setError] = useState(null);
const [fileId, setFileId] = useState(null);
const [fileName, setFileName] = useState('');
+ const [jobNo, setJobNo] = useState('');
+ const [bomName, setBomName] = useState('');
+ const [currentRevision, setCurrentRevision] = useState('');
+ const [availableRevisions, setAvailableRevisions] = useState([]);
const navigate = useNavigate();
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const id = urlParams.get('file_id');
const name = urlParams.get('filename') || '';
+ const job_no = urlParams.get('job_no') || '';
- if (id) {
+ if (id && job_no) {
setFileId(id);
setFileName(decodeURIComponent(name));
+ setJobNo(job_no);
loadMaterials(id);
+ loadAvailableRevisions(job_no, name);
} else {
setLoading(false);
- setError('파일 ID가 지정되지 않았습니다.');
+ setError('파일 ID 또는 Job No가 지정되지 않았습니다.');
}
}, []);
+ // 같은 BOM의 다른 리비전들 로드
+ const loadAvailableRevisions = async (job_no, filename) => {
+ try {
+ const response = await fetchFiles({ job_no });
+ if (Array.isArray(response.data)) {
+ // 같은 BOM 이름의 파일들만 필터링
+ const sameNameFiles = response.data.filter(file =>
+ file.original_filename === filename ||
+ file.bom_name === filename ||
+ file.filename === filename
+ );
+
+ // 리비전 순으로 정렬 (최신부터)
+ const sortedFiles = sameNameFiles.sort((a, b) => {
+ const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
+ const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
+ return revB - revA;
+ });
+
+ setAvailableRevisions(sortedFiles);
+
+ // 현재 파일 정보 설정
+ const currentFile = sortedFiles.find(file => file.id === parseInt(fileId));
+ if (currentFile) {
+ setCurrentRevision(currentFile.revision || 'Rev.0');
+ setBomName(currentFile.bom_name || currentFile.original_filename);
+ }
+ }
+ } catch (err) {
+ console.error('리비전 목록 로드 실패:', err);
+ }
+ };
+
const loadMaterials = async (id) => {
try {
setLoading(true);
@@ -586,21 +632,75 @@ const MaterialsPage = () => {
뒤로가기
- }
- onClick={() => {
- const params = new URLSearchParams(window.location.search);
- navigate(`/purchase-confirmation?${params.toString()}`);
- }}
- disabled={materialSpecs.length === 0}
- sx={{ minWidth: 150 }}
- >
- 구매 확정
-
+
+ {/* 리비전 비교 버튼 */}
+ {availableRevisions.length > 1 && currentRevision !== 'Rev.0' && (
+ }
+ onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${currentRevision}&filename=${encodeURIComponent(fileName)}`)}
+ >
+ 리비전 비교
+
+ )}
+
+ }
+ onClick={() => {
+ const params = new URLSearchParams(window.location.search);
+ navigate(`/purchase-confirmation?${params.toString()}`);
+ }}
+ disabled={materialSpecs.length === 0}
+ sx={{ minWidth: 150 }}
+ >
+ 구매 확정
+
+
+
+ {/* 리비전 선택 */}
+ {availableRevisions.length > 1 && (
+
+
+
+
+ 📋 {bomName}
+
+
+ Job No: {jobNo} | 현재 리비전: {currentRevision}
+
+
+
+
+ 리비전 선택
+
+
+
+
+
+ )}
📋 자재 사양서
diff --git a/frontend/src/pages/RevisionPurchasePage.jsx b/frontend/src/pages/RevisionPurchasePage.jsx
new file mode 100644
index 0000000..1e932df
--- /dev/null
+++ b/frontend/src/pages/RevisionPurchasePage.jsx
@@ -0,0 +1,437 @@
+import React, { useState, useEffect } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import {
+ Box,
+ Card,
+ CardContent,
+ Typography,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Paper,
+ Button,
+ Alert,
+ CircularProgress,
+ Chip,
+ Grid,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+ Tabs,
+ Tab,
+ Divider
+} from '@mui/material';
+import {
+ ArrowBack,
+ ShoppingCart,
+ Compare,
+ Add as AddIcon,
+ Remove as RemoveIcon,
+ TrendingUp,
+ Assessment
+} from '@mui/icons-material';
+import { compareMaterialRevisions, fetchFiles } from '../api';
+
+const RevisionPurchasePage = () => {
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [comparisonResult, setComparisonResult] = useState(null);
+ const [availableRevisions, setAvailableRevisions] = useState([]);
+ const [selectedTab, setSelectedTab] = useState(0);
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+
+ // URL 파라미터에서 정보 추출
+ const jobNo = searchParams.get('job_no');
+ const currentRevision = searchParams.get('current_revision') || searchParams.get('revision');
+ const previousRevision = searchParams.get('previous_revision');
+ const bomName = searchParams.get('bom_name');
+
+ useEffect(() => {
+ if (jobNo && currentRevision) {
+ loadAvailableRevisions();
+ loadComparisonData();
+ } else {
+ setError('필수 파라미터가 누락되었습니다. (job_no, current_revision)');
+ setLoading(false);
+ }
+ }, [jobNo, currentRevision, previousRevision]);
+
+ const loadAvailableRevisions = async () => {
+ try {
+ const response = await fetchFiles({ job_no: jobNo });
+ if (Array.isArray(response.data)) {
+ // BOM별로 그룹화
+ const bomGroups = response.data.reduce((acc, file) => {
+ const key = file.bom_name || file.original_filename;
+ if (!acc[key]) acc[key] = [];
+ acc[key].push(file);
+ return acc;
+ }, {});
+
+ // 현재 BOM과 관련된 리비전들만 필터링
+ let relevantFiles = [];
+ if (bomName) {
+ relevantFiles = bomGroups[bomName] || [];
+ } else {
+ // bomName이 없으면 현재 리비전과 같은 원본파일명을 가진 것들
+ const currentFile = response.data.find(file => file.revision === currentRevision);
+ if (currentFile) {
+ const key = currentFile.bom_name || currentFile.original_filename;
+ relevantFiles = bomGroups[key] || [];
+ }
+ }
+
+ // 리비전 순으로 정렬
+ relevantFiles.sort((a, b) => {
+ const revA = parseInt(a.revision?.replace('Rev.', '') || '0');
+ const revB = parseInt(b.revision?.replace('Rev.', '') || '0');
+ return revB - revA;
+ });
+
+ setAvailableRevisions(relevantFiles);
+ }
+ } catch (err) {
+ console.error('리비전 목록 로드 실패:', err);
+ }
+ };
+
+ const loadComparisonData = async () => {
+ try {
+ setLoading(true);
+ const result = await compareMaterialRevisions(
+ jobNo,
+ currentRevision,
+ previousRevision,
+ true
+ );
+ setComparisonResult(result);
+ } catch (err) {
+ setError(`리비전 비교 실패: ${err.message}`);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleRevisionChange = (type, newRevision) => {
+ const params = new URLSearchParams(searchParams);
+ if (type === 'current') {
+ params.set('current_revision', newRevision);
+ } else {
+ params.set('previous_revision', newRevision);
+ }
+ navigate(`?${params.toString()}`, { replace: true });
+ };
+
+ const calculatePurchaseNeeds = () => {
+ if (!comparisonResult) return { newItems: [], increasedItems: [] };
+
+ const newItems = comparisonResult.new_items || [];
+ const modifiedItems = comparisonResult.modified_items || [];
+
+ // 수량이 증가한 항목들만 필터링
+ const increasedItems = modifiedItems.filter(item =>
+ item.quantity_change > 0
+ );
+
+ return { newItems, increasedItems };
+ };
+
+ const formatCurrency = (amount) => {
+ return new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: 'KRW'
+ }).format(amount);
+ };
+
+ if (loading) {
+ return (
+
+
+
+ 리비전 비교 분석 중...
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ }
+ onClick={() => navigate(-1)}
+ sx={{ mb: 2 }}
+ >
+ 뒤로가기
+
+ {error}
+
+ );
+ }
+
+ const { newItems, increasedItems } = calculatePurchaseNeeds();
+ const totalNewItems = newItems.length;
+ const totalIncreasedItems = increasedItems.length;
+ const totalPurchaseItems = totalNewItems + totalIncreasedItems;
+
+ return (
+
+ {/* 헤더 */}
+
+
+ }
+ onClick={() => navigate(-1)}
+ >
+ 뒤로가기
+
+
+
+ }
+ onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${currentRevision}&prev_revision=${previousRevision}`)}
+ >
+ 상세 비교 보기
+
+
+ }
+ disabled={totalPurchaseItems === 0}
+ >
+ 구매 목록 생성
+
+
+
+
+
+ 🛒 리비전간 추가 구매 필요 자재
+
+
+
+ Job No: {jobNo} | {currentRevision} vs {previousRevision || '이전 리비전'}
+
+
+
+ {/* 리비전 선택 카드 */}
+
+
+
+ 리비전 비교 설정
+
+
+
+
+ 현재 리비전
+
+
+
+
+
+ 이전 리비전
+
+
+
+
+
+
+
+ {/* 구매 요약 */}
+
+
+
+
+
+
+ {totalNewItems}
+
+
+ 신규 자재
+
+
+
+
+
+
+
+
+
+ {totalIncreasedItems}
+
+
+ 수량 증가
+
+
+
+
+
+
+
+
+
+ {totalPurchaseItems}
+
+
+ 총 구매 항목
+
+
+
+
+
+
+ {/* 탭으로 구분된 자재 목록 */}
+
+ setSelectedTab(newValue)}
+ variant="fullWidth"
+ >
+
+
+
+
+
+ {selectedTab === 0 && (
+
+
+ 🆕 신규 추가 자재
+
+ {newItems.length === 0 ? (
+ 새로 추가된 자재가 없습니다.
+ ) : (
+
+
+
+
+ 카테고리
+ 자재 설명
+ 사이즈
+ 재질
+ 수량
+ 단위
+
+
+
+ {newItems.map((item, index) => (
+
+
+
+
+ {item.description}
+ {item.size_spec || '-'}
+ {item.material_grade || '-'}
+
+
+ +{item.quantity}
+
+
+ {item.unit || 'EA'}
+
+ ))}
+
+
+
+ )}
+
+ )}
+
+ {selectedTab === 1 && (
+
+
+ 📈 수량 증가 자재
+
+ {increasedItems.length === 0 ? (
+ 수량이 증가한 자재가 없습니다.
+ ) : (
+
+
+
+
+ 카테고리
+ 자재 설명
+ 사이즈
+ 재질
+ 이전 수량
+ 현재 수량
+ 증가량
+ 단위
+
+
+
+ {increasedItems.map((item, index) => (
+
+
+
+
+ {item.description}
+ {item.size_spec || '-'}
+ {item.material_grade || '-'}
+ {item.previous_quantity}
+ {item.current_quantity}
+
+
+ +{item.quantity_change}
+
+
+ {item.unit || 'EA'}
+
+ ))}
+
+
+
+ )}
+
+ )}
+
+
+
+ {totalPurchaseItems === 0 && (
+
+ 🎉 추가로 구매가 필요한 자재가 없습니다!
+
+ )}
+
+ );
+};
+
+export default RevisionPurchasePage;
\ No newline at end of file