455 lines
16 KiB
Python
455 lines
16 KiB
Python
"""
|
|
구매 추적 및 관리 API
|
|
엑셀 내보내기 이력 및 구매 상태 관리
|
|
"""
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
from typing import Optional, List, Dict, Any
|
|
from datetime import datetime, date
|
|
import json
|
|
|
|
from ..database import get_db
|
|
from ..auth.middleware import get_current_user
|
|
from ..utils.logger import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
router = APIRouter(prefix="/purchase", tags=["Purchase Tracking"])
|
|
|
|
|
|
@router.post("/export-history")
|
|
async def create_export_history(
|
|
file_id: int,
|
|
job_no: Optional[str] = None,
|
|
export_type: str = "full",
|
|
category: Optional[str] = None,
|
|
material_ids: List[int] = [],
|
|
filters_applied: Optional[Dict] = None,
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
엑셀 내보내기 이력 생성 및 자재 추적
|
|
"""
|
|
try:
|
|
# 내보내기 이력 생성
|
|
insert_history = text("""
|
|
INSERT INTO excel_export_history (
|
|
file_id, job_no, exported_by, export_type,
|
|
category, material_count, filters_applied
|
|
) VALUES (
|
|
:file_id, :job_no, :exported_by, :export_type,
|
|
:category, :material_count, :filters_applied
|
|
) RETURNING export_id
|
|
""")
|
|
|
|
result = db.execute(insert_history, {
|
|
"file_id": file_id,
|
|
"job_no": job_no,
|
|
"exported_by": current_user.get("user_id"),
|
|
"export_type": export_type,
|
|
"category": category,
|
|
"material_count": len(material_ids),
|
|
"filters_applied": json.dumps(filters_applied) if filters_applied else None
|
|
})
|
|
|
|
export_id = result.fetchone().export_id
|
|
|
|
# 내보낸 자재들 기록
|
|
if material_ids:
|
|
for material_id in material_ids:
|
|
insert_material = text("""
|
|
INSERT INTO exported_materials (
|
|
export_id, material_id, purchase_status
|
|
) VALUES (
|
|
:export_id, :material_id, 'pending'
|
|
)
|
|
""")
|
|
db.execute(insert_material, {
|
|
"export_id": export_id,
|
|
"material_id": material_id
|
|
})
|
|
|
|
db.commit()
|
|
|
|
logger.info(f"Export history created: {export_id} with {len(material_ids)} materials")
|
|
|
|
return {
|
|
"success": True,
|
|
"export_id": export_id,
|
|
"message": f"{len(material_ids)}개 자재의 내보내기 이력이 저장되었습니다"
|
|
}
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Failed to create export history: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"내보내기 이력 생성 실패: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/export-history")
|
|
async def get_export_history(
|
|
file_id: Optional[int] = None,
|
|
job_no: Optional[str] = None,
|
|
limit: int = 50,
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
엑셀 내보내기 이력 조회
|
|
"""
|
|
try:
|
|
query = text("""
|
|
SELECT
|
|
eeh.export_id,
|
|
eeh.file_id,
|
|
eeh.job_no,
|
|
eeh.export_date,
|
|
eeh.export_type,
|
|
eeh.category,
|
|
eeh.material_count,
|
|
u.name as exported_by_name,
|
|
f.original_filename,
|
|
COUNT(DISTINCT em.material_id) as actual_material_count,
|
|
COUNT(DISTINCT CASE WHEN em.purchase_status = 'pending' THEN em.material_id END) as pending_count,
|
|
COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) as requested_count,
|
|
COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) as ordered_count,
|
|
COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) as received_count
|
|
FROM excel_export_history eeh
|
|
LEFT JOIN users u ON eeh.exported_by = u.user_id
|
|
LEFT JOIN files f ON eeh.file_id = f.id
|
|
LEFT JOIN exported_materials em ON eeh.export_id = em.export_id
|
|
WHERE 1=1
|
|
AND (:file_id IS NULL OR eeh.file_id = :file_id)
|
|
AND (:job_no IS NULL OR eeh.job_no = :job_no)
|
|
GROUP BY
|
|
eeh.export_id, eeh.file_id, eeh.job_no, eeh.export_date,
|
|
eeh.export_type, eeh.category, eeh.material_count,
|
|
u.name, f.original_filename
|
|
ORDER BY eeh.export_date DESC
|
|
LIMIT :limit
|
|
""")
|
|
|
|
results = db.execute(query, {
|
|
"file_id": file_id,
|
|
"job_no": job_no,
|
|
"limit": limit
|
|
}).fetchall()
|
|
|
|
history = []
|
|
for row in results:
|
|
history.append({
|
|
"export_id": row.export_id,
|
|
"file_id": row.file_id,
|
|
"job_no": row.job_no,
|
|
"export_date": row.export_date.isoformat() if row.export_date else None,
|
|
"export_type": row.export_type,
|
|
"category": row.category,
|
|
"material_count": row.material_count,
|
|
"exported_by": row.exported_by_name,
|
|
"file_name": row.original_filename,
|
|
"status_summary": {
|
|
"total": row.actual_material_count,
|
|
"pending": row.pending_count,
|
|
"requested": row.requested_count,
|
|
"ordered": row.ordered_count,
|
|
"received": row.received_count
|
|
}
|
|
})
|
|
|
|
return {
|
|
"success": True,
|
|
"history": history,
|
|
"count": len(history)
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get export history: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"내보내기 이력 조회 실패: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/materials/status")
|
|
async def get_materials_by_status(
|
|
status: Optional[str] = None,
|
|
export_id: Optional[int] = None,
|
|
file_id: Optional[int] = None,
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
구매 상태별 자재 목록 조회
|
|
"""
|
|
try:
|
|
query = text("""
|
|
SELECT
|
|
em.id as exported_material_id,
|
|
em.material_id,
|
|
m.original_description,
|
|
m.classified_category,
|
|
m.quantity,
|
|
m.unit,
|
|
em.purchase_status,
|
|
em.purchase_request_no,
|
|
em.purchase_order_no,
|
|
em.vendor_name,
|
|
em.expected_date,
|
|
em.quantity_ordered,
|
|
em.quantity_received,
|
|
em.unit_price,
|
|
em.total_price,
|
|
em.notes,
|
|
em.updated_at,
|
|
eeh.export_date,
|
|
f.original_filename as file_name,
|
|
j.job_no,
|
|
j.job_name
|
|
FROM exported_materials em
|
|
JOIN materials m ON em.material_id = m.id
|
|
JOIN excel_export_history eeh ON em.export_id = eeh.export_id
|
|
LEFT JOIN files f ON eeh.file_id = f.id
|
|
LEFT JOIN jobs j ON eeh.job_no = j.job_no
|
|
WHERE 1=1
|
|
AND (:status IS NULL OR em.purchase_status = :status)
|
|
AND (:export_id IS NULL OR em.export_id = :export_id)
|
|
AND (:file_id IS NULL OR eeh.file_id = :file_id)
|
|
ORDER BY em.updated_at DESC
|
|
""")
|
|
|
|
results = db.execute(query, {
|
|
"status": status,
|
|
"export_id": export_id,
|
|
"file_id": file_id
|
|
}).fetchall()
|
|
|
|
materials = []
|
|
for row in results:
|
|
materials.append({
|
|
"exported_material_id": row.exported_material_id,
|
|
"material_id": row.material_id,
|
|
"description": row.original_description,
|
|
"category": row.classified_category,
|
|
"quantity": row.quantity,
|
|
"unit": row.unit,
|
|
"purchase_status": row.purchase_status,
|
|
"purchase_request_no": row.purchase_request_no,
|
|
"purchase_order_no": row.purchase_order_no,
|
|
"vendor_name": row.vendor_name,
|
|
"expected_date": row.expected_date.isoformat() if row.expected_date else None,
|
|
"quantity_ordered": row.quantity_ordered,
|
|
"quantity_received": row.quantity_received,
|
|
"unit_price": float(row.unit_price) if row.unit_price else None,
|
|
"total_price": float(row.total_price) if row.total_price else None,
|
|
"notes": row.notes,
|
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
|
"export_date": row.export_date.isoformat() if row.export_date else None,
|
|
"file_name": row.file_name,
|
|
"job_no": row.job_no,
|
|
"job_name": row.job_name
|
|
})
|
|
|
|
return {
|
|
"success": True,
|
|
"materials": materials,
|
|
"count": len(materials)
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get materials by status: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"구매 상태별 자재 조회 실패: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.patch("/materials/{exported_material_id}/status")
|
|
async def update_purchase_status(
|
|
exported_material_id: int,
|
|
new_status: str,
|
|
purchase_request_no: Optional[str] = None,
|
|
purchase_order_no: Optional[str] = None,
|
|
vendor_name: Optional[str] = None,
|
|
expected_date: Optional[date] = None,
|
|
quantity_ordered: Optional[int] = None,
|
|
quantity_received: Optional[int] = None,
|
|
unit_price: Optional[float] = None,
|
|
notes: Optional[str] = None,
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
자재 구매 상태 업데이트
|
|
"""
|
|
try:
|
|
# 현재 상태 조회
|
|
get_current = text("""
|
|
SELECT purchase_status, material_id
|
|
FROM exported_materials
|
|
WHERE id = :id
|
|
""")
|
|
current = db.execute(get_current, {"id": exported_material_id}).fetchone()
|
|
|
|
if not current:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="해당 자재를 찾을 수 없습니다"
|
|
)
|
|
|
|
# 상태 업데이트
|
|
update_query = text("""
|
|
UPDATE exported_materials
|
|
SET
|
|
purchase_status = :new_status,
|
|
purchase_request_no = COALESCE(:pr_no, purchase_request_no),
|
|
purchase_order_no = COALESCE(:po_no, purchase_order_no),
|
|
vendor_name = COALESCE(:vendor, vendor_name),
|
|
expected_date = COALESCE(:expected_date, expected_date),
|
|
quantity_ordered = COALESCE(:qty_ordered, quantity_ordered),
|
|
quantity_received = COALESCE(:qty_received, quantity_received),
|
|
unit_price = COALESCE(:unit_price, unit_price),
|
|
total_price = CASE
|
|
WHEN :unit_price IS NOT NULL AND :qty_ordered IS NOT NULL
|
|
THEN :unit_price * :qty_ordered
|
|
WHEN :unit_price IS NOT NULL AND quantity_ordered IS NOT NULL
|
|
THEN :unit_price * quantity_ordered
|
|
ELSE total_price
|
|
END,
|
|
notes = COALESCE(:notes, notes),
|
|
updated_by = :updated_by,
|
|
requested_date = CASE WHEN :new_status = 'requested' THEN CURRENT_TIMESTAMP ELSE requested_date END,
|
|
ordered_date = CASE WHEN :new_status = 'ordered' THEN CURRENT_TIMESTAMP ELSE ordered_date END,
|
|
received_date = CASE WHEN :new_status = 'received' THEN CURRENT_TIMESTAMP ELSE received_date END
|
|
WHERE id = :id
|
|
""")
|
|
|
|
db.execute(update_query, {
|
|
"id": exported_material_id,
|
|
"new_status": new_status,
|
|
"pr_no": purchase_request_no,
|
|
"po_no": purchase_order_no,
|
|
"vendor": vendor_name,
|
|
"expected_date": expected_date,
|
|
"qty_ordered": quantity_ordered,
|
|
"qty_received": quantity_received,
|
|
"unit_price": unit_price,
|
|
"notes": notes,
|
|
"updated_by": current_user.get("user_id")
|
|
})
|
|
|
|
# 이력 기록
|
|
insert_history = text("""
|
|
INSERT INTO purchase_status_history (
|
|
exported_material_id, material_id,
|
|
previous_status, new_status,
|
|
changed_by, reason
|
|
) VALUES (
|
|
:em_id, :material_id,
|
|
:prev_status, :new_status,
|
|
:changed_by, :reason
|
|
)
|
|
""")
|
|
|
|
db.execute(insert_history, {
|
|
"em_id": exported_material_id,
|
|
"material_id": current.material_id,
|
|
"prev_status": current.purchase_status,
|
|
"new_status": new_status,
|
|
"changed_by": current_user.get("user_id"),
|
|
"reason": notes
|
|
})
|
|
|
|
db.commit()
|
|
|
|
logger.info(f"Purchase status updated: {exported_material_id} from {current.purchase_status} to {new_status}")
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"구매 상태가 {new_status}로 변경되었습니다"
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Failed to update purchase status: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"구매 상태 업데이트 실패: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/status-summary")
|
|
async def get_status_summary(
|
|
file_id: Optional[int] = None,
|
|
job_no: Optional[str] = None,
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
구매 상태 요약 통계
|
|
"""
|
|
try:
|
|
query = text("""
|
|
SELECT
|
|
em.purchase_status,
|
|
COUNT(DISTINCT em.material_id) as material_count,
|
|
SUM(em.quantity_exported) as total_quantity,
|
|
SUM(em.total_price) as total_amount,
|
|
COUNT(DISTINCT em.export_id) as export_count
|
|
FROM exported_materials em
|
|
JOIN excel_export_history eeh ON em.export_id = eeh.export_id
|
|
WHERE 1=1
|
|
AND (:file_id IS NULL OR eeh.file_id = :file_id)
|
|
AND (:job_no IS NULL OR eeh.job_no = :job_no)
|
|
GROUP BY em.purchase_status
|
|
""")
|
|
|
|
results = db.execute(query, {
|
|
"file_id": file_id,
|
|
"job_no": job_no
|
|
}).fetchall()
|
|
|
|
summary = {}
|
|
total_materials = 0
|
|
total_amount = 0
|
|
|
|
for row in results:
|
|
summary[row.purchase_status] = {
|
|
"material_count": row.material_count,
|
|
"total_quantity": row.total_quantity,
|
|
"total_amount": float(row.total_amount) if row.total_amount else 0,
|
|
"export_count": row.export_count
|
|
}
|
|
total_materials += row.material_count
|
|
if row.total_amount:
|
|
total_amount += float(row.total_amount)
|
|
|
|
# 기본 상태들 추가 (없는 경우 0으로)
|
|
for status in ['pending', 'requested', 'ordered', 'received', 'cancelled']:
|
|
if status not in summary:
|
|
summary[status] = {
|
|
"material_count": 0,
|
|
"total_quantity": 0,
|
|
"total_amount": 0,
|
|
"export_count": 0
|
|
}
|
|
|
|
return {
|
|
"success": True,
|
|
"summary": summary,
|
|
"total_materials": total_materials,
|
|
"total_amount": total_amount
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get status summary: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"구매 상태 요약 조회 실패: {str(e)}"
|
|
)
|