From 534015cc7cbb2587da4c305af8119ef3c373b697 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 22 Jul 2025 15:56:40 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=90=EC=9E=AC=20=EB=A6=AC=EB=B9=84?= =?UTF-8?q?=EC=A0=84=20=EB=B9=84=EA=B5=90=20=EB=B0=8F=20=EA=B5=AC=EB=A7=A4?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자재 리비전간 비교 기능 추가 (MaterialComparisonPage) - 버그 해결 필요 - 리비전간 추가 구매 필요 자재 분석 페이지 추가 (RevisionPurchasePage) - 자재 비교 결과 컴포넌트 구현 (MaterialComparisonResult) - 자재 비교 API 라우터 추가 (material_comparison.py) - 로직 개선 필요 - 자재 비교 시스템 데이터베이스 스키마 추가 - FileManager, FileUpload 컴포넌트 개선 - BOMManagerPage 제거 및 새로운 구조로 리팩토링 - 자재 분류기 및 스키마 개선 TODO: 자재 비교 알고리즘 정확도 향상 및 예외 처리 강화 필요 --- backend/app/main.py | 9 +- backend/app/routers/files.py | 146 ++++- backend/app/routers/material_comparison.py | 551 ++++++++++++++++++ backend/app/services/material_classifier.py | 31 +- backend/app/services/materials_schema.py | 68 ++- .../10_add_material_comparison_system.sql | 243 ++++++++ frontend/src/App.jsx | 12 +- frontend/src/api.js | 41 ++ frontend/src/components/FileManager.jsx | 126 +++- frontend/src/components/FileUpload.jsx | 23 +- .../components/MaterialComparisonResult.jsx | 516 ++++++++++++++++ frontend/src/pages/BOMManagerPage.jsx | 219 ------- frontend/src/pages/BOMStatusPage.jsx | 52 +- frontend/src/pages/MaterialComparisonPage.jsx | 234 ++++++++ frontend/src/pages/MaterialsPage.jsx | 136 ++++- frontend/src/pages/RevisionPurchasePage.jsx | 437 ++++++++++++++ 16 files changed, 2577 insertions(+), 267 deletions(-) create mode 100644 backend/app/routers/material_comparison.py create mode 100644 backend/scripts/10_add_material_comparison_system.sql create mode 100644 frontend/src/components/MaterialComparisonResult.jsx delete mode 100644 frontend/src/pages/BOMManagerPage.jsx create mode 100644 frontend/src/pages/MaterialComparisonPage.jsx create mode 100644 frontend/src/pages/RevisionPurchasePage.jsx 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 }); + }} + > + + + + {/* 리비전 업로드 다이얼로그 */} + { + setRevisionDialog({ open: false, file: null }); + setRevisionFile(null); + }} + maxWidth="sm" + fullWidth + > + 리비전 업로드 + + + 기준 파일: {revisionDialog.file?.original_filename} + + + 현재 리비전: {revisionDialog.file?.revision || 'Rev.0'} + + + + + 새 리비전 파일을 선택하세요: + + setRevisionFile(e.target.files[0])} + style={{ width: '100%', padding: '8px' }} + /> + + + {revisionFile && ( + + 선택된 파일: {revisionFile.name} + + )} + + + + + + ); } 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 && ( + + ) + } + /> + + + + } + /> + } + /> + } + /> + + + + {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 ( - - - - {jobNo && jobName && `${jobNo} (${jobName})`} - - {/* BOM 업로드 폼 */} -
- setFilename(e.target.value)} - size="small" - required - sx={{ minWidth: 220 }} - /> - setFile(e.target.files[0])} - disabled={uploading} - style={{ marginRight: 8 }} - /> - - - {error && {error}} - {loading && } - {/* 파일 목록 리스트 */} - - - - - 도면명 - 리비전 - 세부내역 - 리비전 - 삭제 - - - - {files.map(file => ( - - {file.original_filename} - {file.revision} - - - - - - - - - - - ))} - -
-
- {/* 리비전 업로드 다이얼로그 */} - setRevisionDialogOpen(false)}> - 리비전 업로드 - - - 도면명: {revisionTarget?.original_filename} - - setRevisionFile(e.target.files[0])} - /> - - - - - - -
- ); -}; - -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 = () => { - 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 && ( + <> + + + + )} + + + +
+ ); + + if (loading) { + return ( + + {renderHeader()} + + + + + 자재 비교 중... + + + 리비전간 차이점을 분석하고 있습니다 + + + + + ); + } + + if (error) { + return ( + + {renderHeader()} + + + 자재 비교 실패 + + + {error} + + + + + ); + } + + 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 = () => { 뒤로가기 - + + {/* 리비전 비교 버튼 */} + {availableRevisions.length > 1 && currentRevision !== 'Rev.0' && ( + + )} + + +
+ + {/* 리비전 선택 */} + {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 ( + + + {error} + + ); + } + + const { newItems, increasedItems } = calculatePurchaseNeeds(); + const totalNewItems = newItems.length; + const totalIncreasedItems = increasedItems.length; + const totalPurchaseItems = totalNewItems + totalIncreasedItems; + + return ( + + {/* 헤더 */} + + + + + + + + + + + + + 🛒 리비전간 추가 구매 필요 자재 + + + + 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