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

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

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

View File

@@ -43,6 +43,12 @@ try:
except ImportError:
print("purchase 라우터를 찾을 수 없습니다")
try:
from .routers import material_comparison
app.include_router(material_comparison.router, tags=["material-comparison"])
except ImportError:
print("material_comparison 라우터를 찾을 수 없습니다")
# 파일 목록 조회 API
@app.get("/files")
async def get_files(
@@ -94,12 +100,13 @@ async def get_files(
"name": f.original_filename,
"job_no": f.job_no, # job_no 사용
"bom_name": f.bom_name or f.original_filename, # 실제 bom_name 값 사용, 없으면 파일명
"revision": f.revision or "Rev.0", # 실제 리비전 또는 기본값
"parsed_count": f.parsed_count or 0, # 파싱된 자재 수
"bom_type": f.file_type or "unknown", # file_type을 BOM 종류로 사용
"status": "active" if f.is_active else "inactive", # is_active 상태
"file_size": f.file_size,
"created_at": f.upload_date,
"upload_date": f.upload_date,
"revision": f.revision or "Rev.0", # 실제 리비전 또는 기본값
"description": f"파일: {f.original_filename}"
}
for f in files

View File

@@ -169,9 +169,18 @@ def parse_file_data(file_path):
async def upload_file(
file: UploadFile = File(...),
job_no: str = Form(...),
revision: str = Form("Rev.0"),
revision: str = Form("Rev.0"), # 기본값은 Rev.0 (새 BOM)
parent_file_id: Optional[int] = Form(None), # 리비전 업로드 시 부모 파일 ID
bom_name: Optional[str] = Form(None), # BOM 이름 (사용자 입력)
db: Session = Depends(get_db)
):
print(f"📥 업로드 요청 받음:")
print(f" - 파일명: {file.filename}")
print(f" - job_no: {job_no}")
print(f" - revision: {revision}")
print(f" - parent_file_id: {parent_file_id}")
print(f" - bom_name: {bom_name}")
print(f" - parent_file_id 타입: {type(parent_file_id)}")
if not validate_file_extension(file.filename):
raise HTTPException(
status_code=400,
@@ -198,11 +207,67 @@ async def upload_file(
parsed_count = len(materials_data)
print(f"파싱 완료: {parsed_count}개 자재")
# 리비전 업로드인 경우만 자동 리비전 생성
if parent_file_id is not None:
print(f"리비전 업로드 모드: parent_file_id = {parent_file_id}")
# 부모 파일의 정보 조회
parent_query = text("""
SELECT original_filename, revision, bom_name FROM files
WHERE id = :parent_file_id AND job_no = :job_no
""")
parent_result = db.execute(parent_query, {
"parent_file_id": parent_file_id,
"job_no": job_no
})
parent_file = parent_result.fetchone()
if not parent_file:
raise HTTPException(status_code=404, detail="부모 파일을 찾을 수 없습니다.")
# 해당 BOM의 최신 리비전 확인 (bom_name 기준)
bom_name_to_use = parent_file[2] or parent_file[0] # bom_name 우선, 없으면 original_filename
latest_revision_query = text("""
SELECT revision FROM files
WHERE job_no = :job_no
AND (bom_name = :bom_name OR (bom_name IS NULL AND original_filename = :bom_name))
ORDER BY revision DESC
LIMIT 1
""")
latest_result = db.execute(latest_revision_query, {
"job_no": job_no,
"bom_name": bom_name_to_use
})
latest_revision = latest_result.fetchone()
if latest_revision:
latest_rev = latest_revision[0]
if latest_rev.startswith("Rev."):
try:
rev_num = int(latest_rev.replace("Rev.", ""))
revision = f"Rev.{rev_num + 1}"
except ValueError:
revision = "Rev.1"
else:
revision = "Rev.1"
print(f"리비전 업로드: {latest_rev}{revision}")
else:
revision = "Rev.1"
print(f"첫 번째 리비전: {revision}")
# 파일명을 부모와 동일하게 유지
file.filename = parent_file[0]
else:
# 일반 업로드 (새 BOM)
print(f"일반 업로드 모드: 새 BOM 파일 (Rev.0)")
# 파일 정보 저장
print("DB 저장 시작")
file_insert_query = text("""
INSERT INTO files (filename, original_filename, file_path, job_no, revision, description, file_size, parsed_count, is_active)
VALUES (:filename, :original_filename, :file_path, :job_no, :revision, :description, :file_size, :parsed_count, :is_active)
INSERT INTO files (filename, original_filename, file_path, job_no, revision, bom_name, description, file_size, parsed_count, is_active)
VALUES (:filename, :original_filename, :file_path, :job_no, :revision, :bom_name, :description, :file_size, :parsed_count, :is_active)
RETURNING id
""")
@@ -212,6 +277,7 @@ async def upload_file(
"file_path": str(file_path),
"job_no": job_no,
"revision": revision,
"bom_name": bom_name or file.filename, # bom_name 우선, 없으면 파일명
"description": f"BOM 파일 - {parsed_count}개 자재",
"file_size": file.size,
"parsed_count": parsed_count,
@@ -434,8 +500,28 @@ async def upload_file(
else:
end_prep = str(end_prep_info) if end_prep_info else ""
# 재질 정보 - 이미 정제된 material_grade 사용
material_spec = material_data.get("material_grade", "")
# 재질 정보 - 분류 결과에서 상세 정보 추출
material_grade_from_classifier = ""
if isinstance(material_info, dict):
material_grade_from_classifier = material_info.get("grade", "")
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
if material_grade_from_classifier and material_grade_from_classifier != "UNKNOWN":
material_spec = material_grade_from_classifier
# materials 테이블의 material_grade도 업데이트
db.execute(text("""
UPDATE materials
SET material_grade = :new_material_grade
WHERE id = :material_id
"""), {
"new_material_grade": material_grade_from_classifier,
"material_id": material_id
})
print(f"PIPE material_grade 업데이트: {material_grade_from_classifier}")
else:
# 기존 파싱 결과 사용
material_spec = material_data.get("material_grade", "")
# 제조방법 추출
manufacturing_method = ""
@@ -488,6 +574,18 @@ async def upload_file(
material_standard = material_info.get("standard", "")
material_grade = material_info.get("grade", "")
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
if material_grade and material_grade != "UNKNOWN":
db.execute(text("""
UPDATE materials
SET material_grade = :new_material_grade
WHERE id = :material_id
"""), {
"new_material_grade": material_grade,
"material_id": material_id
})
print(f"FITTING material_grade 업데이트: {material_grade}")
# main_size와 reduced_size
main_size = material_data.get("main_nom") or material_data.get("size_spec", "")
reduced_size = material_data.get("red_nom", "")
@@ -572,6 +670,18 @@ async def upload_file(
if isinstance(material_info, dict):
material_standard = material_info.get("standard", "")
material_grade = material_info.get("grade", "")
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
if material_grade and material_grade != "UNKNOWN":
db.execute(text("""
UPDATE materials
SET material_grade = :new_material_grade
WHERE id = :material_id
"""), {
"new_material_grade": material_grade,
"material_id": material_id
})
print(f"FLANGE material_grade 업데이트: {material_grade}")
# 사이즈 정보
size_inches = material_data.get("main_nom") or material_data.get("size_spec", "")
@@ -718,6 +828,18 @@ async def upload_file(
if isinstance(material_info, dict):
material_standard = material_info.get("standard", "")
material_grade = material_info.get("grade", "")
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
if material_grade and material_grade != "UNKNOWN":
db.execute(text("""
UPDATE materials
SET material_grade = :new_material_grade
WHERE id = :material_id
"""), {
"new_material_grade": material_grade,
"material_id": material_id
})
print(f"BOLT material_grade 업데이트: {material_grade}")
# 압력 등급 (150LB 등)
pressure_rating = ""
@@ -821,6 +943,18 @@ async def upload_file(
body_material = material_info.get("grade", "")
# 트림 재질은 일반적으로 바디와 동일하거나 별도 명시
trim_material = body_material
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
if body_material and body_material != "UNKNOWN":
db.execute(text("""
UPDATE materials
SET material_grade = :new_material_grade
WHERE id = :material_id
"""), {
"new_material_grade": body_material,
"material_id": material_id
})
print(f"VALVE material_grade 업데이트: {body_material}")
# 사이즈 정보
size_inches = material_data.get("main_nom") or material_data.get("size_spec", "")
@@ -879,6 +1013,8 @@ async def upload_file(
"original_filename": file.filename,
"file_id": file_id,
"materials_count": materials_inserted,
"saved_materials_count": materials_inserted,
"revision": revision, # 생성된 리비전 정보 추가
"parsed_count": parsed_count
}

View File

@@ -0,0 +1,551 @@
"""
자재 비교 및 발주 추적 API
- 리비전간 자재 비교
- 추가 발주 필요량 계산
- 발주 상태 관리
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text
import json
from typing import List, Optional, Dict
from datetime import datetime
from ..database import get_db
router = APIRouter(prefix="/materials", tags=["material-comparison"])
@router.post("/compare-revisions")
async def compare_material_revisions(
job_no: str,
current_revision: str,
previous_revision: Optional[str] = None,
save_result: bool = True,
db: Session = Depends(get_db)
):
"""
리비전간 자재 비교 및 추가 발주 필요량 계산
- 해시 기반 고성능 비교
- 누적 재고 고려한 실제 구매 필요량 계산
"""
try:
# 1. 파일 정보 조회
current_file = await get_file_by_revision(db, job_no, current_revision)
if not current_file:
raise HTTPException(status_code=404, detail=f"{job_no} {current_revision} 파일을 찾을 수 없습니다")
# 2. 이전 리비전 자동 탐지
if not previous_revision:
previous_revision = await get_previous_revision(db, job_no, current_revision)
previous_file = None
if previous_revision:
previous_file = await get_file_by_revision(db, job_no, previous_revision)
# 3. 자재 비교 실행
comparison_result = await perform_material_comparison(
db, current_file, previous_file, job_no
)
# 4. 결과 저장 (선택사항)
comparison_id = None
if save_result and previous_file and previous_revision:
comparison_id = await save_comparison_result(
db, job_no, current_revision, previous_revision,
current_file["id"], previous_file["id"], comparison_result
)
return {
"success": True,
"job_no": job_no,
"current_revision": current_revision,
"previous_revision": previous_revision,
"comparison_id": comparison_id,
"summary": comparison_result["summary"],
"new_items": comparison_result["new_items"],
"modified_items": comparison_result["modified_items"],
"removed_items": comparison_result["removed_items"],
"purchase_summary": comparison_result["purchase_summary"]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"자재 비교 실패: {str(e)}")
@router.get("/comparison-history")
async def get_comparison_history(
job_no: str = Query(..., description="Job 번호"),
limit: int = Query(10, ge=1, le=50),
db: Session = Depends(get_db)
):
"""
자재 비교 이력 조회
"""
try:
query = text("""
SELECT
id, current_revision, previous_revision,
new_items_count, modified_items_count, removed_items_count,
upload_date, created_by
FROM material_revisions_comparison
WHERE job_no = :job_no
ORDER BY upload_date DESC
LIMIT :limit
""")
result = db.execute(query, {"job_no": job_no, "limit": limit})
comparisons = result.fetchall()
return {
"success": True,
"job_no": job_no,
"comparisons": [
{
"id": comp[0],
"current_revision": comp[1],
"previous_revision": comp[2],
"new_items_count": comp[3],
"modified_items_count": comp[4],
"removed_items_count": comp[5],
"upload_date": comp[6],
"created_by": comp[7]
}
for comp in comparisons
]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"비교 이력 조회 실패: {str(e)}")
@router.get("/inventory-status")
async def get_material_inventory_status(
job_no: str = Query(..., description="Job 번호"),
material_hash: Optional[str] = Query(None, description="특정 자재 해시"),
db: Session = Depends(get_db)
):
"""
자재별 누적 재고 현황 조회
"""
try:
# 임시로 빈 결과 반환 (추후 개선)
return {
"success": True,
"job_no": job_no,
"inventory": []
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"재고 현황 조회 실패: {str(e)}")
@router.post("/confirm-purchase")
async def confirm_material_purchase(
job_no: str,
revision: str,
confirmations: List[Dict],
confirmed_by: str = "system",
db: Session = Depends(get_db)
):
"""
자재 발주 확정 처리
confirmations = [
{
"material_hash": "abc123",
"confirmed_quantity": 100,
"supplier_name": "ABC공급업체",
"unit_price": 1000
}
]
"""
try:
confirmed_items = []
for confirmation in confirmations:
# 발주 추적 테이블에 저장/업데이트
upsert_query = text("""
INSERT INTO material_purchase_tracking (
job_no, material_hash, revision, description, size_spec, unit,
bom_quantity, calculated_quantity, confirmed_quantity,
purchase_status, supplier_name, unit_price, total_price,
confirmed_by, confirmed_at
)
SELECT
:job_no, m.material_hash, :revision, m.original_description,
m.size_spec, m.unit, m.quantity, :calculated_qty, :confirmed_qty,
'CONFIRMED', :supplier_name, :unit_price, :total_price,
:confirmed_by, CURRENT_TIMESTAMP
FROM materials m
WHERE m.material_hash = :material_hash
AND m.file_id = (
SELECT id FROM files
WHERE job_no = :job_no AND revision = :revision
ORDER BY upload_date DESC LIMIT 1
)
LIMIT 1
ON CONFLICT (job_no, material_hash, revision)
DO UPDATE SET
confirmed_quantity = :confirmed_qty,
purchase_status = 'CONFIRMED',
supplier_name = :supplier_name,
unit_price = :unit_price,
total_price = :total_price,
confirmed_by = :confirmed_by,
confirmed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING id, description, confirmed_quantity
""")
calculated_qty = confirmation.get("calculated_quantity", confirmation["confirmed_quantity"])
total_price = confirmation["confirmed_quantity"] * confirmation.get("unit_price", 0)
result = db.execute(upsert_query, {
"job_no": job_no,
"revision": revision,
"material_hash": confirmation["material_hash"],
"calculated_qty": calculated_qty,
"confirmed_qty": confirmation["confirmed_quantity"],
"supplier_name": confirmation.get("supplier_name", ""),
"unit_price": confirmation.get("unit_price", 0),
"total_price": total_price,
"confirmed_by": confirmed_by
})
confirmed_item = result.fetchone()
if confirmed_item:
confirmed_items.append({
"id": confirmed_item[0],
"material_hash": confirmed_item[1],
"confirmed_quantity": confirmed_item[2],
"supplier_name": confirmed_item[3],
"unit_price": confirmed_item[4],
"total_price": confirmed_item[5]
})
db.commit()
return {
"success": True,
"message": f"{len(confirmed_items)}개 자재 발주가 확정되었습니다",
"confirmed_items": confirmed_items,
"job_no": job_no,
"revision": revision
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"발주 확정 실패: {str(e)}")
@router.get("/purchase-status")
async def get_purchase_status(
job_no: str = Query(..., description="Job 번호"),
revision: Optional[str] = Query(None, description="리비전 (전체 조회시 생략)"),
status: Optional[str] = Query(None, description="발주 상태 필터"),
db: Session = Depends(get_db)
):
"""
발주 상태 조회
"""
try:
where_conditions = ["job_no = :job_no"]
params = {"job_no": job_no}
if revision:
where_conditions.append("revision = :revision")
params["revision"] = revision
if status:
where_conditions.append("purchase_status = :status")
params["status"] = status
query = text(f"""
SELECT
material_hash, revision, description, size_spec, unit,
bom_quantity, calculated_quantity, confirmed_quantity,
purchase_status, supplier_name, unit_price, total_price,
order_date, delivery_date, confirmed_by, confirmed_at
FROM material_purchase_tracking
WHERE {' AND '.join(where_conditions)}
ORDER BY revision DESC, description
""")
result = db.execute(query, params)
purchases = result.fetchall()
# 상태별 요약
status_summary = {}
total_amount = 0
for purchase in purchases:
status_key = purchase.purchase_status
if status_key not in status_summary:
status_summary[status_key] = {"count": 0, "total_amount": 0}
status_summary[status_key]["count"] += 1
status_summary[status_key]["total_amount"] += purchase.total_price or 0
total_amount += purchase.total_price or 0
return {
"success": True,
"job_no": job_no,
"revision": revision,
"purchases": [purchase._asdict() if hasattr(purchase, '_asdict') else dict(zip(purchase.keys(), purchase)) for purchase in purchases],
"summary": {
"total_items": len(purchases),
"total_amount": total_amount,
"status_breakdown": status_summary
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"발주 상태 조회 실패: {str(e)}")
# ========== 헬퍼 함수들 ==========
async def get_file_by_revision(db: Session, job_no: str, revision: str) -> Optional[Dict]:
"""리비전으로 파일 정보 조회"""
query = text("""
SELECT id, original_filename, revision, upload_date
FROM files
WHERE job_no = :job_no AND revision = :revision AND is_active = TRUE
ORDER BY upload_date DESC
LIMIT 1
""")
result = db.execute(query, {"job_no": job_no, "revision": revision})
file_row = result.fetchone()
if file_row:
return {
"id": file_row[0],
"original_filename": file_row[1],
"revision": file_row[2],
"upload_date": file_row[3]
}
return None
async def get_previous_revision(db: Session, job_no: str, current_revision: str) -> Optional[str]:
"""이전 리비전 자동 탐지"""
query = text("""
SELECT revision
FROM files
WHERE job_no = :job_no AND revision < :current_revision AND is_active = TRUE
ORDER BY revision DESC
LIMIT 1
""")
result = db.execute(query, {"job_no": job_no, "current_revision": current_revision})
prev_row = result.fetchone()
if prev_row is not None:
return prev_row[0]
return None
async def perform_material_comparison(
db: Session,
current_file: Dict,
previous_file: Optional[Dict],
job_no: str
) -> Dict:
"""
핵심 자재 비교 로직
- 해시 기반 고성능 비교
- 누적 재고 고려한 실제 구매 필요량 계산
"""
# 1. 현재 리비전 자재 목록 (해시별로 그룹화)
current_materials = await get_materials_by_hash(db, current_file["id"])
# 2. 이전 리비전 자재 목록
previous_materials = {}
if previous_file:
previous_materials = await get_materials_by_hash(db, previous_file["id"])
# 3. 현재까지의 누적 재고 조회
current_inventory = await get_current_inventory(db, job_no)
# 4. 비교 실행
new_items = []
modified_items = []
removed_items = []
purchase_summary = {
"additional_purchase_needed": 0,
"total_new_items": 0,
"total_increased_items": 0
}
# 신규/변경 항목 찾기
for material_hash, current_item in current_materials.items():
current_qty = current_item["quantity"]
available_stock = current_inventory.get(material_hash, 0)
if material_hash not in previous_materials:
# 완전히 새로운 항목
additional_needed = max(current_qty - available_stock, 0)
new_items.append({
"material_hash": material_hash,
"description": current_item["description"],
"size_spec": current_item["size_spec"],
"material_grade": current_item["material_grade"],
"quantity": current_qty,
"available_stock": available_stock,
"additional_needed": additional_needed
})
purchase_summary["additional_purchase_needed"] += additional_needed
purchase_summary["total_new_items"] += 1
else:
# 기존 항목 - 수량 변경 체크
previous_qty = previous_materials[material_hash]["quantity"]
qty_diff = current_qty - previous_qty
if qty_diff != 0:
additional_needed = max(current_qty - available_stock, 0)
modified_items.append({
"material_hash": material_hash,
"description": current_item["description"],
"size_spec": current_item["size_spec"],
"previous_quantity": previous_qty,
"current_quantity": current_qty,
"quantity_diff": qty_diff,
"available_stock": available_stock,
"additional_needed": additional_needed
})
if additional_needed > 0:
purchase_summary["additional_purchase_needed"] += additional_needed
purchase_summary["total_increased_items"] += 1
# 삭제된 항목 찾기
for material_hash, previous_item in previous_materials.items():
if material_hash not in current_materials:
removed_items.append({
"material_hash": material_hash,
"description": previous_item["description"],
"size_spec": previous_item["size_spec"],
"quantity": previous_item["quantity"]
})
return {
"summary": {
"total_current_items": len(current_materials),
"total_previous_items": len(previous_materials),
"new_items_count": len(new_items),
"modified_items_count": len(modified_items),
"removed_items_count": len(removed_items)
},
"new_items": new_items,
"modified_items": modified_items,
"removed_items": removed_items,
"purchase_summary": purchase_summary
}
async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
"""파일의 자재를 해시별로 그룹화하여 조회"""
import hashlib
query = text("""
SELECT
original_description,
size_spec,
material_grade,
SUM(quantity) as quantity,
classified_category
FROM materials
WHERE file_id = :file_id
GROUP BY original_description, size_spec, material_grade, classified_category
""")
result = db.execute(query, {"file_id": file_id})
materials = result.fetchall()
materials_dict = {}
for mat in materials:
# 자재 해시 생성 (description + size_spec + material_grade)
hash_source = f"{mat[0] or ''}|{mat[1] or ''}|{mat[2] or ''}"
material_hash = hashlib.md5(hash_source.encode()).hexdigest()
materials_dict[material_hash] = {
"material_hash": material_hash,
"original_description": mat[0],
"size_spec": mat[1],
"material_grade": mat[2],
"quantity": float(mat[3]) if mat[3] else 0.0,
"classified_category": mat[4]
}
return materials_dict
async def get_current_inventory(db: Session, job_no: str) -> Dict[str, float]:
"""현재까지의 누적 재고량 조회"""
query = text("""
SELECT material_hash, available_stock
FROM material_inventory_status
WHERE job_no = :job_no
""")
result = db.execute(query, {"job_no": job_no})
inventory = result.fetchall()
return {inv.material_hash: float(inv.available_stock or 0) for inv in inventory}
async def save_comparison_result(
db: Session,
job_no: str,
current_revision: str,
previous_revision: str,
current_file_id: int,
previous_file_id: int,
comparison_result: Dict
) -> int:
"""비교 결과를 데이터베이스에 저장"""
# 메인 비교 레코드 저장
insert_query = text("""
INSERT INTO material_revisions_comparison (
job_no, current_revision, previous_revision,
current_file_id, previous_file_id,
total_current_items, total_previous_items,
new_items_count, modified_items_count, removed_items_count,
comparison_details, created_by
) VALUES (
:job_no, :current_revision, :previous_revision,
:current_file_id, :previous_file_id,
:total_current_items, :total_previous_items,
:new_items_count, :modified_items_count, :removed_items_count,
:comparison_details, 'system'
)
ON CONFLICT (job_no, current_revision, previous_revision)
DO UPDATE SET
total_current_items = :total_current_items,
total_previous_items = :total_previous_items,
new_items_count = :new_items_count,
modified_items_count = :modified_items_count,
removed_items_count = :removed_items_count,
comparison_details = :comparison_details,
upload_date = CURRENT_TIMESTAMP
RETURNING id
""")
import json
summary = comparison_result["summary"]
result = db.execute(insert_query, {
"job_no": job_no,
"current_revision": current_revision,
"previous_revision": previous_revision,
"current_file_id": current_file_id,
"previous_file_id": previous_file_id,
"total_current_items": summary["total_current_items"],
"total_previous_items": summary["total_previous_items"],
"new_items_count": summary["new_items_count"],
"modified_items_count": summary["modified_items_count"],
"removed_items_count": summary["removed_items_count"],
"comparison_details": json.dumps(comparison_result, ensure_ascii=False)
})
comparison_id = result.fetchone()[0]
db.commit()
return comparison_id

View File

@@ -131,13 +131,16 @@ def check_astm_standard(description: str, standard: str, standard_data: Dict) ->
for pattern in standard_data["patterns"]:
match = re.search(pattern, description)
if match:
grade_code = match.group(1) if match.groups() else ""
full_grade = f"ASTM {standard}" + (f" {grade_code}" if grade_code else "")
return {
"standard": f"ASTM {standard}",
"grade": f"ASTM {standard}",
"material_type": determine_material_type(standard, ""),
"grade": full_grade,
"material_type": determine_material_type(standard, grade_code),
"manufacturing": standard_data.get("manufacturing", "UNKNOWN"),
"confidence": 0.9,
"evidence": [f"ASTM_{standard}: Direct Match"]
"evidence": [f"ASTM_{standard}: {grade_code if grade_code else 'Direct Match'}"]
}
# 하위 분류가 있는 경우 (A182, A234 등)
@@ -149,9 +152,27 @@ def check_astm_standard(description: str, standard: str, standard_data: Dict) ->
grade_code = match.group(1) if match.groups() else ""
grade_info = subtype_data["grades"].get(grade_code, {})
# A312의 경우 TP304 형태로 전체 grade 표시
if standard == "A312" and grade_code and not grade_code.startswith("TP"):
full_grade = f"ASTM {standard} TP{grade_code}"
elif grade_code.startswith("TP"):
full_grade = f"ASTM {standard} {grade_code}"
# A403의 경우 WP304 형태로 전체 grade 표시
elif standard == "A403" and grade_code and not grade_code.startswith("WP"):
full_grade = f"ASTM {standard} WP{grade_code}"
elif grade_code.startswith("WP"):
full_grade = f"ASTM {standard} {grade_code}"
# A420의 경우 WPL3 형태로 전체 grade 표시
elif standard == "A420" and grade_code and not grade_code.startswith("WPL"):
full_grade = f"ASTM {standard} WPL{grade_code}"
elif grade_code.startswith("WPL"):
full_grade = f"ASTM {standard} {grade_code}"
else:
full_grade = f"ASTM {standard} {grade_code}" if grade_code else f"ASTM {standard}"
return {
"standard": f"ASTM {standard}",
"grade": f"ASTM {standard} {grade_code}",
"grade": full_grade,
"material_type": determine_material_type(standard, grade_code),
"manufacturing": subtype_data.get("manufacturing", "UNKNOWN"),
"composition": grade_info.get("composition", ""),
@@ -275,7 +296,7 @@ def get_manufacturing_method_from_material(material_result: Dict) -> str:
# 직접 매핑
if 'A182' in material_standard or 'A105' in material_standard:
return 'FORGED'
elif 'A234' in material_standard or 'A403' in material_standard:
elif 'A234' in material_standard or 'A403' in material_standard or 'A420' in material_standard:
return 'WELDED_FABRICATED'
elif 'A216' in material_standard or 'A351' in material_standard:
return 'CAST'

View File

@@ -165,7 +165,11 @@ MATERIAL_STANDARDS = {
"patterns": [
r"ASTM\s+A403\s+(?:GR\s*)?WP\s*(\d{3}[LH]*)",
r"A403\s+(?:GR\s*)?WP\s*(\d{3}[LH]*)",
r"ASME\s+SA403\s+(?:GR\s*)?WP\s*(\d{3}[LH]*)"
r"ASME\s+SA403\s+(?:GR\s*)?WP\s*(\d{3}[LH]*)",
r"ASTM\s+A403\s+(WP\d{3}[LH]*)",
r"A403\s+(WP\d{3}[LH]*)",
r"(WP\d{3}[LH]*)\s+A403",
r"(WP\d{3}[LH]*)"
],
"grades": {
"WP304": {
@@ -191,6 +195,37 @@ MATERIAL_STANDARDS = {
},
"manufacturing": "WELDED_FABRICATED"
}
},
"A420": {
"low_temp_carbon": {
"patterns": [
r"ASTM\s+A420\s+(?:GR\s*)?WPL\s*(\d+)",
r"A420\s+(?:GR\s*)?WPL\s*(\d+)",
r"ASME\s+SA420\s+(?:GR\s*)?WPL\s*(\d+)",
r"ASTM\s+A420\s+(WPL\d+)",
r"A420\s+(WPL\d+)",
r"(WPL\d+)\s+A420",
r"(WPL\d+)"
],
"grades": {
"WPL1": {
"composition": "탄소강",
"temp_min": "-29°C",
"applications": "저온용 피팅"
},
"WPL3": {
"composition": "3.5Ni",
"temp_min": "-46°C",
"applications": "저온용 피팅"
},
"WPL6": {
"composition": "탄소강",
"temp_min": "-46°C",
"applications": "저온용 피팅"
}
},
"manufacturing": "WELDED_FABRICATED"
}
}
},
@@ -289,7 +324,11 @@ MATERIAL_STANDARDS = {
"A312": {
"patterns": [
r"ASTM\s+A312\s+TP\s*(\d{3}[LH]*)",
r"A312\s+TP\s*(\d{3}[LH]*)"
r"A312\s+TP\s*(\d{3}[LH]*)",
r"ASTM\s+A312\s+(TP\d{3}[LH]*)",
r"A312\s+(TP\d{3}[LH]*)",
r"(TP\d{3}[LH]*)\s+A312",
r"(TP\d{3}[LH]*)"
],
"grades": {
"TP304": {
@@ -310,6 +349,31 @@ MATERIAL_STANDARDS = {
}
},
"manufacturing": "SEAMLESS"
},
"A333": {
"patterns": [
r"ASTM\s+A333\s+(?:GR\s*)?(\d+)",
r"A333\s+(?:GR\s*)?(\d+)",
r"ASME\s+SA333\s+(?:GR\s*)?(\d+)"
],
"grades": {
"1": {
"composition": "탄소강",
"temp_min": "-29°C",
"applications": "저온용 배관"
},
"3": {
"composition": "3.5Ni",
"temp_min": "-46°C",
"applications": "저온용 배관"
},
"6": {
"composition": "탄소강",
"temp_min": "-46°C",
"applications": "저온용 배관"
}
},
"manufacturing": "SEAMLESS"
}
}
},