Initial commit - production deployment
This commit is contained in:
1
services/suppliers/app/api/__init__.py
Normal file
1
services/suppliers/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# services/suppliers/app/api/__init__.py
|
||||
575
services/suppliers/app/api/analytics.py
Normal file
575
services/suppliers/app/api/analytics.py
Normal file
@@ -0,0 +1,575 @@
|
||||
# services/suppliers/app/api/analytics.py
|
||||
"""
|
||||
Supplier Analytics API endpoints (ANALYTICS)
|
||||
Consolidates performance metrics, delivery stats, and all analytics operations
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
import structlog
|
||||
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.access_control import require_user_role, analytics_tier_required
|
||||
from shared.routing import RouteBuilder
|
||||
from app.core.database import get_db
|
||||
from app.services.performance_service import PerformanceTrackingService, AlertService
|
||||
from app.services.dashboard_service import DashboardService
|
||||
from app.schemas.performance import (
|
||||
PerformanceMetric, Alert, PerformanceDashboardSummary,
|
||||
SupplierPerformanceInsights, PerformanceAnalytics, BusinessModelInsights,
|
||||
AlertSummary, PerformanceReportRequest, ExportDataResponse
|
||||
)
|
||||
from app.models.performance import PerformancePeriod, PerformanceMetricType, AlertType, AlertSeverity
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Create route builder for consistent URL structure
|
||||
route_builder = RouteBuilder('suppliers')
|
||||
|
||||
router = APIRouter(tags=["analytics"])
|
||||
|
||||
|
||||
# ===== Dependency Injection =====
|
||||
|
||||
async def get_performance_service() -> PerformanceTrackingService:
|
||||
"""Get performance tracking service"""
|
||||
return PerformanceTrackingService()
|
||||
|
||||
async def get_alert_service() -> AlertService:
|
||||
"""Get alert service"""
|
||||
return AlertService()
|
||||
|
||||
async def get_dashboard_service() -> DashboardService:
|
||||
"""Get dashboard service"""
|
||||
return DashboardService()
|
||||
|
||||
|
||||
# ===== Performance Metrics =====
|
||||
|
||||
@router.post(
|
||||
route_builder.build_analytics_route("performance/{supplier_id}/calculate"),
|
||||
response_model=PerformanceMetric
|
||||
)
|
||||
async def calculate_supplier_performance(
|
||||
tenant_id: UUID = Path(...),
|
||||
supplier_id: UUID = Path(...),
|
||||
period: PerformancePeriod = Query(...),
|
||||
period_start: datetime = Query(...),
|
||||
period_end: datetime = Query(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
performance_service: PerformanceTrackingService = Depends(get_performance_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Calculate performance metrics for a supplier"""
|
||||
try:
|
||||
metric = await performance_service.calculate_supplier_performance(
|
||||
db, supplier_id, tenant_id, period, period_start, period_end
|
||||
)
|
||||
|
||||
if not metric:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Unable to calculate performance metrics"
|
||||
)
|
||||
|
||||
logger.info("Performance metrics calculated",
|
||||
tenant_id=str(tenant_id),
|
||||
supplier_id=str(supplier_id),
|
||||
period=period.value)
|
||||
|
||||
return metric
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error calculating performance metrics",
|
||||
tenant_id=str(tenant_id),
|
||||
supplier_id=str(supplier_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to calculate performance metrics"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("performance/{supplier_id}/metrics"),
|
||||
response_model=List[PerformanceMetric]
|
||||
)
|
||||
async def get_supplier_performance_metrics(
|
||||
tenant_id: UUID = Path(...),
|
||||
supplier_id: UUID = Path(...),
|
||||
metric_type: Optional[PerformanceMetricType] = Query(None),
|
||||
period: Optional[PerformancePeriod] = Query(None),
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get performance metrics for a supplier"""
|
||||
try:
|
||||
from app.models.performance import SupplierPerformanceMetric
|
||||
from sqlalchemy import select, and_, desc
|
||||
|
||||
# Build query for performance metrics
|
||||
query = select(SupplierPerformanceMetric).where(
|
||||
and_(
|
||||
SupplierPerformanceMetric.supplier_id == supplier_id,
|
||||
SupplierPerformanceMetric.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if metric_type:
|
||||
query = query.where(SupplierPerformanceMetric.metric_type == metric_type)
|
||||
|
||||
if date_from:
|
||||
query = query.where(SupplierPerformanceMetric.calculated_at >= date_from)
|
||||
|
||||
if date_to:
|
||||
query = query.where(SupplierPerformanceMetric.calculated_at <= date_to)
|
||||
|
||||
# Order by most recent and apply limit
|
||||
query = query.order_by(desc(SupplierPerformanceMetric.calculated_at)).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
metrics = result.scalars().all()
|
||||
|
||||
logger.info("Retrieved performance metrics",
|
||||
tenant_id=str(tenant_id),
|
||||
supplier_id=str(supplier_id),
|
||||
count=len(metrics))
|
||||
|
||||
return metrics
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting performance metrics",
|
||||
tenant_id=str(tenant_id),
|
||||
supplier_id=str(supplier_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve performance metrics"
|
||||
)
|
||||
|
||||
|
||||
# ===== Alert Management =====
|
||||
|
||||
@router.post(
|
||||
route_builder.build_analytics_route("performance/alerts/evaluate"),
|
||||
response_model=List[Alert]
|
||||
)
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def evaluate_performance_alerts(
|
||||
tenant_id: UUID = Path(...),
|
||||
supplier_id: Optional[UUID] = Query(None, description="Specific supplier to evaluate"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
alert_service: AlertService = Depends(get_alert_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Evaluate and create performance-based alerts"""
|
||||
try:
|
||||
alerts = await alert_service.evaluate_performance_alerts(db, tenant_id, supplier_id)
|
||||
|
||||
logger.info("Performance alerts evaluated",
|
||||
tenant_id=str(tenant_id),
|
||||
alerts_created=len(alerts))
|
||||
|
||||
return alerts
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error evaluating performance alerts",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to evaluate performance alerts"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("performance/alerts"),
|
||||
response_model=List[Alert]
|
||||
)
|
||||
async def get_supplier_alerts(
|
||||
tenant_id: UUID = Path(...),
|
||||
supplier_id: Optional[UUID] = Query(None),
|
||||
alert_type: Optional[AlertType] = Query(None),
|
||||
severity: Optional[AlertSeverity] = Query(None),
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get supplier alerts with filtering"""
|
||||
try:
|
||||
from app.models.performance import SupplierAlert
|
||||
from sqlalchemy import select, and_, desc
|
||||
|
||||
# Build query for alerts
|
||||
query = select(SupplierAlert).where(
|
||||
SupplierAlert.tenant_id == tenant_id
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if supplier_id:
|
||||
query = query.where(SupplierAlert.supplier_id == supplier_id)
|
||||
|
||||
if alert_type:
|
||||
query = query.where(SupplierAlert.alert_type == alert_type)
|
||||
|
||||
if severity:
|
||||
query = query.where(SupplierAlert.severity == severity)
|
||||
|
||||
if date_from:
|
||||
query = query.where(SupplierAlert.created_at >= date_from)
|
||||
|
||||
if date_to:
|
||||
query = query.where(SupplierAlert.created_at <= date_to)
|
||||
|
||||
# Order by most recent and apply limit
|
||||
query = query.order_by(desc(SupplierAlert.created_at)).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
alerts = result.scalars().all()
|
||||
|
||||
logger.info("Retrieved supplier alerts",
|
||||
tenant_id=str(tenant_id),
|
||||
count=len(alerts))
|
||||
|
||||
return alerts
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting supplier alerts",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve supplier alerts"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("performance/alerts/summary"),
|
||||
response_model=List[AlertSummary]
|
||||
)
|
||||
async def get_alert_summary(
|
||||
tenant_id: UUID = Path(...),
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
dashboard_service: DashboardService = Depends(get_dashboard_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get alert summary by type and severity"""
|
||||
try:
|
||||
summary = await dashboard_service.get_alert_summary(db, tenant_id, date_from, date_to)
|
||||
|
||||
return summary
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting alert summary",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve alert summary"
|
||||
)
|
||||
|
||||
|
||||
# ===== Dashboard Analytics =====
|
||||
|
||||
@router.get(
|
||||
route_builder.build_dashboard_route("performance/summary"),
|
||||
response_model=PerformanceDashboardSummary
|
||||
)
|
||||
async def get_performance_dashboard_summary(
|
||||
tenant_id: UUID = Path(...),
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
dashboard_service: DashboardService = Depends(get_dashboard_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get comprehensive performance dashboard summary"""
|
||||
try:
|
||||
summary = await dashboard_service.get_performance_dashboard_summary(
|
||||
db, tenant_id, date_from, date_to
|
||||
)
|
||||
|
||||
logger.info("Performance dashboard summary retrieved",
|
||||
tenant_id=str(tenant_id))
|
||||
|
||||
return summary
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting dashboard summary",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve dashboard summary"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("performance/{supplier_id}/insights"),
|
||||
response_model=SupplierPerformanceInsights
|
||||
)
|
||||
async def get_supplier_performance_insights(
|
||||
tenant_id: UUID = Path(...),
|
||||
supplier_id: UUID = Path(...),
|
||||
days_back: int = Query(30, ge=1, le=365),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
dashboard_service: DashboardService = Depends(get_dashboard_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get detailed performance insights for a specific supplier"""
|
||||
try:
|
||||
insights = await dashboard_service.get_supplier_performance_insights(
|
||||
db, tenant_id, supplier_id, days_back
|
||||
)
|
||||
|
||||
logger.info("Supplier performance insights retrieved",
|
||||
tenant_id=str(tenant_id),
|
||||
supplier_id=str(supplier_id))
|
||||
|
||||
return insights
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting supplier insights",
|
||||
tenant_id=str(tenant_id),
|
||||
supplier_id=str(supplier_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve supplier insights"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("performance/performance"),
|
||||
response_model=PerformanceAnalytics
|
||||
)
|
||||
@analytics_tier_required
|
||||
async def get_performance_analytics(
|
||||
tenant_id: UUID = Path(...),
|
||||
period_days: int = Query(90, ge=1, le=365),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
dashboard_service: DashboardService = Depends(get_dashboard_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get advanced performance analytics"""
|
||||
try:
|
||||
analytics = await dashboard_service.get_performance_analytics(
|
||||
db, tenant_id, period_days
|
||||
)
|
||||
|
||||
logger.info("Performance analytics retrieved",
|
||||
tenant_id=str(tenant_id),
|
||||
period_days=period_days)
|
||||
|
||||
return analytics
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting performance analytics",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve performance analytics"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("performance/business-model"),
|
||||
response_model=BusinessModelInsights
|
||||
)
|
||||
@analytics_tier_required
|
||||
async def get_business_model_insights(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
dashboard_service: DashboardService = Depends(get_dashboard_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get business model detection and insights"""
|
||||
try:
|
||||
insights = await dashboard_service.get_business_model_insights(db, tenant_id)
|
||||
|
||||
logger.info("Business model insights retrieved",
|
||||
tenant_id=str(tenant_id),
|
||||
detected_model=insights.detected_model)
|
||||
|
||||
return insights
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting business model insights",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve business model insights"
|
||||
)
|
||||
|
||||
|
||||
# ===== Export and Reporting =====
|
||||
|
||||
@router.post(
|
||||
route_builder.build_analytics_route("performance/reports/generate"),
|
||||
response_model=ExportDataResponse
|
||||
)
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def generate_performance_report(
|
||||
report_request: PerformanceReportRequest,
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Generate a performance report"""
|
||||
try:
|
||||
# TODO: Implement report generation
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Report generation not yet implemented"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error generating performance report",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to generate performance report"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("performance/export")
|
||||
)
|
||||
async def export_performance_data(
|
||||
tenant_id: UUID = Path(...),
|
||||
format: str = Query("json", description="Export format: json, csv, excel"),
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
supplier_ids: Optional[List[UUID]] = Query(None),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Export performance data"""
|
||||
try:
|
||||
if format.lower() not in ["json", "csv", "excel"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Unsupported export format. Use: json, csv, excel"
|
||||
)
|
||||
|
||||
# TODO: Implement data export
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Data export not yet implemented"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error exporting performance data",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to export performance data"
|
||||
)
|
||||
|
||||
|
||||
# ===== Configuration and Health =====
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("performance/config")
|
||||
)
|
||||
async def get_performance_config(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep)
|
||||
):
|
||||
"""Get performance tracking configuration"""
|
||||
try:
|
||||
from app.core.config import settings
|
||||
|
||||
config = {
|
||||
"performance_tracking": {
|
||||
"enabled": settings.PERFORMANCE_TRACKING_ENABLED,
|
||||
"calculation_interval_minutes": settings.PERFORMANCE_CALCULATION_INTERVAL_MINUTES,
|
||||
"cache_ttl_seconds": settings.PERFORMANCE_CACHE_TTL
|
||||
},
|
||||
"thresholds": {
|
||||
"excellent_delivery_rate": settings.EXCELLENT_DELIVERY_RATE,
|
||||
"good_delivery_rate": settings.GOOD_DELIVERY_RATE,
|
||||
"acceptable_delivery_rate": settings.ACCEPTABLE_DELIVERY_RATE,
|
||||
"poor_delivery_rate": settings.POOR_DELIVERY_RATE,
|
||||
"excellent_quality_rate": settings.EXCELLENT_QUALITY_RATE,
|
||||
"good_quality_rate": settings.GOOD_QUALITY_RATE,
|
||||
"acceptable_quality_rate": settings.ACCEPTABLE_QUALITY_RATE,
|
||||
"poor_quality_rate": settings.POOR_QUALITY_RATE
|
||||
},
|
||||
"alerts": {
|
||||
"enabled": settings.ALERTS_ENABLED,
|
||||
"evaluation_interval_minutes": settings.ALERT_EVALUATION_INTERVAL_MINUTES,
|
||||
"retention_days": settings.ALERT_RETENTION_DAYS,
|
||||
"critical_delivery_delay_hours": settings.CRITICAL_DELIVERY_DELAY_HOURS,
|
||||
"critical_quality_rejection_rate": settings.CRITICAL_QUALITY_REJECTION_RATE
|
||||
},
|
||||
"dashboard": {
|
||||
"cache_ttl_seconds": settings.DASHBOARD_CACHE_TTL,
|
||||
"refresh_interval_seconds": settings.DASHBOARD_REFRESH_INTERVAL,
|
||||
"default_analytics_period_days": settings.DEFAULT_ANALYTICS_PERIOD_DAYS
|
||||
},
|
||||
"business_model": {
|
||||
"detection_enabled": settings.ENABLE_BUSINESS_MODEL_DETECTION,
|
||||
"central_bakery_threshold": settings.CENTRAL_BAKERY_THRESHOLD_SUPPLIERS,
|
||||
"individual_bakery_threshold": settings.INDIVIDUAL_BAKERY_THRESHOLD_SUPPLIERS
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting performance config",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve performance configuration"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("performance/health")
|
||||
)
|
||||
async def get_performance_health(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep)
|
||||
):
|
||||
"""Get performance service health status"""
|
||||
try:
|
||||
return {
|
||||
"service": "suppliers-performance",
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"tenant_id": str(tenant_id),
|
||||
"features": {
|
||||
"performance_tracking": "enabled",
|
||||
"alerts": "enabled",
|
||||
"dashboard_analytics": "enabled",
|
||||
"business_model_detection": "enabled"
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting performance health",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get performance health status"
|
||||
)
|
||||
237
services/suppliers/app/api/audit.py
Normal file
237
services/suppliers/app/api/audit.py
Normal file
@@ -0,0 +1,237 @@
|
||||
# services/suppliers/app/api/audit.py
|
||||
"""
|
||||
Audit Logs API - Retrieve audit trail for suppliers service
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||
from typing import Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
import structlog
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import AuditLog
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.access_control import require_user_role
|
||||
from shared.routing import RouteBuilder
|
||||
from shared.models.audit_log_schemas import (
|
||||
AuditLogResponse,
|
||||
AuditLogListResponse,
|
||||
AuditLogStatsResponse
|
||||
)
|
||||
from app.core.database import database_manager
|
||||
|
||||
route_builder = RouteBuilder('suppliers')
|
||||
router = APIRouter(tags=["audit-logs"])
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""Database session dependency"""
|
||||
async with database_manager.get_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("audit-logs"),
|
||||
response_model=AuditLogListResponse
|
||||
)
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def get_audit_logs(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
start_date: Optional[datetime] = Query(None, description="Filter logs from this date"),
|
||||
end_date: Optional[datetime] = Query(None, description="Filter logs until this date"),
|
||||
user_id: Optional[UUID] = Query(None, description="Filter by user ID"),
|
||||
action: Optional[str] = Query(None, description="Filter by action type"),
|
||||
resource_type: Optional[str] = Query(None, description="Filter by resource type"),
|
||||
severity: Optional[str] = Query(None, description="Filter by severity level"),
|
||||
search: Optional[str] = Query(None, description="Search in description field"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||
offset: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get audit logs for suppliers service.
|
||||
Requires admin or owner role.
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Retrieving audit logs",
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user.get("user_id"),
|
||||
filters={
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"action": action,
|
||||
"resource_type": resource_type,
|
||||
"severity": severity
|
||||
}
|
||||
)
|
||||
|
||||
# Build query filters
|
||||
filters = [AuditLog.tenant_id == tenant_id]
|
||||
|
||||
if start_date:
|
||||
filters.append(AuditLog.created_at >= start_date)
|
||||
if end_date:
|
||||
filters.append(AuditLog.created_at <= end_date)
|
||||
if user_id:
|
||||
filters.append(AuditLog.user_id == user_id)
|
||||
if action:
|
||||
filters.append(AuditLog.action == action)
|
||||
if resource_type:
|
||||
filters.append(AuditLog.resource_type == resource_type)
|
||||
if severity:
|
||||
filters.append(AuditLog.severity == severity)
|
||||
if search:
|
||||
filters.append(AuditLog.description.ilike(f"%{search}%"))
|
||||
|
||||
# Count total matching records
|
||||
count_query = select(func.count()).select_from(AuditLog).where(and_(*filters))
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Fetch paginated results
|
||||
query = (
|
||||
select(AuditLog)
|
||||
.where(and_(*filters))
|
||||
.order_by(AuditLog.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
audit_logs = result.scalars().all()
|
||||
|
||||
# Convert to response models
|
||||
items = [AuditLogResponse.from_orm(log) for log in audit_logs]
|
||||
|
||||
logger.info(
|
||||
"Successfully retrieved audit logs",
|
||||
tenant_id=tenant_id,
|
||||
total=total,
|
||||
returned=len(items)
|
||||
)
|
||||
|
||||
return AuditLogListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
has_more=(offset + len(items)) < total
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to retrieve audit logs",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to retrieve audit logs: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("audit-logs/stats"),
|
||||
response_model=AuditLogStatsResponse
|
||||
)
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def get_audit_log_stats(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
start_date: Optional[datetime] = Query(None, description="Filter logs from this date"),
|
||||
end_date: Optional[datetime] = Query(None, description="Filter logs until this date"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get audit log statistics for suppliers service.
|
||||
Requires admin or owner role.
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Retrieving audit log statistics",
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Build base filters
|
||||
filters = [AuditLog.tenant_id == tenant_id]
|
||||
if start_date:
|
||||
filters.append(AuditLog.created_at >= start_date)
|
||||
if end_date:
|
||||
filters.append(AuditLog.created_at <= end_date)
|
||||
|
||||
# Total events
|
||||
count_query = select(func.count()).select_from(AuditLog).where(and_(*filters))
|
||||
total_result = await db.execute(count_query)
|
||||
total_events = total_result.scalar() or 0
|
||||
|
||||
# Events by action
|
||||
action_query = (
|
||||
select(AuditLog.action, func.count().label('count'))
|
||||
.where(and_(*filters))
|
||||
.group_by(AuditLog.action)
|
||||
)
|
||||
action_result = await db.execute(action_query)
|
||||
events_by_action = {row.action: row.count for row in action_result}
|
||||
|
||||
# Events by severity
|
||||
severity_query = (
|
||||
select(AuditLog.severity, func.count().label('count'))
|
||||
.where(and_(*filters))
|
||||
.group_by(AuditLog.severity)
|
||||
)
|
||||
severity_result = await db.execute(severity_query)
|
||||
events_by_severity = {row.severity: row.count for row in severity_result}
|
||||
|
||||
# Events by resource type
|
||||
resource_query = (
|
||||
select(AuditLog.resource_type, func.count().label('count'))
|
||||
.where(and_(*filters))
|
||||
.group_by(AuditLog.resource_type)
|
||||
)
|
||||
resource_result = await db.execute(resource_query)
|
||||
events_by_resource_type = {row.resource_type: row.count for row in resource_result}
|
||||
|
||||
# Date range
|
||||
date_range_query = (
|
||||
select(
|
||||
func.min(AuditLog.created_at).label('min_date'),
|
||||
func.max(AuditLog.created_at).label('max_date')
|
||||
)
|
||||
.where(and_(*filters))
|
||||
)
|
||||
date_result = await db.execute(date_range_query)
|
||||
date_row = date_result.one()
|
||||
|
||||
logger.info(
|
||||
"Successfully retrieved audit log statistics",
|
||||
tenant_id=tenant_id,
|
||||
total_events=total_events
|
||||
)
|
||||
|
||||
return AuditLogStatsResponse(
|
||||
total_events=total_events,
|
||||
events_by_action=events_by_action,
|
||||
events_by_severity=events_by_severity,
|
||||
events_by_resource_type=events_by_resource_type,
|
||||
date_range={
|
||||
"min": date_row.min_date,
|
||||
"max": date_row.max_date
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to retrieve audit log statistics",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to retrieve audit log statistics: {str(e)}"
|
||||
)
|
||||
45
services/suppliers/app/api/internal.py
Normal file
45
services/suppliers/app/api/internal.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Internal API for Suppliers Service
|
||||
Handles internal service-to-service operations
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import settings
|
||||
from app.models.suppliers import Supplier, SupplierStatus
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
|
||||
@router.get("/count")
|
||||
async def get_supplier_count(
|
||||
tenant_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get count of active suppliers for onboarding status check.
|
||||
Internal endpoint for tenant service.
|
||||
"""
|
||||
try:
|
||||
count = await db.scalar(
|
||||
select(func.count()).select_from(Supplier)
|
||||
.where(
|
||||
Supplier.tenant_id == UUID(tenant_id),
|
||||
Supplier.status == SupplierStatus.active
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"count": count or 0,
|
||||
"tenant_id": tenant_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get supplier count", tenant_id=tenant_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get supplier count: {str(e)}")
|
||||
401
services/suppliers/app/api/internal_demo.py
Normal file
401
services/suppliers/app/api/internal_demo.py
Normal file
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
Internal Demo Cloning API for Suppliers Service
|
||||
Service-to-service endpoint for cloning supplier data
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
import structlog
|
||||
import uuid
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.suppliers import Supplier
|
||||
from app.core.config import settings
|
||||
|
||||
# Import demo_dates utilities at the top level
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
from shared.utils.demo_dates import adjust_date_for_demo, resolve_time_marker
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter(prefix="/internal/demo", tags=["internal"])
|
||||
|
||||
# Base demo tenant IDs
|
||||
DEMO_TENANT_PROFESSIONAL = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
|
||||
|
||||
|
||||
def parse_date_field(
|
||||
field_value: any,
|
||||
session_time: datetime,
|
||||
field_name: str = "date"
|
||||
) -> Optional[datetime]:
|
||||
"""
|
||||
Parse a date field from JSON, supporting BASE_TS markers and ISO timestamps.
|
||||
|
||||
Args:
|
||||
field_value: The date field value (can be BASE_TS marker, ISO string, or None)
|
||||
session_time: Session creation time (timezone-aware UTC)
|
||||
field_name: Name of the field (for logging)
|
||||
|
||||
Returns:
|
||||
Timezone-aware UTC datetime or None
|
||||
"""
|
||||
if field_value is None:
|
||||
return None
|
||||
|
||||
# Handle BASE_TS markers
|
||||
if isinstance(field_value, str) and field_value.startswith("BASE_TS"):
|
||||
try:
|
||||
return resolve_time_marker(field_value, session_time)
|
||||
except (ValueError, AttributeError) as e:
|
||||
logger.warning(
|
||||
"Failed to resolve BASE_TS marker",
|
||||
field_name=field_name,
|
||||
marker=field_value,
|
||||
error=str(e)
|
||||
)
|
||||
return None
|
||||
|
||||
# Handle ISO timestamps (legacy format - convert to absolute datetime)
|
||||
if isinstance(field_value, str) and ('T' in field_value or 'Z' in field_value):
|
||||
try:
|
||||
parsed_date = datetime.fromisoformat(field_value.replace('Z', '+00:00'))
|
||||
# Adjust relative to session time
|
||||
return adjust_date_for_demo(parsed_date, session_time)
|
||||
except (ValueError, AttributeError) as e:
|
||||
logger.warning(
|
||||
"Failed to parse ISO timestamp",
|
||||
field_name=field_name,
|
||||
value=field_value,
|
||||
error=str(e)
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"Unknown date format",
|
||||
field_name=field_name,
|
||||
value=field_value,
|
||||
value_type=type(field_value).__name__
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/clone")
|
||||
async def clone_demo_data(
|
||||
base_tenant_id: str,
|
||||
virtual_tenant_id: str,
|
||||
demo_account_type: str,
|
||||
session_id: Optional[str] = None,
|
||||
session_created_at: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Clone suppliers service data for a virtual demo tenant
|
||||
|
||||
This endpoint creates fresh demo data by:
|
||||
1. Loading seed data from JSON files
|
||||
2. Applying XOR-based ID transformation
|
||||
3. Adjusting dates relative to session creation time
|
||||
4. Creating records in the virtual tenant
|
||||
|
||||
Args:
|
||||
base_tenant_id: Template tenant UUID (for reference)
|
||||
virtual_tenant_id: Target virtual tenant UUID
|
||||
demo_account_type: Type of demo account
|
||||
session_id: Originating session ID for tracing
|
||||
session_created_at: Session creation timestamp for date adjustment
|
||||
|
||||
Returns:
|
||||
Cloning status and record counts
|
||||
"""
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
try:
|
||||
# Validate UUIDs
|
||||
virtual_uuid = uuid.UUID(virtual_tenant_id)
|
||||
|
||||
# Parse session creation time for date adjustment
|
||||
if session_created_at:
|
||||
try:
|
||||
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
session_time = start_time
|
||||
else:
|
||||
session_time = start_time
|
||||
|
||||
logger.info(
|
||||
"Starting suppliers data cloning",
|
||||
base_tenant_id=base_tenant_id,
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
demo_account_type=demo_account_type,
|
||||
session_id=session_id,
|
||||
session_created_at=session_created_at
|
||||
)
|
||||
|
||||
# Load seed data from JSON files
|
||||
from shared.utils.seed_data_paths import get_seed_data_path
|
||||
|
||||
if demo_account_type == "professional":
|
||||
json_file = get_seed_data_path("professional", "05-suppliers.json")
|
||||
elif demo_account_type == "enterprise":
|
||||
json_file = get_seed_data_path("enterprise", "05-suppliers.json")
|
||||
elif demo_account_type == "enterprise_child":
|
||||
json_file = get_seed_data_path("enterprise", "05-suppliers.json", child_id=base_tenant_id)
|
||||
else:
|
||||
raise ValueError(f"Invalid demo account type: {demo_account_type}")
|
||||
|
||||
# Load JSON data
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
seed_data = json.load(f)
|
||||
|
||||
# Track cloning statistics
|
||||
stats = {
|
||||
"suppliers": 0
|
||||
}
|
||||
|
||||
# Create Suppliers
|
||||
for supplier_data in seed_data.get('suppliers', []):
|
||||
# Transform supplier ID using XOR
|
||||
from shared.utils.demo_id_transformer import transform_id
|
||||
try:
|
||||
supplier_uuid = uuid.UUID(supplier_data['id'])
|
||||
transformed_id = transform_id(supplier_data['id'], virtual_uuid)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to parse supplier UUID",
|
||||
supplier_id=supplier_data['id'],
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid UUID format in supplier data: {str(e)}"
|
||||
)
|
||||
|
||||
# Parse date fields (supports BASE_TS markers and ISO timestamps)
|
||||
adjusted_created_at = parse_date_field(
|
||||
supplier_data.get('created_at'),
|
||||
session_time,
|
||||
"created_at"
|
||||
)
|
||||
adjusted_updated_at = parse_date_field(
|
||||
supplier_data.get('updated_at'),
|
||||
session_time,
|
||||
"updated_at"
|
||||
) or adjusted_created_at # Fallback to created_at if not provided
|
||||
|
||||
# Map supplier_type to enum if it's a string
|
||||
from app.models.suppliers import SupplierType, SupplierStatus, PaymentTerms
|
||||
|
||||
supplier_type_value = supplier_data.get('supplier_type')
|
||||
if supplier_type_value is None:
|
||||
# Default to multi if supplier_type not provided
|
||||
supplier_type_value = SupplierType.multi
|
||||
elif isinstance(supplier_type_value, str):
|
||||
try:
|
||||
supplier_type_value = SupplierType[supplier_type_value]
|
||||
except KeyError:
|
||||
supplier_type_value = SupplierType.multi
|
||||
|
||||
# Map payment_terms to enum if it's a string
|
||||
payment_terms_value = supplier_data.get('payment_terms', 'net_30')
|
||||
if isinstance(payment_terms_value, str):
|
||||
try:
|
||||
payment_terms_value = PaymentTerms[payment_terms_value]
|
||||
except KeyError:
|
||||
payment_terms_value = PaymentTerms.net_30
|
||||
|
||||
# Map status to enum if provided
|
||||
status_value = supplier_data.get('status', 'active')
|
||||
if isinstance(status_value, str):
|
||||
try:
|
||||
status_value = SupplierStatus[status_value]
|
||||
except KeyError:
|
||||
status_value = SupplierStatus.active
|
||||
|
||||
# Map created_by and updated_by - use a system user UUID if not provided
|
||||
system_user_id = uuid.UUID('00000000-0000-0000-0000-000000000000')
|
||||
created_by = supplier_data.get('created_by', str(system_user_id))
|
||||
updated_by = supplier_data.get('updated_by', str(system_user_id))
|
||||
|
||||
new_supplier = Supplier(
|
||||
id=str(transformed_id),
|
||||
tenant_id=virtual_uuid,
|
||||
name=supplier_data['name'],
|
||||
supplier_code=supplier_data.get('supplier_code'),
|
||||
tax_id=supplier_data.get('tax_id'),
|
||||
registration_number=supplier_data.get('registration_number'),
|
||||
supplier_type=supplier_type_value,
|
||||
status=status_value,
|
||||
contact_person=supplier_data.get('contact_person'),
|
||||
email=supplier_data.get('email'),
|
||||
phone=supplier_data.get('phone'),
|
||||
mobile=supplier_data.get('mobile'),
|
||||
website=supplier_data.get('website'),
|
||||
address_line1=supplier_data.get('address_line1'),
|
||||
address_line2=supplier_data.get('address_line2'),
|
||||
city=supplier_data.get('city'),
|
||||
state_province=supplier_data.get('state_province'),
|
||||
postal_code=supplier_data.get('postal_code'),
|
||||
country=supplier_data.get('country'),
|
||||
payment_terms=payment_terms_value,
|
||||
credit_limit=supplier_data.get('credit_limit', 0.0),
|
||||
currency=supplier_data.get('currency', 'EUR'),
|
||||
standard_lead_time=supplier_data.get('standard_lead_time', 3),
|
||||
minimum_order_amount=supplier_data.get('minimum_order_amount'),
|
||||
delivery_area=supplier_data.get('delivery_area'),
|
||||
quality_rating=supplier_data.get('quality_rating', 0.0),
|
||||
delivery_rating=supplier_data.get('delivery_rating', 0.0),
|
||||
total_orders=supplier_data.get('total_orders', 0),
|
||||
total_amount=supplier_data.get('total_amount', 0.0),
|
||||
trust_score=supplier_data.get('trust_score', 0.0),
|
||||
is_preferred_supplier=supplier_data.get('is_preferred_supplier', False),
|
||||
auto_approve_enabled=supplier_data.get('auto_approve_enabled', False),
|
||||
total_pos_count=supplier_data.get('total_pos_count', 0),
|
||||
approved_pos_count=supplier_data.get('approved_pos_count', 0),
|
||||
on_time_delivery_rate=supplier_data.get('on_time_delivery_rate', 0.0),
|
||||
fulfillment_rate=supplier_data.get('fulfillment_rate', 0.0),
|
||||
last_performance_update=parse_date_field(
|
||||
supplier_data.get('last_performance_update'),
|
||||
session_time,
|
||||
"last_performance_update"
|
||||
),
|
||||
approved_by=supplier_data.get('approved_by'),
|
||||
approved_at=parse_date_field(
|
||||
supplier_data.get('approved_at'),
|
||||
session_time,
|
||||
"approved_at"
|
||||
),
|
||||
rejection_reason=supplier_data.get('rejection_reason'),
|
||||
notes=supplier_data.get('notes'),
|
||||
certifications=supplier_data.get('certifications'),
|
||||
business_hours=supplier_data.get('business_hours'),
|
||||
specializations=supplier_data.get('specializations'),
|
||||
created_at=adjusted_created_at,
|
||||
updated_at=adjusted_updated_at,
|
||||
created_by=created_by,
|
||||
updated_by=updated_by
|
||||
)
|
||||
db.add(new_supplier)
|
||||
stats["suppliers"] += 1
|
||||
|
||||
|
||||
await db.commit()
|
||||
|
||||
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
||||
|
||||
logger.info(
|
||||
"Suppliers data cloned successfully",
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
suppliers_cloned=stats["suppliers"],
|
||||
duration_ms=duration_ms
|
||||
)
|
||||
|
||||
return {
|
||||
"service": "suppliers",
|
||||
"status": "completed",
|
||||
"records_cloned": stats["suppliers"],
|
||||
"duration_ms": duration_ms,
|
||||
"details": {
|
||||
"suppliers": stats["suppliers"],
|
||||
"virtual_tenant_id": str(virtual_tenant_id)
|
||||
}
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error("Invalid UUID format", error=str(e), virtual_tenant_id=virtual_tenant_id)
|
||||
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to clone suppliers data",
|
||||
error=str(e),
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
# Rollback on error
|
||||
await db.rollback()
|
||||
|
||||
return {
|
||||
"service": "suppliers",
|
||||
"status": "failed",
|
||||
"records_cloned": 0,
|
||||
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/clone/health")
|
||||
async def clone_health_check():
|
||||
"""
|
||||
Health check for internal cloning endpoint
|
||||
Used by orchestrator to verify service availability
|
||||
"""
|
||||
return {
|
||||
"service": "suppliers",
|
||||
"clone_endpoint": "available",
|
||||
"version": "2.0.0"
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/tenant/{virtual_tenant_id}")
|
||||
async def delete_demo_tenant_data(
|
||||
virtual_tenant_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Delete all demo data for a virtual tenant.
|
||||
This endpoint is idempotent - safe to call multiple times.
|
||||
"""
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
records_deleted = {
|
||||
"suppliers": 0,
|
||||
"total": 0
|
||||
}
|
||||
|
||||
try:
|
||||
# Delete suppliers
|
||||
result = await db.execute(
|
||||
delete(Supplier)
|
||||
.where(Supplier.tenant_id == virtual_tenant_id)
|
||||
)
|
||||
records_deleted["suppliers"] = result.rowcount
|
||||
|
||||
records_deleted["total"] = records_deleted["suppliers"]
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"demo_data_deleted",
|
||||
service="suppliers",
|
||||
virtual_tenant_id=str(virtual_tenant_id),
|
||||
records_deleted=records_deleted
|
||||
)
|
||||
|
||||
return {
|
||||
"service": "suppliers",
|
||||
"status": "deleted",
|
||||
"virtual_tenant_id": str(virtual_tenant_id),
|
||||
"records_deleted": records_deleted,
|
||||
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
"demo_data_deletion_failed",
|
||||
service="suppliers",
|
||||
virtual_tenant_id=str(virtual_tenant_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to delete demo data: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
276
services/suppliers/app/api/supplier_operations.py
Normal file
276
services/suppliers/app/api/supplier_operations.py
Normal file
@@ -0,0 +1,276 @@
|
||||
# services/suppliers/app/api/supplier_operations.py
|
||||
"""
|
||||
Supplier Business Operations API endpoints (BUSINESS)
|
||||
Handles approvals, status updates, active/top suppliers, and delivery/PO operations
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
import structlog
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.services.supplier_service import SupplierService
|
||||
from app.schemas.suppliers import (
|
||||
SupplierApproval, SupplierResponse, SupplierSummary, SupplierStatistics
|
||||
)
|
||||
from app.models.suppliers import SupplierType
|
||||
from app.models import AuditLog
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.routing import RouteBuilder
|
||||
from shared.auth.access_control import require_user_role
|
||||
from shared.security import create_audit_logger, AuditSeverity, AuditAction
|
||||
|
||||
# Create route builder for consistent URL structure
|
||||
route_builder = RouteBuilder('suppliers')
|
||||
|
||||
|
||||
router = APIRouter(tags=["supplier-operations"])
|
||||
logger = structlog.get_logger()
|
||||
audit_logger = create_audit_logger("suppliers-service", AuditLog)
|
||||
|
||||
|
||||
# ===== Supplier Operations =====
|
||||
|
||||
@router.get(route_builder.build_operations_route("statistics"), response_model=SupplierStatistics)
|
||||
async def get_supplier_statistics(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get supplier statistics for dashboard"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
stats = await service.get_supplier_statistics(UUID(tenant_id))
|
||||
return SupplierStatistics(**stats)
|
||||
except Exception as e:
|
||||
logger.error("Error getting supplier statistics", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve statistics")
|
||||
|
||||
|
||||
@router.get(route_builder.build_operations_route("suppliers/active"), response_model=List[SupplierSummary])
|
||||
async def get_active_suppliers(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get all active suppliers"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
suppliers = await service.get_active_suppliers(UUID(tenant_id))
|
||||
return [SupplierSummary.from_orm(supplier) for supplier in suppliers]
|
||||
except Exception as e:
|
||||
logger.error("Error getting active suppliers", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve active suppliers")
|
||||
|
||||
|
||||
@router.get(route_builder.build_operations_route("suppliers/top"), response_model=List[SupplierSummary])
|
||||
async def get_top_suppliers(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
limit: int = Query(10, ge=1, le=50, description="Number of top suppliers to return"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get top performing suppliers"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
suppliers = await service.get_top_suppliers(UUID(tenant_id), limit)
|
||||
return [SupplierSummary.from_orm(supplier) for supplier in suppliers]
|
||||
except Exception as e:
|
||||
logger.error("Error getting top suppliers", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve top suppliers")
|
||||
|
||||
|
||||
@router.get(route_builder.build_operations_route("suppliers/pending-review"), response_model=List[SupplierSummary])
|
||||
async def get_suppliers_needing_review(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
days_since_last_order: int = Query(30, ge=1, le=365, description="Days since last order"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get suppliers that may need performance review"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
suppliers = await service.get_suppliers_needing_review(
|
||||
UUID(tenant_id), days_since_last_order
|
||||
)
|
||||
return [SupplierSummary.from_orm(supplier) for supplier in suppliers]
|
||||
except Exception as e:
|
||||
logger.error("Error getting suppliers needing review", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve suppliers needing review")
|
||||
|
||||
|
||||
@router.post(route_builder.build_resource_action_route("", "supplier_id", "approve"), response_model=SupplierResponse)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def approve_supplier(
|
||||
approval_data: SupplierApproval,
|
||||
supplier_id: UUID = Path(..., description="Supplier ID"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Approve or reject a pending supplier"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
|
||||
# Check supplier exists
|
||||
existing_supplier = await service.get_supplier(supplier_id)
|
||||
if not existing_supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
if approval_data.action == "approve":
|
||||
supplier = await service.approve_supplier(
|
||||
supplier_id=supplier_id,
|
||||
approved_by=current_user["user_id"],
|
||||
notes=approval_data.notes
|
||||
)
|
||||
elif approval_data.action == "reject":
|
||||
if not approval_data.notes:
|
||||
raise HTTPException(status_code=400, detail="Rejection reason is required")
|
||||
supplier = await service.reject_supplier(
|
||||
supplier_id=supplier_id,
|
||||
rejection_reason=approval_data.notes,
|
||||
rejected_by=current_user["user_id"]
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=400, detail="Supplier is not in pending approval status")
|
||||
|
||||
return SupplierResponse.from_orm(supplier)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error processing supplier approval", supplier_id=str(supplier_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to process supplier approval")
|
||||
|
||||
|
||||
@router.get(route_builder.build_resource_detail_route("types", "supplier_type"), response_model=List[SupplierSummary])
|
||||
async def get_suppliers_by_type(
|
||||
supplier_type: str = Path(..., description="Supplier type"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get suppliers by type"""
|
||||
try:
|
||||
# Validate supplier type
|
||||
try:
|
||||
type_enum = SupplierType(supplier_type.upper())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid supplier type")
|
||||
|
||||
service = SupplierService(db)
|
||||
suppliers = await service.get_suppliers_by_type(UUID(tenant_id), type_enum)
|
||||
return [SupplierSummary.from_orm(supplier) for supplier in suppliers]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error getting suppliers by type", supplier_type=supplier_type, error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve suppliers by type")
|
||||
|
||||
|
||||
@router.get(route_builder.build_operations_route("count"))
|
||||
async def get_supplier_count(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get total count of suppliers for a tenant
|
||||
Used for subscription usage tracking and dashboard metrics
|
||||
"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
suppliers = await service.get_active_suppliers(tenant_id=UUID(current_user["tenant_id"]))
|
||||
count = len(suppliers)
|
||||
|
||||
return {"count": count}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting supplier count", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve supplier count")
|
||||
|
||||
# ============================================================================
|
||||
# Tenant Data Deletion Operations (Internal Service Only)
|
||||
# ============================================================================
|
||||
|
||||
from shared.auth.access_control import service_only_access
|
||||
from shared.services.tenant_deletion import TenantDataDeletionResult
|
||||
from app.services.tenant_deletion_service import SuppliersTenantDeletionService
|
||||
|
||||
|
||||
@router.delete(
|
||||
route_builder.build_base_route("tenant/{tenant_id}", include_tenant_prefix=False),
|
||||
response_model=dict
|
||||
)
|
||||
@service_only_access
|
||||
async def delete_tenant_data(
|
||||
tenant_id: str = Path(..., description="Tenant ID to delete data for"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Delete all suppliers data for a tenant (Internal service only)
|
||||
"""
|
||||
try:
|
||||
logger.info("suppliers.tenant_deletion.api_called", tenant_id=tenant_id)
|
||||
|
||||
deletion_service = SuppliersTenantDeletionService(db)
|
||||
result = await deletion_service.safe_delete_tenant_data(tenant_id)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Tenant data deletion failed: {', '.join(result.errors)}"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Tenant data deletion completed successfully",
|
||||
"summary": result.to_dict()
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("suppliers.tenant_deletion.api_error", tenant_id=tenant_id, error=str(e), exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete tenant data: {str(e)}")
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("tenant/{tenant_id}/deletion-preview", include_tenant_prefix=False),
|
||||
response_model=dict
|
||||
)
|
||||
@service_only_access
|
||||
async def preview_tenant_data_deletion(
|
||||
tenant_id: str = Path(..., description="Tenant ID to preview deletion for"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Preview what data would be deleted for a tenant (dry-run)
|
||||
"""
|
||||
try:
|
||||
logger.info("suppliers.tenant_deletion.preview_called", tenant_id=tenant_id)
|
||||
|
||||
deletion_service = SuppliersTenantDeletionService(db)
|
||||
preview_data = await deletion_service.get_tenant_data_preview(tenant_id)
|
||||
result = TenantDataDeletionResult(tenant_id=tenant_id, service_name=deletion_service.service_name)
|
||||
result.deleted_counts = preview_data
|
||||
result.success = True
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Tenant deletion preview failed: {', '.join(result.errors)}"
|
||||
)
|
||||
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"service": "suppliers-service",
|
||||
"data_counts": result.deleted_counts,
|
||||
"total_items": sum(result.deleted_counts.values())
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("suppliers.tenant_deletion.preview_error", tenant_id=tenant_id, error=str(e), exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to preview tenant data deletion: {str(e)}")
|
||||
722
services/suppliers/app/api/suppliers.py
Normal file
722
services/suppliers/app/api/suppliers.py
Normal file
@@ -0,0 +1,722 @@
|
||||
# services/suppliers/app/api/suppliers.py
|
||||
"""
|
||||
Supplier CRUD API endpoints (ATOMIC)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
import httpx
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import get_db
|
||||
from app.services.supplier_service import SupplierService
|
||||
from app.models.suppliers import SupplierPriceList
|
||||
from app.models import AuditLog
|
||||
from app.schemas.suppliers import (
|
||||
SupplierCreate, SupplierUpdate, SupplierResponse, SupplierSummary,
|
||||
SupplierSearchParams, SupplierDeletionSummary,
|
||||
SupplierPriceListCreate, SupplierPriceListUpdate, SupplierPriceListResponse
|
||||
)
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.routing import RouteBuilder
|
||||
from shared.auth.access_control import require_user_role
|
||||
from shared.security import create_audit_logger, AuditSeverity, AuditAction
|
||||
|
||||
# Create route builder for consistent URL structure
|
||||
route_builder = RouteBuilder('suppliers')
|
||||
|
||||
|
||||
router = APIRouter(tags=["suppliers"])
|
||||
logger = structlog.get_logger()
|
||||
audit_logger = create_audit_logger("suppliers-service", AuditLog)
|
||||
|
||||
@router.post(route_builder.build_base_route(""), response_model=SupplierResponse)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def create_supplier(
|
||||
supplier_data: SupplierCreate,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a new supplier"""
|
||||
try:
|
||||
# CRITICAL: Check subscription limit before creating
|
||||
from app.core.config import settings
|
||||
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
try:
|
||||
limit_check_response = await client.get(
|
||||
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/suppliers/can-add",
|
||||
headers={
|
||||
"x-user-id": str(current_user.get('user_id')),
|
||||
"x-tenant-id": str(tenant_id)
|
||||
}
|
||||
)
|
||||
|
||||
if limit_check_response.status_code == 200:
|
||||
limit_check = limit_check_response.json()
|
||||
|
||||
if not limit_check.get('can_add', False):
|
||||
logger.warning(
|
||||
"Supplier limit exceeded",
|
||||
tenant_id=tenant_id,
|
||||
current=limit_check.get('current_count'),
|
||||
max=limit_check.get('max_allowed'),
|
||||
reason=limit_check.get('reason')
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": "supplier_limit_exceeded",
|
||||
"message": limit_check.get('reason', 'Supplier limit exceeded'),
|
||||
"current_count": limit_check.get('current_count'),
|
||||
"max_allowed": limit_check.get('max_allowed'),
|
||||
"upgrade_required": True
|
||||
}
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to check supplier limit, allowing creation",
|
||||
tenant_id=tenant_id,
|
||||
status_code=limit_check_response.status_code
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("Timeout checking supplier limit, allowing creation", tenant_id=tenant_id)
|
||||
except httpx.RequestError as e:
|
||||
logger.warning("Error checking supplier limit, allowing creation", tenant_id=tenant_id, error=str(e))
|
||||
|
||||
service = SupplierService(db)
|
||||
|
||||
# Get user role from current_user dict
|
||||
user_role = current_user.get("role", "member").lower()
|
||||
|
||||
supplier = await service.create_supplier(
|
||||
tenant_id=UUID(tenant_id),
|
||||
supplier_data=supplier_data,
|
||||
created_by=current_user["user_id"],
|
||||
created_by_role=user_role
|
||||
)
|
||||
|
||||
logger.info("Supplier created successfully", tenant_id=tenant_id, supplier_id=str(supplier.id), supplier_name=supplier.name)
|
||||
|
||||
return SupplierResponse.from_orm(supplier)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error creating supplier", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to create supplier")
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route(""), response_model=List[SupplierSummary])
|
||||
async def list_suppliers(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
search_term: Optional[str] = Query(None, description="Search term"),
|
||||
supplier_type: Optional[str] = Query(None, description="Supplier type filter"),
|
||||
status: Optional[str] = Query(None, description="Status filter"),
|
||||
limit: int = Query(50, ge=1, le=1000, description="Number of results to return"),
|
||||
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""List suppliers with optional filters"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
search_params = SupplierSearchParams(
|
||||
search_term=search_term,
|
||||
supplier_type=supplier_type,
|
||||
status=status,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
suppliers = await service.search_suppliers(
|
||||
tenant_id=UUID(tenant_id),
|
||||
search_params=search_params
|
||||
)
|
||||
return [SupplierSummary.from_orm(supplier) for supplier in suppliers]
|
||||
except Exception as e:
|
||||
logger.error("Error listing suppliers", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve suppliers")
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route("batch"), response_model=List[SupplierSummary])
|
||||
async def get_suppliers_batch(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
ids: str = Query(..., description="Comma-separated supplier IDs"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get multiple suppliers in a single call for performance optimization.
|
||||
|
||||
This endpoint is designed to eliminate N+1 query patterns when fetching
|
||||
supplier data for multiple purchase orders or other entities.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
ids: Comma-separated supplier IDs (e.g., "abc123,def456,xyz789")
|
||||
|
||||
Returns:
|
||||
List of supplier summaries for the requested IDs
|
||||
"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
|
||||
# Parse comma-separated IDs
|
||||
supplier_ids = [id.strip() for id in ids.split(",") if id.strip()]
|
||||
|
||||
if not supplier_ids:
|
||||
return []
|
||||
|
||||
if len(supplier_ids) > 100:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Maximum 100 supplier IDs allowed per batch request"
|
||||
)
|
||||
|
||||
# Convert to UUIDs
|
||||
try:
|
||||
uuid_ids = [UUID(id) for id in supplier_ids]
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid supplier ID format: {e}")
|
||||
|
||||
# Fetch suppliers
|
||||
suppliers = await service.get_suppliers_batch(tenant_id=UUID(tenant_id), supplier_ids=uuid_ids)
|
||||
|
||||
logger.info(
|
||||
"Batch retrieved suppliers",
|
||||
tenant_id=tenant_id,
|
||||
requested_count=len(supplier_ids),
|
||||
found_count=len(suppliers)
|
||||
)
|
||||
|
||||
return [SupplierSummary.from_orm(supplier) for supplier in suppliers]
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error batch retrieving suppliers", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve suppliers")
|
||||
|
||||
|
||||
@router.get(route_builder.build_resource_detail_route("", "supplier_id"), response_model=SupplierResponse)
|
||||
async def get_supplier(
|
||||
supplier_id: UUID = Path(..., description="Supplier ID"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get supplier by ID"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
supplier = await service.get_supplier(supplier_id)
|
||||
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
return SupplierResponse.from_orm(supplier)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error getting supplier", supplier_id=str(supplier_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve supplier")
|
||||
|
||||
|
||||
@router.put(route_builder.build_resource_detail_route("", "supplier_id"), response_model=SupplierResponse)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def update_supplier(
|
||||
supplier_data: SupplierUpdate,
|
||||
supplier_id: UUID = Path(..., description="Supplier ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update supplier information"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
|
||||
# Check supplier exists
|
||||
existing_supplier = await service.get_supplier(supplier_id)
|
||||
if not existing_supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
supplier = await service.update_supplier(
|
||||
supplier_id=supplier_id,
|
||||
supplier_data=supplier_data,
|
||||
updated_by=current_user["user_id"]
|
||||
)
|
||||
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
return SupplierResponse.from_orm(supplier)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error updating supplier", supplier_id=str(supplier_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to update supplier")
|
||||
|
||||
|
||||
@router.delete(route_builder.build_resource_detail_route("", "supplier_id"))
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def delete_supplier(
|
||||
supplier_id: UUID = Path(..., description="Supplier ID"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete supplier (soft delete, Admin+ only)"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
|
||||
# Check supplier exists
|
||||
existing_supplier = await service.get_supplier(supplier_id)
|
||||
if not existing_supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
# Capture supplier data before deletion
|
||||
supplier_data = {
|
||||
"supplier_name": existing_supplier.name,
|
||||
"supplier_type": existing_supplier.supplier_type,
|
||||
"contact_person": existing_supplier.contact_person,
|
||||
"email": existing_supplier.email
|
||||
}
|
||||
|
||||
success = await service.delete_supplier(supplier_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
# Log audit event for supplier deletion
|
||||
try:
|
||||
# Get sync db session for audit logging
|
||||
from app.core.database import SessionLocal
|
||||
sync_db = SessionLocal()
|
||||
try:
|
||||
await audit_logger.log_deletion(
|
||||
db_session=sync_db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user["user_id"],
|
||||
resource_type="supplier",
|
||||
resource_id=str(supplier_id),
|
||||
resource_data=supplier_data,
|
||||
description=f"Admin {current_user.get('email', 'unknown')} deleted supplier",
|
||||
endpoint=f"/suppliers/{supplier_id}",
|
||||
method="DELETE"
|
||||
)
|
||||
sync_db.commit()
|
||||
finally:
|
||||
sync_db.close()
|
||||
except Exception as audit_error:
|
||||
logger.warning("Failed to log audit event", error=str(audit_error))
|
||||
|
||||
logger.info("Deleted supplier",
|
||||
supplier_id=str(supplier_id),
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user["user_id"])
|
||||
|
||||
return {"message": "Supplier deleted successfully"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error deleting supplier", supplier_id=str(supplier_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to delete supplier")
|
||||
|
||||
|
||||
@router.delete(
|
||||
route_builder.build_resource_action_route("", "supplier_id", "hard"),
|
||||
response_model=SupplierDeletionSummary
|
||||
)
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def hard_delete_supplier(
|
||||
supplier_id: UUID = Path(..., description="Supplier ID"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Hard delete supplier and all associated data (Admin/Owner only, permanent)"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
|
||||
# Check supplier exists
|
||||
existing_supplier = await service.get_supplier(supplier_id)
|
||||
if not existing_supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
# Capture supplier data before deletion
|
||||
supplier_data = {
|
||||
"id": str(existing_supplier.id),
|
||||
"name": existing_supplier.name,
|
||||
"status": existing_supplier.status.value,
|
||||
"supplier_code": existing_supplier.supplier_code
|
||||
}
|
||||
|
||||
# Perform hard deletion
|
||||
deletion_summary = await service.hard_delete_supplier(supplier_id, UUID(tenant_id))
|
||||
|
||||
# Log audit event for hard deletion
|
||||
try:
|
||||
# Get sync db session for audit logging
|
||||
from app.core.database import SessionLocal
|
||||
sync_db = SessionLocal()
|
||||
try:
|
||||
await audit_logger.log_deletion(
|
||||
db_session=sync_db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user["user_id"],
|
||||
resource_type="supplier",
|
||||
resource_id=str(supplier_id),
|
||||
resource_data=supplier_data,
|
||||
description=f"Hard deleted supplier '{supplier_data['name']}' and all associated data",
|
||||
endpoint=f"/suppliers/{supplier_id}/hard",
|
||||
method="DELETE",
|
||||
metadata=deletion_summary
|
||||
)
|
||||
sync_db.commit()
|
||||
finally:
|
||||
sync_db.close()
|
||||
except Exception as audit_error:
|
||||
logger.warning("Failed to log audit event", error=str(audit_error))
|
||||
|
||||
logger.info("Hard deleted supplier",
|
||||
supplier_id=str(supplier_id),
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user["user_id"],
|
||||
deletion_summary=deletion_summary)
|
||||
|
||||
return deletion_summary
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error hard deleting supplier", supplier_id=str(supplier_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to hard delete supplier")
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("count"),
|
||||
response_model=dict
|
||||
)
|
||||
async def count_suppliers(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get count of suppliers for a tenant"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
|
||||
# Use search with maximum allowed limit to get all suppliers
|
||||
search_params = SupplierSearchParams(limit=1000)
|
||||
suppliers = await service.search_suppliers(
|
||||
tenant_id=UUID(tenant_id),
|
||||
search_params=search_params
|
||||
)
|
||||
|
||||
count = len(suppliers)
|
||||
logger.info("Retrieved supplier count", tenant_id=tenant_id, count=count)
|
||||
|
||||
return {"count": count}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error counting suppliers", tenant_id=tenant_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to count suppliers")
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_resource_action_route("", "supplier_id", "products"),
|
||||
response_model=List[Dict[str, Any]]
|
||||
)
|
||||
async def get_supplier_products(
|
||||
supplier_id: UUID = Path(..., description="Supplier ID"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
is_active: bool = Query(True, description="Filter by active price lists"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get list of product IDs that a supplier provides
|
||||
Returns a list of inventory product IDs from the supplier's price list
|
||||
"""
|
||||
try:
|
||||
# Query supplier price lists
|
||||
query = select(SupplierPriceList).where(
|
||||
SupplierPriceList.tenant_id == UUID(tenant_id),
|
||||
SupplierPriceList.supplier_id == supplier_id
|
||||
)
|
||||
|
||||
if is_active:
|
||||
query = query.where(SupplierPriceList.is_active == True)
|
||||
|
||||
result = await db.execute(query)
|
||||
price_lists = result.scalars().all()
|
||||
|
||||
# Extract unique product IDs
|
||||
product_ids = list(set([str(pl.inventory_product_id) for pl in price_lists]))
|
||||
|
||||
logger.info(
|
||||
"Retrieved supplier products",
|
||||
supplier_id=str(supplier_id),
|
||||
product_count=len(product_ids)
|
||||
)
|
||||
|
||||
return [{"inventory_product_id": pid} for pid in product_ids]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error getting supplier products",
|
||||
supplier_id=str(supplier_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to retrieve supplier products"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_resource_action_route("", "supplier_id", "price-lists"),
|
||||
response_model=List[SupplierPriceListResponse]
|
||||
)
|
||||
async def get_supplier_price_lists(
|
||||
supplier_id: UUID = Path(..., description="Supplier ID"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
is_active: bool = Query(True, description="Filter by active price lists"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get all price list items for a supplier"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
price_lists = await service.get_supplier_price_lists(
|
||||
supplier_id=supplier_id,
|
||||
tenant_id=UUID(tenant_id),
|
||||
is_active=is_active
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Retrieved supplier price lists",
|
||||
supplier_id=str(supplier_id),
|
||||
count=len(price_lists)
|
||||
)
|
||||
|
||||
return [SupplierPriceListResponse.from_orm(pl) for pl in price_lists]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error getting supplier price lists",
|
||||
supplier_id=str(supplier_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to retrieve supplier price lists"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_resource_action_route("", "supplier_id", "price-lists/{price_list_id}"),
|
||||
response_model=SupplierPriceListResponse
|
||||
)
|
||||
async def get_supplier_price_list(
|
||||
supplier_id: UUID = Path(..., description="Supplier ID"),
|
||||
price_list_id: UUID = Path(..., description="Price List ID"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get specific price list item for a supplier"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
price_list = await service.get_supplier_price_list(
|
||||
price_list_id=price_list_id,
|
||||
tenant_id=UUID(tenant_id)
|
||||
)
|
||||
|
||||
if not price_list:
|
||||
raise HTTPException(status_code=404, detail="Price list item not found")
|
||||
|
||||
logger.info(
|
||||
"Retrieved supplier price list item",
|
||||
supplier_id=str(supplier_id),
|
||||
price_list_id=str(price_list_id)
|
||||
)
|
||||
|
||||
return SupplierPriceListResponse.from_orm(price_list)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error getting supplier price list item",
|
||||
supplier_id=str(supplier_id),
|
||||
price_list_id=str(price_list_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to retrieve supplier price list item"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
route_builder.build_resource_action_route("", "supplier_id", "price-lists"),
|
||||
response_model=SupplierPriceListResponse
|
||||
)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def create_supplier_price_list(
|
||||
supplier_id: UUID = Path(..., description="Supplier ID"),
|
||||
price_list_data: SupplierPriceListCreate = None,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a new price list item for a supplier"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
|
||||
# Verify supplier exists
|
||||
supplier = await service.get_supplier(supplier_id)
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
price_list = await service.create_supplier_price_list(
|
||||
supplier_id=supplier_id,
|
||||
price_list_data=price_list_data,
|
||||
tenant_id=UUID(tenant_id),
|
||||
created_by=UUID(current_user["user_id"])
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Created supplier price list item",
|
||||
supplier_id=str(supplier_id),
|
||||
price_list_id=str(price_list.id)
|
||||
)
|
||||
|
||||
return SupplierPriceListResponse.from_orm(price_list)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error creating supplier price list item",
|
||||
supplier_id=str(supplier_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to create supplier price list item"
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
route_builder.build_resource_action_route("", "supplier_id", "price-lists/{price_list_id}"),
|
||||
response_model=SupplierPriceListResponse
|
||||
)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def update_supplier_price_list(
|
||||
supplier_id: UUID = Path(..., description="Supplier ID"),
|
||||
price_list_id: UUID = Path(..., description="Price List ID"),
|
||||
price_list_data: SupplierPriceListUpdate = None,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update a price list item for a supplier"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
|
||||
# Verify supplier and price list exist
|
||||
supplier = await service.get_supplier(supplier_id)
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
price_list = await service.get_supplier_price_list(
|
||||
price_list_id=price_list_id,
|
||||
tenant_id=UUID(tenant_id)
|
||||
)
|
||||
if not price_list:
|
||||
raise HTTPException(status_code=404, detail="Price list item not found")
|
||||
|
||||
updated_price_list = await service.update_supplier_price_list(
|
||||
price_list_id=price_list_id,
|
||||
price_list_data=price_list_data,
|
||||
tenant_id=UUID(tenant_id),
|
||||
updated_by=UUID(current_user["user_id"])
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Updated supplier price list item",
|
||||
supplier_id=str(supplier_id),
|
||||
price_list_id=str(price_list_id)
|
||||
)
|
||||
|
||||
return SupplierPriceListResponse.from_orm(updated_price_list)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error updating supplier price list item",
|
||||
supplier_id=str(supplier_id),
|
||||
price_list_id=str(price_list_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to update supplier price list item"
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
route_builder.build_resource_action_route("", "supplier_id", "price-lists/{price_list_id}")
|
||||
)
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def delete_supplier_price_list(
|
||||
supplier_id: UUID = Path(..., description="Supplier ID"),
|
||||
price_list_id: UUID = Path(..., description="Price List ID"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete a price list item for a supplier"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
|
||||
# Verify supplier and price list exist
|
||||
supplier = await service.get_supplier(supplier_id)
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
price_list = await service.get_supplier_price_list(
|
||||
price_list_id=price_list_id,
|
||||
tenant_id=UUID(tenant_id)
|
||||
)
|
||||
if not price_list:
|
||||
raise HTTPException(status_code=404, detail="Price list item not found")
|
||||
|
||||
success = await service.delete_supplier_price_list(
|
||||
price_list_id=price_list_id,
|
||||
tenant_id=UUID(tenant_id)
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Price list item not found")
|
||||
|
||||
logger.info(
|
||||
"Deleted supplier price list item",
|
||||
supplier_id=str(supplier_id),
|
||||
price_list_id=str(price_list_id)
|
||||
)
|
||||
|
||||
return {"message": "Price list item deleted successfully"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error deleting supplier price list item",
|
||||
supplier_id=str(supplier_id),
|
||||
price_list_id=str(price_list_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to delete supplier price list item"
|
||||
)
|
||||
Reference in New Issue
Block a user