feat: 자재 리비전 비교 및 구매 목록 시스템 구현
- 자재 리비전간 비교 기능 추가 (MaterialComparisonPage) - 버그 해결 필요 - 리비전간 추가 구매 필요 자재 분석 페이지 추가 (RevisionPurchasePage) - 자재 비교 결과 컴포넌트 구현 (MaterialComparisonResult) - 자재 비교 API 라우터 추가 (material_comparison.py) - 로직 개선 필요 - 자재 비교 시스템 데이터베이스 스키마 추가 - FileManager, FileUpload 컴포넌트 개선 - BOMManagerPage 제거 및 새로운 구조로 리팩토링 - 자재 분류기 및 스키마 개선 TODO: 자재 비교 알고리즘 정확도 향상 및 예외 처리 강화 필요
This commit is contained in:
@@ -43,6 +43,12 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
print("purchase 라우터를 찾을 수 없습니다")
|
print("purchase 라우터를 찾을 수 없습니다")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .routers import material_comparison
|
||||||
|
app.include_router(material_comparison.router, tags=["material-comparison"])
|
||||||
|
except ImportError:
|
||||||
|
print("material_comparison 라우터를 찾을 수 없습니다")
|
||||||
|
|
||||||
# 파일 목록 조회 API
|
# 파일 목록 조회 API
|
||||||
@app.get("/files")
|
@app.get("/files")
|
||||||
async def get_files(
|
async def get_files(
|
||||||
@@ -94,12 +100,13 @@ async def get_files(
|
|||||||
"name": f.original_filename,
|
"name": f.original_filename,
|
||||||
"job_no": f.job_no, # job_no 사용
|
"job_no": f.job_no, # job_no 사용
|
||||||
"bom_name": f.bom_name or f.original_filename, # 실제 bom_name 값 사용, 없으면 파일명
|
"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 종류로 사용
|
"bom_type": f.file_type or "unknown", # file_type을 BOM 종류로 사용
|
||||||
"status": "active" if f.is_active else "inactive", # is_active 상태
|
"status": "active" if f.is_active else "inactive", # is_active 상태
|
||||||
"file_size": f.file_size,
|
"file_size": f.file_size,
|
||||||
"created_at": f.upload_date,
|
"created_at": f.upload_date,
|
||||||
"upload_date": f.upload_date,
|
"upload_date": f.upload_date,
|
||||||
"revision": f.revision or "Rev.0", # 실제 리비전 또는 기본값
|
|
||||||
"description": f"파일: {f.original_filename}"
|
"description": f"파일: {f.original_filename}"
|
||||||
}
|
}
|
||||||
for f in files
|
for f in files
|
||||||
|
|||||||
@@ -169,9 +169,18 @@ def parse_file_data(file_path):
|
|||||||
async def upload_file(
|
async def upload_file(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
job_no: str = Form(...),
|
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)
|
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):
|
if not validate_file_extension(file.filename):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
@@ -198,11 +207,67 @@ async def upload_file(
|
|||||||
parsed_count = len(materials_data)
|
parsed_count = len(materials_data)
|
||||||
print(f"파싱 완료: {parsed_count}개 자재")
|
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 저장 시작")
|
print("DB 저장 시작")
|
||||||
file_insert_query = text("""
|
file_insert_query = text("""
|
||||||
INSERT INTO files (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, :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
|
RETURNING id
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -212,6 +277,7 @@ async def upload_file(
|
|||||||
"file_path": str(file_path),
|
"file_path": str(file_path),
|
||||||
"job_no": job_no,
|
"job_no": job_no,
|
||||||
"revision": revision,
|
"revision": revision,
|
||||||
|
"bom_name": bom_name or file.filename, # bom_name 우선, 없으면 파일명
|
||||||
"description": f"BOM 파일 - {parsed_count}개 자재",
|
"description": f"BOM 파일 - {parsed_count}개 자재",
|
||||||
"file_size": file.size,
|
"file_size": file.size,
|
||||||
"parsed_count": parsed_count,
|
"parsed_count": parsed_count,
|
||||||
@@ -434,8 +500,28 @@ async def upload_file(
|
|||||||
else:
|
else:
|
||||||
end_prep = str(end_prep_info) if end_prep_info 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 = ""
|
manufacturing_method = ""
|
||||||
@@ -488,6 +574,18 @@ async def upload_file(
|
|||||||
material_standard = material_info.get("standard", "")
|
material_standard = material_info.get("standard", "")
|
||||||
material_grade = material_info.get("grade", "")
|
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와 reduced_size
|
||||||
main_size = material_data.get("main_nom") or material_data.get("size_spec", "")
|
main_size = material_data.get("main_nom") or material_data.get("size_spec", "")
|
||||||
reduced_size = material_data.get("red_nom", "")
|
reduced_size = material_data.get("red_nom", "")
|
||||||
@@ -572,6 +670,18 @@ async def upload_file(
|
|||||||
if isinstance(material_info, dict):
|
if isinstance(material_info, dict):
|
||||||
material_standard = material_info.get("standard", "")
|
material_standard = material_info.get("standard", "")
|
||||||
material_grade = material_info.get("grade", "")
|
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", "")
|
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):
|
if isinstance(material_info, dict):
|
||||||
material_standard = material_info.get("standard", "")
|
material_standard = material_info.get("standard", "")
|
||||||
material_grade = material_info.get("grade", "")
|
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 등)
|
# 압력 등급 (150LB 등)
|
||||||
pressure_rating = ""
|
pressure_rating = ""
|
||||||
@@ -821,6 +943,18 @@ async def upload_file(
|
|||||||
body_material = material_info.get("grade", "")
|
body_material = material_info.get("grade", "")
|
||||||
# 트림 재질은 일반적으로 바디와 동일하거나 별도 명시
|
# 트림 재질은 일반적으로 바디와 동일하거나 별도 명시
|
||||||
trim_material = body_material
|
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", "")
|
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,
|
"original_filename": file.filename,
|
||||||
"file_id": file_id,
|
"file_id": file_id,
|
||||||
"materials_count": materials_inserted,
|
"materials_count": materials_inserted,
|
||||||
|
"saved_materials_count": materials_inserted,
|
||||||
|
"revision": revision, # 생성된 리비전 정보 추가
|
||||||
"parsed_count": parsed_count
|
"parsed_count": parsed_count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
551
backend/app/routers/material_comparison.py
Normal file
551
backend/app/routers/material_comparison.py
Normal 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
|
||||||
@@ -131,13 +131,16 @@ def check_astm_standard(description: str, standard: str, standard_data: Dict) ->
|
|||||||
for pattern in standard_data["patterns"]:
|
for pattern in standard_data["patterns"]:
|
||||||
match = re.search(pattern, description)
|
match = re.search(pattern, description)
|
||||||
if match:
|
if match:
|
||||||
|
grade_code = match.group(1) if match.groups() else ""
|
||||||
|
full_grade = f"ASTM {standard}" + (f" {grade_code}" if grade_code else "")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"standard": f"ASTM {standard}",
|
"standard": f"ASTM {standard}",
|
||||||
"grade": f"ASTM {standard}",
|
"grade": full_grade,
|
||||||
"material_type": determine_material_type(standard, ""),
|
"material_type": determine_material_type(standard, grade_code),
|
||||||
"manufacturing": standard_data.get("manufacturing", "UNKNOWN"),
|
"manufacturing": standard_data.get("manufacturing", "UNKNOWN"),
|
||||||
"confidence": 0.9,
|
"confidence": 0.9,
|
||||||
"evidence": [f"ASTM_{standard}: Direct Match"]
|
"evidence": [f"ASTM_{standard}: {grade_code if grade_code else 'Direct Match'}"]
|
||||||
}
|
}
|
||||||
|
|
||||||
# 하위 분류가 있는 경우 (A182, A234 등)
|
# 하위 분류가 있는 경우 (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_code = match.group(1) if match.groups() else ""
|
||||||
grade_info = subtype_data["grades"].get(grade_code, {})
|
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 {
|
return {
|
||||||
"standard": f"ASTM {standard}",
|
"standard": f"ASTM {standard}",
|
||||||
"grade": f"ASTM {standard} {grade_code}",
|
"grade": full_grade,
|
||||||
"material_type": determine_material_type(standard, grade_code),
|
"material_type": determine_material_type(standard, grade_code),
|
||||||
"manufacturing": subtype_data.get("manufacturing", "UNKNOWN"),
|
"manufacturing": subtype_data.get("manufacturing", "UNKNOWN"),
|
||||||
"composition": grade_info.get("composition", ""),
|
"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:
|
if 'A182' in material_standard or 'A105' in material_standard:
|
||||||
return 'FORGED'
|
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'
|
return 'WELDED_FABRICATED'
|
||||||
elif 'A216' in material_standard or 'A351' in material_standard:
|
elif 'A216' in material_standard or 'A351' in material_standard:
|
||||||
return 'CAST'
|
return 'CAST'
|
||||||
|
|||||||
@@ -165,7 +165,11 @@ MATERIAL_STANDARDS = {
|
|||||||
"patterns": [
|
"patterns": [
|
||||||
r"ASTM\s+A403\s+(?:GR\s*)?WP\s*(\d{3}[LH]*)",
|
r"ASTM\s+A403\s+(?:GR\s*)?WP\s*(\d{3}[LH]*)",
|
||||||
r"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": {
|
"grades": {
|
||||||
"WP304": {
|
"WP304": {
|
||||||
@@ -191,6 +195,37 @@ MATERIAL_STANDARDS = {
|
|||||||
},
|
},
|
||||||
"manufacturing": "WELDED_FABRICATED"
|
"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": {
|
"A312": {
|
||||||
"patterns": [
|
"patterns": [
|
||||||
r"ASTM\s+A312\s+TP\s*(\d{3}[LH]*)",
|
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": {
|
"grades": {
|
||||||
"TP304": {
|
"TP304": {
|
||||||
@@ -310,6 +349,31 @@ MATERIAL_STANDARDS = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"manufacturing": "SEAMLESS"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
243
backend/scripts/10_add_material_comparison_system.sql
Normal file
243
backend/scripts/10_add_material_comparison_system.sql
Normal 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;
|
||||||
@@ -1,20 +1,24 @@
|
|||||||
import React from 'react';
|
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 ProjectSelectionPage from './pages/ProjectSelectionPage';
|
||||||
import BOMManagerPage from './pages/BOMManagerPage';
|
|
||||||
import MaterialsPage from './pages/MaterialsPage';
|
import MaterialsPage from './pages/MaterialsPage';
|
||||||
import BOMStatusPage from './pages/BOMStatusPage';
|
import BOMStatusPage from './pages/BOMStatusPage';
|
||||||
import PurchaseConfirmationPage from './pages/PurchaseConfirmationPage';
|
import PurchaseConfirmationPage from './pages/PurchaseConfirmationPage';
|
||||||
|
import MaterialComparisonPage from './pages/MaterialComparisonPage';
|
||||||
|
import RevisionPurchasePage from './pages/RevisionPurchasePage';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<ProjectSelectionPage />} />
|
<Route path="/" element={<ProjectSelectionPage />} />
|
||||||
<Route path="/bom-manager" element={<BOMManagerPage />} />
|
{/* BOM 관리는 /bom-status로 통일 */}
|
||||||
<Route path="/materials" element={<MaterialsPage />} />
|
<Route path="/bom-manager" element={<Navigate to="/bom-status" replace />} />
|
||||||
<Route path="/bom-status" element={<BOMStatusPage />} />
|
<Route path="/bom-status" element={<BOMStatusPage />} />
|
||||||
|
<Route path="/materials" element={<MaterialsPage />} />
|
||||||
<Route path="/purchase-confirmation" element={<PurchaseConfirmationPage />} />
|
<Route path="/purchase-confirmation" element={<PurchaseConfirmationPage />} />
|
||||||
|
<Route path="/material-comparison" element={<MaterialComparisonPage />} />
|
||||||
|
<Route path="/revision-purchase" element={<RevisionPurchasePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -131,4 +131,45 @@ export function generateSpoolIdentifier(dwgName, areaNumber, spoolNumber) {
|
|||||||
area_number: areaNumber,
|
area_number: areaNumber,
|
||||||
spool_number: spoolNumber
|
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 }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -34,15 +34,19 @@ import {
|
|||||||
FileUpload,
|
FileUpload,
|
||||||
Warning,
|
Warning,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Error
|
Error,
|
||||||
|
Update
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { fetchFiles, deleteFile } from '../api';
|
import { fetchFiles, deleteFile, uploadFile } from '../api';
|
||||||
import Toast from './Toast';
|
import Toast from './Toast';
|
||||||
|
|
||||||
function FileManager({ selectedProject }) {
|
function FileManager({ selectedProject }) {
|
||||||
const [files, setFiles] = useState([]);
|
const [files, setFiles] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [deleteDialog, setDeleteDialog] = useState({ open: false, file: null });
|
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 [toast, setToast] = useState({ open: false, message: '', type: 'info' });
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = 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) => {
|
const getStatusColor = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'completed':
|
case 'completed':
|
||||||
@@ -396,6 +451,17 @@ function FileManager({ selectedProject }) {
|
|||||||
>
|
>
|
||||||
<Visibility />
|
<Visibility />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="warning"
|
||||||
|
title="리비전 업로드"
|
||||||
|
onClick={() => {
|
||||||
|
console.log('🔄 리비전 버튼 클릭, 파일 정보:', file);
|
||||||
|
setRevisionDialog({ open: true, file });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Update />
|
||||||
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
color="error"
|
color="error"
|
||||||
@@ -461,6 +527,62 @@ function FileManager({ selectedProject }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,16 +29,19 @@ import {
|
|||||||
Description,
|
Description,
|
||||||
AutoAwesome,
|
AutoAwesome,
|
||||||
Category,
|
Category,
|
||||||
Science
|
Science,
|
||||||
|
Compare
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
import { uploadFile as uploadFileApi, fetchMaterialsSummary } from '../api';
|
import { uploadFile as uploadFileApi, fetchMaterialsSummary } from '../api';
|
||||||
import Toast from './Toast';
|
import Toast from './Toast';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
function FileUpload({ selectedProject, onUploadSuccess }) {
|
function FileUpload({ selectedProject, onUploadSuccess }) {
|
||||||
console.log('=== FileUpload 컴포넌트 렌더링 ===');
|
console.log('=== FileUpload 컴포넌트 렌더링 ===');
|
||||||
console.log('selectedProject:', selectedProject);
|
console.log('selectedProject:', selectedProject);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [uploadResult, setUploadResult] = useState(null);
|
const [uploadResult, setUploadResult] = useState(null);
|
||||||
@@ -131,7 +134,7 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('job_no', selectedProject.job_no);
|
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_name', file.name); // BOM 이름으로 파일명 사용
|
||||||
formData.append('bom_type', 'excel'); // 파일 타입
|
formData.append('bom_type', 'excel'); // 파일 타입
|
||||||
formData.append('description', ''); // 설명 (빈 문자열)
|
formData.append('description', ''); // 설명 (빈 문자열)
|
||||||
@@ -139,7 +142,7 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
|||||||
console.log('FormData 내용:', {
|
console.log('FormData 내용:', {
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
jobNo: selectedProject.job_no,
|
jobNo: selectedProject.job_no,
|
||||||
revision: 'Rev.0',
|
revision: 'Rev.0', // 새 BOM은 항상 Rev.0
|
||||||
bomName: file.name,
|
bomName: file.name,
|
||||||
bomType: 'excel'
|
bomType: 'excel'
|
||||||
});
|
});
|
||||||
@@ -458,7 +461,7 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ mt: 2, display: 'flex', gap: 2 }}>
|
<Box sx={{ mt: 2, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => window.location.href = '/materials'}
|
onClick={() => window.location.href = '/materials'}
|
||||||
@@ -466,6 +469,18 @@ function FileUpload({ selectedProject, onUploadSuccess }) {
|
|||||||
>
|
>
|
||||||
자재 목록 보기
|
자재 목록 보기
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={resetUpload}
|
onClick={resetUpload}
|
||||||
|
|||||||
516
frontend/src/components/MaterialComparisonResult.jsx
Normal file
516
frontend/src/components/MaterialComparisonResult.jsx
Normal 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;
|
||||||
@@ -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;
|
|
||||||
@@ -137,7 +137,7 @@ const BOMStatusPage = () => {
|
|||||||
formData.append('bom_name', revisionDialog.bomName);
|
formData.append('bom_name', revisionDialog.bomName);
|
||||||
formData.append('bom_type', 'excel');
|
formData.append('bom_type', 'excel');
|
||||||
formData.append('description', '');
|
formData.append('description', '');
|
||||||
formData.append('parent_bom_id', revisionDialog.parentId);
|
formData.append('parent_file_id', revisionDialog.parentId);
|
||||||
|
|
||||||
const response = await uploadFileApi(formData);
|
const response = await uploadFileApi(formData);
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ const BOMStatusPage = () => {
|
|||||||
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
|
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
|
||||||
← 뒤로가기
|
← 뒤로가기
|
||||||
</Button>
|
</Button>
|
||||||
<Typography variant="h4" gutterBottom>BOM 업로드 및 현황</Typography>
|
<Typography variant="h4" gutterBottom>📊 BOM 관리 시스템</Typography>
|
||||||
{jobNo && jobName && (
|
{jobNo && jobName && (
|
||||||
<Typography variant="h6" color="primary" sx={{ mb: 3 }}>
|
<Typography variant="h6" color="primary" sx={{ mb: 3 }}>
|
||||||
{jobNo} - {jobName}
|
{jobNo} - {jobName}
|
||||||
@@ -257,30 +257,46 @@ const BOMStatusPage = () => {
|
|||||||
{Object.entries(groupFilesByBOM()).map(([bomKey, bomFiles]) => (
|
{Object.entries(groupFilesByBOM()).map(([bomKey, bomFiles]) => (
|
||||||
bomFiles.map((file, index) => (
|
bomFiles.map((file, index) => (
|
||||||
<TableRow key={file.id} sx={{
|
<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>
|
<TableCell>
|
||||||
<Typography variant="body2" fontWeight={index === 0 ? 'bold' : 'normal'}>
|
<Typography variant="body2" fontWeight={index === 0 ? 'bold' : 'normal'}>
|
||||||
{file.bom_name || bomKey}
|
{file.bom_name || bomKey}
|
||||||
</Typography>
|
</Typography>
|
||||||
{index === 0 && bomFiles.length > 1 && (
|
{index === 0 && bomFiles.length > 1 && (
|
||||||
<Typography variant="caption" color="textSecondary">
|
<Typography variant="caption" color="primary">
|
||||||
(최신 리비전)
|
(최신 리비전)
|
||||||
</Typography>
|
</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>
|
||||||
<TableCell>{file.filename || file.original_filename}</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
color={index === 0 ? 'primary' : 'textSecondary'}
|
color={index === 0 ? 'primary' : 'textSecondary'}
|
||||||
|
fontWeight={index === 0 ? 'bold' : 'normal'}
|
||||||
>
|
>
|
||||||
{file.revision || 'Rev.0'}
|
{file.revision || 'Rev.0'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{file.parsed_count || '-'}</TableCell>
|
|
||||||
<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>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button
|
<Button
|
||||||
@@ -306,6 +322,28 @@ const BOMStatusPage = () => {
|
|||||||
리비전
|
리비전
|
||||||
</Button>
|
</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}¤t_revision=${file.revision}&bom_name=${encodeURIComponent(file.bom_name || bomKey)}`)}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
구매 필요
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
color="error"
|
color="error"
|
||||||
|
|||||||
234
frontend/src/pages/MaterialComparisonPage.jsx
Normal file
234
frontend/src/pages/MaterialComparisonPage.jsx
Normal 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;
|
||||||
@@ -16,11 +16,17 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Chip,
|
Chip,
|
||||||
Divider
|
Divider,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
Grid
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import ShoppingCart from '@mui/icons-material/ShoppingCart';
|
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 MaterialsPage = () => {
|
||||||
const [materials, setMaterials] = useState([]);
|
const [materials, setMaterials] = useState([]);
|
||||||
@@ -28,23 +34,63 @@ const MaterialsPage = () => {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [fileId, setFileId] = useState(null);
|
const [fileId, setFileId] = useState(null);
|
||||||
const [fileName, setFileName] = useState('');
|
const [fileName, setFileName] = useState('');
|
||||||
|
const [jobNo, setJobNo] = useState('');
|
||||||
|
const [bomName, setBomName] = useState('');
|
||||||
|
const [currentRevision, setCurrentRevision] = useState('');
|
||||||
|
const [availableRevisions, setAvailableRevisions] = useState([]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const id = urlParams.get('file_id');
|
const id = urlParams.get('file_id');
|
||||||
const name = urlParams.get('filename') || '';
|
const name = urlParams.get('filename') || '';
|
||||||
|
const job_no = urlParams.get('job_no') || '';
|
||||||
|
|
||||||
if (id) {
|
if (id && job_no) {
|
||||||
setFileId(id);
|
setFileId(id);
|
||||||
setFileName(decodeURIComponent(name));
|
setFileName(decodeURIComponent(name));
|
||||||
|
setJobNo(job_no);
|
||||||
loadMaterials(id);
|
loadMaterials(id);
|
||||||
|
loadAvailableRevisions(job_no, name);
|
||||||
} else {
|
} else {
|
||||||
setLoading(false);
|
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) => {
|
const loadMaterials = async (id) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -586,21 +632,75 @@ const MaterialsPage = () => {
|
|||||||
뒤로가기
|
뒤로가기
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||||
variant="contained"
|
{/* 리비전 비교 버튼 */}
|
||||||
color="success"
|
{availableRevisions.length > 1 && currentRevision !== 'Rev.0' && (
|
||||||
size="large"
|
<Button
|
||||||
startIcon={<ShoppingCart />}
|
variant="outlined"
|
||||||
onClick={() => {
|
color="secondary"
|
||||||
const params = new URLSearchParams(window.location.search);
|
startIcon={<CompareIcon />}
|
||||||
navigate(`/purchase-confirmation?${params.toString()}`);
|
onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${currentRevision}&filename=${encodeURIComponent(fileName)}`)}
|
||||||
}}
|
>
|
||||||
disabled={materialSpecs.length === 0}
|
리비전 비교
|
||||||
sx={{ minWidth: 150 }}
|
</Button>
|
||||||
>
|
)}
|
||||||
구매 확정
|
|
||||||
</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>
|
</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>
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
📋 자재 사양서
|
📋 자재 사양서
|
||||||
|
|||||||
437
frontend/src/pages/RevisionPurchasePage.jsx
Normal file
437
frontend/src/pages/RevisionPurchasePage.jsx
Normal 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;
|
||||||
Reference in New Issue
Block a user