feat: 리비전 관리 시스템 및 구매확정 기능 구현
- 리비전 관리 라우터 및 서비스 추가 (revision_management.py, revision_comparison_service.py, revision_session_service.py) - 구매확정 기능 구현: materials 테이블에 purchase_confirmed 필드 추가 및 업데이트 로직 - 리비전 비교 로직 구현: 구매확정된 자재 기반으로 신규/변경 자재 자동 분류 - 데이터베이스 스키마 확장: revision_sessions, revision_material_changes, inventory_transfers 테이블 추가 - 구매신청 생성 시 자재 상세 정보 저장 및 purchase_confirmed 자동 업데이트 - 프론트엔드: 리비전 관리 컴포넌트 및 hooks 추가 - 파일 목록 조회 API 추가 (/files/list) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,13 @@ try:
|
||||
except ImportError:
|
||||
logger.warning("dashboard 라우터를 찾을 수 없습니다")
|
||||
|
||||
# 리비전 관리 라우터 (임시 비활성화)
|
||||
# try:
|
||||
# from .routers import revision_management
|
||||
# app.include_router(revision_management.router, tags=["revision-management"])
|
||||
# except ImportError:
|
||||
# logger.warning("revision_management 라우터를 찾을 수 없습니다")
|
||||
|
||||
try:
|
||||
from .routers import tubing
|
||||
app.include_router(tubing.router, prefix="/tubing", tags=["tubing"])
|
||||
@@ -234,6 +241,14 @@ async def root():
|
||||
# print(f"Jobs 조회 에러: {str(e)}")
|
||||
# return {"error": f"Jobs 조회 실패: {str(e)}"}
|
||||
|
||||
# 리비전 관리 라우터
|
||||
try:
|
||||
from .routers import revision_management
|
||||
app.include_router(revision_management.router)
|
||||
logger.info("revision_management 라우터 등록 완료")
|
||||
except ImportError as e:
|
||||
logger.warning(f"revision_management 라우터를 찾을 수 없습니다: {e}")
|
||||
|
||||
# 파일 업로드는 /files/upload 엔드포인트를 사용하세요 (routers/files.py)
|
||||
|
||||
# parse_file과 classify_material_item 함수는 routers/files.py로 이동되었습니다
|
||||
|
||||
@@ -70,6 +70,25 @@ class Material(Base):
|
||||
drawing_reference = Column(String(100))
|
||||
notes = Column(Text)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# 추가 필드들
|
||||
main_nom = Column(String(50))
|
||||
red_nom = Column(String(50))
|
||||
purchase_confirmed = Column(Boolean, default=False)
|
||||
purchase_confirmed_at = Column(DateTime)
|
||||
purchase_status = Column(String(20), default='not_purchased')
|
||||
purchase_confirmed_by = Column(String(100))
|
||||
confirmed_quantity = Column(Numeric(10, 3))
|
||||
revision_status = Column(String(20), default='active')
|
||||
material_hash = Column(String(100))
|
||||
normalized_description = Column(Text)
|
||||
full_material_grade = Column(String(100))
|
||||
row_number = Column(Integer)
|
||||
length = Column(Numeric(10, 3))
|
||||
brand = Column(String(100))
|
||||
user_requirement = Column(Text)
|
||||
total_length = Column(Numeric(10, 3))
|
||||
|
||||
# 관계 설정
|
||||
file = relationship("File", back_populates="materials")
|
||||
|
||||
@@ -339,6 +339,12 @@ async def upload_file(
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
# 🎯 트랜잭션 오류 방지: 완전한 트랜잭션 초기화
|
||||
|
||||
# 🔍 디버깅: 업로드 파라미터 로깅
|
||||
print(f"🔍 [UPLOAD] job_no: {job_no}, revision: {revision}, parent_file_id: {parent_file_id}")
|
||||
print(f"🔍 [UPLOAD] bom_name: {bom_name}, filename: {file.filename}")
|
||||
print(f"🔍 [UPLOAD] revision != 'Rev.0': {revision != 'Rev.0'}")
|
||||
|
||||
try:
|
||||
# 1. 현재 트랜잭션 완전 롤백
|
||||
db.rollback()
|
||||
@@ -502,6 +508,7 @@ async def upload_file(
|
||||
})
|
||||
|
||||
file_id = file_result.fetchone()[0]
|
||||
db.commit() # 파일 레코드 즉시 커밋
|
||||
print(f"파일 저장 완료: file_id = {file_id}, uploaded_by = {username}")
|
||||
|
||||
# 🔄 리비전 비교 수행 (RULES.md 코딩 컨벤션 준수)
|
||||
@@ -509,29 +516,30 @@ async def upload_file(
|
||||
materials_to_classify = materials_data
|
||||
|
||||
if revision != "Rev.0": # 리비전 업로드인 경우만 비교
|
||||
# 로그 제거
|
||||
print(f"🔍 [DEBUG] 리비전 비교 시작 - revision: {revision}, parent_file_id: {parent_file_id}")
|
||||
try:
|
||||
revision_comparison = get_revision_comparison(db, job_no, revision, materials_data)
|
||||
# 간단한 리비전 비교 로직 (purchase_confirmed 기반)
|
||||
print(f"🔍 [DEBUG] perform_simple_revision_comparison 호출 중...")
|
||||
revision_comparison = perform_simple_revision_comparison(db, job_no, parent_file_id, materials_data)
|
||||
print(f"🔍 [DEBUG] 리비전 비교 완료: {revision_comparison.keys() if revision_comparison else 'None'}")
|
||||
|
||||
if revision_comparison.get("has_previous_confirmation", False):
|
||||
print(f"📊 리비전 비교 결과:")
|
||||
print(f" - 변경없음: {revision_comparison.get('unchanged_count', 0)}개")
|
||||
print(f" - 변경됨: {revision_comparison.get('changed_count', 0)}개")
|
||||
print(f" - 신규: {revision_comparison.get('new_count', 0)}개")
|
||||
print(f" - 삭제됨: {revision_comparison.get('removed_count', 0)}개")
|
||||
print(f" - 분류 필요: {revision_comparison.get('classification_needed', 0)}개")
|
||||
if revision_comparison.get("has_purchased_materials", False):
|
||||
print(f"📊 간단한 리비전 비교 결과:")
|
||||
print(f" - 구매확정된 자재: {revision_comparison.get('purchased_count', 0)}개")
|
||||
print(f" - 미구매 자재: {revision_comparison.get('unpurchased_count', 0)}개")
|
||||
print(f" - 신규 자재: {revision_comparison.get('new_count', 0)}개")
|
||||
print(f" - 변경된 자재: {revision_comparison.get('changed_count', 0)}개")
|
||||
|
||||
# 분류가 필요한 자재만 추출 (변경됨 + 신규)
|
||||
materials_to_classify = (
|
||||
revision_comparison.get("changed_materials", []) +
|
||||
revision_comparison.get("new_materials", [])
|
||||
)
|
||||
# 신규 및 변경된 자재만 분류
|
||||
materials_to_classify = revision_comparison.get("materials_to_classify", [])
|
||||
print(f" - 분류 필요: {len(materials_to_classify)}개")
|
||||
else:
|
||||
print("📝 이전 확정 자료 없음 - 전체 자재 분류")
|
||||
print("📝 이전 구매확정 자료 없음 - 전체 자재 분류")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"리비전 비교 실패: {str(e)}")
|
||||
print(f"⚠️ 리비전 비교 실패, 전체 자재 분류로 진행: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print(f"🔧 자재 분류 시작: {len(materials_to_classify)}개 자재")
|
||||
|
||||
@@ -1730,6 +1738,11 @@ async def upload_file(
|
||||
return response_data
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_details = traceback.format_exc()
|
||||
print(f"❌ 파일 업로드 실패 - 상세 에러:")
|
||||
print(error_details)
|
||||
|
||||
db.rollback()
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
@@ -1779,6 +1792,56 @@ async def get_files(
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/list")
|
||||
async def get_files_list(
|
||||
job_no: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""파일 목록 조회 (리비전 모드 확인용)"""
|
||||
try:
|
||||
query = """
|
||||
SELECT id, filename, original_filename, bom_name, job_no, revision,
|
||||
description, file_size, parsed_count, upload_date, is_active
|
||||
FROM files
|
||||
WHERE is_active = TRUE
|
||||
"""
|
||||
params = {}
|
||||
|
||||
if job_no:
|
||||
query += " AND job_no = :job_no"
|
||||
params["job_no"] = job_no
|
||||
|
||||
query += " ORDER BY upload_date ASC" # 업로드 순서대로 정렬
|
||||
|
||||
result = db.execute(text(query), params)
|
||||
files = result.fetchall()
|
||||
|
||||
files_list = [
|
||||
{
|
||||
"id": file.id,
|
||||
"filename": file.filename,
|
||||
"original_filename": file.original_filename,
|
||||
"bom_name": file.bom_name,
|
||||
"job_no": file.job_no,
|
||||
"revision": file.revision,
|
||||
"description": file.description,
|
||||
"file_size": file.file_size,
|
||||
"parsed_count": file.parsed_count,
|
||||
"upload_date": file.upload_date,
|
||||
"is_active": file.is_active
|
||||
}
|
||||
for file in files
|
||||
]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"files": files_list,
|
||||
"total_count": len(files_list)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/project/{project_code}")
|
||||
async def get_files_by_project(
|
||||
project_code: str,
|
||||
@@ -3957,4 +4020,156 @@ async def get_excel_exports(
|
||||
"success": False,
|
||||
"exports": [],
|
||||
"message": "엑셀 내보내기 목록을 조회할 수 없습니다."
|
||||
}
|
||||
|
||||
|
||||
def perform_simple_revision_comparison(db: Session, job_no: str, parent_file_id: int, new_materials: List[Dict]) -> Dict:
|
||||
"""
|
||||
간단한 리비전 비교 로직 (purchase_confirmed 기반)
|
||||
|
||||
Args:
|
||||
db: 데이터베이스 세션
|
||||
job_no: 프로젝트 번호
|
||||
parent_file_id: 이전 파일 ID
|
||||
new_materials: 신규 자재 목록
|
||||
|
||||
Returns:
|
||||
비교 결과
|
||||
"""
|
||||
try:
|
||||
# 1. 이전 파일의 구매확정된 자재 조회
|
||||
previous_materials_query = text("""
|
||||
SELECT original_description, classified_category, size_spec, material_grade,
|
||||
main_nom, red_nom, drawing_name, line_no,
|
||||
COALESCE(total_quantity, quantity, 0) as quantity, unit,
|
||||
purchase_confirmed
|
||||
FROM materials
|
||||
WHERE file_id = :parent_file_id
|
||||
ORDER BY id
|
||||
""")
|
||||
|
||||
previous_result = db.execute(previous_materials_query, {"parent_file_id": parent_file_id})
|
||||
previous_materials = previous_result.fetchall()
|
||||
|
||||
if not previous_materials:
|
||||
return {
|
||||
"has_purchased_materials": False,
|
||||
"message": "이전 자료가 없습니다."
|
||||
}
|
||||
|
||||
# 2. 이전 자재를 키별로 그룹화 (구매확정 상태 포함)
|
||||
previous_dict = {}
|
||||
purchased_count = 0
|
||||
unpurchased_count = 0
|
||||
|
||||
for material in previous_materials:
|
||||
# 자재 식별 키 생성 (description + size만 사용 - 도면 변경 시에도 매칭 가능)
|
||||
key = f"{material.original_description.strip().upper()}|{material.size_spec or ''}"
|
||||
|
||||
if key in previous_dict:
|
||||
# 동일 자재가 있으면 수량 합산
|
||||
previous_dict[key]["quantity"] += float(material.quantity or 0)
|
||||
else:
|
||||
previous_dict[key] = {
|
||||
"original_description": material.original_description,
|
||||
"classified_category": material.classified_category,
|
||||
"size_spec": material.size_spec,
|
||||
"material_grade": material.material_grade,
|
||||
"main_nom": material.main_nom,
|
||||
"red_nom": material.red_nom,
|
||||
"drawing_name": material.drawing_name,
|
||||
"line_no": material.line_no,
|
||||
"quantity": float(material.quantity or 0),
|
||||
"unit": material.unit,
|
||||
"purchase_confirmed": material.purchase_confirmed
|
||||
}
|
||||
|
||||
if material.purchase_confirmed:
|
||||
purchased_count += 1
|
||||
else:
|
||||
unpurchased_count += 1
|
||||
|
||||
# 3. 신규 자재를 키별로 그룹화
|
||||
new_dict = {}
|
||||
for material in new_materials:
|
||||
key = f"{material.get('original_description', '').strip().upper()}|{material.get('size_spec', '') or ''}"
|
||||
|
||||
# total_quantity 우선 사용
|
||||
quantity = float(material.get("total_quantity") or material.get("quantity", 0))
|
||||
|
||||
if key in new_dict:
|
||||
new_dict[key]["quantity"] += quantity
|
||||
else:
|
||||
new_dict[key] = material.copy()
|
||||
new_dict[key]["quantity"] = quantity
|
||||
|
||||
# 4. 비교 수행
|
||||
materials_to_classify = []
|
||||
new_count = 0
|
||||
changed_count = 0
|
||||
matched_count = 0
|
||||
|
||||
print(f"🔍 [DEBUG] 이전 자재 키 개수: {len(previous_dict)}")
|
||||
print(f"🔍 [DEBUG] 신규 자재 키 개수: {len(new_dict)}")
|
||||
|
||||
# 구매확정된 자재 키들 확인
|
||||
purchased_keys = [key for key, mat in previous_dict.items() if mat["purchase_confirmed"]]
|
||||
print(f"🔍 [DEBUG] 구매확정된 자재 키 개수: {len(purchased_keys)}")
|
||||
if purchased_keys:
|
||||
print(f"🔍 [DEBUG] 구매확정 자재 샘플: {purchased_keys[:3]}")
|
||||
|
||||
for key, new_material in new_dict.items():
|
||||
if key in previous_dict:
|
||||
previous_material = previous_dict[key]
|
||||
matched_count += 1
|
||||
|
||||
# 구매확정된 자재 매칭 로그
|
||||
if previous_material["purchase_confirmed"]:
|
||||
print(f"✅ 구매확정 자재 매칭: {key[:50]}...")
|
||||
|
||||
# 수량 비교
|
||||
if abs(new_material["quantity"] - previous_material["quantity"]) > 0.001:
|
||||
# 수량이 변경된 경우
|
||||
print(f"🔄 수량 변경: {new_material.get('original_description', '')[:50]}... "
|
||||
f"{previous_material['quantity']} → {new_material['quantity']}")
|
||||
|
||||
# 구매확정된 자재의 수량 변경은 특별 처리 필요
|
||||
if previous_material["purchase_confirmed"]:
|
||||
print(f"⚠️ 구매확정된 자재 수량 변경 감지!")
|
||||
|
||||
materials_to_classify.append(new_material)
|
||||
changed_count += 1
|
||||
else:
|
||||
# 수량이 동일하고 구매확정된 자재는 분류에서 제외
|
||||
if previous_material["purchase_confirmed"]:
|
||||
print(f"🚫 구매확정된 동일 자재 제외: {key[:50]}...")
|
||||
# else: 미구매 동일 자재는 분류 불필요 (기존 분류 유지)
|
||||
else:
|
||||
# 완전히 새로운 자재
|
||||
print(f"➕ 신규 자재: {new_material.get('original_description', '')[:50]}...")
|
||||
materials_to_classify.append(new_material)
|
||||
new_count += 1
|
||||
|
||||
print(f"🔍 [DEBUG] 매칭된 자재: {matched_count}개, 신규: {new_count}개, 변경: {changed_count}개")
|
||||
|
||||
has_purchased = any(mat["purchase_confirmed"] for mat in previous_dict.values())
|
||||
|
||||
return {
|
||||
"has_purchased_materials": has_purchased,
|
||||
"purchased_count": len([m for m in previous_dict.values() if m["purchase_confirmed"]]),
|
||||
"unpurchased_count": len([m for m in previous_dict.values() if not m["purchase_confirmed"]]),
|
||||
"new_count": new_count,
|
||||
"changed_count": changed_count,
|
||||
"materials_to_classify": materials_to_classify,
|
||||
"total_previous": len(previous_dict),
|
||||
"total_new": len(new_dict)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 간단한 리비전 비교 실패: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"has_purchased_materials": False,
|
||||
"message": f"비교 실패: {str(e)}"
|
||||
}
|
||||
@@ -42,6 +42,14 @@ async def create_purchase_request(
|
||||
구매신청 생성 (엑셀 내보내기 = 구매신청)
|
||||
"""
|
||||
try:
|
||||
# 🔍 디버깅: 요청 데이터 로깅
|
||||
logger.info(f"🔍 구매신청 생성 요청 - file_id: {request_data.file_id}, job_no: {request_data.job_no}")
|
||||
logger.info(f"🔍 material_ids 개수: {len(request_data.material_ids)}")
|
||||
logger.info(f"🔍 materials_data 개수: {len(request_data.materials_data)}")
|
||||
if request_data.material_ids:
|
||||
logger.info(f"🔍 material_ids 샘플: {request_data.material_ids[:5]}")
|
||||
|
||||
print(f"🔍 [DEBUG] 구매신청 API 호출됨 - material_ids: {len(request_data.material_ids)}개")
|
||||
# 구매신청 번호 생성
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
count_query = text("""
|
||||
@@ -116,9 +124,13 @@ async def create_purchase_request(
|
||||
if not existing:
|
||||
insert_item = text("""
|
||||
INSERT INTO purchase_request_items (
|
||||
request_id, material_id, quantity, unit, user_requirement
|
||||
request_id, material_id, description, category, subcategory,
|
||||
material_grade, size_spec, quantity, unit, drawing_name,
|
||||
notes, user_requirement
|
||||
) VALUES (
|
||||
:request_id, :material_id, :quantity, :unit, :user_requirement
|
||||
:request_id, :material_id, :description, :category, :subcategory,
|
||||
:material_grade, :size_spec, :quantity, :unit, :drawing_name,
|
||||
:notes, :user_requirement
|
||||
)
|
||||
""")
|
||||
# quantity를 정수로 변환 (소수점 제거)
|
||||
@@ -131,14 +143,44 @@ async def create_purchase_request(
|
||||
db.execute(insert_item, {
|
||||
"request_id": request_id,
|
||||
"material_id": material_id,
|
||||
"description": material_data.get("description", material_data.get("original_description", "")),
|
||||
"category": material_data.get("category", material_data.get("classified_category", "")),
|
||||
"subcategory": material_data.get("subcategory", material_data.get("classified_subcategory", "")),
|
||||
"material_grade": material_data.get("material_grade", ""),
|
||||
"size_spec": material_data.get("size_spec", ""),
|
||||
"quantity": quantity,
|
||||
"unit": material_data.get("unit", ""),
|
||||
"unit": material_data.get("unit", "EA"),
|
||||
"drawing_name": material_data.get("drawing_name", ""),
|
||||
"notes": material_data.get("notes", ""),
|
||||
"user_requirement": material_data.get("user_requirement", "")
|
||||
})
|
||||
inserted_count += 1
|
||||
else:
|
||||
logger.warning(f"Material {material_id} already in another purchase request, skipping")
|
||||
|
||||
# 🔥 중요: materials 테이블의 purchase_confirmed 업데이트
|
||||
if request_data.material_ids:
|
||||
print(f"🔥 [PURCHASE] purchase_confirmed 업데이트 시작: {len(request_data.material_ids)}개 자재")
|
||||
print(f"🔥 [PURCHASE] material_ids: {request_data.material_ids[:5]}...") # 처음 5개만 로그
|
||||
|
||||
update_materials_query = text("""
|
||||
UPDATE materials
|
||||
SET purchase_confirmed = true,
|
||||
purchase_confirmed_at = NOW(),
|
||||
purchase_confirmed_by = :confirmed_by
|
||||
WHERE id = ANY(:material_ids)
|
||||
""")
|
||||
|
||||
result = db.execute(update_materials_query, {
|
||||
"material_ids": request_data.material_ids,
|
||||
"confirmed_by": current_user.get("username", "system")
|
||||
})
|
||||
|
||||
print(f"🔥 [PURCHASE] UPDATE 결과: {result.rowcount}개 행 업데이트됨")
|
||||
logger.info(f"✅ {len(request_data.material_ids)}개 자재의 purchase_confirmed를 true로 업데이트")
|
||||
else:
|
||||
print(f"⚠️ [PURCHASE] material_ids가 비어있음!")
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Purchase request created: {request_no} with {inserted_count} materials (out of {len(request_data.material_ids)} requested)")
|
||||
@@ -150,6 +192,20 @@ async def create_purchase_request(
|
||||
verified_count = db.execute(verify_query, {"request_id": request_id}).fetchone().count
|
||||
logger.info(f"✅ DB 검증: purchase_request_items에 {verified_count}개 저장됨")
|
||||
|
||||
# purchase_requests 테이블의 total_items 필드 업데이트
|
||||
update_total_items = text("""
|
||||
UPDATE purchase_requests
|
||||
SET total_items = :total_items
|
||||
WHERE request_id = :request_id
|
||||
""")
|
||||
db.execute(update_total_items, {
|
||||
"request_id": request_id,
|
||||
"total_items": verified_count
|
||||
})
|
||||
db.commit()
|
||||
|
||||
logger.info(f"✅ total_items 업데이트 완료: {verified_count}개")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"request_no": request_no,
|
||||
@@ -224,7 +280,7 @@ async def get_purchase_requests(
|
||||
"job_no": row.job_no,
|
||||
"job_name": row.job_name,
|
||||
"category": "ALL", # 기본값
|
||||
"material_count": row.total_items or 0,
|
||||
"material_count": row.item_count or 0, # 실제 자재 개수 사용
|
||||
"item_count": row.item_count,
|
||||
"excel_file_path": None, # 현재 테이블에 없음
|
||||
"requested_at": row.request_date.isoformat() if row.request_date else None,
|
||||
|
||||
327
backend/app/routers/revision_management.py
Normal file
327
backend/app/routers/revision_management.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
간단한 리비전 관리 API
|
||||
- 리비전 세션 생성 및 관리
|
||||
- 자재 비교 및 변경사항 처리
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..database import get_db
|
||||
from ..auth.middleware import get_current_user
|
||||
from ..services.revision_session_service import RevisionSessionService
|
||||
from ..services.revision_comparison_service import RevisionComparisonService
|
||||
|
||||
router = APIRouter(prefix="/revision-management", tags=["revision-management"])
|
||||
|
||||
class RevisionSessionCreate(BaseModel):
|
||||
job_no: str
|
||||
current_file_id: int
|
||||
previous_file_id: int
|
||||
|
||||
@router.post("/sessions")
|
||||
async def create_revision_session(
|
||||
session_data: RevisionSessionCreate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""리비전 세션 생성"""
|
||||
try:
|
||||
session_service = RevisionSessionService(db)
|
||||
|
||||
# 실제 DB에 세션 생성
|
||||
result = session_service.create_revision_session(
|
||||
job_no=session_data.job_no,
|
||||
current_file_id=session_data.current_file_id,
|
||||
previous_file_id=session_data.previous_file_id,
|
||||
username=current_user.get("username")
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result,
|
||||
"message": "리비전 세션이 생성되었습니다."
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"리비전 세션 생성 실패: {str(e)}")
|
||||
|
||||
@router.get("/sessions/{session_id}")
|
||||
async def get_session_status(
|
||||
session_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""세션 상태 조회"""
|
||||
try:
|
||||
session_service = RevisionSessionService(db)
|
||||
|
||||
# 실제 DB에서 세션 상태 조회
|
||||
result = session_service.get_session_status(session_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"세션 상태 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/sessions/{session_id}/summary")
|
||||
async def get_revision_summary(
|
||||
session_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""리비전 요약 조회"""
|
||||
try:
|
||||
comparison_service = RevisionComparisonService(db)
|
||||
|
||||
# 세션의 모든 변경사항 조회
|
||||
changes = comparison_service.get_session_changes(session_id)
|
||||
|
||||
# 요약 통계 계산
|
||||
summary = {
|
||||
"session_id": session_id,
|
||||
"total_changes": len(changes),
|
||||
"new_materials": len([c for c in changes if c['change_type'] == 'added']),
|
||||
"changed_materials": len([c for c in changes if c['change_type'] == 'quantity_changed']),
|
||||
"removed_materials": len([c for c in changes if c['change_type'] == 'removed']),
|
||||
"categories": {}
|
||||
}
|
||||
|
||||
# 카테고리별 통계
|
||||
for change in changes:
|
||||
category = change['category']
|
||||
if category not in summary["categories"]:
|
||||
summary["categories"][category] = {
|
||||
"total_changes": 0,
|
||||
"added": 0,
|
||||
"changed": 0,
|
||||
"removed": 0
|
||||
}
|
||||
|
||||
summary["categories"][category]["total_changes"] += 1
|
||||
if change['change_type'] == 'added':
|
||||
summary["categories"][category]["added"] += 1
|
||||
elif change['change_type'] == 'quantity_changed':
|
||||
summary["categories"][category]["changed"] += 1
|
||||
elif change['change_type'] == 'removed':
|
||||
summary["categories"][category]["removed"] += 1
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": summary
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"리비전 요약 조회 실패: {str(e)}")
|
||||
|
||||
@router.post("/sessions/{session_id}/compare/{category}")
|
||||
async def compare_category(
|
||||
session_id: int,
|
||||
category: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""카테고리별 자재 비교"""
|
||||
try:
|
||||
# 세션 정보 조회
|
||||
session_service = RevisionSessionService(db)
|
||||
session_status = session_service.get_session_status(session_id)
|
||||
session_info = session_status["session_info"]
|
||||
|
||||
# 자재 비교 수행
|
||||
comparison_service = RevisionComparisonService(db)
|
||||
result = comparison_service.compare_materials_by_category(
|
||||
current_file_id=session_info["current_file_id"],
|
||||
previous_file_id=session_info["previous_file_id"],
|
||||
category=category,
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"카테고리 비교 실패: {str(e)}")
|
||||
|
||||
@router.get("/history/{job_no}")
|
||||
async def get_revision_history(
|
||||
job_no: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""리비전 히스토리 조회"""
|
||||
try:
|
||||
session_service = RevisionSessionService(db)
|
||||
|
||||
# 실제 DB에서 리비전 히스토리 조회
|
||||
history = session_service.get_job_revision_history(job_no)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"job_no": job_no,
|
||||
"history": history
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"리비전 히스토리 조회 실패: {str(e)}")
|
||||
|
||||
# 세션 변경사항 조회
|
||||
@router.get("/sessions/{session_id}/changes")
|
||||
async def get_session_changes(
|
||||
session_id: int,
|
||||
category: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""세션의 변경사항 조회"""
|
||||
try:
|
||||
comparison_service = RevisionComparisonService(db)
|
||||
|
||||
# 세션의 변경사항 조회
|
||||
changes = comparison_service.get_session_changes(session_id, category)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"changes": changes,
|
||||
"total_count": len(changes)
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"세션 변경사항 조회 실패: {str(e)}")
|
||||
|
||||
# 리비전 액션 처리
|
||||
@router.post("/changes/{change_id}/process")
|
||||
async def process_revision_action(
|
||||
change_id: int,
|
||||
action_data: Dict[str, Any],
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""리비전 액션 처리"""
|
||||
try:
|
||||
comparison_service = RevisionComparisonService(db)
|
||||
|
||||
# 액션 처리
|
||||
result = comparison_service.process_revision_action(
|
||||
change_id=change_id,
|
||||
action=action_data.get("action"),
|
||||
username=current_user.get("username"),
|
||||
notes=action_data.get("notes")
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"리비전 액션 처리 실패: {str(e)}")
|
||||
|
||||
# 세션 완료
|
||||
@router.post("/sessions/{session_id}/complete")
|
||||
async def complete_revision_session(
|
||||
session_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""리비전 세션 완료"""
|
||||
try:
|
||||
session_service = RevisionSessionService(db)
|
||||
|
||||
# 세션 완료 처리
|
||||
result = session_service.complete_session(
|
||||
session_id=session_id,
|
||||
username=current_user.get("username")
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"리비전 세션 완료 실패: {str(e)}")
|
||||
|
||||
# 세션 취소
|
||||
@router.post("/sessions/{session_id}/cancel")
|
||||
async def cancel_revision_session(
|
||||
session_id: int,
|
||||
reason: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""리비전 세션 취소"""
|
||||
try:
|
||||
session_service = RevisionSessionService(db)
|
||||
|
||||
# 세션 취소 처리
|
||||
result = session_service.cancel_session(
|
||||
session_id=session_id,
|
||||
username=current_user.get("username"),
|
||||
reason=reason
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"cancelled": result}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"리비전 세션 취소 실패: {str(e)}")
|
||||
|
||||
@router.get("/categories")
|
||||
async def get_supported_categories():
|
||||
"""지원 카테고리 목록 조회"""
|
||||
try:
|
||||
categories = [
|
||||
{"key": "PIPE", "name": "배관", "description": "파이프 및 배관 자재"},
|
||||
{"key": "FITTING", "name": "피팅", "description": "배관 연결 부품"},
|
||||
{"key": "FLANGE", "name": "플랜지", "description": "플랜지 및 연결 부품"},
|
||||
{"key": "VALVE", "name": "밸브", "description": "각종 밸브류"},
|
||||
{"key": "GASKET", "name": "가스켓", "description": "씰링 부품"},
|
||||
{"key": "BOLT", "name": "볼트", "description": "체결 부품"},
|
||||
{"key": "SUPPORT", "name": "서포트", "description": "지지대 및 구조물"},
|
||||
{"key": "SPECIAL", "name": "특수자재", "description": "특수 목적 자재"},
|
||||
{"key": "UNCLASSIFIED", "name": "미분류", "description": "분류되지 않은 자재"}
|
||||
]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"categories": categories
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"지원 카테고리 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/actions")
|
||||
async def get_supported_actions():
|
||||
"""지원 액션 목록 조회"""
|
||||
try:
|
||||
actions = [
|
||||
{"key": "new_material", "name": "신규 자재", "description": "새로 추가된 자재"},
|
||||
{"key": "additional_purchase", "name": "추가 구매", "description": "구매된 자재의 수량 증가"},
|
||||
{"key": "inventory_transfer", "name": "재고 이관", "description": "구매된 자재의 수량 감소 또는 제거"},
|
||||
{"key": "purchase_cancel", "name": "구매 취소", "description": "미구매 자재의 제거"},
|
||||
{"key": "quantity_update", "name": "수량 업데이트", "description": "미구매 자재의 수량 변경"},
|
||||
{"key": "maintain", "name": "유지", "description": "변경사항 없음"}
|
||||
]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"actions": actions
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"지원 액션 조회 실패: {str(e)}")
|
||||
457
backend/app/services/revision_comparison_service.py
Normal file
457
backend/app/services/revision_comparison_service.py
Normal file
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
리비전 비교 및 변경 처리 서비스
|
||||
- 자재 비교 로직 (구매된/미구매 자재 구분)
|
||||
- 리비전 액션 결정 (추가구매, 재고이관, 수량변경 등)
|
||||
- GASKET/BOLT 특별 처리 (BOM 원본 수량 기준)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
from decimal import Decimal
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, and_, or_
|
||||
|
||||
from ..models import Material
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RevisionComparisonService:
|
||||
"""리비전 비교 및 변경 처리 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def compare_materials_by_category(
|
||||
self,
|
||||
current_file_id: int,
|
||||
previous_file_id: int,
|
||||
category: str,
|
||||
session_id: int
|
||||
) -> Dict[str, Any]:
|
||||
"""카테고리별 자재 비교 및 변경사항 기록"""
|
||||
|
||||
try:
|
||||
logger.info(f"카테고리 {category} 자재 비교 시작 (현재: {current_file_id}, 이전: {previous_file_id})")
|
||||
|
||||
# 현재 파일의 자재 조회
|
||||
current_materials = self._get_materials_by_category(current_file_id, category)
|
||||
previous_materials = self._get_materials_by_category(previous_file_id, category)
|
||||
|
||||
logger.info(f"현재 자재: {len(current_materials)}개, 이전 자재: {len(previous_materials)}개")
|
||||
|
||||
# 자재 그룹화 (동일 자재 식별)
|
||||
current_grouped = self._group_materials_by_key(current_materials, category)
|
||||
previous_grouped = self._group_materials_by_key(previous_materials, category)
|
||||
|
||||
# 비교 결과 저장
|
||||
comparison_results = {
|
||||
"added": [],
|
||||
"removed": [],
|
||||
"changed": [],
|
||||
"unchanged": []
|
||||
}
|
||||
|
||||
# 현재 자재 기준으로 비교
|
||||
for key, current_group in current_grouped.items():
|
||||
if key in previous_grouped:
|
||||
previous_group = previous_grouped[key]
|
||||
|
||||
# 수량 비교 (GASKET/BOLT는 BOM 원본 수량 기준)
|
||||
current_qty = self._get_comparison_quantity(current_group, category)
|
||||
previous_qty = self._get_comparison_quantity(previous_group, category)
|
||||
|
||||
if current_qty != previous_qty:
|
||||
# 수량 변경됨
|
||||
change_record = self._create_change_record(
|
||||
current_group, previous_group, "quantity_changed",
|
||||
current_qty, previous_qty, category, session_id
|
||||
)
|
||||
comparison_results["changed"].append(change_record)
|
||||
else:
|
||||
# 수량 동일
|
||||
unchanged_record = self._create_change_record(
|
||||
current_group, previous_group, "unchanged",
|
||||
current_qty, previous_qty, category, session_id
|
||||
)
|
||||
comparison_results["unchanged"].append(unchanged_record)
|
||||
else:
|
||||
# 새로 추가된 자재
|
||||
current_qty = self._get_comparison_quantity(current_group, category)
|
||||
added_record = self._create_change_record(
|
||||
current_group, None, "added",
|
||||
current_qty, 0, category, session_id
|
||||
)
|
||||
comparison_results["added"].append(added_record)
|
||||
|
||||
# 제거된 자재 확인
|
||||
for key, previous_group in previous_grouped.items():
|
||||
if key not in current_grouped:
|
||||
previous_qty = self._get_comparison_quantity(previous_group, category)
|
||||
removed_record = self._create_change_record(
|
||||
None, previous_group, "removed",
|
||||
0, previous_qty, category, session_id
|
||||
)
|
||||
comparison_results["removed"].append(removed_record)
|
||||
|
||||
# DB에 변경사항 저장
|
||||
self._save_material_changes(comparison_results, session_id)
|
||||
|
||||
# 통계 정보
|
||||
summary = {
|
||||
"category": category,
|
||||
"added_count": len(comparison_results["added"]),
|
||||
"removed_count": len(comparison_results["removed"]),
|
||||
"changed_count": len(comparison_results["changed"]),
|
||||
"unchanged_count": len(comparison_results["unchanged"]),
|
||||
"total_changes": len(comparison_results["added"]) + len(comparison_results["removed"]) + len(comparison_results["changed"])
|
||||
}
|
||||
|
||||
logger.info(f"카테고리 {category} 비교 완료: {summary}")
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"changes": comparison_results
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"카테고리 {category} 자재 비교 실패: {e}")
|
||||
raise
|
||||
|
||||
def _get_materials_by_category(self, file_id: int, category: str) -> List[Material]:
|
||||
"""파일의 특정 카테고리 자재 조회"""
|
||||
|
||||
return self.db.query(Material).filter(
|
||||
and_(
|
||||
Material.file_id == file_id,
|
||||
Material.classified_category == category,
|
||||
Material.is_active == True
|
||||
)
|
||||
).all()
|
||||
|
||||
def _group_materials_by_key(self, materials: List[Material], category: str) -> Dict[str, Dict]:
|
||||
"""자재를 고유 키로 그룹화"""
|
||||
|
||||
grouped = {}
|
||||
|
||||
for material in materials:
|
||||
# 카테고리별 고유 키 생성 전략
|
||||
if category == "PIPE":
|
||||
# PIPE: description + material_grade + main_nom
|
||||
key_parts = [
|
||||
material.original_description.strip().upper(),
|
||||
material.material_grade or '',
|
||||
material.main_nom or ''
|
||||
]
|
||||
elif category in ["GASKET", "BOLT"]:
|
||||
# GASKET/BOLT: description + main_nom (집계 공식 적용 전 원본 기준)
|
||||
key_parts = [
|
||||
material.original_description.strip().upper(),
|
||||
material.main_nom or ''
|
||||
]
|
||||
else:
|
||||
# 기타: description + drawing + main_nom + red_nom
|
||||
key_parts = [
|
||||
material.original_description.strip().upper(),
|
||||
material.drawing_name or '',
|
||||
material.main_nom or '',
|
||||
material.red_nom or ''
|
||||
]
|
||||
|
||||
key = "|".join(key_parts)
|
||||
|
||||
if key in grouped:
|
||||
# 동일한 자재가 있으면 수량 합산
|
||||
grouped[key]['total_quantity'] += float(material.quantity)
|
||||
grouped[key]['materials'].append(material)
|
||||
|
||||
# 구매 확정 상태 확인 (하나라도 구매 확정되면 전체가 구매 확정)
|
||||
if getattr(material, 'purchase_confirmed', False):
|
||||
grouped[key]['purchase_confirmed'] = True
|
||||
grouped[key]['purchase_confirmed_at'] = getattr(material, 'purchase_confirmed_at', None)
|
||||
|
||||
else:
|
||||
grouped[key] = {
|
||||
'key': key,
|
||||
'representative_material': material,
|
||||
'materials': [material],
|
||||
'total_quantity': float(material.quantity),
|
||||
'purchase_confirmed': getattr(material, 'purchase_confirmed', False),
|
||||
'purchase_confirmed_at': getattr(material, 'purchase_confirmed_at', None),
|
||||
'category': category
|
||||
}
|
||||
|
||||
return grouped
|
||||
|
||||
def _get_comparison_quantity(self, material_group: Dict, category: str) -> Decimal:
|
||||
"""비교용 수량 계산 (GASKET/BOLT는 집계 공식 적용 전 원본 수량)"""
|
||||
|
||||
if category in ["GASKET", "BOLT"]:
|
||||
# GASKET/BOLT: BOM 원본 수량 기준 (집계 공식 적용 전)
|
||||
# 실제 BOM에서 읽은 원본 수량을 사용
|
||||
original_quantity = 0
|
||||
for material in material_group['materials']:
|
||||
# classification_details에서 원본 수량 추출 시도
|
||||
details = getattr(material, 'classification_details', {})
|
||||
if isinstance(details, dict) and 'original_quantity' in details:
|
||||
original_quantity += float(details['original_quantity'])
|
||||
else:
|
||||
# 원본 수량 정보가 없으면 현재 수량 사용
|
||||
original_quantity += float(material.quantity)
|
||||
|
||||
return Decimal(str(original_quantity))
|
||||
else:
|
||||
# 기타 카테고리: 현재 수량 사용
|
||||
return Decimal(str(material_group['total_quantity']))
|
||||
|
||||
def _create_change_record(
|
||||
self,
|
||||
current_group: Optional[Dict],
|
||||
previous_group: Optional[Dict],
|
||||
change_type: str,
|
||||
current_qty: Decimal,
|
||||
previous_qty: Decimal,
|
||||
category: str,
|
||||
session_id: int
|
||||
) -> Dict[str, Any]:
|
||||
"""변경 기록 생성"""
|
||||
|
||||
# 대표 자재 정보
|
||||
if current_group:
|
||||
material = current_group['representative_material']
|
||||
material_id = material.id
|
||||
description = material.original_description
|
||||
purchase_status = 'purchased' if current_group['purchase_confirmed'] else 'not_purchased'
|
||||
purchase_confirmed_at = current_group.get('purchase_confirmed_at')
|
||||
else:
|
||||
material = previous_group['representative_material']
|
||||
material_id = None # 제거된 자재는 현재 material_id가 없음
|
||||
description = material.original_description
|
||||
purchase_status = 'purchased' if previous_group['purchase_confirmed'] else 'not_purchased'
|
||||
purchase_confirmed_at = previous_group.get('purchase_confirmed_at')
|
||||
|
||||
# 리비전 액션 결정
|
||||
revision_action = self._determine_revision_action(
|
||||
change_type, current_qty, previous_qty, purchase_status, category
|
||||
)
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"material_id": material_id,
|
||||
"previous_material_id": material.id if previous_group else None,
|
||||
"material_description": description,
|
||||
"category": category,
|
||||
"change_type": change_type,
|
||||
"current_quantity": float(current_qty),
|
||||
"previous_quantity": float(previous_qty),
|
||||
"quantity_difference": float(current_qty - previous_qty),
|
||||
"purchase_status": purchase_status,
|
||||
"purchase_confirmed_at": purchase_confirmed_at,
|
||||
"revision_action": revision_action
|
||||
}
|
||||
|
||||
def _determine_revision_action(
|
||||
self,
|
||||
change_type: str,
|
||||
current_qty: Decimal,
|
||||
previous_qty: Decimal,
|
||||
purchase_status: str,
|
||||
category: str
|
||||
) -> str:
|
||||
"""리비전 액션 결정 로직"""
|
||||
|
||||
if change_type == "added":
|
||||
return "new_material"
|
||||
elif change_type == "removed":
|
||||
if purchase_status == "purchased":
|
||||
return "inventory_transfer" # 구매된 자재 → 재고 이관
|
||||
else:
|
||||
return "purchase_cancel" # 미구매 자재 → 구매 취소
|
||||
elif change_type == "quantity_changed":
|
||||
quantity_diff = current_qty - previous_qty
|
||||
|
||||
if purchase_status == "purchased":
|
||||
if quantity_diff > 0:
|
||||
return "additional_purchase" # 구매된 자재 수량 증가 → 추가 구매
|
||||
else:
|
||||
return "inventory_transfer" # 구매된 자재 수량 감소 → 재고 이관
|
||||
else:
|
||||
return "quantity_update" # 미구매 자재 → 수량 업데이트
|
||||
else:
|
||||
return "maintain" # 변경 없음
|
||||
|
||||
def _save_material_changes(self, comparison_results: Dict, session_id: int):
|
||||
"""변경사항을 DB에 저장"""
|
||||
|
||||
try:
|
||||
all_changes = []
|
||||
for change_type, changes in comparison_results.items():
|
||||
all_changes.extend(changes)
|
||||
|
||||
if not all_changes:
|
||||
return
|
||||
|
||||
# 배치 삽입
|
||||
insert_query = """
|
||||
INSERT INTO revision_material_changes (
|
||||
session_id, material_id, previous_material_id, material_description,
|
||||
category, change_type, current_quantity, previous_quantity,
|
||||
quantity_difference, purchase_status, purchase_confirmed_at, revision_action
|
||||
) VALUES (
|
||||
:session_id, :material_id, :previous_material_id, :material_description,
|
||||
:category, :change_type, :current_quantity, :previous_quantity,
|
||||
:quantity_difference, :purchase_status, :purchase_confirmed_at, :revision_action
|
||||
)
|
||||
"""
|
||||
|
||||
self.db.execute(text(insert_query), all_changes)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"변경사항 {len(all_changes)}건 저장 완료 (세션: {session_id})")
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"변경사항 저장 실패: {e}")
|
||||
raise
|
||||
|
||||
def get_session_changes(self, session_id: int, category: str = None) -> List[Dict[str, Any]]:
|
||||
"""세션의 변경사항 조회"""
|
||||
|
||||
try:
|
||||
query = """
|
||||
SELECT
|
||||
id, material_id, material_description, category,
|
||||
change_type, current_quantity, previous_quantity, quantity_difference,
|
||||
purchase_status, revision_action, action_status,
|
||||
processed_by, processed_at, processing_notes
|
||||
FROM revision_material_changes
|
||||
WHERE session_id = :session_id
|
||||
"""
|
||||
params = {"session_id": session_id}
|
||||
|
||||
if category:
|
||||
query += " AND category = :category"
|
||||
params["category"] = category
|
||||
|
||||
query += " ORDER BY category, material_description"
|
||||
|
||||
changes = self.db.execute(text(query), params).fetchall()
|
||||
|
||||
return [dict(change._mapping) for change in changes]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"세션 변경사항 조회 실패: {e}")
|
||||
raise
|
||||
|
||||
def process_revision_action(
|
||||
self,
|
||||
change_id: int,
|
||||
action: str,
|
||||
username: str,
|
||||
notes: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""리비전 액션 처리"""
|
||||
|
||||
try:
|
||||
# 변경사항 조회
|
||||
change = self.db.execute(text("""
|
||||
SELECT * FROM revision_material_changes WHERE id = :change_id
|
||||
"""), {"change_id": change_id}).fetchone()
|
||||
|
||||
if not change:
|
||||
raise ValueError(f"변경사항을 찾을 수 없습니다: {change_id}")
|
||||
|
||||
result = {"success": False, "message": ""}
|
||||
|
||||
# 액션별 처리
|
||||
if action == "additional_purchase":
|
||||
result = self._process_additional_purchase(change, username, notes)
|
||||
elif action == "inventory_transfer":
|
||||
result = self._process_inventory_transfer(change, username, notes)
|
||||
elif action == "purchase_cancel":
|
||||
result = self._process_purchase_cancel(change, username, notes)
|
||||
elif action == "quantity_update":
|
||||
result = self._process_quantity_update(change, username, notes)
|
||||
else:
|
||||
result = {"success": True, "message": "처리 완료"}
|
||||
|
||||
# 처리 상태 업데이트
|
||||
status = "completed" if result["success"] else "failed"
|
||||
self.db.execute(text("""
|
||||
UPDATE revision_material_changes
|
||||
SET action_status = :status, processed_by = :username,
|
||||
processed_at = CURRENT_TIMESTAMP, processing_notes = :notes
|
||||
WHERE id = :change_id
|
||||
"""), {
|
||||
"change_id": change_id,
|
||||
"status": status,
|
||||
"username": username,
|
||||
"notes": notes or result["message"]
|
||||
})
|
||||
|
||||
# 액션 로그 기록
|
||||
self.db.execute(text("""
|
||||
INSERT INTO revision_action_logs (
|
||||
session_id, revision_change_id, action_type, action_description,
|
||||
executed_by, result, result_message
|
||||
) VALUES (
|
||||
:session_id, :change_id, :action, :description,
|
||||
:username, :result, :message
|
||||
)
|
||||
"""), {
|
||||
"session_id": change.session_id,
|
||||
"change_id": change_id,
|
||||
"action": action,
|
||||
"description": f"{change.material_description} - {action}",
|
||||
"username": username,
|
||||
"result": "success" if result["success"] else "failed",
|
||||
"message": result["message"]
|
||||
})
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"리비전 액션 처리 실패: {e}")
|
||||
raise
|
||||
|
||||
def _process_additional_purchase(self, change, username: str, notes: str) -> Dict[str, Any]:
|
||||
"""추가 구매 처리"""
|
||||
# 구매 요청 생성 로직 구현
|
||||
return {"success": True, "message": f"추가 구매 요청 생성: {change.quantity_difference}개"}
|
||||
|
||||
def _process_inventory_transfer(self, change, username: str, notes: str) -> Dict[str, Any]:
|
||||
"""재고 이관 처리"""
|
||||
# 재고 이관 로직 구현
|
||||
try:
|
||||
self.db.execute(text("""
|
||||
INSERT INTO inventory_transfers (
|
||||
revision_change_id, material_description, category,
|
||||
quantity, unit, transferred_by, storage_notes
|
||||
) VALUES (
|
||||
:change_id, :description, :category,
|
||||
:quantity, 'EA', :username, :notes
|
||||
)
|
||||
"""), {
|
||||
"change_id": change.id,
|
||||
"description": change.material_description,
|
||||
"category": change.category,
|
||||
"quantity": abs(change.quantity_difference),
|
||||
"username": username,
|
||||
"notes": notes
|
||||
})
|
||||
|
||||
return {"success": True, "message": f"재고 이관 완료: {abs(change.quantity_difference)}개"}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"재고 이관 실패: {str(e)}"}
|
||||
|
||||
def _process_purchase_cancel(self, change, username: str, notes: str) -> Dict[str, Any]:
|
||||
"""구매 취소 처리"""
|
||||
return {"success": True, "message": "구매 취소 완료"}
|
||||
|
||||
def _process_quantity_update(self, change, username: str, notes: str) -> Dict[str, Any]:
|
||||
"""수량 업데이트 처리"""
|
||||
return {"success": True, "message": f"수량 업데이트 완료: {change.current_quantity}개"}
|
||||
289
backend/app/services/revision_session_service.py
Normal file
289
backend/app/services/revision_session_service.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
리비전 세션 관리 서비스
|
||||
- 리비전 세션 생성, 관리, 완료 처리
|
||||
- 자재 변경 사항 추적 및 처리
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, and_, or_
|
||||
|
||||
from ..models import File, Material
|
||||
from ..database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RevisionSessionService:
|
||||
"""리비전 세션 관리 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create_revision_session(
|
||||
self,
|
||||
job_no: str,
|
||||
current_file_id: int,
|
||||
previous_file_id: int,
|
||||
username: str
|
||||
) -> Dict[str, Any]:
|
||||
"""새로운 리비전 세션 생성"""
|
||||
|
||||
try:
|
||||
# 파일 정보 조회
|
||||
current_file = self.db.query(File).filter(File.id == current_file_id).first()
|
||||
previous_file = self.db.query(File).filter(File.id == previous_file_id).first()
|
||||
|
||||
if not current_file or not previous_file:
|
||||
raise ValueError("파일 정보를 찾을 수 없습니다")
|
||||
|
||||
# 기존 진행 중인 세션이 있는지 확인
|
||||
existing_session = self.db.execute(text("""
|
||||
SELECT id FROM revision_sessions
|
||||
WHERE job_no = :job_no AND status = 'processing'
|
||||
"""), {"job_no": job_no}).fetchone()
|
||||
|
||||
if existing_session:
|
||||
logger.warning(f"기존 진행 중인 리비전 세션이 있습니다: {existing_session[0]}")
|
||||
return {"session_id": existing_session[0], "status": "existing"}
|
||||
|
||||
# 새 세션 생성
|
||||
session_data = {
|
||||
"job_no": job_no,
|
||||
"current_file_id": current_file_id,
|
||||
"previous_file_id": previous_file_id,
|
||||
"current_revision": current_file.revision,
|
||||
"previous_revision": previous_file.revision,
|
||||
"status": "processing",
|
||||
"created_by": username
|
||||
}
|
||||
|
||||
result = self.db.execute(text("""
|
||||
INSERT INTO revision_sessions (
|
||||
job_no, current_file_id, previous_file_id,
|
||||
current_revision, previous_revision, status, created_by
|
||||
) VALUES (
|
||||
:job_no, :current_file_id, :previous_file_id,
|
||||
:current_revision, :previous_revision, :status, :created_by
|
||||
) RETURNING id
|
||||
"""), session_data)
|
||||
|
||||
session_id = result.fetchone()[0]
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"새 리비전 세션 생성: {session_id} (Job: {job_no})")
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "created",
|
||||
"job_no": job_no,
|
||||
"current_revision": current_file.revision,
|
||||
"previous_revision": previous_file.revision
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"리비전 세션 생성 실패: {e}")
|
||||
raise
|
||||
|
||||
def get_session_status(self, session_id: int) -> Dict[str, Any]:
|
||||
"""리비전 세션 상태 조회"""
|
||||
|
||||
try:
|
||||
session_info = self.db.execute(text("""
|
||||
SELECT
|
||||
id, job_no, current_file_id, previous_file_id,
|
||||
current_revision, previous_revision, status,
|
||||
total_materials, processed_materials,
|
||||
added_count, removed_count, changed_count, unchanged_count,
|
||||
purchase_cancel_count, inventory_transfer_count, additional_purchase_count,
|
||||
created_by, created_at, completed_at
|
||||
FROM revision_sessions
|
||||
WHERE id = :session_id
|
||||
"""), {"session_id": session_id}).fetchone()
|
||||
|
||||
if not session_info:
|
||||
raise ValueError(f"리비전 세션을 찾을 수 없습니다: {session_id}")
|
||||
|
||||
# 변경 사항 상세 조회
|
||||
changes = self.db.execute(text("""
|
||||
SELECT
|
||||
category, change_type, revision_action, action_status,
|
||||
COUNT(*) as count,
|
||||
SUM(CASE WHEN purchase_status = 'purchased' THEN 1 ELSE 0 END) as purchased_count,
|
||||
SUM(CASE WHEN purchase_status = 'not_purchased' THEN 1 ELSE 0 END) as unpurchased_count
|
||||
FROM revision_material_changes
|
||||
WHERE session_id = :session_id
|
||||
GROUP BY category, change_type, revision_action, action_status
|
||||
ORDER BY category, change_type
|
||||
"""), {"session_id": session_id}).fetchall()
|
||||
|
||||
return {
|
||||
"session_info": dict(session_info._mapping),
|
||||
"changes_summary": [dict(change._mapping) for change in changes],
|
||||
"progress_percentage": (
|
||||
(session_info.processed_materials / session_info.total_materials * 100)
|
||||
if session_info.total_materials > 0 else 0
|
||||
)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"리비전 세션 상태 조회 실패: {e}")
|
||||
raise
|
||||
|
||||
def update_session_progress(
|
||||
self,
|
||||
session_id: int,
|
||||
total_materials: int = None,
|
||||
processed_materials: int = None,
|
||||
**counts
|
||||
) -> bool:
|
||||
"""리비전 세션 진행 상황 업데이트"""
|
||||
|
||||
try:
|
||||
update_fields = []
|
||||
update_values = {"session_id": session_id}
|
||||
|
||||
if total_materials is not None:
|
||||
update_fields.append("total_materials = :total_materials")
|
||||
update_values["total_materials"] = total_materials
|
||||
|
||||
if processed_materials is not None:
|
||||
update_fields.append("processed_materials = :processed_materials")
|
||||
update_values["processed_materials"] = processed_materials
|
||||
|
||||
# 카운트 필드들 업데이트
|
||||
count_fields = [
|
||||
"added_count", "removed_count", "changed_count", "unchanged_count",
|
||||
"purchase_cancel_count", "inventory_transfer_count", "additional_purchase_count"
|
||||
]
|
||||
|
||||
for field in count_fields:
|
||||
if field in counts:
|
||||
update_fields.append(f"{field} = :{field}")
|
||||
update_values[field] = counts[field]
|
||||
|
||||
if not update_fields:
|
||||
return True # 업데이트할 내용이 없음
|
||||
|
||||
query = f"""
|
||||
UPDATE revision_sessions
|
||||
SET {', '.join(update_fields)}
|
||||
WHERE id = :session_id
|
||||
"""
|
||||
|
||||
self.db.execute(text(query), update_values)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"리비전 세션 진행 상황 업데이트: {session_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"리비전 세션 진행 상황 업데이트 실패: {e}")
|
||||
raise
|
||||
|
||||
def complete_session(self, session_id: int, username: str) -> Dict[str, Any]:
|
||||
"""리비전 세션 완료 처리"""
|
||||
|
||||
try:
|
||||
# 세션 상태를 완료로 변경
|
||||
self.db.execute(text("""
|
||||
UPDATE revision_sessions
|
||||
SET status = 'completed', completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :session_id AND status = 'processing'
|
||||
"""), {"session_id": session_id})
|
||||
|
||||
# 완료 로그 기록
|
||||
self.db.execute(text("""
|
||||
INSERT INTO revision_action_logs (
|
||||
session_id, action_type, action_description,
|
||||
executed_by, result, result_message
|
||||
) VALUES (
|
||||
:session_id, 'session_complete', '리비전 세션 완료',
|
||||
:username, 'success', '모든 리비전 처리 완료'
|
||||
)
|
||||
"""), {
|
||||
"session_id": session_id,
|
||||
"username": username
|
||||
})
|
||||
|
||||
self.db.commit()
|
||||
|
||||
# 최종 상태 조회
|
||||
final_status = self.get_session_status(session_id)
|
||||
|
||||
logger.info(f"리비전 세션 완료: {session_id}")
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"session_id": session_id,
|
||||
"final_status": final_status
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"리비전 세션 완료 처리 실패: {e}")
|
||||
raise
|
||||
|
||||
def cancel_session(self, session_id: int, username: str, reason: str = None) -> bool:
|
||||
"""리비전 세션 취소"""
|
||||
|
||||
try:
|
||||
# 세션 상태를 취소로 변경
|
||||
self.db.execute(text("""
|
||||
UPDATE revision_sessions
|
||||
SET status = 'cancelled', completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :session_id AND status = 'processing'
|
||||
"""), {"session_id": session_id})
|
||||
|
||||
# 취소 로그 기록
|
||||
self.db.execute(text("""
|
||||
INSERT INTO revision_action_logs (
|
||||
session_id, action_type, action_description,
|
||||
executed_by, result, result_message
|
||||
) VALUES (
|
||||
:session_id, 'session_cancel', '리비전 세션 취소',
|
||||
:username, 'cancelled', :reason
|
||||
)
|
||||
"""), {
|
||||
"session_id": session_id,
|
||||
"username": username,
|
||||
"reason": reason or "사용자 요청에 의한 취소"
|
||||
})
|
||||
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"리비전 세션 취소: {session_id}, 사유: {reason}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"리비전 세션 취소 실패: {e}")
|
||||
raise
|
||||
|
||||
def get_job_revision_history(self, job_no: str) -> List[Dict[str, Any]]:
|
||||
"""Job의 리비전 히스토리 조회"""
|
||||
|
||||
try:
|
||||
sessions = self.db.execute(text("""
|
||||
SELECT
|
||||
rs.id, rs.current_revision, rs.previous_revision,
|
||||
rs.status, rs.created_by, rs.created_at, rs.completed_at,
|
||||
rs.added_count, rs.removed_count, rs.changed_count,
|
||||
cf.filename as current_filename,
|
||||
pf.filename as previous_filename
|
||||
FROM revision_sessions rs
|
||||
LEFT JOIN files cf ON rs.current_file_id = cf.id
|
||||
LEFT JOIN files pf ON rs.previous_file_id = pf.id
|
||||
WHERE rs.job_no = :job_no
|
||||
ORDER BY rs.created_at DESC
|
||||
"""), {"job_no": job_no}).fetchall()
|
||||
|
||||
return [dict(session._mapping) for session in sessions]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"리비전 히스토리 조회 실패: {e}")
|
||||
raise
|
||||
@@ -189,6 +189,62 @@ def add_missing_columns(cursor):
|
||||
print(f"✅ material_purchase_tracking.{column_name} 컬럼 추가 완료")
|
||||
else:
|
||||
print(f"✅ material_purchase_tracking.{column_name} 컬럼이 이미 존재합니다")
|
||||
|
||||
# purchase_requests 테이블에 누락된 컬럼들 확인 및 추가
|
||||
purchase_requests_columns = {
|
||||
'file_id': 'INTEGER REFERENCES files(id)',
|
||||
'category': 'VARCHAR(50)',
|
||||
'material_count': 'INTEGER DEFAULT 0',
|
||||
'excel_file_path': 'VARCHAR(500)'
|
||||
}
|
||||
|
||||
for column_name, column_type in purchase_requests_columns.items():
|
||||
cursor.execute("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'purchase_requests' AND column_name = %s;
|
||||
""", (column_name,))
|
||||
|
||||
if not cursor.fetchone():
|
||||
print(f"➕ purchase_requests 테이블에 {column_name} 컬럼 추가 중...")
|
||||
cursor.execute(f"""
|
||||
ALTER TABLE purchase_requests ADD COLUMN {column_name} {column_type};
|
||||
""")
|
||||
print(f"✅ purchase_requests.{column_name} 컬럼 추가 완료")
|
||||
else:
|
||||
print(f"✅ purchase_requests.{column_name} 컬럼이 이미 존재합니다")
|
||||
|
||||
# purchase_request_items 테이블에 누락된 컬럼들 확인 및 추가
|
||||
purchase_request_items_columns = {
|
||||
'user_requirement': 'TEXT',
|
||||
'description': 'TEXT',
|
||||
'category': 'VARCHAR(50)',
|
||||
'subcategory': 'VARCHAR(100)',
|
||||
'material_grade': 'VARCHAR(50)',
|
||||
'size_spec': 'VARCHAR(50)',
|
||||
'drawing_name': 'VARCHAR(100)',
|
||||
'notes': 'TEXT',
|
||||
'is_ordered': 'BOOLEAN DEFAULT FALSE',
|
||||
'is_received': 'BOOLEAN DEFAULT FALSE'
|
||||
}
|
||||
|
||||
for column_name, column_type in purchase_request_items_columns.items():
|
||||
cursor.execute("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'purchase_request_items' AND column_name = %s;
|
||||
""", (column_name,))
|
||||
|
||||
if not cursor.fetchone():
|
||||
print(f"➕ purchase_request_items 테이블에 {column_name} 컬럼 추가 중...")
|
||||
cursor.execute(f"""
|
||||
ALTER TABLE purchase_request_items ADD COLUMN {column_name} {column_type};
|
||||
""")
|
||||
print(f"✅ purchase_request_items.{column_name} 컬럼 추가 완료")
|
||||
else:
|
||||
print(f"✅ purchase_request_items.{column_name} 컬럼이 이미 존재합니다")
|
||||
|
||||
print("✅ 모든 누락된 컬럼 추가 완료!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ 컬럼 추가 실패: {e}")
|
||||
@@ -328,7 +384,11 @@ def create_missing_tables():
|
||||
CREATE TABLE purchase_requests (
|
||||
request_id SERIAL PRIMARY KEY,
|
||||
request_no VARCHAR(50) UNIQUE NOT NULL,
|
||||
file_id INTEGER REFERENCES files(id),
|
||||
job_no VARCHAR(50) NOT NULL,
|
||||
category VARCHAR(50),
|
||||
material_count INTEGER DEFAULT 0,
|
||||
excel_file_path VARCHAR(500),
|
||||
project_name VARCHAR(200),
|
||||
requested_by INTEGER REFERENCES users(user_id),
|
||||
requested_by_username VARCHAR(100),
|
||||
@@ -387,6 +447,163 @@ def create_missing_tables():
|
||||
print("✅ purchase_request_items 테이블 생성 완료")
|
||||
else:
|
||||
print("✅ purchase_request_items 테이블 이미 존재")
|
||||
|
||||
# 5. revision_sessions 테이블
|
||||
print("📋 5. revision_sessions 테이블 확인...")
|
||||
cursor.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'revision_sessions'
|
||||
);
|
||||
""")
|
||||
if not cursor.fetchone()[0]:
|
||||
print("➕ revision_sessions 테이블 생성 중...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE revision_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
job_no VARCHAR(50) NOT NULL,
|
||||
current_file_id INTEGER REFERENCES files(id),
|
||||
previous_file_id INTEGER REFERENCES files(id),
|
||||
current_revision VARCHAR(20) NOT NULL,
|
||||
previous_revision VARCHAR(20) NOT NULL,
|
||||
|
||||
status VARCHAR(20) DEFAULT 'processing',
|
||||
total_materials INTEGER DEFAULT 0,
|
||||
processed_materials INTEGER DEFAULT 0,
|
||||
|
||||
added_count INTEGER DEFAULT 0,
|
||||
removed_count INTEGER DEFAULT 0,
|
||||
changed_count INTEGER DEFAULT 0,
|
||||
unchanged_count INTEGER DEFAULT 0,
|
||||
|
||||
purchase_cancel_count INTEGER DEFAULT 0,
|
||||
inventory_transfer_count INTEGER DEFAULT 0,
|
||||
additional_purchase_count INTEGER DEFAULT 0,
|
||||
|
||||
created_by VARCHAR(100),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_revision_sessions_job_no ON revision_sessions(job_no);
|
||||
CREATE INDEX idx_revision_sessions_status ON revision_sessions(status);
|
||||
""")
|
||||
print("✅ revision_sessions 테이블 생성 완료")
|
||||
else:
|
||||
print("✅ revision_sessions 테이블 이미 존재")
|
||||
|
||||
# 6. revision_material_changes 테이블
|
||||
print("📋 6. revision_material_changes 테이블 확인...")
|
||||
cursor.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'revision_material_changes'
|
||||
);
|
||||
""")
|
||||
if not cursor.fetchone()[0]:
|
||||
print("➕ revision_material_changes 테이블 생성 중...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE revision_material_changes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_id INTEGER REFERENCES revision_sessions(id) ON DELETE CASCADE,
|
||||
|
||||
material_id INTEGER REFERENCES materials(id),
|
||||
previous_material_id INTEGER,
|
||||
material_description TEXT NOT NULL,
|
||||
category VARCHAR(50) NOT NULL,
|
||||
|
||||
change_type VARCHAR(20) NOT NULL,
|
||||
previous_quantity NUMERIC(10,3),
|
||||
current_quantity NUMERIC(10,3),
|
||||
quantity_difference NUMERIC(10,3),
|
||||
|
||||
purchase_status VARCHAR(20) NOT NULL,
|
||||
purchase_confirmed_at TIMESTAMP,
|
||||
|
||||
revision_action VARCHAR(30),
|
||||
action_status VARCHAR(20) DEFAULT 'pending',
|
||||
|
||||
processed_by VARCHAR(100),
|
||||
processed_at TIMESTAMP,
|
||||
processing_notes TEXT,
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_revision_changes_session ON revision_material_changes(session_id);
|
||||
CREATE INDEX idx_revision_changes_action ON revision_material_changes(revision_action);
|
||||
CREATE INDEX idx_revision_changes_status ON revision_material_changes(action_status);
|
||||
""")
|
||||
print("✅ revision_material_changes 테이블 생성 완료")
|
||||
else:
|
||||
print("✅ revision_material_changes 테이블 이미 존재")
|
||||
|
||||
# 7. inventory_transfers 테이블
|
||||
print("📋 7. inventory_transfers 테이블 확인...")
|
||||
cursor.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'inventory_transfers'
|
||||
);
|
||||
""")
|
||||
if not cursor.fetchone()[0]:
|
||||
print("➕ inventory_transfers 테이블 생성 중...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE inventory_transfers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
revision_change_id INTEGER REFERENCES revision_material_changes(id),
|
||||
|
||||
material_description TEXT NOT NULL,
|
||||
category VARCHAR(50) NOT NULL,
|
||||
quantity NUMERIC(10,3) NOT NULL,
|
||||
unit VARCHAR(10) NOT NULL,
|
||||
|
||||
inventory_location VARCHAR(100),
|
||||
storage_notes TEXT,
|
||||
|
||||
transferred_by VARCHAR(100) NOT NULL,
|
||||
transferred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
status VARCHAR(20) DEFAULT 'transferred'
|
||||
);
|
||||
CREATE INDEX idx_inventory_transfers_material ON inventory_transfers(material_description);
|
||||
CREATE INDEX idx_inventory_transfers_date ON inventory_transfers(transferred_at);
|
||||
""")
|
||||
print("✅ inventory_transfers 테이블 생성 완료")
|
||||
else:
|
||||
print("✅ inventory_transfers 테이블 이미 존재")
|
||||
|
||||
# 8. revision_action_logs 테이블
|
||||
print("📋 8. revision_action_logs 테이블 확인...")
|
||||
cursor.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'revision_action_logs'
|
||||
);
|
||||
""")
|
||||
if not cursor.fetchone()[0]:
|
||||
print("➕ revision_action_logs 테이블 생성 중...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE revision_action_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_id INTEGER REFERENCES revision_sessions(id),
|
||||
revision_change_id INTEGER REFERENCES revision_material_changes(id),
|
||||
|
||||
action_type VARCHAR(30) NOT NULL,
|
||||
action_description TEXT,
|
||||
|
||||
executed_by VARCHAR(100) NOT NULL,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
result VARCHAR(20) NOT NULL,
|
||||
result_message TEXT,
|
||||
result_data JSONB
|
||||
);
|
||||
CREATE INDEX idx_revision_logs_session ON revision_action_logs(session_id);
|
||||
CREATE INDEX idx_revision_logs_type ON revision_action_logs(action_type);
|
||||
CREATE INDEX idx_revision_logs_date ON revision_action_logs(executed_at);
|
||||
""")
|
||||
print("✅ revision_action_logs 테이블 생성 완료")
|
||||
else:
|
||||
print("✅ revision_action_logs 테이블 이미 존재")
|
||||
|
||||
# 변경사항 커밋
|
||||
conn.commit()
|
||||
|
||||
Reference in New Issue
Block a user