REFACTOR ALL APIs
This commit is contained in:
314
services/inventory/app/api/analytics.py
Normal file
314
services/inventory/app/api/analytics.py
Normal file
@@ -0,0 +1,314 @@
|
||||
# services/inventory/app/api/analytics.py
|
||||
"""
|
||||
Analytics API endpoints for Inventory Service
|
||||
Following standardized URL structure: /api/v1/tenants/{tenant_id}/inventory/analytics/{operation}
|
||||
Requires: Professional or Enterprise subscription tier
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.access_control import analytics_tier_required
|
||||
from app.core.database import get_db
|
||||
from app.services.inventory_service import InventoryService
|
||||
from app.services.dashboard_service import DashboardService
|
||||
from app.services.food_safety_service import FoodSafetyService
|
||||
from app.schemas.dashboard import (
|
||||
InventoryAnalytics,
|
||||
BusinessModelInsights,
|
||||
)
|
||||
from shared.routing import RouteBuilder
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Create route builder for consistent URL structure
|
||||
route_builder = RouteBuilder('inventory')
|
||||
|
||||
router = APIRouter(tags=["inventory-analytics"])
|
||||
|
||||
|
||||
# ===== Dependency Injection =====
|
||||
|
||||
async def get_dashboard_service(db: AsyncSession = Depends(get_db)) -> DashboardService:
|
||||
"""Get dashboard service with dependencies"""
|
||||
return DashboardService(
|
||||
inventory_service=InventoryService(),
|
||||
food_safety_service=FoodSafetyService()
|
||||
)
|
||||
|
||||
|
||||
# ===== ANALYTICS ENDPOINTS (Professional/Enterprise Only) =====
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("inventory-insights"),
|
||||
response_model=InventoryAnalytics
|
||||
)
|
||||
@analytics_tier_required
|
||||
async def get_inventory_analytics(
|
||||
tenant_id: UUID = Path(...),
|
||||
days_back: int = Query(30, ge=1, le=365, description="Number of days to analyze"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
dashboard_service: DashboardService = Depends(get_dashboard_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get advanced inventory analytics (Professional/Enterprise only)
|
||||
|
||||
Provides:
|
||||
- Stock turnover rates
|
||||
- Inventory valuation trends
|
||||
- ABC analysis
|
||||
- Stockout risk predictions
|
||||
- Seasonal patterns
|
||||
"""
|
||||
try:
|
||||
analytics = await dashboard_service.get_inventory_analytics(db, tenant_id, days_back)
|
||||
|
||||
logger.info("Inventory analytics retrieved",
|
||||
tenant_id=str(tenant_id),
|
||||
days_analyzed=days_back,
|
||||
user_id=current_user.get('user_id'))
|
||||
|
||||
return analytics
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting inventory analytics",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve inventory analytics"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("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 insights based on inventory patterns (Professional/Enterprise only)
|
||||
|
||||
Analyzes inventory patterns to provide insights on:
|
||||
- Detected business model (retail, wholesale, production, etc.)
|
||||
- Product mix recommendations
|
||||
- Inventory optimization suggestions
|
||||
"""
|
||||
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,
|
||||
user_id=current_user.get('user_id'))
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("turnover-rate"),
|
||||
response_model=dict
|
||||
)
|
||||
@analytics_tier_required
|
||||
async def get_inventory_turnover_rate(
|
||||
tenant_id: UUID = Path(...),
|
||||
start_date: Optional[datetime] = Query(None, description="Start date for analysis"),
|
||||
end_date: Optional[datetime] = Query(None, description="End date for analysis"),
|
||||
category: Optional[str] = Query(None, description="Filter by category"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Calculate inventory turnover rate (Professional/Enterprise only)
|
||||
|
||||
Metrics:
|
||||
- Overall turnover rate
|
||||
- By category
|
||||
- By product
|
||||
- Trend analysis
|
||||
"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
|
||||
# Set default dates if not provided
|
||||
if not end_date:
|
||||
end_date = datetime.now()
|
||||
if not start_date:
|
||||
start_date = end_date - timedelta(days=90)
|
||||
|
||||
# Calculate turnover metrics
|
||||
turnover_data = await service.calculate_turnover_rate(
|
||||
tenant_id,
|
||||
start_date,
|
||||
end_date,
|
||||
category
|
||||
)
|
||||
|
||||
logger.info("Turnover rate calculated",
|
||||
tenant_id=str(tenant_id),
|
||||
category=category,
|
||||
user_id=current_user.get('user_id'))
|
||||
|
||||
return turnover_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error calculating turnover rate",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to calculate turnover rate"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("abc-analysis"),
|
||||
response_model=dict
|
||||
)
|
||||
@analytics_tier_required
|
||||
async def get_abc_analysis(
|
||||
tenant_id: UUID = Path(...),
|
||||
days_back: int = Query(90, ge=30, le=365, description="Days to analyze"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Perform ABC analysis on inventory (Professional/Enterprise only)
|
||||
|
||||
Categorizes inventory items by:
|
||||
- A: High-value items requiring tight control
|
||||
- B: Moderate-value items with moderate control
|
||||
- C: Low-value items with simple control
|
||||
"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
|
||||
abc_analysis = await service.perform_abc_analysis(tenant_id, days_back)
|
||||
|
||||
logger.info("ABC analysis completed",
|
||||
tenant_id=str(tenant_id),
|
||||
days_analyzed=days_back,
|
||||
user_id=current_user.get('user_id'))
|
||||
|
||||
return abc_analysis
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error performing ABC analysis",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to perform ABC analysis"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("stockout-predictions"),
|
||||
response_model=dict
|
||||
)
|
||||
@analytics_tier_required
|
||||
async def get_stockout_predictions(
|
||||
tenant_id: UUID = Path(...),
|
||||
forecast_days: int = Query(30, ge=7, le=90, description="Days to forecast"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Predict potential stockouts (Professional/Enterprise only)
|
||||
|
||||
Provides:
|
||||
- Items at risk of stockout
|
||||
- Predicted stockout dates
|
||||
- Recommended reorder quantities
|
||||
- Lead time considerations
|
||||
"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
|
||||
predictions = await service.predict_stockouts(tenant_id, forecast_days)
|
||||
|
||||
logger.info("Stockout predictions generated",
|
||||
tenant_id=str(tenant_id),
|
||||
forecast_days=forecast_days,
|
||||
at_risk_items=len(predictions.get('items_at_risk', [])),
|
||||
user_id=current_user.get('user_id'))
|
||||
|
||||
return predictions
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error predicting stockouts",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to predict stockouts"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("waste-analysis"),
|
||||
response_model=dict
|
||||
)
|
||||
@analytics_tier_required
|
||||
async def get_waste_analysis(
|
||||
tenant_id: UUID = Path(...),
|
||||
start_date: Optional[datetime] = Query(None, description="Start date"),
|
||||
end_date: Optional[datetime] = Query(None, description="End date"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Analyze inventory waste and expiration (Professional/Enterprise only)
|
||||
|
||||
Metrics:
|
||||
- Total waste value
|
||||
- Waste by category
|
||||
- Expiration patterns
|
||||
- Optimization recommendations
|
||||
"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
|
||||
# Set default dates
|
||||
if not end_date:
|
||||
end_date = datetime.now()
|
||||
if not start_date:
|
||||
start_date = end_date - timedelta(days=30)
|
||||
|
||||
waste_analysis = await service.analyze_waste(tenant_id, start_date, end_date)
|
||||
|
||||
logger.info("Waste analysis completed",
|
||||
tenant_id=str(tenant_id),
|
||||
total_waste_value=waste_analysis.get('total_waste_value', 0),
|
||||
user_id=current_user.get('user_id'))
|
||||
|
||||
return waste_analysis
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error analyzing waste",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to analyze waste"
|
||||
)
|
||||
@@ -13,6 +13,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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.inventory_service import InventoryService
|
||||
from app.services.food_safety_service import FoodSafetyService
|
||||
@@ -31,6 +33,9 @@ from app.schemas.dashboard import (
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Create route builder for consistent URL structure
|
||||
route_builder = RouteBuilder('inventory')
|
||||
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
@@ -46,7 +51,10 @@ async def get_dashboard_service(db: AsyncSession = Depends(get_db)) -> Dashboard
|
||||
|
||||
# ===== Main Dashboard Endpoints =====
|
||||
|
||||
@router.get("/tenants/{tenant_id}/dashboard/summary", response_model=InventoryDashboardSummary)
|
||||
@router.get(
|
||||
route_builder.build_dashboard_route("summary"),
|
||||
response_model=InventoryDashboardSummary
|
||||
)
|
||||
async def get_inventory_dashboard_summary(
|
||||
tenant_id: UUID = Path(...),
|
||||
filters: Optional[DashboardFilter] = None,
|
||||
@@ -74,7 +82,10 @@ async def get_inventory_dashboard_summary(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/dashboard/food-safety", response_model=FoodSafetyDashboard)
|
||||
@router.get(
|
||||
route_builder.build_dashboard_route("food-safety"),
|
||||
response_model=FoodSafetyDashboard
|
||||
)
|
||||
async def get_food_safety_dashboard(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
@@ -101,7 +112,11 @@ async def get_food_safety_dashboard(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/dashboard/analytics", response_model=InventoryAnalytics)
|
||||
@router.get(
|
||||
route_builder.build_dashboard_route("analytics"),
|
||||
response_model=InventoryAnalytics
|
||||
)
|
||||
@analytics_tier_required
|
||||
async def get_inventory_analytics(
|
||||
tenant_id: UUID = Path(...),
|
||||
days_back: int = Query(30, ge=1, le=365, description="Number of days to analyze"),
|
||||
@@ -129,7 +144,10 @@ async def get_inventory_analytics(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/dashboard/business-model", response_model=BusinessModelInsights)
|
||||
@router.get(
|
||||
route_builder.build_dashboard_route("business-model"),
|
||||
response_model=BusinessModelInsights
|
||||
)
|
||||
async def get_business_model_insights(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
@@ -158,7 +176,10 @@ async def get_business_model_insights(
|
||||
|
||||
# ===== Detailed Dashboard Data Endpoints =====
|
||||
|
||||
@router.get("/tenants/{tenant_id}/dashboard/stock-status", response_model=List[StockStatusSummary])
|
||||
@router.get(
|
||||
route_builder.build_dashboard_route("stock-status"),
|
||||
response_model=List[StockStatusSummary]
|
||||
)
|
||||
async def get_stock_status_by_category(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
@@ -181,7 +202,10 @@ async def get_stock_status_by_category(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/dashboard/alerts-summary", response_model=List[AlertSummary])
|
||||
@router.get(
|
||||
route_builder.build_dashboard_route("alerts-summary"),
|
||||
response_model=List[AlertSummary]
|
||||
)
|
||||
async def get_alerts_summary(
|
||||
tenant_id: UUID = Path(...),
|
||||
filters: Optional[AlertsFilter] = None,
|
||||
@@ -205,7 +229,10 @@ async def get_alerts_summary(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/dashboard/recent-activity", response_model=List[RecentActivity])
|
||||
@router.get(
|
||||
route_builder.build_dashboard_route("recent-activity"),
|
||||
response_model=List[RecentActivity]
|
||||
)
|
||||
async def get_recent_activity(
|
||||
tenant_id: UUID = Path(...),
|
||||
limit: int = Query(20, ge=1, le=100, description="Number of activities to return"),
|
||||
@@ -234,7 +261,9 @@ async def get_recent_activity(
|
||||
|
||||
# ===== Real-time Data Endpoints =====
|
||||
|
||||
@router.get("/tenants/{tenant_id}/dashboard/live-metrics")
|
||||
@router.get(
|
||||
route_builder.build_dashboard_route("live-metrics")
|
||||
)
|
||||
async def get_live_metrics(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
@@ -261,7 +290,9 @@ async def get_live_metrics(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/dashboard/temperature-status")
|
||||
@router.get(
|
||||
route_builder.build_dashboard_route("temperature-status")
|
||||
)
|
||||
async def get_temperature_monitoring_status(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
@@ -289,7 +320,9 @@ async def get_temperature_monitoring_status(
|
||||
|
||||
# ===== Dashboard Configuration Endpoints =====
|
||||
|
||||
@router.get("/tenants/{tenant_id}/dashboard/config")
|
||||
@router.get(
|
||||
route_builder.build_dashboard_route("config")
|
||||
)
|
||||
async def get_dashboard_config(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep)
|
||||
@@ -335,7 +368,9 @@ async def get_dashboard_config(
|
||||
|
||||
# ===== Export and Reporting Endpoints =====
|
||||
|
||||
@router.get("/tenants/{tenant_id}/export/summary")
|
||||
@router.get(
|
||||
route_builder.build_operations_route("export/summary")
|
||||
)
|
||||
async def export_dashboard_summary(
|
||||
tenant_id: UUID = Path(...),
|
||||
format: str = Query("json", description="Export format: json, csv, excel"),
|
||||
@@ -380,7 +415,9 @@ async def export_dashboard_summary(
|
||||
|
||||
# ===== Health and Status Endpoints =====
|
||||
|
||||
@router.get("/tenants/{tenant_id}/health")
|
||||
@router.get(
|
||||
route_builder.build_base_route("health")
|
||||
)
|
||||
async def get_dashboard_health(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep)
|
||||
|
||||
@@ -1,634 +0,0 @@
|
||||
# ================================================================
|
||||
# services/inventory/app/api/food_safety.py
|
||||
# ================================================================
|
||||
"""
|
||||
Food Safety API endpoints for Inventory Service
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from app.core.database import get_db
|
||||
from app.services.food_safety_service import FoodSafetyService
|
||||
from app.schemas.food_safety import (
|
||||
FoodSafetyComplianceCreate,
|
||||
FoodSafetyComplianceUpdate,
|
||||
FoodSafetyComplianceResponse,
|
||||
TemperatureLogCreate,
|
||||
TemperatureLogResponse,
|
||||
FoodSafetyAlertCreate,
|
||||
FoodSafetyAlertUpdate,
|
||||
FoodSafetyAlertResponse,
|
||||
BulkTemperatureLogCreate,
|
||||
FoodSafetyFilter,
|
||||
TemperatureMonitoringFilter,
|
||||
FoodSafetyMetrics,
|
||||
TemperatureAnalytics
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
router = APIRouter(prefix="/food-safety", tags=["food-safety"])
|
||||
|
||||
|
||||
# ===== Dependency Injection =====
|
||||
|
||||
async def get_food_safety_service() -> FoodSafetyService:
|
||||
"""Get food safety service instance"""
|
||||
return FoodSafetyService()
|
||||
|
||||
|
||||
# ===== Compliance Management Endpoints =====
|
||||
|
||||
@router.post("/tenants/{tenant_id}/compliance", response_model=FoodSafetyComplianceResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_compliance_record(
|
||||
compliance_data: FoodSafetyComplianceCreate,
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
food_safety_service: FoodSafetyService = Depends(get_food_safety_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a new food safety compliance record"""
|
||||
try:
|
||||
# Ensure tenant_id matches
|
||||
compliance_data.tenant_id = tenant_id
|
||||
|
||||
compliance = await food_safety_service.create_compliance_record(
|
||||
db,
|
||||
compliance_data,
|
||||
user_id=UUID(current_user["sub"])
|
||||
)
|
||||
|
||||
logger.info("Compliance record created",
|
||||
compliance_id=str(compliance.id),
|
||||
standard=compliance.standard)
|
||||
|
||||
return compliance
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning("Invalid compliance data", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error creating compliance record", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create compliance record"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/compliance", response_model=List[FoodSafetyComplianceResponse])
|
||||
async def get_compliance_records(
|
||||
tenant_id: UUID = Path(...),
|
||||
ingredient_id: Optional[UUID] = Query(None, description="Filter by ingredient ID"),
|
||||
standard: Optional[str] = Query(None, description="Filter by compliance standard"),
|
||||
status_filter: Optional[str] = Query(None, description="Filter by compliance status"),
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get compliance records with filtering"""
|
||||
try:
|
||||
# Build query filters
|
||||
filters = {}
|
||||
if ingredient_id:
|
||||
filters["ingredient_id"] = ingredient_id
|
||||
if standard:
|
||||
filters["standard"] = standard
|
||||
if status_filter:
|
||||
filters["compliance_status"] = status_filter
|
||||
|
||||
# Query compliance records
|
||||
query = """
|
||||
SELECT * FROM food_safety_compliance
|
||||
WHERE tenant_id = :tenant_id AND is_active = true
|
||||
"""
|
||||
params = {"tenant_id": tenant_id}
|
||||
|
||||
if filters:
|
||||
for key, value in filters.items():
|
||||
query += f" AND {key} = :{key}"
|
||||
params[key] = value
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT :limit OFFSET :skip"
|
||||
params.update({"limit": limit, "skip": skip})
|
||||
|
||||
result = await db.execute(query, params)
|
||||
records = result.fetchall()
|
||||
|
||||
return [
|
||||
FoodSafetyComplianceResponse(**dict(record))
|
||||
for record in records
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting compliance records", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve compliance records"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/tenants/{tenant_id}/compliance/{compliance_id}", response_model=FoodSafetyComplianceResponse)
|
||||
async def update_compliance_record(
|
||||
compliance_data: FoodSafetyComplianceUpdate,
|
||||
tenant_id: UUID = Path(...),
|
||||
compliance_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
food_safety_service: FoodSafetyService = Depends(get_food_safety_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update an existing compliance record"""
|
||||
try:
|
||||
compliance = await food_safety_service.update_compliance_record(
|
||||
db,
|
||||
compliance_id,
|
||||
tenant_id,
|
||||
compliance_data,
|
||||
user_id=UUID(current_user["sub"])
|
||||
)
|
||||
|
||||
if not compliance:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Compliance record not found"
|
||||
)
|
||||
|
||||
logger.info("Compliance record updated",
|
||||
compliance_id=str(compliance.id))
|
||||
|
||||
return compliance
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating compliance record",
|
||||
compliance_id=str(compliance_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update compliance record"
|
||||
)
|
||||
|
||||
|
||||
# ===== Temperature Monitoring Endpoints =====
|
||||
|
||||
@router.post("/tenants/{tenant_id}/temperature", response_model=TemperatureLogResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def log_temperature(
|
||||
temp_data: TemperatureLogCreate,
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
food_safety_service: FoodSafetyService = Depends(get_food_safety_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Log a temperature reading"""
|
||||
try:
|
||||
# Ensure tenant_id matches
|
||||
temp_data.tenant_id = tenant_id
|
||||
|
||||
temp_log = await food_safety_service.log_temperature(
|
||||
db,
|
||||
temp_data,
|
||||
user_id=UUID(current_user["sub"])
|
||||
)
|
||||
|
||||
logger.info("Temperature logged",
|
||||
location=temp_data.storage_location,
|
||||
temperature=temp_data.temperature_celsius)
|
||||
|
||||
return temp_log
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error logging temperature", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to log temperature"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/temperature/bulk", response_model=List[TemperatureLogResponse])
|
||||
async def bulk_log_temperatures(
|
||||
bulk_data: BulkTemperatureLogCreate,
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
food_safety_service: FoodSafetyService = Depends(get_food_safety_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Bulk log temperature readings"""
|
||||
try:
|
||||
# Ensure tenant_id matches for all readings
|
||||
for reading in bulk_data.readings:
|
||||
reading.tenant_id = tenant_id
|
||||
|
||||
temp_logs = await food_safety_service.bulk_log_temperatures(
|
||||
db,
|
||||
bulk_data.readings,
|
||||
user_id=UUID(current_user["sub"])
|
||||
)
|
||||
|
||||
logger.info("Bulk temperature logging completed",
|
||||
count=len(bulk_data.readings))
|
||||
|
||||
return temp_logs
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error bulk logging temperatures", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to bulk log temperatures"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/temperature", response_model=List[TemperatureLogResponse])
|
||||
async def get_temperature_logs(
|
||||
tenant_id: UUID = Path(...),
|
||||
location: Optional[str] = Query(None, description="Filter by storage location"),
|
||||
equipment_id: Optional[str] = Query(None, description="Filter by equipment ID"),
|
||||
date_from: Optional[datetime] = Query(None, description="Start date for filtering"),
|
||||
date_to: Optional[datetime] = Query(None, description="End date for filtering"),
|
||||
violations_only: bool = Query(False, description="Show only temperature violations"),
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get temperature logs with filtering"""
|
||||
try:
|
||||
# Build query
|
||||
where_conditions = ["tenant_id = :tenant_id"]
|
||||
params = {"tenant_id": tenant_id}
|
||||
|
||||
if location:
|
||||
where_conditions.append("storage_location ILIKE :location")
|
||||
params["location"] = f"%{location}%"
|
||||
|
||||
if equipment_id:
|
||||
where_conditions.append("equipment_id = :equipment_id")
|
||||
params["equipment_id"] = equipment_id
|
||||
|
||||
if date_from:
|
||||
where_conditions.append("recorded_at >= :date_from")
|
||||
params["date_from"] = date_from
|
||||
|
||||
if date_to:
|
||||
where_conditions.append("recorded_at <= :date_to")
|
||||
params["date_to"] = date_to
|
||||
|
||||
if violations_only:
|
||||
where_conditions.append("is_within_range = false")
|
||||
|
||||
where_clause = " AND ".join(where_conditions)
|
||||
|
||||
query = f"""
|
||||
SELECT * FROM temperature_logs
|
||||
WHERE {where_clause}
|
||||
ORDER BY recorded_at DESC
|
||||
LIMIT :limit OFFSET :skip
|
||||
"""
|
||||
params.update({"limit": limit, "skip": skip})
|
||||
|
||||
result = await db.execute(query, params)
|
||||
logs = result.fetchall()
|
||||
|
||||
return [
|
||||
TemperatureLogResponse(**dict(log))
|
||||
for log in logs
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting temperature logs", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve temperature logs"
|
||||
)
|
||||
|
||||
|
||||
# ===== Alert Management Endpoints =====
|
||||
|
||||
@router.post("/tenants/{tenant_id}/alerts", response_model=FoodSafetyAlertResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_food_safety_alert(
|
||||
alert_data: FoodSafetyAlertCreate,
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
food_safety_service: FoodSafetyService = Depends(get_food_safety_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a food safety alert"""
|
||||
try:
|
||||
# Ensure tenant_id matches
|
||||
alert_data.tenant_id = tenant_id
|
||||
|
||||
alert = await food_safety_service.create_food_safety_alert(
|
||||
db,
|
||||
alert_data,
|
||||
user_id=UUID(current_user["sub"])
|
||||
)
|
||||
|
||||
logger.info("Food safety alert created",
|
||||
alert_id=str(alert.id),
|
||||
alert_type=alert.alert_type)
|
||||
|
||||
return alert
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error creating food safety alert", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create food safety alert"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/alerts", response_model=List[FoodSafetyAlertResponse])
|
||||
async def get_food_safety_alerts(
|
||||
tenant_id: UUID = Path(...),
|
||||
alert_type: Optional[str] = Query(None, description="Filter by alert type"),
|
||||
severity: Optional[str] = Query(None, description="Filter by severity"),
|
||||
status_filter: Optional[str] = Query(None, description="Filter by status"),
|
||||
unresolved_only: bool = Query(True, description="Show only unresolved alerts"),
|
||||
skip: int = Query(0, ge=0, description="Number of alerts to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of alerts to return"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get food safety alerts with filtering"""
|
||||
try:
|
||||
# Build query filters
|
||||
where_conditions = ["tenant_id = :tenant_id"]
|
||||
params = {"tenant_id": tenant_id}
|
||||
|
||||
if alert_type:
|
||||
where_conditions.append("alert_type = :alert_type")
|
||||
params["alert_type"] = alert_type
|
||||
|
||||
if severity:
|
||||
where_conditions.append("severity = :severity")
|
||||
params["severity"] = severity
|
||||
|
||||
if status_filter:
|
||||
where_conditions.append("status = :status")
|
||||
params["status"] = status_filter
|
||||
elif unresolved_only:
|
||||
where_conditions.append("status NOT IN ('resolved', 'dismissed')")
|
||||
|
||||
where_clause = " AND ".join(where_conditions)
|
||||
|
||||
query = f"""
|
||||
SELECT * FROM food_safety_alerts
|
||||
WHERE {where_clause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit OFFSET :skip
|
||||
"""
|
||||
params.update({"limit": limit, "skip": skip})
|
||||
|
||||
result = await db.execute(query, params)
|
||||
alerts = result.fetchall()
|
||||
|
||||
return [
|
||||
FoodSafetyAlertResponse(**dict(alert))
|
||||
for alert in alerts
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting food safety alerts", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve food safety alerts"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/tenants/{tenant_id}/alerts/{alert_id}", response_model=FoodSafetyAlertResponse)
|
||||
async def update_food_safety_alert(
|
||||
alert_data: FoodSafetyAlertUpdate,
|
||||
tenant_id: UUID = Path(...),
|
||||
alert_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update a food safety alert"""
|
||||
try:
|
||||
# Get existing alert
|
||||
alert_query = "SELECT * FROM food_safety_alerts WHERE id = :alert_id AND tenant_id = :tenant_id"
|
||||
result = await db.execute(alert_query, {"alert_id": alert_id, "tenant_id": tenant_id})
|
||||
alert_record = result.fetchone()
|
||||
|
||||
if not alert_record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Food safety alert not found"
|
||||
)
|
||||
|
||||
# Update alert fields
|
||||
update_fields = alert_data.dict(exclude_unset=True)
|
||||
if update_fields:
|
||||
set_clauses = []
|
||||
params = {"alert_id": alert_id, "tenant_id": tenant_id}
|
||||
|
||||
for field, value in update_fields.items():
|
||||
set_clauses.append(f"{field} = :{field}")
|
||||
params[field] = value
|
||||
|
||||
# Add updated timestamp and user
|
||||
set_clauses.append("updated_at = NOW()")
|
||||
set_clauses.append("updated_by = :updated_by")
|
||||
params["updated_by"] = UUID(current_user["sub"])
|
||||
|
||||
update_query = f"""
|
||||
UPDATE food_safety_alerts
|
||||
SET {', '.join(set_clauses)}
|
||||
WHERE id = :alert_id AND tenant_id = :tenant_id
|
||||
"""
|
||||
|
||||
await db.execute(update_query, params)
|
||||
await db.commit()
|
||||
|
||||
# Get updated alert
|
||||
result = await db.execute(alert_query, {"alert_id": alert_id, "tenant_id": tenant_id})
|
||||
updated_alert = result.fetchone()
|
||||
|
||||
logger.info("Food safety alert updated",
|
||||
alert_id=str(alert_id))
|
||||
|
||||
return FoodSafetyAlertResponse(**dict(updated_alert))
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating food safety alert",
|
||||
alert_id=str(alert_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update food safety alert"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/alerts/{alert_id}/acknowledge")
|
||||
async def acknowledge_alert(
|
||||
tenant_id: UUID = Path(...),
|
||||
alert_id: UUID = Path(...),
|
||||
notes: Optional[str] = Query(None, description="Acknowledgment notes"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Acknowledge a food safety alert"""
|
||||
try:
|
||||
# Update alert to acknowledged status
|
||||
update_query = """
|
||||
UPDATE food_safety_alerts
|
||||
SET status = 'acknowledged',
|
||||
acknowledged_at = NOW(),
|
||||
acknowledged_by = :user_id,
|
||||
investigation_notes = COALESCE(investigation_notes, '') || :notes,
|
||||
updated_at = NOW(),
|
||||
updated_by = :user_id
|
||||
WHERE id = :alert_id AND tenant_id = :tenant_id
|
||||
"""
|
||||
|
||||
result = await db.execute(update_query, {
|
||||
"alert_id": alert_id,
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": UUID(current_user["sub"]),
|
||||
"notes": f"\nAcknowledged: {notes}" if notes else "\nAcknowledged"
|
||||
})
|
||||
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Food safety alert not found"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info("Food safety alert acknowledged",
|
||||
alert_id=str(alert_id))
|
||||
|
||||
return {"message": "Alert acknowledged successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error acknowledging alert",
|
||||
alert_id=str(alert_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to acknowledge alert"
|
||||
)
|
||||
|
||||
|
||||
# ===== Analytics and Reporting Endpoints =====
|
||||
|
||||
@router.get("/tenants/{tenant_id}/metrics", response_model=FoodSafetyMetrics)
|
||||
async def get_food_safety_metrics(
|
||||
tenant_id: UUID = Path(...),
|
||||
days_back: int = Query(30, ge=1, le=365, description="Number of days to analyze"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get food safety performance metrics"""
|
||||
try:
|
||||
# Calculate compliance rate
|
||||
compliance_query = """
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN compliance_status = 'compliant' THEN 1 END) as compliant
|
||||
FROM food_safety_compliance
|
||||
WHERE tenant_id = :tenant_id AND is_active = true
|
||||
"""
|
||||
|
||||
result = await db.execute(compliance_query, {"tenant_id": tenant_id})
|
||||
compliance_stats = result.fetchone()
|
||||
|
||||
compliance_rate = 0.0
|
||||
if compliance_stats.total > 0:
|
||||
compliance_rate = (compliance_stats.compliant / compliance_stats.total) * 100
|
||||
|
||||
# Calculate temperature compliance
|
||||
temp_query = """
|
||||
SELECT
|
||||
COUNT(*) as total_readings,
|
||||
COUNT(CASE WHEN is_within_range THEN 1 END) as compliant_readings
|
||||
FROM temperature_logs
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND recorded_at > NOW() - INTERVAL '%s days'
|
||||
""" % days_back
|
||||
|
||||
result = await db.execute(temp_query, {"tenant_id": tenant_id})
|
||||
temp_stats = result.fetchone()
|
||||
|
||||
temp_compliance_rate = 0.0
|
||||
if temp_stats.total_readings > 0:
|
||||
temp_compliance_rate = (temp_stats.compliant_readings / temp_stats.total_readings) * 100
|
||||
|
||||
# Get alert metrics
|
||||
alert_query = """
|
||||
SELECT
|
||||
COUNT(*) as total_alerts,
|
||||
COUNT(CASE WHEN is_recurring THEN 1 END) as recurring_alerts,
|
||||
COUNT(CASE WHEN regulatory_action_required THEN 1 END) as regulatory_violations,
|
||||
AVG(CASE WHEN response_time_minutes IS NOT NULL THEN response_time_minutes END) as avg_response_time,
|
||||
AVG(CASE WHEN resolution_time_minutes IS NOT NULL THEN resolution_time_minutes END) as avg_resolution_time
|
||||
FROM food_safety_alerts
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND created_at > NOW() - INTERVAL '%s days'
|
||||
""" % days_back
|
||||
|
||||
result = await db.execute(alert_query, {"tenant_id": tenant_id})
|
||||
alert_stats = result.fetchone()
|
||||
|
||||
return FoodSafetyMetrics(
|
||||
compliance_rate=Decimal(str(compliance_rate)),
|
||||
temperature_compliance_rate=Decimal(str(temp_compliance_rate)),
|
||||
alert_response_time_avg=Decimal(str(alert_stats.avg_response_time or 0)),
|
||||
alert_resolution_time_avg=Decimal(str(alert_stats.avg_resolution_time or 0)),
|
||||
recurring_issues_count=alert_stats.recurring_alerts or 0,
|
||||
regulatory_violations=alert_stats.regulatory_violations or 0,
|
||||
certification_coverage=Decimal(str(compliance_rate)), # Same as compliance rate for now
|
||||
audit_score_avg=Decimal("85.0"), # Would calculate from actual audit data
|
||||
risk_score=Decimal("3.2") # Would calculate from risk assessments
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting food safety metrics", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve food safety metrics"
|
||||
)
|
||||
|
||||
|
||||
# ===== Health and Status Endpoints =====
|
||||
|
||||
@router.get("/tenants/{tenant_id}/status")
|
||||
async def get_food_safety_status(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep)
|
||||
):
|
||||
"""Get food safety service status"""
|
||||
try:
|
||||
return {
|
||||
"service": "food-safety",
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"tenant_id": str(tenant_id),
|
||||
"features": {
|
||||
"compliance_tracking": "enabled",
|
||||
"temperature_monitoring": "enabled",
|
||||
"automated_alerts": "enabled",
|
||||
"regulatory_reporting": "enabled"
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting food safety status", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get food safety status"
|
||||
)
|
||||
262
services/inventory/app/api/food_safety_alerts.py
Normal file
262
services/inventory/app/api/food_safety_alerts.py
Normal file
@@ -0,0 +1,262 @@
|
||||
# services/inventory/app/api/food_safety_alerts.py
|
||||
"""
|
||||
Food Safety Alerts API - ATOMIC CRUD operations on FoodSafetyAlert model
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.access_control import require_user_role
|
||||
from shared.routing import RouteBuilder
|
||||
from app.core.database import get_db
|
||||
from app.services.food_safety_service import FoodSafetyService
|
||||
from app.schemas.food_safety import (
|
||||
FoodSafetyAlertCreate,
|
||||
FoodSafetyAlertUpdate,
|
||||
FoodSafetyAlertResponse
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
route_builder = RouteBuilder('inventory')
|
||||
router = APIRouter(tags=["food-safety-alerts"])
|
||||
|
||||
|
||||
async def get_food_safety_service() -> FoodSafetyService:
|
||||
"""Get food safety service instance"""
|
||||
return FoodSafetyService()
|
||||
|
||||
|
||||
@router.post(
|
||||
route_builder.build_base_route("food-safety/alerts"),
|
||||
response_model=FoodSafetyAlertResponse,
|
||||
status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def create_food_safety_alert(
|
||||
alert_data: FoodSafetyAlertCreate,
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
food_safety_service: FoodSafetyService = Depends(get_food_safety_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a food safety alert"""
|
||||
try:
|
||||
alert_data.tenant_id = tenant_id
|
||||
|
||||
alert = await food_safety_service.create_food_safety_alert(
|
||||
db,
|
||||
alert_data,
|
||||
user_id=UUID(current_user["sub"])
|
||||
)
|
||||
|
||||
logger.info("Food safety alert created",
|
||||
alert_id=str(alert.id),
|
||||
alert_type=alert.alert_type)
|
||||
|
||||
return alert
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error creating food safety alert", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create food safety alert"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("food-safety/alerts"),
|
||||
response_model=List[FoodSafetyAlertResponse]
|
||||
)
|
||||
async def get_food_safety_alerts(
|
||||
tenant_id: UUID = Path(...),
|
||||
alert_type: Optional[str] = Query(None, description="Filter by alert type"),
|
||||
severity: Optional[str] = Query(None, description="Filter by severity"),
|
||||
status_filter: Optional[str] = Query(None, description="Filter by status"),
|
||||
unresolved_only: bool = Query(True, description="Show only unresolved alerts"),
|
||||
skip: int = Query(0, ge=0, description="Number of alerts to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of alerts to return"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get food safety alerts with filtering"""
|
||||
try:
|
||||
where_conditions = ["tenant_id = :tenant_id"]
|
||||
params = {"tenant_id": tenant_id}
|
||||
|
||||
if alert_type:
|
||||
where_conditions.append("alert_type = :alert_type")
|
||||
params["alert_type"] = alert_type
|
||||
|
||||
if severity:
|
||||
where_conditions.append("severity = :severity")
|
||||
params["severity"] = severity
|
||||
|
||||
if status_filter:
|
||||
where_conditions.append("status = :status")
|
||||
params["status"] = status_filter
|
||||
elif unresolved_only:
|
||||
where_conditions.append("status NOT IN ('resolved', 'dismissed')")
|
||||
|
||||
where_clause = " AND ".join(where_conditions)
|
||||
|
||||
query = f"""
|
||||
SELECT * FROM food_safety_alerts
|
||||
WHERE {where_clause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit OFFSET :skip
|
||||
"""
|
||||
params.update({"limit": limit, "skip": skip})
|
||||
|
||||
result = await db.execute(query, params)
|
||||
alerts = result.fetchall()
|
||||
|
||||
return [
|
||||
FoodSafetyAlertResponse(**dict(alert))
|
||||
for alert in alerts
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting food safety alerts", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve food safety alerts"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_resource_detail_route("food-safety/alerts", "alert_id"),
|
||||
response_model=FoodSafetyAlertResponse
|
||||
)
|
||||
async def get_food_safety_alert(
|
||||
alert_id: UUID = Path(...),
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get specific food safety alert"""
|
||||
try:
|
||||
query = "SELECT * FROM food_safety_alerts WHERE id = :alert_id AND tenant_id = :tenant_id"
|
||||
result = await db.execute(query, {"alert_id": alert_id, "tenant_id": tenant_id})
|
||||
alert = result.fetchone()
|
||||
|
||||
if not alert:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Food safety alert not found"
|
||||
)
|
||||
|
||||
return FoodSafetyAlertResponse(**dict(alert))
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error getting food safety alert", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve food safety alert"
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
route_builder.build_resource_detail_route("food-safety/alerts", "alert_id"),
|
||||
response_model=FoodSafetyAlertResponse
|
||||
)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def update_food_safety_alert(
|
||||
alert_data: FoodSafetyAlertUpdate,
|
||||
tenant_id: UUID = Path(...),
|
||||
alert_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update a food safety alert"""
|
||||
try:
|
||||
alert_query = "SELECT * FROM food_safety_alerts WHERE id = :alert_id AND tenant_id = :tenant_id"
|
||||
result = await db.execute(alert_query, {"alert_id": alert_id, "tenant_id": tenant_id})
|
||||
alert_record = result.fetchone()
|
||||
|
||||
if not alert_record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Food safety alert not found"
|
||||
)
|
||||
|
||||
update_fields = alert_data.dict(exclude_unset=True)
|
||||
if update_fields:
|
||||
set_clauses = []
|
||||
params = {"alert_id": alert_id, "tenant_id": tenant_id}
|
||||
|
||||
for field, value in update_fields.items():
|
||||
set_clauses.append(f"{field} = :{field}")
|
||||
params[field] = value
|
||||
|
||||
set_clauses.append("updated_at = NOW()")
|
||||
set_clauses.append("updated_by = :updated_by")
|
||||
params["updated_by"] = UUID(current_user["sub"])
|
||||
|
||||
update_query = f"""
|
||||
UPDATE food_safety_alerts
|
||||
SET {', '.join(set_clauses)}
|
||||
WHERE id = :alert_id AND tenant_id = :tenant_id
|
||||
"""
|
||||
|
||||
await db.execute(update_query, params)
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(alert_query, {"alert_id": alert_id, "tenant_id": tenant_id})
|
||||
updated_alert = result.fetchone()
|
||||
|
||||
logger.info("Food safety alert updated",
|
||||
alert_id=str(alert_id))
|
||||
|
||||
return FoodSafetyAlertResponse(**dict(updated_alert))
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating food safety alert",
|
||||
alert_id=str(alert_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update food safety alert"
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
route_builder.build_resource_detail_route("food-safety/alerts", "alert_id"),
|
||||
status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def delete_food_safety_alert(
|
||||
alert_id: UUID = Path(...),
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete food safety alert"""
|
||||
try:
|
||||
query = "DELETE FROM food_safety_alerts WHERE id = :alert_id AND tenant_id = :tenant_id"
|
||||
result = await db.execute(query, {"alert_id": alert_id, "tenant_id": tenant_id})
|
||||
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Food safety alert not found"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error deleting food safety alert", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete food safety alert"
|
||||
)
|
||||
250
services/inventory/app/api/food_safety_compliance.py
Normal file
250
services/inventory/app/api/food_safety_compliance.py
Normal file
@@ -0,0 +1,250 @@
|
||||
# services/inventory/app/api/food_safety_compliance.py
|
||||
"""
|
||||
Food Safety Compliance API - ATOMIC CRUD operations on FoodSafetyCompliance model
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.access_control import require_user_role
|
||||
from shared.routing import RouteBuilder
|
||||
from app.core.database import get_db
|
||||
from app.services.food_safety_service import FoodSafetyService
|
||||
from app.schemas.food_safety import (
|
||||
FoodSafetyComplianceCreate,
|
||||
FoodSafetyComplianceUpdate,
|
||||
FoodSafetyComplianceResponse
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
route_builder = RouteBuilder('inventory')
|
||||
router = APIRouter(tags=["food-safety-compliance"])
|
||||
|
||||
|
||||
async def get_food_safety_service() -> FoodSafetyService:
|
||||
"""Get food safety service instance"""
|
||||
return FoodSafetyService()
|
||||
|
||||
|
||||
@router.post(
|
||||
route_builder.build_base_route("food-safety/compliance"),
|
||||
response_model=FoodSafetyComplianceResponse,
|
||||
status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def create_compliance_record(
|
||||
compliance_data: FoodSafetyComplianceCreate,
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
food_safety_service: FoodSafetyService = Depends(get_food_safety_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a new food safety compliance record"""
|
||||
try:
|
||||
compliance_data.tenant_id = tenant_id
|
||||
|
||||
compliance = await food_safety_service.create_compliance_record(
|
||||
db,
|
||||
compliance_data,
|
||||
user_id=UUID(current_user["sub"])
|
||||
)
|
||||
|
||||
logger.info("Compliance record created",
|
||||
compliance_id=str(compliance.id),
|
||||
standard=compliance.standard)
|
||||
|
||||
return compliance
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning("Invalid compliance data", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error creating compliance record", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create compliance record"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("food-safety/compliance"),
|
||||
response_model=List[FoodSafetyComplianceResponse]
|
||||
)
|
||||
async def get_compliance_records(
|
||||
tenant_id: UUID = Path(...),
|
||||
ingredient_id: Optional[UUID] = Query(None, description="Filter by ingredient ID"),
|
||||
standard: Optional[str] = Query(None, description="Filter by compliance standard"),
|
||||
status_filter: Optional[str] = Query(None, description="Filter by compliance status"),
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get compliance records with filtering"""
|
||||
try:
|
||||
filters = {}
|
||||
if ingredient_id:
|
||||
filters["ingredient_id"] = ingredient_id
|
||||
if standard:
|
||||
filters["standard"] = standard
|
||||
if status_filter:
|
||||
filters["compliance_status"] = status_filter
|
||||
|
||||
query = """
|
||||
SELECT * FROM food_safety_compliance
|
||||
WHERE tenant_id = :tenant_id AND is_active = true
|
||||
"""
|
||||
params = {"tenant_id": tenant_id}
|
||||
|
||||
if filters:
|
||||
for key, value in filters.items():
|
||||
query += f" AND {key} = :{key}"
|
||||
params[key] = value
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT :limit OFFSET :skip"
|
||||
params.update({"limit": limit, "skip": skip})
|
||||
|
||||
result = await db.execute(query, params)
|
||||
records = result.fetchall()
|
||||
|
||||
return [
|
||||
FoodSafetyComplianceResponse(**dict(record))
|
||||
for record in records
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting compliance records", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve compliance records"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_resource_detail_route("food-safety/compliance", "compliance_id"),
|
||||
response_model=FoodSafetyComplianceResponse
|
||||
)
|
||||
async def get_compliance_record(
|
||||
compliance_id: UUID = Path(...),
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get specific compliance record"""
|
||||
try:
|
||||
query = "SELECT * FROM food_safety_compliance WHERE id = :compliance_id AND tenant_id = :tenant_id"
|
||||
result = await db.execute(query, {"compliance_id": compliance_id, "tenant_id": tenant_id})
|
||||
record = result.fetchone()
|
||||
|
||||
if not record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Compliance record not found"
|
||||
)
|
||||
|
||||
return FoodSafetyComplianceResponse(**dict(record))
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error getting compliance record", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve compliance record"
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
route_builder.build_resource_detail_route("food-safety/compliance", "compliance_id"),
|
||||
response_model=FoodSafetyComplianceResponse
|
||||
)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def update_compliance_record(
|
||||
compliance_data: FoodSafetyComplianceUpdate,
|
||||
tenant_id: UUID = Path(...),
|
||||
compliance_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
food_safety_service: FoodSafetyService = Depends(get_food_safety_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update an existing compliance record"""
|
||||
try:
|
||||
compliance = await food_safety_service.update_compliance_record(
|
||||
db,
|
||||
compliance_id,
|
||||
tenant_id,
|
||||
compliance_data,
|
||||
user_id=UUID(current_user["sub"])
|
||||
)
|
||||
|
||||
if not compliance:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Compliance record not found"
|
||||
)
|
||||
|
||||
logger.info("Compliance record updated",
|
||||
compliance_id=str(compliance.id))
|
||||
|
||||
return compliance
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating compliance record",
|
||||
compliance_id=str(compliance_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update compliance record"
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
route_builder.build_resource_detail_route("food-safety/compliance", "compliance_id"),
|
||||
status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def delete_compliance_record(
|
||||
compliance_id: UUID = Path(...),
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete (soft delete) compliance record"""
|
||||
try:
|
||||
query = """
|
||||
UPDATE food_safety_compliance
|
||||
SET is_active = false, updated_at = NOW(), updated_by = :user_id
|
||||
WHERE id = :compliance_id AND tenant_id = :tenant_id
|
||||
"""
|
||||
result = await db.execute(query, {
|
||||
"compliance_id": compliance_id,
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": UUID(current_user["sub"])
|
||||
})
|
||||
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Compliance record not found"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error deleting compliance record", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete compliance record"
|
||||
)
|
||||
288
services/inventory/app/api/food_safety_operations.py
Normal file
288
services/inventory/app/api/food_safety_operations.py
Normal file
@@ -0,0 +1,288 @@
|
||||
# services/inventory/app/api/food_safety_operations.py
|
||||
"""
|
||||
Food Safety Operations API - Business operations for food safety management
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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.food_safety_service import FoodSafetyService
|
||||
from app.schemas.food_safety import FoodSafetyMetrics
|
||||
|
||||
logger = structlog.get_logger()
|
||||
route_builder = RouteBuilder('inventory')
|
||||
router = APIRouter(tags=["food-safety-operations"])
|
||||
|
||||
|
||||
async def get_food_safety_service() -> FoodSafetyService:
|
||||
"""Get food safety service instance"""
|
||||
return FoodSafetyService()
|
||||
|
||||
|
||||
@router.post(
|
||||
route_builder.build_nested_resource_route("food-safety/alerts", "alert_id", "acknowledge"),
|
||||
response_model=dict
|
||||
)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def acknowledge_alert(
|
||||
tenant_id: UUID = Path(...),
|
||||
alert_id: UUID = Path(...),
|
||||
notes: Optional[str] = Query(None, description="Acknowledgment notes"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Acknowledge a food safety alert"""
|
||||
try:
|
||||
update_query = """
|
||||
UPDATE food_safety_alerts
|
||||
SET status = 'acknowledged',
|
||||
acknowledged_at = NOW(),
|
||||
acknowledged_by = :user_id,
|
||||
investigation_notes = COALESCE(investigation_notes, '') || :notes,
|
||||
updated_at = NOW(),
|
||||
updated_by = :user_id
|
||||
WHERE id = :alert_id AND tenant_id = :tenant_id
|
||||
"""
|
||||
|
||||
result = await db.execute(update_query, {
|
||||
"alert_id": alert_id,
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": UUID(current_user["sub"]),
|
||||
"notes": f"\nAcknowledged: {notes}" if notes else "\nAcknowledged"
|
||||
})
|
||||
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Food safety alert not found"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info("Food safety alert acknowledged",
|
||||
alert_id=str(alert_id))
|
||||
|
||||
return {"message": "Alert acknowledged successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error acknowledging alert",
|
||||
alert_id=str(alert_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to acknowledge alert"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("food-safety-metrics"),
|
||||
response_model=FoodSafetyMetrics
|
||||
)
|
||||
@analytics_tier_required
|
||||
async def get_food_safety_metrics(
|
||||
tenant_id: UUID = Path(...),
|
||||
days_back: int = Query(30, ge=1, le=365, description="Number of days to analyze"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get food safety performance metrics"""
|
||||
try:
|
||||
compliance_query = """
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN compliance_status = 'compliant' THEN 1 END) as compliant
|
||||
FROM food_safety_compliance
|
||||
WHERE tenant_id = :tenant_id AND is_active = true
|
||||
"""
|
||||
|
||||
result = await db.execute(compliance_query, {"tenant_id": tenant_id})
|
||||
compliance_stats = result.fetchone()
|
||||
|
||||
compliance_rate = 0.0
|
||||
if compliance_stats.total > 0:
|
||||
compliance_rate = (compliance_stats.compliant / compliance_stats.total) * 100
|
||||
|
||||
temp_query = """
|
||||
SELECT
|
||||
COUNT(*) as total_readings,
|
||||
COUNT(CASE WHEN is_within_range THEN 1 END) as compliant_readings
|
||||
FROM temperature_logs
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND recorded_at > NOW() - INTERVAL '%s days'
|
||||
""" % days_back
|
||||
|
||||
result = await db.execute(temp_query, {"tenant_id": tenant_id})
|
||||
temp_stats = result.fetchone()
|
||||
|
||||
temp_compliance_rate = 0.0
|
||||
if temp_stats.total_readings > 0:
|
||||
temp_compliance_rate = (temp_stats.compliant_readings / temp_stats.total_readings) * 100
|
||||
|
||||
alert_query = """
|
||||
SELECT
|
||||
COUNT(*) as total_alerts,
|
||||
COUNT(CASE WHEN is_recurring THEN 1 END) as recurring_alerts,
|
||||
COUNT(CASE WHEN regulatory_action_required THEN 1 END) as regulatory_violations,
|
||||
AVG(CASE WHEN response_time_minutes IS NOT NULL THEN response_time_minutes END) as avg_response_time,
|
||||
AVG(CASE WHEN resolution_time_minutes IS NOT NULL THEN resolution_time_minutes END) as avg_resolution_time
|
||||
FROM food_safety_alerts
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND created_at > NOW() - INTERVAL '%s days'
|
||||
""" % days_back
|
||||
|
||||
result = await db.execute(alert_query, {"tenant_id": tenant_id})
|
||||
alert_stats = result.fetchone()
|
||||
|
||||
return FoodSafetyMetrics(
|
||||
compliance_rate=Decimal(str(compliance_rate)),
|
||||
temperature_compliance_rate=Decimal(str(temp_compliance_rate)),
|
||||
alert_response_time_avg=Decimal(str(alert_stats.avg_response_time or 0)),
|
||||
alert_resolution_time_avg=Decimal(str(alert_stats.avg_resolution_time or 0)),
|
||||
recurring_issues_count=alert_stats.recurring_alerts or 0,
|
||||
regulatory_violations=alert_stats.regulatory_violations or 0,
|
||||
certification_coverage=Decimal(str(compliance_rate)),
|
||||
audit_score_avg=Decimal("85.0"),
|
||||
risk_score=Decimal("3.2")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting food safety metrics", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve food safety metrics"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_operations_route("food-safety/status")
|
||||
)
|
||||
async def get_food_safety_status(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep)
|
||||
):
|
||||
"""Get food safety service status"""
|
||||
try:
|
||||
return {
|
||||
"service": "food-safety",
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"tenant_id": str(tenant_id),
|
||||
"features": {
|
||||
"compliance_tracking": "enabled",
|
||||
"temperature_monitoring": "enabled",
|
||||
"automated_alerts": "enabled",
|
||||
"regulatory_reporting": "enabled"
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting food safety status", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get food safety status"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_operations_route("food-safety/temperature/violations")
|
||||
)
|
||||
async def get_temperature_violations(
|
||||
tenant_id: UUID = Path(...),
|
||||
days_back: int = Query(7, ge=1, le=90, description="Days to analyze"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get temperature violations summary"""
|
||||
try:
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(*) as total_violations,
|
||||
COUNT(DISTINCT storage_location) as affected_locations,
|
||||
COUNT(DISTINCT equipment_id) as affected_equipment,
|
||||
AVG(ABS(temperature_celsius - (min_temp_celsius + max_temp_celsius)/2)) as avg_deviation
|
||||
FROM temperature_logs
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND is_within_range = false
|
||||
AND recorded_at > NOW() - INTERVAL '%s days'
|
||||
""" % days_back
|
||||
|
||||
result = await db.execute(query, {"tenant_id": tenant_id})
|
||||
stats = result.fetchone()
|
||||
|
||||
return {
|
||||
"period_days": days_back,
|
||||
"total_violations": stats.total_violations or 0,
|
||||
"affected_locations": stats.affected_locations or 0,
|
||||
"affected_equipment": stats.affected_equipment or 0,
|
||||
"average_deviation_celsius": float(stats.avg_deviation or 0)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting temperature violations", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get temperature violations"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_operations_route("food-safety/compliance/summary")
|
||||
)
|
||||
async def get_compliance_summary(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get compliance summary by standard"""
|
||||
try:
|
||||
query = """
|
||||
SELECT
|
||||
standard,
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN compliance_status = 'compliant' THEN 1 END) as compliant,
|
||||
COUNT(CASE WHEN compliance_status = 'non_compliant' THEN 1 END) as non_compliant,
|
||||
COUNT(CASE WHEN compliance_status = 'pending' THEN 1 END) as pending
|
||||
FROM food_safety_compliance
|
||||
WHERE tenant_id = :tenant_id AND is_active = true
|
||||
GROUP BY standard
|
||||
ORDER BY standard
|
||||
"""
|
||||
|
||||
result = await db.execute(query, {"tenant_id": tenant_id})
|
||||
records = result.fetchall()
|
||||
|
||||
summary = []
|
||||
for record in records:
|
||||
compliance_rate = (record.compliant / record.total * 100) if record.total > 0 else 0
|
||||
summary.append({
|
||||
"standard": record.standard,
|
||||
"total_items": record.total,
|
||||
"compliant": record.compliant,
|
||||
"non_compliant": record.non_compliant,
|
||||
"pending": record.pending,
|
||||
"compliance_rate": round(compliance_rate, 2)
|
||||
})
|
||||
|
||||
return {
|
||||
"tenant_id": str(tenant_id),
|
||||
"standards": summary,
|
||||
"total_standards": len(summary)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting compliance summary", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get compliance summary"
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
# services/inventory/app/api/ingredients.py
|
||||
"""
|
||||
API endpoints for ingredient management
|
||||
Base CRUD operations for inventory ingredients resources
|
||||
Following standardized URL structure: /api/v1/tenants/{tenant_id}/inventory/{resource}
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
@@ -15,10 +16,15 @@ from app.schemas.inventory import (
|
||||
IngredientUpdate,
|
||||
IngredientResponse,
|
||||
StockResponse,
|
||||
InventoryFilter,
|
||||
PaginatedResponse
|
||||
StockCreate,
|
||||
StockUpdate,
|
||||
)
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.access_control import require_user_role, admin_role_required, owner_role_required
|
||||
from shared.routing import RouteBuilder
|
||||
|
||||
# Create route builder for consistent URL structure
|
||||
route_builder = RouteBuilder('inventory')
|
||||
|
||||
router = APIRouter(tags=["ingredients"])
|
||||
|
||||
@@ -34,26 +40,32 @@ def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> U
|
||||
return UUID(user_id)
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/ingredients", response_model=IngredientResponse)
|
||||
# ===== INGREDIENTS ENDPOINTS =====
|
||||
|
||||
@router.post(
|
||||
route_builder.build_base_route("ingredients"),
|
||||
response_model=IngredientResponse,
|
||||
status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def create_ingredient(
|
||||
ingredient_data: IngredientCreate,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a new ingredient"""
|
||||
"""Create a new ingredient (Admin/Manager only)"""
|
||||
try:
|
||||
# Extract user ID - handle service tokens that don't have UUID user_ids
|
||||
# Extract user ID - handle service tokens
|
||||
raw_user_id = current_user.get('user_id')
|
||||
if current_user.get('type') == 'service':
|
||||
# For service tokens, user_id might not be a UUID, so set to None
|
||||
user_id = None
|
||||
else:
|
||||
try:
|
||||
user_id = UUID(raw_user_id)
|
||||
except (ValueError, TypeError):
|
||||
user_id = None
|
||||
|
||||
|
||||
service = InventoryService()
|
||||
ingredient = await service.create_ingredient(ingredient_data, tenant_id, user_id)
|
||||
return ingredient
|
||||
@@ -69,14 +81,16 @@ async def create_ingredient(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/ingredients/count")
|
||||
@router.get(
|
||||
route_builder.build_base_route("ingredients/count"),
|
||||
response_model=dict
|
||||
)
|
||||
async def count_ingredients(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Get count of ingredients for a tenant"""
|
||||
|
||||
):
|
||||
"""Get count of ingredients for a tenant (All users)"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
count = await service.count_ingredients_by_tenant(tenant_id)
|
||||
@@ -93,23 +107,27 @@ async def count_ingredients(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/ingredients/{ingredient_id}", response_model=IngredientResponse)
|
||||
@router.get(
|
||||
route_builder.build_resource_detail_route("ingredients", "ingredient_id"),
|
||||
response_model=IngredientResponse
|
||||
)
|
||||
async def get_ingredient(
|
||||
ingredient_id: UUID,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get ingredient by ID"""
|
||||
"""Get ingredient by ID (All users)"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
ingredient = await service.get_ingredient(ingredient_id, tenant_id)
|
||||
|
||||
|
||||
if not ingredient:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ingredient not found"
|
||||
)
|
||||
|
||||
|
||||
return ingredient
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -120,24 +138,29 @@ async def get_ingredient(
|
||||
)
|
||||
|
||||
|
||||
@router.put("/tenants/{tenant_id}/ingredients/{ingredient_id}", response_model=IngredientResponse)
|
||||
@router.put(
|
||||
route_builder.build_resource_detail_route("ingredients", "ingredient_id"),
|
||||
response_model=IngredientResponse
|
||||
)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def update_ingredient(
|
||||
ingredient_id: UUID,
|
||||
ingredient_data: IngredientUpdate,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update ingredient"""
|
||||
try:
|
||||
"""Update ingredient (Admin/Manager/User)"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
ingredient = await service.update_ingredient(ingredient_id, ingredient_data, tenant_id)
|
||||
|
||||
|
||||
if not ingredient:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ingredient not found"
|
||||
)
|
||||
|
||||
|
||||
return ingredient
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
@@ -153,24 +176,27 @@ async def update_ingredient(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/ingredients", response_model=List[IngredientResponse])
|
||||
@router.get(
|
||||
route_builder.build_base_route("ingredients"),
|
||||
response_model=List[IngredientResponse]
|
||||
)
|
||||
async def list_ingredients(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||
category: Optional[str] = Query(None, description="Filter by category"),
|
||||
product_type: Optional[str] = Query(None, description="Filter by product type (ingredient or finished_product)"),
|
||||
product_type: Optional[str] = Query(None, description="Filter by product type"),
|
||||
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||
is_low_stock: Optional[bool] = Query(None, description="Filter by low stock status"),
|
||||
needs_reorder: Optional[bool] = Query(None, description="Filter by reorder needed"),
|
||||
search: Optional[str] = Query(None, description="Search in name, SKU, or barcode"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""List ingredients with filtering"""
|
||||
"""List ingredients with filtering (All users)"""
|
||||
try:
|
||||
|
||||
service = InventoryService()
|
||||
|
||||
|
||||
# Build filters
|
||||
filters = {}
|
||||
if category:
|
||||
@@ -185,7 +211,7 @@ async def list_ingredients(
|
||||
filters['needs_reorder'] = needs_reorder
|
||||
if search:
|
||||
filters['search'] = search
|
||||
|
||||
|
||||
ingredients = await service.get_ingredients(tenant_id, skip, limit, filters)
|
||||
return ingredients
|
||||
except Exception as e:
|
||||
@@ -195,13 +221,18 @@ async def list_ingredients(
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/tenants/{tenant_id}/ingredients/{ingredient_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete(
|
||||
route_builder.build_resource_detail_route("ingredients", "ingredient_id"),
|
||||
status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
@admin_role_required
|
||||
async def soft_delete_ingredient(
|
||||
ingredient_id: UUID,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Soft delete ingredient (mark as inactive)"""
|
||||
"""Soft delete ingredient - mark as inactive (Admin only)"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
result = await service.soft_delete_ingredient(ingredient_id, tenant_id)
|
||||
@@ -218,13 +249,18 @@ async def soft_delete_ingredient(
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/tenants/{tenant_id}/ingredients/{ingredient_id}/hard")
|
||||
@router.delete(
|
||||
route_builder.build_nested_resource_route("ingredients", "ingredient_id", "hard"),
|
||||
response_model=dict
|
||||
)
|
||||
@admin_role_required
|
||||
async def hard_delete_ingredient(
|
||||
ingredient_id: UUID,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Hard delete ingredient and all associated data (stock, movements, etc.)"""
|
||||
"""Hard delete ingredient and all associated data (Admin only)"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
deletion_summary = await service.hard_delete_ingredient(ingredient_id, tenant_id)
|
||||
@@ -241,16 +277,19 @@ async def hard_delete_ingredient(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/ingredients/{ingredient_id}/stock", response_model=List[StockResponse])
|
||||
@router.get(
|
||||
route_builder.build_nested_resource_route("ingredients", "ingredient_id", "stock"),
|
||||
response_model=List[StockResponse]
|
||||
)
|
||||
async def get_ingredient_stock(
|
||||
ingredient_id: UUID,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
include_unavailable: bool = Query(False, description="Include unavailable stock"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock entries for an ingredient"""
|
||||
"""Get stock entries for an ingredient (All users)"""
|
||||
try:
|
||||
|
||||
service = InventoryService()
|
||||
stock_entries = await service.get_stock_by_ingredient(
|
||||
ingredient_id, tenant_id, include_unavailable
|
||||
@@ -260,4 +299,4 @@ async def get_ingredient_stock(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get ingredient stock"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,22 +1,151 @@
|
||||
# services/inventory/app/api/classification.py
|
||||
# services/inventory/app/api/inventory_operations.py
|
||||
"""
|
||||
Product Classification API Endpoints
|
||||
AI-powered product classification for onboarding automation
|
||||
Inventory Operations API - Business operations for inventory management
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID, uuid4
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel, Field
|
||||
import structlog
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.inventory_service import InventoryService
|
||||
from app.services.product_classifier import ProductClassifierService, get_product_classifier
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.access_control import require_user_role
|
||||
from shared.routing import RouteBuilder
|
||||
|
||||
router = APIRouter(tags=["classification"])
|
||||
logger = structlog.get_logger()
|
||||
route_builder = RouteBuilder('inventory')
|
||||
router = APIRouter(tags=["inventory-operations"])
|
||||
|
||||
|
||||
def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> UUID:
|
||||
"""Extract user ID from current user context"""
|
||||
user_id = current_user.get('user_id')
|
||||
if not user_id:
|
||||
if current_user.get('type') == 'service':
|
||||
return None
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User ID not found in context"
|
||||
)
|
||||
try:
|
||||
return UUID(user_id)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# ===== Stock Operations =====
|
||||
|
||||
@router.post(
|
||||
route_builder.build_operations_route("consume-stock"),
|
||||
response_model=dict
|
||||
)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def consume_stock(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
ingredient_id: UUID = Query(..., description="Ingredient ID to consume"),
|
||||
quantity: float = Query(..., gt=0, description="Quantity to consume"),
|
||||
reference_number: Optional[str] = Query(None, description="Reference number"),
|
||||
notes: Optional[str] = Query(None, description="Additional notes"),
|
||||
fifo: bool = Query(True, description="Use FIFO method"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Consume stock for production"""
|
||||
try:
|
||||
user_id = get_current_user_id(current_user)
|
||||
service = InventoryService()
|
||||
consumed_items = await service.consume_stock(
|
||||
ingredient_id, quantity, tenant_id, user_id, reference_number, notes, fifo
|
||||
)
|
||||
return {
|
||||
"ingredient_id": str(ingredient_id),
|
||||
"total_quantity_consumed": quantity,
|
||||
"consumed_items": consumed_items,
|
||||
"method": "FIFO" if fifo else "LIFO"
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to consume stock"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_operations_route("stock/expiring"),
|
||||
response_model=List[dict]
|
||||
)
|
||||
async def get_expiring_stock(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
days_ahead: int = Query(7, ge=1, le=365, description="Days ahead to check"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock items expiring within specified days"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
expiring_items = await service.check_expiration_alerts(tenant_id, days_ahead)
|
||||
return expiring_items
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get expiring stock"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_operations_route("stock/low-stock"),
|
||||
response_model=List[dict]
|
||||
)
|
||||
async def get_low_stock(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get ingredients with low stock levels"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
low_stock_items = await service.check_low_stock_alerts(tenant_id)
|
||||
return low_stock_items
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get low stock items"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_operations_route("stock/summary"),
|
||||
response_model=dict
|
||||
)
|
||||
async def get_stock_summary(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock summary for tenant"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
summary = await service.get_inventory_summary(tenant_id)
|
||||
return summary.dict()
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get stock summary"
|
||||
)
|
||||
|
||||
|
||||
# ===== Product Classification Operations =====
|
||||
|
||||
class ProductClassificationRequest(BaseModel):
|
||||
"""Request for single product classification"""
|
||||
product_name: str = Field(..., description="Product name to classify")
|
||||
@@ -48,7 +177,7 @@ class ProductSuggestionResponse(BaseModel):
|
||||
|
||||
class BusinessModelAnalysisResponse(BaseModel):
|
||||
"""Response with business model analysis"""
|
||||
model: str # production, retail, hybrid
|
||||
model: str
|
||||
confidence: float
|
||||
ingredient_count: int
|
||||
finished_product_count: int
|
||||
@@ -65,7 +194,10 @@ class BatchClassificationResponse(BaseModel):
|
||||
low_confidence_count: int
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/inventory/classify-product", response_model=ProductSuggestionResponse)
|
||||
@router.post(
|
||||
route_builder.build_operations_route("classify-product"),
|
||||
response_model=ProductSuggestionResponse
|
||||
)
|
||||
async def classify_single_product(
|
||||
request: ProductClassificationRequest,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
@@ -74,15 +206,13 @@ async def classify_single_product(
|
||||
):
|
||||
"""Classify a single product for inventory creation"""
|
||||
try:
|
||||
# Classify the product
|
||||
suggestion = classifier.classify_product(
|
||||
request.product_name,
|
||||
request.product_name,
|
||||
request.sales_volume
|
||||
)
|
||||
|
||||
# Convert to response format
|
||||
|
||||
response = ProductSuggestionResponse(
|
||||
suggestion_id=str(uuid4()), # Generate unique ID for tracking
|
||||
suggestion_id=str(uuid4()),
|
||||
original_name=suggestion.original_name,
|
||||
suggested_name=suggestion.suggested_name,
|
||||
product_type=suggestion.product_type.value,
|
||||
@@ -96,22 +226,25 @@ async def classify_single_product(
|
||||
suggested_supplier=suggestion.suggested_supplier,
|
||||
notes=suggestion.notes
|
||||
)
|
||||
|
||||
logger.info("Classified single product",
|
||||
product=request.product_name,
|
||||
|
||||
logger.info("Classified single product",
|
||||
product=request.product_name,
|
||||
classification=suggestion.product_type.value,
|
||||
confidence=suggestion.confidence_score,
|
||||
tenant_id=tenant_id)
|
||||
|
||||
|
||||
return response
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to classify product",
|
||||
logger.error("Failed to classify product",
|
||||
error=str(e), product=request.product_name, tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=f"Classification failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/inventory/classify-products-batch", response_model=BatchClassificationResponse)
|
||||
@router.post(
|
||||
route_builder.build_operations_route("classify-products-batch"),
|
||||
response_model=BatchClassificationResponse
|
||||
)
|
||||
async def classify_products_batch(
|
||||
request: BatchClassificationRequest,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
@@ -122,15 +255,12 @@ async def classify_products_batch(
|
||||
try:
|
||||
if not request.products:
|
||||
raise HTTPException(status_code=400, detail="No products provided for classification")
|
||||
|
||||
# Extract product names and volumes
|
||||
|
||||
product_names = [p.product_name for p in request.products]
|
||||
sales_volumes = {p.product_name: p.sales_volume for p in request.products if p.sales_volume}
|
||||
|
||||
# Classify products in batch
|
||||
|
||||
suggestions = classifier.classify_products_batch(product_names, sales_volumes)
|
||||
|
||||
# Convert suggestions to response format
|
||||
|
||||
suggestion_responses = []
|
||||
for suggestion in suggestions:
|
||||
suggestion_responses.append(ProductSuggestionResponse(
|
||||
@@ -148,33 +278,31 @@ async def classify_products_batch(
|
||||
suggested_supplier=suggestion.suggested_supplier,
|
||||
notes=suggestion.notes
|
||||
))
|
||||
|
||||
# Analyze business model with enhanced detection
|
||||
|
||||
# Analyze business model
|
||||
ingredient_count = sum(1 for s in suggestions if s.product_type.value == 'ingredient')
|
||||
finished_count = sum(1 for s in suggestions if s.product_type.value == 'finished_product')
|
||||
semi_finished_count = sum(1 for s in suggestions if 'semi' in s.suggested_name.lower() or 'frozen' in s.suggested_name.lower() or 'pre' in s.suggested_name.lower())
|
||||
total = len(suggestions)
|
||||
ingredient_ratio = ingredient_count / total if total > 0 else 0
|
||||
semi_finished_ratio = semi_finished_count / total if total > 0 else 0
|
||||
|
||||
# Enhanced business model determination
|
||||
|
||||
if ingredient_ratio >= 0.7:
|
||||
model = 'individual_bakery' # Full production from raw ingredients
|
||||
model = 'individual_bakery'
|
||||
elif ingredient_ratio <= 0.2 and semi_finished_ratio >= 0.3:
|
||||
model = 'central_baker_satellite' # Receives semi-finished products from central baker
|
||||
model = 'central_baker_satellite'
|
||||
elif ingredient_ratio <= 0.3:
|
||||
model = 'retail_bakery' # Sells finished products from suppliers
|
||||
model = 'retail_bakery'
|
||||
else:
|
||||
model = 'hybrid_bakery' # Mixed model
|
||||
|
||||
# Calculate confidence based on clear distinction
|
||||
model = 'hybrid_bakery'
|
||||
|
||||
if model == 'individual_bakery':
|
||||
confidence = min(ingredient_ratio * 1.2, 0.95)
|
||||
elif model == 'central_baker_satellite':
|
||||
confidence = min((semi_finished_ratio + (1 - ingredient_ratio)) / 2 * 1.2, 0.95)
|
||||
else:
|
||||
confidence = max(abs(ingredient_ratio - 0.5) * 2, 0.1)
|
||||
|
||||
|
||||
recommendations = {
|
||||
'individual_bakery': [
|
||||
'Set up raw ingredient inventory management',
|
||||
@@ -203,7 +331,7 @@ async def classify_products_batch(
|
||||
'Configure multi-tier inventory categories'
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
business_model_analysis = BusinessModelAnalysisResponse(
|
||||
model=model,
|
||||
confidence=confidence,
|
||||
@@ -212,11 +340,10 @@ async def classify_products_batch(
|
||||
ingredient_ratio=ingredient_ratio,
|
||||
recommendations=recommendations.get(model, [])
|
||||
)
|
||||
|
||||
# Count confidence levels
|
||||
|
||||
high_confidence_count = sum(1 for s in suggestions if s.confidence_score >= 0.7)
|
||||
low_confidence_count = sum(1 for s in suggestions if s.confidence_score < 0.6)
|
||||
|
||||
|
||||
response = BatchClassificationResponse(
|
||||
suggestions=suggestion_responses,
|
||||
business_model_analysis=business_model_analysis,
|
||||
@@ -224,17 +351,17 @@ async def classify_products_batch(
|
||||
high_confidence_count=high_confidence_count,
|
||||
low_confidence_count=low_confidence_count
|
||||
)
|
||||
|
||||
logger.info("Batch classification complete",
|
||||
|
||||
logger.info("Batch classification complete",
|
||||
total_products=len(suggestions),
|
||||
business_model=model,
|
||||
high_confidence=high_confidence_count,
|
||||
low_confidence=low_confidence_count,
|
||||
tenant_id=tenant_id)
|
||||
|
||||
|
||||
return response
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed batch classification",
|
||||
logger.error("Failed batch classification",
|
||||
error=str(e), products_count=len(request.products), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=f"Batch classification failed: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Batch classification failed: {str(e)}")
|
||||
@@ -1,6 +1,6 @@
|
||||
# services/inventory/app/api/stock.py
|
||||
# services/inventory/app/api/stock_entries.py
|
||||
"""
|
||||
API endpoints for stock management
|
||||
Stock Entries API - ATOMIC CRUD operations on Stock model
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
@@ -12,24 +12,25 @@ import structlog
|
||||
from app.core.database import get_db
|
||||
from app.services.inventory_service import InventoryService
|
||||
from app.schemas.inventory import (
|
||||
StockCreate,
|
||||
StockUpdate,
|
||||
StockCreate,
|
||||
StockUpdate,
|
||||
StockResponse,
|
||||
StockMovementCreate,
|
||||
StockMovementResponse,
|
||||
StockFilter
|
||||
StockMovementResponse
|
||||
)
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.access_control import require_user_role, admin_role_required
|
||||
from shared.routing import RouteBuilder
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter(tags=["stock"])
|
||||
route_builder = RouteBuilder('inventory')
|
||||
router = APIRouter(tags=["stock-entries"])
|
||||
|
||||
|
||||
# Helper function to extract user ID from user object
|
||||
def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> UUID:
|
||||
"""Extract user ID from current user context"""
|
||||
user_id = current_user.get('user_id')
|
||||
if not user_id:
|
||||
# Handle service tokens that don't have UUID user_ids
|
||||
if current_user.get('type') == 'service':
|
||||
return None
|
||||
raise HTTPException(
|
||||
@@ -42,7 +43,12 @@ def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> U
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/stock", response_model=StockResponse)
|
||||
@router.post(
|
||||
route_builder.build_base_route("stock"),
|
||||
response_model=StockResponse,
|
||||
status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def add_stock(
|
||||
stock_data: StockCreate,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
@@ -51,9 +57,7 @@ async def add_stock(
|
||||
):
|
||||
"""Add new stock entry"""
|
||||
try:
|
||||
# Extract user ID - handle service tokens
|
||||
user_id = get_current_user_id(current_user)
|
||||
|
||||
service = InventoryService()
|
||||
stock = await service.add_stock(stock_data, tenant_id, user_id)
|
||||
return stock
|
||||
@@ -69,103 +73,17 @@ async def add_stock(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/stock/consume")
|
||||
async def consume_stock(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
ingredient_id: UUID = Query(..., description="Ingredient ID to consume"),
|
||||
quantity: float = Query(..., gt=0, description="Quantity to consume"),
|
||||
reference_number: Optional[str] = Query(None, description="Reference number (e.g., production order)"),
|
||||
notes: Optional[str] = Query(None, description="Additional notes"),
|
||||
fifo: bool = Query(True, description="Use FIFO (First In, First Out) method"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Consume stock for production"""
|
||||
try:
|
||||
# Extract user ID - handle service tokens
|
||||
user_id = get_current_user_id(current_user)
|
||||
|
||||
service = InventoryService()
|
||||
consumed_items = await service.consume_stock(
|
||||
ingredient_id, quantity, tenant_id, user_id, reference_number, notes, fifo
|
||||
)
|
||||
return {
|
||||
"ingredient_id": str(ingredient_id),
|
||||
"total_quantity_consumed": quantity,
|
||||
"consumed_items": consumed_items,
|
||||
"method": "FIFO" if fifo else "LIFO"
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to consume stock"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/stock/expiring", response_model=List[dict])
|
||||
async def get_expiring_stock(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
days_ahead: int = Query(7, ge=1, le=365, description="Days ahead to check for expiring items"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock items expiring within specified days"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
expiring_items = await service.check_expiration_alerts(tenant_id, days_ahead)
|
||||
return expiring_items
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get expiring stock"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/stock/low-stock", response_model=List[dict])
|
||||
async def get_low_stock(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get ingredients with low stock levels"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
low_stock_items = await service.check_low_stock_alerts(tenant_id)
|
||||
return low_stock_items
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get low stock items"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/stock/summary", response_model=dict)
|
||||
async def get_stock_summary(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock summary for dashboard"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
summary = await service.get_inventory_summary(tenant_id)
|
||||
return summary.dict()
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get stock summary"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/stock", response_model=List[StockResponse])
|
||||
@router.get(
|
||||
route_builder.build_base_route("stock"),
|
||||
response_model=List[StockResponse]
|
||||
)
|
||||
async def get_stock(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||
ingredient_id: Optional[UUID] = Query(None, description="Filter by ingredient"),
|
||||
available_only: bool = Query(True, description="Show only available stock"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock entries with filtering"""
|
||||
@@ -182,54 +100,27 @@ async def get_stock(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/stock/movements", response_model=List[StockMovementResponse])
|
||||
async def get_stock_movements(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||
ingredient_id: Optional[UUID] = Query(None, description="Filter by ingredient"),
|
||||
movement_type: Optional[str] = Query(None, description="Filter by movement type"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock movements with filtering"""
|
||||
logger.info("🌐 API endpoint reached!",
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
skip=skip,
|
||||
limit=limit)
|
||||
|
||||
try:
|
||||
service = InventoryService()
|
||||
movements = await service.get_stock_movements(
|
||||
tenant_id, skip, limit, ingredient_id, movement_type
|
||||
)
|
||||
logger.info("📈 Returning movements", count=len(movements))
|
||||
return movements
|
||||
except Exception as e:
|
||||
logger.error("❌ Failed to get stock movements", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get stock movements"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/stock/{stock_id}", response_model=StockResponse)
|
||||
@router.get(
|
||||
route_builder.build_resource_detail_route("stock", "stock_id"),
|
||||
response_model=StockResponse
|
||||
)
|
||||
async def get_stock_entry(
|
||||
stock_id: UUID = Path(..., description="Stock entry ID"),
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get specific stock entry"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
stock = await service.get_stock_entry(stock_id, tenant_id)
|
||||
|
||||
|
||||
if not stock:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Stock entry not found"
|
||||
)
|
||||
|
||||
|
||||
return stock
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -240,24 +131,29 @@ async def get_stock_entry(
|
||||
)
|
||||
|
||||
|
||||
@router.put("/tenants/{tenant_id}/stock/{stock_id}", response_model=StockResponse)
|
||||
@router.put(
|
||||
route_builder.build_resource_detail_route("stock", "stock_id"),
|
||||
response_model=StockResponse
|
||||
)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def update_stock(
|
||||
stock_data: StockUpdate,
|
||||
stock_id: UUID = Path(..., description="Stock entry ID"),
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update stock entry"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
stock = await service.update_stock(stock_id, stock_data, tenant_id)
|
||||
|
||||
|
||||
if not stock:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Stock entry not found"
|
||||
)
|
||||
|
||||
|
||||
return stock
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
@@ -273,23 +169,28 @@ async def update_stock(
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/tenants/{tenant_id}/stock/{stock_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete(
|
||||
route_builder.build_resource_detail_route("stock", "stock_id"),
|
||||
status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
@admin_role_required
|
||||
async def delete_stock(
|
||||
stock_id: UUID = Path(..., description="Stock entry ID"),
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete stock entry (mark as unavailable)"""
|
||||
"""Delete stock entry"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
deleted = await service.delete_stock(stock_id, tenant_id)
|
||||
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Stock entry not found"
|
||||
)
|
||||
|
||||
|
||||
return None
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -300,7 +201,47 @@ async def delete_stock(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/stock/movements", response_model=StockMovementResponse)
|
||||
@router.get(
|
||||
route_builder.build_base_route("stock/movements"),
|
||||
response_model=List[StockMovementResponse]
|
||||
)
|
||||
async def get_stock_movements(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||
ingredient_id: Optional[UUID] = Query(None, description="Filter by ingredient"),
|
||||
movement_type: Optional[str] = Query(None, description="Filter by movement type"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock movements with filtering"""
|
||||
logger.info("API endpoint reached!",
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
skip=skip,
|
||||
limit=limit)
|
||||
|
||||
try:
|
||||
service = InventoryService()
|
||||
movements = await service.get_stock_movements(
|
||||
tenant_id, skip, limit, ingredient_id, movement_type
|
||||
)
|
||||
logger.info("Returning movements", count=len(movements))
|
||||
return movements
|
||||
except Exception as e:
|
||||
logger.error("Failed to get stock movements", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get stock movements"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
route_builder.build_base_route("stock/movements"),
|
||||
response_model=StockMovementResponse,
|
||||
status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def create_stock_movement(
|
||||
movement_data: StockMovementCreate,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
@@ -309,9 +250,7 @@ async def create_stock_movement(
|
||||
):
|
||||
"""Create stock movement record"""
|
||||
try:
|
||||
# Extract user ID - handle service tokens
|
||||
user_id = get_current_user_id(current_user)
|
||||
|
||||
service = InventoryService()
|
||||
movement = await service.create_stock_movement(movement_data, tenant_id, user_id)
|
||||
return movement
|
||||
@@ -325,5 +264,3 @@ async def create_stock_movement(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create stock movement"
|
||||
)
|
||||
|
||||
|
||||
240
services/inventory/app/api/temperature_logs.py
Normal file
240
services/inventory/app/api/temperature_logs.py
Normal file
@@ -0,0 +1,240 @@
|
||||
# services/inventory/app/api/temperature_logs.py
|
||||
"""
|
||||
Temperature Logs API - ATOMIC CRUD operations on TemperatureLog model
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.access_control import require_user_role
|
||||
from shared.routing import RouteBuilder
|
||||
from app.core.database import get_db
|
||||
from app.services.food_safety_service import FoodSafetyService
|
||||
from app.schemas.food_safety import (
|
||||
TemperatureLogCreate,
|
||||
TemperatureLogResponse,
|
||||
BulkTemperatureLogCreate
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
route_builder = RouteBuilder('inventory')
|
||||
router = APIRouter(tags=["temperature-logs"])
|
||||
|
||||
|
||||
async def get_food_safety_service() -> FoodSafetyService:
|
||||
"""Get food safety service instance"""
|
||||
return FoodSafetyService()
|
||||
|
||||
|
||||
@router.post(
|
||||
route_builder.build_base_route("food-safety/temperature"),
|
||||
response_model=TemperatureLogResponse,
|
||||
status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def log_temperature(
|
||||
temp_data: TemperatureLogCreate,
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
food_safety_service: FoodSafetyService = Depends(get_food_safety_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Log a temperature reading"""
|
||||
try:
|
||||
temp_data.tenant_id = tenant_id
|
||||
|
||||
temp_log = await food_safety_service.log_temperature(
|
||||
db,
|
||||
temp_data,
|
||||
user_id=UUID(current_user["sub"])
|
||||
)
|
||||
|
||||
logger.info("Temperature logged",
|
||||
location=temp_data.storage_location,
|
||||
temperature=temp_data.temperature_celsius)
|
||||
|
||||
return temp_log
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error logging temperature", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to log temperature"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
route_builder.build_base_route("food-safety/temperature/bulk"),
|
||||
response_model=List[TemperatureLogResponse],
|
||||
status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def bulk_log_temperatures(
|
||||
bulk_data: BulkTemperatureLogCreate,
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
food_safety_service: FoodSafetyService = Depends(get_food_safety_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Bulk log temperature readings"""
|
||||
try:
|
||||
for reading in bulk_data.readings:
|
||||
reading.tenant_id = tenant_id
|
||||
|
||||
temp_logs = await food_safety_service.bulk_log_temperatures(
|
||||
db,
|
||||
bulk_data.readings,
|
||||
user_id=UUID(current_user["sub"])
|
||||
)
|
||||
|
||||
logger.info("Bulk temperature logging completed",
|
||||
count=len(bulk_data.readings))
|
||||
|
||||
return temp_logs
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error bulk logging temperatures", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to bulk log temperatures"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("food-safety/temperature"),
|
||||
response_model=List[TemperatureLogResponse]
|
||||
)
|
||||
async def get_temperature_logs(
|
||||
tenant_id: UUID = Path(...),
|
||||
location: Optional[str] = Query(None, description="Filter by storage location"),
|
||||
equipment_id: Optional[str] = Query(None, description="Filter by equipment ID"),
|
||||
date_from: Optional[datetime] = Query(None, description="Start date for filtering"),
|
||||
date_to: Optional[datetime] = Query(None, description="End date for filtering"),
|
||||
violations_only: bool = Query(False, description="Show only temperature violations"),
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get temperature logs with filtering"""
|
||||
try:
|
||||
where_conditions = ["tenant_id = :tenant_id"]
|
||||
params = {"tenant_id": tenant_id}
|
||||
|
||||
if location:
|
||||
where_conditions.append("storage_location ILIKE :location")
|
||||
params["location"] = f"%{location}%"
|
||||
|
||||
if equipment_id:
|
||||
where_conditions.append("equipment_id = :equipment_id")
|
||||
params["equipment_id"] = equipment_id
|
||||
|
||||
if date_from:
|
||||
where_conditions.append("recorded_at >= :date_from")
|
||||
params["date_from"] = date_from
|
||||
|
||||
if date_to:
|
||||
where_conditions.append("recorded_at <= :date_to")
|
||||
params["date_to"] = date_to
|
||||
|
||||
if violations_only:
|
||||
where_conditions.append("is_within_range = false")
|
||||
|
||||
where_clause = " AND ".join(where_conditions)
|
||||
|
||||
query = f"""
|
||||
SELECT * FROM temperature_logs
|
||||
WHERE {where_clause}
|
||||
ORDER BY recorded_at DESC
|
||||
LIMIT :limit OFFSET :skip
|
||||
"""
|
||||
params.update({"limit": limit, "skip": skip})
|
||||
|
||||
result = await db.execute(query, params)
|
||||
logs = result.fetchall()
|
||||
|
||||
return [
|
||||
TemperatureLogResponse(**dict(log))
|
||||
for log in logs
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting temperature logs", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve temperature logs"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_resource_detail_route("food-safety/temperature", "log_id"),
|
||||
response_model=TemperatureLogResponse
|
||||
)
|
||||
async def get_temperature_log(
|
||||
log_id: UUID = Path(...),
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get specific temperature log"""
|
||||
try:
|
||||
query = "SELECT * FROM temperature_logs WHERE id = :log_id AND tenant_id = :tenant_id"
|
||||
result = await db.execute(query, {"log_id": log_id, "tenant_id": tenant_id})
|
||||
log = result.fetchone()
|
||||
|
||||
if not log:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Temperature log not found"
|
||||
)
|
||||
|
||||
return TemperatureLogResponse(**dict(log))
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error getting temperature log", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve temperature log"
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
route_builder.build_resource_detail_route("food-safety/temperature", "log_id"),
|
||||
status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def delete_temperature_log(
|
||||
log_id: UUID = Path(...),
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete temperature log"""
|
||||
try:
|
||||
query = "DELETE FROM temperature_logs WHERE id = :log_id AND tenant_id = :tenant_id"
|
||||
result = await db.execute(query, {"log_id": log_id, "tenant_id": tenant_id})
|
||||
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Temperature log not found"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error deleting temperature log", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete temperature log"
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
# services/inventory/app/api/transformations.py
|
||||
"""
|
||||
API endpoints for product transformations
|
||||
Following standardized URL structure with role-based access control
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
@@ -17,8 +18,14 @@ from app.schemas.inventory import (
|
||||
)
|
||||
from app.models.inventory import ProductionStage
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.access_control import require_user_role, admin_role_required
|
||||
from shared.routing import RouteBuilder
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Create route builder for consistent URL structure
|
||||
route_builder = RouteBuilder('inventory')
|
||||
|
||||
router = APIRouter(tags=["transformations"])
|
||||
|
||||
|
||||
@@ -40,7 +47,12 @@ def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> U
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/transformations", response_model=ProductTransformationResponse)
|
||||
@router.post(
|
||||
route_builder.build_base_route("transformations"),
|
||||
response_model=ProductTransformationResponse,
|
||||
status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def create_transformation(
|
||||
transformation_data: ProductTransformationCreate,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
@@ -68,7 +80,10 @@ async def create_transformation(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/transformations", response_model=List[ProductTransformationResponse])
|
||||
@router.get(
|
||||
route_builder.build_base_route("transformations"),
|
||||
response_model=List[ProductTransformationResponse]
|
||||
)
|
||||
async def get_transformations(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
@@ -94,7 +109,10 @@ async def get_transformations(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/transformations/{transformation_id}", response_model=ProductTransformationResponse)
|
||||
@router.get(
|
||||
route_builder.build_resource_detail_route("transformations", "transformation_id"),
|
||||
response_model=ProductTransformationResponse
|
||||
)
|
||||
async def get_transformation(
|
||||
transformation_id: UUID = Path(..., description="Transformation ID"),
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
@@ -122,7 +140,10 @@ async def get_transformation(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/transformations/summary", response_model=dict)
|
||||
@router.get(
|
||||
route_builder.build_base_route("transformations/summary"),
|
||||
response_model=dict
|
||||
)
|
||||
async def get_transformation_summary(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
days_back: int = Query(30, ge=1, le=365, description="Days back for summary"),
|
||||
@@ -141,7 +162,11 @@ async def get_transformation_summary(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/transformations/par-bake-to-fresh")
|
||||
@router.post(
|
||||
route_builder.build_operations_route("transformations/par-bake-to-fresh"),
|
||||
response_model=dict
|
||||
)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
async def create_par_bake_transformation(
|
||||
source_ingredient_id: UUID = Query(..., description="Par-baked ingredient ID"),
|
||||
target_ingredient_id: UUID = Query(..., description="Fresh baked ingredient ID"),
|
||||
|
||||
Reference in New Issue
Block a user