feat: 자재 리비전 비교 및 구매 목록 시스템 구현
- 자재 리비전간 비교 기능 추가 (MaterialComparisonPage) - 버그 해결 필요 - 리비전간 추가 구매 필요 자재 분석 페이지 추가 (RevisionPurchasePage) - 자재 비교 결과 컴포넌트 구현 (MaterialComparisonResult) - 자재 비교 API 라우터 추가 (material_comparison.py) - 로직 개선 필요 - 자재 비교 시스템 데이터베이스 스키마 추가 - FileManager, FileUpload 컴포넌트 개선 - BOMManagerPage 제거 및 새로운 구조로 리팩토링 - 자재 분류기 및 스키마 개선 TODO: 자재 비교 알고리즘 정확도 향상 및 예외 처리 강화 필요
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
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"]:
|
||||
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'
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user