REFACTOR ALL APIs

This commit is contained in:
Urtzi Alfaro
2025-10-06 15:27:01 +02:00
parent dc8221bd2f
commit 38fb98bc27
166 changed files with 18454 additions and 13605 deletions

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

View File

@@ -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)

View File

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

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

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

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

View File

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

View File

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

View File

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

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

View File

@@ -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"),