Initial commit - production deployment

This commit is contained in:
2026-01-21 17:17:16 +01:00
commit c23d00dd92
2289 changed files with 638440 additions and 0 deletions

View File

@@ -0,0 +1 @@
# services/suppliers/app/api/__init__.py

View 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"
)

View 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)}"
)

View 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)}")

View 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)}"
)

View 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)}")

View 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"
)