feat: 자재 리비전 비교 및 구매 목록 시스템 구현

- 자재 리비전간 비교 기능 추가 (MaterialComparisonPage) - 버그 해결 필요
- 리비전간 추가 구매 필요 자재 분석 페이지 추가 (RevisionPurchasePage)
- 자재 비교 결과 컴포넌트 구현 (MaterialComparisonResult)
- 자재 비교 API 라우터 추가 (material_comparison.py) - 로직 개선 필요
- 자재 비교 시스템 데이터베이스 스키마 추가
- FileManager, FileUpload 컴포넌트 개선
- BOMManagerPage 제거 및 새로운 구조로 리팩토링
- 자재 분류기 및 스키마 개선

TODO: 자재 비교 알고리즘 정확도 향상 및 예외 처리 강화 필요
This commit is contained in:
Hyungi Ahn
2025-07-22 15:56:40 +09:00
parent 6ca1cd17e2
commit 534015cc7c
16 changed files with 2577 additions and 267 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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'

View File

@@ -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"
}
}
},

View File

@@ -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;

View File

@@ -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 (
<Router>
<Routes>
<Route path="/" element={<ProjectSelectionPage />} />
<Route path="/bom-manager" element={<BOMManagerPage />} />
<Route path="/materials" element={<MaterialsPage />} />
{/* BOM 관리는 /bom-status로 통일 */}
<Route path="/bom-manager" element={<Navigate to="/bom-status" replace />} />
<Route path="/bom-status" element={<BOMStatusPage />} />
<Route path="/materials" element={<MaterialsPage />} />
<Route path="/purchase-confirmation" element={<PurchaseConfirmationPage />} />
<Route path="/material-comparison" element={<MaterialComparisonPage />} />
<Route path="/revision-purchase" element={<RevisionPurchasePage />} />
</Routes>
</Router>
);

View File

@@ -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 }
});
}

View File

@@ -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 }) {
>
<Visibility />
</IconButton>
<IconButton
size="small"
color="warning"
title="리비전 업로드"
onClick={() => {
console.log('🔄 리비전 버튼 클릭, 파일 정보:', file);
setRevisionDialog({ open: true, file });
}}
>
<Update />
</IconButton>
<IconButton
size="small"
color="error"
@@ -461,6 +527,62 @@ function FileManager({ selectedProject }) {
</Button>
</DialogActions>
</Dialog>
{/* 리비전 업로드 다이얼로그 */}
<Dialog
open={revisionDialog.open}
onClose={() => {
setRevisionDialog({ open: false, file: null });
setRevisionFile(null);
}}
maxWidth="sm"
fullWidth
>
<DialogTitle>리비전 업로드</DialogTitle>
<DialogContent>
<Typography variant="body1" gutterBottom>
<strong>기준 파일:</strong> {revisionDialog.file?.original_filename}
</Typography>
<Typography variant="body2" color="textSecondary" gutterBottom>
현재 리비전: {revisionDialog.file?.revision || 'Rev.0'}
</Typography>
<Box sx={{ mt: 2 }}>
<Typography variant="body2" gutterBottom>
리비전 파일을 선택하세요:
</Typography>
<input
type="file"
accept=".xlsx,.xls,.csv"
onChange={(e) => setRevisionFile(e.target.files[0])}
style={{ width: '100%', padding: '8px' }}
/>
</Box>
{revisionFile && (
<Alert severity="info" sx={{ mt: 2 }}>
선택된 파일: {revisionFile.name}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setRevisionDialog({ open: false, file: null });
setRevisionFile(null);
}}
>
취소
</Button>
<Button
onClick={handleRevisionUpload}
variant="contained"
disabled={!revisionFile || uploading}
>
{uploading ? '업로드 중...' : '리비전 업로드'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -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 }) {
</Alert>
)}
<Box sx={{ mt: 2, display: 'flex', gap: 2 }}>
<Box sx={{ mt: 2, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Button
variant="contained"
onClick={() => window.location.href = '/materials'}
@@ -466,6 +469,18 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
>
자재 목록 보기
</Button>
{uploadResult?.revision && uploadResult.revision !== 'Rev.0' && uploadResult.revision !== undefined && (
<Button
variant="contained"
color="secondary"
onClick={() => navigate(`/material-comparison?job_no=${selectedProject.job_no}&revision=${uploadResult.revision}&filename=${encodeURIComponent(uploadResult.original_filename || uploadResult.filename)}`)}
startIcon={<Compare />}
>
이전 리비전과 비교 ({uploadResult.revision})
</Button>
)}
<Button
variant="outlined"
onClick={resetUpload}

View File

@@ -0,0 +1,516 @@
import React, { useState } from 'react';
import {
Box,
Card,
CardContent,
CardHeader,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
Alert,
Tabs,
Tab,
Button,
Checkbox,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Stack,
Divider
} from '@mui/material';
import {
Add as AddIcon,
Edit as EditIcon,
ShoppingCart as ShoppingCartIcon,
CheckCircle as CheckCircleIcon,
Warning as WarningIcon,
Remove as RemoveIcon
} from '@mui/icons-material';
const MaterialComparisonResult = ({
comparison,
onConfirmPurchase,
loading = false
}) => {
const [selectedTab, setSelectedTab] = useState(0);
const [selectedItems, setSelectedItems] = useState(new Set());
const [confirmDialog, setConfirmDialog] = useState(false);
const [purchaseConfirmations, setPurchaseConfirmations] = useState({});
if (!comparison || !comparison.success) {
return (
<Alert severity="info">
비교할 이전 리비전이 없거나 비교 데이터를 불러올 없습니다.
</Alert>
);
}
const { summary, new_items, modified_items, removed_items, purchase_summary } = comparison;
// 탭 변경 핸들러
const handleTabChange = (event, newValue) => {
setSelectedTab(newValue);
setSelectedItems(new Set()); // 탭 변경시 선택 초기화
};
// 아이템 선택 핸들러
const handleItemSelect = (materialHash, checked) => {
const newSelected = new Set(selectedItems);
if (checked) {
newSelected.add(materialHash);
} else {
newSelected.delete(materialHash);
}
setSelectedItems(newSelected);
};
// 전체 선택/해제
const handleSelectAll = (items, checked) => {
const newSelected = new Set(selectedItems);
items.forEach(item => {
if (checked) {
newSelected.add(item.material_hash);
} else {
newSelected.delete(item.material_hash);
}
});
setSelectedItems(newSelected);
};
// 발주 확정 다이얼로그 열기
const handleOpenConfirmDialog = () => {
const confirmations = {};
// 선택된 신규 항목
new_items.forEach(item => {
if (selectedItems.has(item.material_hash)) {
confirmations[item.material_hash] = {
material_hash: item.material_hash,
description: item.description,
confirmed_quantity: item.additional_needed,
supplier_name: '',
unit_price: 0
};
}
});
// 선택된 변경 항목 (추가 필요량만)
modified_items.forEach(item => {
if (selectedItems.has(item.material_hash) && item.additional_needed > 0) {
confirmations[item.material_hash] = {
material_hash: item.material_hash,
description: item.description,
confirmed_quantity: item.additional_needed,
supplier_name: '',
unit_price: 0
};
}
});
setPurchaseConfirmations(confirmations);
setConfirmDialog(true);
};
// 발주 확정 실행
const handleConfirmPurchase = () => {
const confirmationList = Object.values(purchaseConfirmations).filter(
conf => conf.confirmed_quantity > 0
);
if (confirmationList.length > 0) {
onConfirmPurchase?.(confirmationList);
}
setConfirmDialog(false);
setSelectedItems(new Set());
};
// 수량 변경 핸들러
const handleQuantityChange = (materialHash, quantity) => {
setPurchaseConfirmations(prev => ({
...prev,
[materialHash]: {
...prev[materialHash],
confirmed_quantity: parseFloat(quantity) || 0
}
}));
};
// 공급업체 변경 핸들러
const handleSupplierChange = (materialHash, supplier) => {
setPurchaseConfirmations(prev => ({
...prev,
[materialHash]: {
...prev[materialHash],
supplier_name: supplier
}
}));
};
// 단가 변경 핸들러
const handlePriceChange = (materialHash, price) => {
setPurchaseConfirmations(prev => ({
...prev,
[materialHash]: {
...prev[materialHash],
unit_price: parseFloat(price) || 0
}
}));
};
const renderSummaryCard = () => (
<Card sx={{ mb: 2 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
리비전 비교 요약
</Typography>
<Stack direction="row" spacing={2} flexWrap="wrap">
<Chip
icon={<AddIcon />}
label={`신규: ${summary.new_items_count}`}
color="success"
variant="outlined"
/>
<Chip
icon={<EditIcon />}
label={`변경: ${summary.modified_items_count}`}
color="warning"
variant="outlined"
/>
<Chip
icon={<RemoveIcon />}
label={`삭제: ${summary.removed_items_count}`}
color="error"
variant="outlined"
/>
</Stack>
{purchase_summary.additional_purchase_needed > 0 && (
<Alert severity="warning" sx={{ mt: 2 }}>
<Typography variant="body2">
<strong>추가 발주 필요:</strong> {purchase_summary.additional_purchase_needed} 항목
(신규 {purchase_summary.total_new_items} + 증량 {purchase_summary.total_increased_items})
</Typography>
</Alert>
)}
</CardContent>
</Card>
);
const renderNewItemsTable = () => (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
indeterminate={selectedItems.size > 0 && selectedItems.size < new_items.length}
checked={new_items.length > 0 && selectedItems.size === new_items.length}
onChange={(e) => handleSelectAll(new_items, e.target.checked)}
/>
</TableCell>
<TableCell>품명</TableCell>
<TableCell>사이즈</TableCell>
<TableCell align="right">필요수량</TableCell>
<TableCell align="right">기존재고</TableCell>
<TableCell align="right">추가필요</TableCell>
<TableCell>재질</TableCell>
</TableRow>
</TableHead>
<TableBody>
{new_items.map((item, index) => (
<TableRow
key={item.material_hash}
selected={selectedItems.has(item.material_hash)}
hover
>
<TableCell padding="checkbox">
<Checkbox
checked={selectedItems.has(item.material_hash)}
onChange={(e) => handleItemSelect(item.material_hash, e.target.checked)}
/>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.description}
</Typography>
</TableCell>
<TableCell>{item.size_spec}</TableCell>
<TableCell align="right">
<Chip
label={item.quantity}
size="small"
color="primary"
variant="outlined"
/>
</TableCell>
<TableCell align="right">{item.available_stock}</TableCell>
<TableCell align="right">
<Chip
label={item.additional_needed}
size="small"
color={item.additional_needed > 0 ? "error" : "success"}
/>
</TableCell>
<TableCell>
<Typography variant="caption" color="textSecondary">
{item.material_grade}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
const renderModifiedItemsTable = () => (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
indeterminate={selectedItems.size > 0 && selectedItems.size < modified_items.length}
checked={modified_items.length > 0 && selectedItems.size === modified_items.length}
onChange={(e) => handleSelectAll(modified_items, e.target.checked)}
/>
</TableCell>
<TableCell>품명</TableCell>
<TableCell>사이즈</TableCell>
<TableCell align="right">이전수량</TableCell>
<TableCell align="right">현재수량</TableCell>
<TableCell align="right">증감</TableCell>
<TableCell align="right">기존재고</TableCell>
<TableCell align="right">추가필요</TableCell>
</TableRow>
</TableHead>
<TableBody>
{modified_items.map((item, index) => (
<TableRow
key={item.material_hash}
selected={selectedItems.has(item.material_hash)}
hover
>
<TableCell padding="checkbox">
<Checkbox
checked={selectedItems.has(item.material_hash)}
onChange={(e) => handleItemSelect(item.material_hash, e.target.checked)}
disabled={item.additional_needed <= 0} // 추가 필요량이 없으면 선택 불가
/>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.description}
</Typography>
</TableCell>
<TableCell>{item.size_spec}</TableCell>
<TableCell align="right">{item.previous_quantity}</TableCell>
<TableCell align="right">{item.current_quantity}</TableCell>
<TableCell align="right">
<Chip
label={item.quantity_diff > 0 ? `+${item.quantity_diff}` : item.quantity_diff}
size="small"
color={item.quantity_diff > 0 ? "error" : "success"}
/>
</TableCell>
<TableCell align="right">{item.available_stock}</TableCell>
<TableCell align="right">
<Chip
label={item.additional_needed}
size="small"
color={item.additional_needed > 0 ? "error" : "success"}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
const renderRemovedItemsTable = () => (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>품명</TableCell>
<TableCell>사이즈</TableCell>
<TableCell align="right">삭제된 수량</TableCell>
</TableRow>
</TableHead>
<TableBody>
{removed_items.map((item, index) => (
<TableRow key={item.material_hash}>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.description}
</Typography>
</TableCell>
<TableCell>{item.size_spec}</TableCell>
<TableCell align="right">
<Chip
label={item.quantity}
size="small"
color="default"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
const renderConfirmDialog = () => (
<Dialog
open={confirmDialog}
onClose={() => setConfirmDialog(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
<Stack direction="row" alignItems="center" spacing={1}>
<ShoppingCartIcon />
<Typography variant="h6">발주 확정</Typography>
</Stack>
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
선택한 {Object.keys(purchaseConfirmations).length} 항목의 발주를 확정합니다.
수량과 공급업체 정보를 확인해주세요.
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>품명</TableCell>
<TableCell align="right">확정수량</TableCell>
<TableCell>공급업체</TableCell>
<TableCell align="right">단가</TableCell>
<TableCell align="right">총액</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.values(purchaseConfirmations).map((conf) => (
<TableRow key={conf.material_hash}>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{conf.description}
</Typography>
</TableCell>
<TableCell align="right">
<TextField
size="small"
type="number"
value={conf.confirmed_quantity}
onChange={(e) => handleQuantityChange(conf.material_hash, e.target.value)}
sx={{ width: 80 }}
/>
</TableCell>
<TableCell>
<TextField
size="small"
placeholder="공급업체"
value={conf.supplier_name}
onChange={(e) => handleSupplierChange(conf.material_hash, e.target.value)}
sx={{ width: 120 }}
/>
</TableCell>
<TableCell align="right">
<TextField
size="small"
type="number"
placeholder="0"
value={conf.unit_price}
onChange={(e) => handlePriceChange(conf.material_hash, e.target.value)}
sx={{ width: 80 }}
/>
</TableCell>
<TableCell align="right">
<Typography variant="body2">
{(conf.confirmed_quantity * conf.unit_price).toLocaleString()}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDialog(false)}>
취소
</Button>
<Button
onClick={handleConfirmPurchase}
variant="contained"
startIcon={<CheckCircleIcon />}
disabled={loading}
>
발주 확정
</Button>
</DialogActions>
</Dialog>
);
return (
<Box>
{renderSummaryCard()}
<Card>
<CardHeader
title="자재 비교 상세"
action={
selectedItems.size > 0 && (
<Button
variant="contained"
startIcon={<ShoppingCartIcon />}
onClick={handleOpenConfirmDialog}
disabled={loading}
>
선택 항목 발주 확정 ({selectedItems.size})
</Button>
)
}
/>
<CardContent>
<Tabs value={selectedTab} onChange={handleTabChange}>
<Tab
label={`신규 항목 (${new_items.length})`}
icon={<AddIcon />}
/>
<Tab
label={`수량 변경 (${modified_items.length})`}
icon={<EditIcon />}
/>
<Tab
label={`삭제 항목 (${removed_items.length})`}
icon={<RemoveIcon />}
/>
</Tabs>
<Box sx={{ mt: 2 }}>
{selectedTab === 0 && renderNewItemsTable()}
{selectedTab === 1 && renderModifiedItemsTable()}
{selectedTab === 2 && renderRemovedItemsTable()}
</Box>
</CardContent>
</Card>
{renderConfirmDialog()}
</Box>
);
};
export default MaterialComparisonResult;

View File

@@ -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 (
<Box sx={{ maxWidth: 1000, mx: 'auto', mt: 4 }}>
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
프로젝트 선택
</Button>
<Typography variant="h5" sx={{ mb: 2 }}>
{jobNo && jobName && `${jobNo} (${jobName})`}
</Typography>
{/* BOM 업로드 폼 */}
<form onSubmit={handleUpload} style={{ marginBottom: 24, display: 'flex', gap: 16, alignItems: 'center' }}>
<TextField
label="도면명(파일명)"
value={filename}
onChange={e => setFilename(e.target.value)}
size="small"
required
sx={{ minWidth: 220 }}
/>
<input
type="file"
accept=".csv,.xlsx,.xls"
onChange={e => setFile(e.target.files[0])}
disabled={uploading}
style={{ marginRight: 8 }}
/>
<Button type="submit" variant="contained" disabled={!file || !filename || uploading}>
업로드
</Button>
</form>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{loading && <CircularProgress sx={{ mt: 4 }} />}
{/* 파일 목록 리스트 */}
<TableContainer component={Paper} sx={{ mt: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>도면명</TableCell>
<TableCell>리비전</TableCell>
<TableCell>세부내역</TableCell>
<TableCell>리비전</TableCell>
<TableCell>삭제</TableCell>
</TableRow>
</TableHead>
<TableBody>
{files.map(file => (
<TableRow key={file.id}>
<TableCell>{file.original_filename}</TableCell>
<TableCell>{file.revision}</TableCell>
<TableCell>
<Button size="small" variant="outlined" onClick={() => handleViewMaterials(file)}>
자재확인
</Button>
</TableCell>
<TableCell>
<Button size="small" variant="outlined" color="info" onClick={() => { setRevisionTarget(file); setRevisionDialogOpen(true); }}>
리비전
</Button>
</TableCell>
<TableCell>
<Button size="small" variant="outlined" color="error" onClick={() => handleDelete(file.id, file.original_filename)}>
삭제
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{/* 리비전 업로드 다이얼로그 */}
<Dialog open={revisionDialogOpen} onClose={() => setRevisionDialogOpen(false)}>
<DialogTitle>리비전 업로드</DialogTitle>
<DialogContent>
<Typography sx={{ mb: 2 }}>
도면명: <b>{revisionTarget?.original_filename}</b>
</Typography>
<input
type="file"
accept=".csv,.xlsx,.xls"
onChange={e => setRevisionFile(e.target.files[0])}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setRevisionDialogOpen(false)}>취소</Button>
<Button variant="contained" onClick={handleRevisionUpload} disabled={!revisionFile || uploading}>
업로드
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default BOMManagerPage;

View File

@@ -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 = () => {
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
뒤로가기
</Button>
<Typography variant="h4" gutterBottom>BOM 업로드 현황</Typography>
<Typography variant="h4" gutterBottom>📊 BOM 관리 시스템</Typography>
{jobNo && jobName && (
<Typography variant="h6" color="primary" sx={{ mb: 3 }}>
{jobNo} - {jobName}
@@ -257,30 +257,46 @@ const BOMStatusPage = () => {
{Object.entries(groupFilesByBOM()).map(([bomKey, bomFiles]) => (
bomFiles.map((file, index) => (
<TableRow key={file.id} sx={{
backgroundColor: index === 0 ? 'rgba(25, 118, 210, 0.08)' : 'inherit'
backgroundColor: index === 0 ? 'rgba(25, 118, 210, 0.08)' : 'rgba(0, 0, 0, 0.02)'
}}>
<TableCell>
<Typography variant="body2" fontWeight={index === 0 ? 'bold' : 'normal'}>
{file.bom_name || bomKey}
</Typography>
{index === 0 && bomFiles.length > 1 && (
<Typography variant="caption" color="textSecondary">
<Typography variant="caption" color="primary">
(최신 리비전)
</Typography>
)}
{index > 0 && (
<Typography variant="caption" color="textSecondary">
(이전 버전)
</Typography>
)}
</TableCell>
<TableCell>
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
{file.filename || file.original_filename}
</Typography>
</TableCell>
<TableCell>{file.filename || file.original_filename}</TableCell>
<TableCell>
<Typography
variant="body2"
color={index === 0 ? 'primary' : 'textSecondary'}
fontWeight={index === 0 ? 'bold' : 'normal'}
>
{file.revision || 'Rev.0'}
</Typography>
</TableCell>
<TableCell>{file.parsed_count || '-'}</TableCell>
<TableCell>
{file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'}
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
{file.parsed_count || 0}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
{file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'}
</Typography>
</TableCell>
<TableCell>
<Button
@@ -306,6 +322,28 @@ const BOMStatusPage = () => {
리비전
</Button>
)}
{file.revision !== 'Rev.0' && index < 3 && (
<>
<Button
size="small"
variant="outlined"
color="secondary"
onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${file.revision}&filename=${encodeURIComponent(file.original_filename)}`)}
sx={{ mr: 1 }}
>
비교
</Button>
<Button
size="small"
variant="outlined"
color="success"
onClick={() => navigate(`/revision-purchase?job_no=${jobNo}&current_revision=${file.revision}&bom_name=${encodeURIComponent(file.bom_name || bomKey)}`)}
sx={{ mr: 1 }}
>
구매 필요
</Button>
</>
)}
<Button
size="small"
color="error"

View File

@@ -0,0 +1,234 @@
import React, { useState, useEffect } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import {
Box,
Container,
Typography,
Button,
CircularProgress,
Alert,
Breadcrumbs,
Link,
Stack
} from '@mui/material';
import {
ArrowBack,
Refresh,
History
} from '@mui/icons-material';
import MaterialComparisonResult from '../components/MaterialComparisonResult';
import { compareMaterialRevisions, confirmMaterialPurchase } from '../api';
const MaterialComparisonPage = () => {
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 = () => (
<Box sx={{ mb: 3 }}>
<Breadcrumbs sx={{ mb: 2 }}>
<Link
component="button"
variant="body2"
onClick={() => navigate('/jobs')}
sx={{ textDecoration: 'none' }}
>
프로젝트 목록
</Link>
<Link
component="button"
variant="body2"
onClick={() => navigate(`/materials?job_no=${jobNo}`)}
sx={{ textDecoration: 'none' }}
>
{jobNo}
</Link>
<Typography variant="body2" color="textPrimary">
자재 비교
</Typography>
</Breadcrumbs>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="h4" gutterBottom>
자재 리비전 비교
</Typography>
<Typography variant="body1" color="textSecondary">
{filename && `파일: ${filename}`}
<br />
{previousRevision ?
`${previousRevision}${currentRevision} 비교` :
`${currentRevision} (이전 리비전 없음)`
}
</Typography>
</Box>
<Stack direction="row" spacing={2}>
<Button
variant="outlined"
startIcon={<Refresh />}
onClick={handleRefresh}
disabled={loading}
>
새로고침
</Button>
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={handleGoBack}
>
돌아가기
</Button>
</Stack>
</Stack>
</Box>
);
if (loading) {
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
{renderHeader()}
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<Stack alignItems="center" spacing={2}>
<CircularProgress size={60} />
<Typography variant="h6" color="textSecondary">
자재 비교 ...
</Typography>
<Typography variant="body2" color="textSecondary">
리비전간 차이점을 분석하고 있습니다
</Typography>
</Stack>
</Box>
</Container>
);
}
if (error) {
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
{renderHeader()}
<Alert severity="error" sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom>
자재 비교 실패
</Typography>
<Typography variant="body2">
{error}
</Typography>
</Alert>
<Button
variant="contained"
startIcon={<Refresh />}
onClick={handleRefresh}
>
다시 시도
</Button>
</Container>
);
}
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
{renderHeader()}
{comparisonResult ? (
<MaterialComparisonResult
comparison={comparisonResult}
onConfirmPurchase={handleConfirmPurchase}
loading={confirmLoading}
/>
) : (
<Alert severity="info">
비교 결과가 없습니다.
</Alert>
)}
</Container>
);
};
export default MaterialComparisonPage;

View File

@@ -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 = () => {
뒤로가기
</Button>
<Button
variant="contained"
color="success"
size="large"
startIcon={<ShoppingCart />}
onClick={() => {
const params = new URLSearchParams(window.location.search);
navigate(`/purchase-confirmation?${params.toString()}`);
}}
disabled={materialSpecs.length === 0}
sx={{ minWidth: 150 }}
>
구매 확정
</Button>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
{/* 리비전 비교 버튼 */}
{availableRevisions.length > 1 && currentRevision !== 'Rev.0' && (
<Button
variant="outlined"
color="secondary"
startIcon={<CompareIcon />}
onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${currentRevision}&filename=${encodeURIComponent(fileName)}`)}
>
리비전 비교
</Button>
)}
<Button
variant="contained"
color="success"
size="large"
startIcon={<ShoppingCart />}
onClick={() => {
const params = new URLSearchParams(window.location.search);
navigate(`/purchase-confirmation?${params.toString()}`);
}}
disabled={materialSpecs.length === 0}
sx={{ minWidth: 150 }}
>
구매 확정
</Button>
</Box>
</Box>
{/* 리비전 선택 */}
{availableRevisions.length > 1 && (
<Card sx={{ mb: 3, p: 2 }}>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} md={6}>
<Typography variant="h6" gutterBottom>
📋 {bomName}
</Typography>
<Typography variant="body2" color="textSecondary">
Job No: {jobNo} | 현재 리비전: <strong>{currentRevision}</strong>
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth size="small">
<InputLabel>리비전 선택</InputLabel>
<Select
value={fileId || ''}
label="리비전 선택"
onChange={(e) => {
const selectedFileId = e.target.value;
const selectedFile = availableRevisions.find(file => file.id === selectedFileId);
if (selectedFile) {
// 새로운 리비전 페이지로 이동
navigate(`/materials?file_id=${selectedFileId}&job_no=${jobNo}&filename=${encodeURIComponent(selectedFile.original_filename || selectedFile.filename)}`);
window.location.reload(); // 페이지 새로고침으로 데이터 갱신
}
}}
>
{availableRevisions.map((file) => (
<MenuItem key={file.id} value={file.id}>
{file.revision || 'Rev.0'} ({file.parsed_count || 0} 자재) - {new Date(file.upload_date).toLocaleDateString('ko-KR')}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
</Card>
)}
<Typography variant="h4" component="h1" gutterBottom>
📋 자재 사양서

View File

@@ -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 (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
<Typography variant="h6" sx={{ ml: 2 }}>
리비전 비교 분석 ...
</Typography>
</Box>
);
}
if (error) {
return (
<Box sx={{ maxWidth: 1200, mx: 'auto', mt: 4, px: 2 }}>
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={() => navigate(-1)}
sx={{ mb: 2 }}
>
뒤로가기
</Button>
<Alert severity="error">{error}</Alert>
</Box>
);
}
const { newItems, increasedItems } = calculatePurchaseNeeds();
const totalNewItems = newItems.length;
const totalIncreasedItems = increasedItems.length;
const totalPurchaseItems = totalNewItems + totalIncreasedItems;
return (
<Box sx={{ maxWidth: 1400, mx: 'auto', mt: 4, px: 2 }}>
{/* 헤더 */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={() => navigate(-1)}
>
뒤로가기
</Button>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
variant="outlined"
startIcon={<Assessment />}
onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${currentRevision}&prev_revision=${previousRevision}`)}
>
상세 비교 보기
</Button>
<Button
variant="contained"
color="success"
startIcon={<ShoppingCart />}
disabled={totalPurchaseItems === 0}
>
구매 목록 생성
</Button>
</Box>
</Box>
<Typography variant="h4" gutterBottom>
🛒 리비전간 추가 구매 필요 자재
</Typography>
<Typography variant="h6" color="textSecondary">
Job No: {jobNo} | {currentRevision} vs {previousRevision || '이전 리비전'}
</Typography>
</Box>
{/* 리비전 선택 카드 */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
리비전 비교 설정
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>현재 리비전</InputLabel>
<Select
value={currentRevision}
label="현재 리비전"
onChange={(e) => handleRevisionChange('current', e.target.value)}
>
{availableRevisions.map((file) => (
<MenuItem key={file.id} value={file.revision}>
{file.revision} ({file.parsed_count || 0} 자재)
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>이전 리비전</InputLabel>
<Select
value={previousRevision || ''}
label="이전 리비전"
onChange={(e) => handleRevisionChange('previous', e.target.value)}
>
<MenuItem value="">자동 선택 (직전 리비전)</MenuItem>
{availableRevisions
.filter(file => file.revision !== currentRevision)
.map((file) => (
<MenuItem key={file.id} value={file.revision}>
{file.revision} ({file.parsed_count || 0} 자재)
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
</CardContent>
</Card>
{/* 구매 요약 */}
<Grid container spacing={3} sx={{ mb: 3 }}>
<Grid item xs={12} md={4}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<AddIcon color="primary" sx={{ fontSize: 48, mb: 1 }} />
<Typography variant="h4" color="primary">
{totalNewItems}
</Typography>
<Typography variant="h6">
신규 자재
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<TrendingUp color="warning" sx={{ fontSize: 48, mb: 1 }} />
<Typography variant="h4" color="warning.main">
{totalIncreasedItems}
</Typography>
<Typography variant="h6">
수량 증가
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<ShoppingCart color="success" sx={{ fontSize: 48, mb: 1 }} />
<Typography variant="h4" color="success.main">
{totalPurchaseItems}
</Typography>
<Typography variant="h6">
구매 항목
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{/* 탭으로 구분된 자재 목록 */}
<Card>
<Tabs
value={selectedTab}
onChange={(e, newValue) => setSelectedTab(newValue)}
variant="fullWidth"
>
<Tab label={`신규 자재 (${totalNewItems})`} />
<Tab label={`수량 증가 (${totalIncreasedItems})`} />
</Tabs>
<CardContent>
{selectedTab === 0 && (
<Box>
<Typography variant="h6" gutterBottom color="primary">
🆕 신규 추가 자재
</Typography>
{newItems.length === 0 ? (
<Alert severity="info">새로 추가된 자재가 없습니다.</Alert>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>카테고리</TableCell>
<TableCell>자재 설명</TableCell>
<TableCell>사이즈</TableCell>
<TableCell>재질</TableCell>
<TableCell>수량</TableCell>
<TableCell>단위</TableCell>
</TableRow>
</TableHead>
<TableBody>
{newItems.map((item, index) => (
<TableRow key={index}>
<TableCell>
<Chip
label={item.category}
size="small"
color="primary"
/>
</TableCell>
<TableCell>{item.description}</TableCell>
<TableCell>{item.size_spec || '-'}</TableCell>
<TableCell>{item.material_grade || '-'}</TableCell>
<TableCell>
<Typography variant="body2" fontWeight="bold" color="primary">
+{item.quantity}
</Typography>
</TableCell>
<TableCell>{item.unit || 'EA'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
)}
{selectedTab === 1 && (
<Box>
<Typography variant="h6" gutterBottom color="warning.main">
📈 수량 증가 자재
</Typography>
{increasedItems.length === 0 ? (
<Alert severity="info">수량이 증가한 자재가 없습니다.</Alert>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>카테고리</TableCell>
<TableCell>자재 설명</TableCell>
<TableCell>사이즈</TableCell>
<TableCell>재질</TableCell>
<TableCell>이전 수량</TableCell>
<TableCell>현재 수량</TableCell>
<TableCell>증가량</TableCell>
<TableCell>단위</TableCell>
</TableRow>
</TableHead>
<TableBody>
{increasedItems.map((item, index) => (
<TableRow key={index}>
<TableCell>
<Chip
label={item.category}
size="small"
color="warning"
/>
</TableCell>
<TableCell>{item.description}</TableCell>
<TableCell>{item.size_spec || '-'}</TableCell>
<TableCell>{item.material_grade || '-'}</TableCell>
<TableCell>{item.previous_quantity}</TableCell>
<TableCell>{item.current_quantity}</TableCell>
<TableCell>
<Typography variant="body2" fontWeight="bold" color="warning.main">
+{item.quantity_change}
</Typography>
</TableCell>
<TableCell>{item.unit || 'EA'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
)}
</CardContent>
</Card>
{totalPurchaseItems === 0 && (
<Alert severity="success" sx={{ mt: 3 }}>
🎉 추가로 구매가 필요한 자재가 없습니다!
</Alert>
)}
</Box>
);
};
export default RevisionPurchasePage;