Improve the frontend and repository layer

This commit is contained in:
Urtzi Alfaro
2025-10-23 07:44:54 +02:00
parent 8d30172483
commit 07c33fa578
112 changed files with 14726 additions and 2733 deletions

View File

@@ -0,0 +1,464 @@
# services/inventory/app/repositories/dashboard_repository.py
"""
Dashboard Repository for complex dashboard queries
"""
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime
from decimal import Decimal
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
logger = structlog.get_logger()
class DashboardRepository:
"""Repository for dashboard-specific database queries"""
def __init__(self, session: AsyncSession):
self.session = session
async def get_business_model_metrics(self, tenant_id: UUID) -> Dict[str, Any]:
"""Get ingredient metrics for business model detection"""
try:
query = text("""
SELECT
COUNT(*) as total_ingredients,
COUNT(CASE WHEN product_type::text = 'finished_product' THEN 1 END) as finished_products,
COUNT(CASE WHEN product_type::text = 'ingredient' THEN 1 END) as raw_ingredients,
COUNT(DISTINCT st.supplier_id) as supplier_count,
AVG(CASE WHEN s.available_quantity IS NOT NULL THEN s.available_quantity ELSE 0 END) as avg_stock_level
FROM ingredients i
LEFT JOIN (
SELECT ingredient_id, SUM(available_quantity) as available_quantity
FROM stock WHERE tenant_id = :tenant_id GROUP BY ingredient_id
) s ON i.id = s.ingredient_id
LEFT JOIN (
SELECT ingredient_id, supplier_id
FROM stock WHERE tenant_id = :tenant_id AND supplier_id IS NOT NULL
GROUP BY ingredient_id, supplier_id
) st ON i.id = st.ingredient_id
WHERE i.tenant_id = :tenant_id AND i.is_active = true
""")
result = await self.session.execute(query, {"tenant_id": tenant_id})
row = result.fetchone()
if not row:
return {
"total_ingredients": 0,
"finished_products": 0,
"raw_ingredients": 0,
"supplier_count": 0,
"avg_stock_level": 0
}
return {
"total_ingredients": row.total_ingredients,
"finished_products": row.finished_products,
"raw_ingredients": row.raw_ingredients,
"supplier_count": row.supplier_count,
"avg_stock_level": float(row.avg_stock_level) if row.avg_stock_level else 0
}
except Exception as e:
logger.error("Failed to get business model metrics", error=str(e), tenant_id=str(tenant_id))
raise
async def get_stock_by_category(self, tenant_id: UUID) -> Dict[str, Dict[str, Any]]:
"""Get stock breakdown by category"""
try:
query = text("""
SELECT
COALESCE(i.ingredient_category::text, i.product_category::text, 'other') as category,
COUNT(*) as count,
COALESCE(SUM(s.available_quantity * s.unit_cost), 0) as total_value
FROM ingredients i
LEFT JOIN (
SELECT ingredient_id, SUM(available_quantity) as available_quantity, AVG(unit_cost) as unit_cost
FROM stock WHERE tenant_id = :tenant_id GROUP BY ingredient_id
) s ON i.id = s.ingredient_id
WHERE i.tenant_id = :tenant_id AND i.is_active = true
GROUP BY category
""")
result = await self.session.execute(query, {"tenant_id": tenant_id})
categories = {}
for row in result.fetchall():
categories[row.category] = {
"count": row.count,
"total_value": float(row.total_value)
}
return categories
except Exception as e:
logger.error("Failed to get stock by category", error=str(e), tenant_id=str(tenant_id))
raise
async def get_alerts_by_severity(self, tenant_id: UUID) -> Dict[str, int]:
"""Get active alerts breakdown by severity"""
try:
query = text("""
SELECT severity, COUNT(*) as count
FROM food_safety_alerts
WHERE tenant_id = :tenant_id AND status = 'active'
GROUP BY severity
""")
result = await self.session.execute(query, {"tenant_id": tenant_id})
alerts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for row in result.fetchall():
alerts[row.severity] = row.count
return alerts
except Exception as e:
logger.error("Failed to get alerts by severity", error=str(e), tenant_id=str(tenant_id))
raise
async def get_movements_by_type(self, tenant_id: UUID, days: int = 7) -> Dict[str, int]:
"""Get stock movements breakdown by type for recent period"""
try:
query = text("""
SELECT sm.movement_type, COUNT(*) as count
FROM stock_movements sm
JOIN ingredients i ON sm.ingredient_id = i.id
WHERE i.tenant_id = :tenant_id
AND sm.movement_date > NOW() - INTERVAL '7 days'
GROUP BY sm.movement_type
""")
result = await self.session.execute(query, {"tenant_id": tenant_id})
movements = {}
for row in result.fetchall():
movements[row.movement_type] = row.count
return movements
except Exception as e:
logger.error("Failed to get movements by type", error=str(e), tenant_id=str(tenant_id))
raise
async def get_alert_trend(self, tenant_id: UUID, days: int = 30) -> List[Dict[str, Any]]:
"""Get alert trend over time"""
try:
query = text(f"""
SELECT
DATE(created_at) as alert_date,
COUNT(*) as alert_count,
COUNT(CASE WHEN severity IN ('high', 'critical') THEN 1 END) as high_severity_count
FROM food_safety_alerts
WHERE tenant_id = :tenant_id
AND created_at > NOW() - INTERVAL '{days} days'
GROUP BY DATE(created_at)
ORDER BY alert_date
""")
result = await self.session.execute(query, {"tenant_id": tenant_id})
return [
{
"date": row.alert_date.isoformat(),
"total_alerts": row.alert_count,
"high_severity_alerts": row.high_severity_count
}
for row in result.fetchall()
]
except Exception as e:
logger.error("Failed to get alert trend", error=str(e), tenant_id=str(tenant_id))
raise
async def get_recent_stock_movements(
self,
tenant_id: UUID,
limit: int = 20
) -> List[Dict[str, Any]]:
"""Get recent stock movements"""
try:
query = text("""
SELECT
'stock_movement' as activity_type,
CASE
WHEN movement_type = 'PURCHASE' THEN 'Stock added: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
WHEN movement_type = 'PRODUCTION_USE' THEN 'Stock consumed: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
WHEN movement_type = 'WASTE' THEN 'Stock wasted: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
WHEN movement_type = 'ADJUSTMENT' THEN 'Stock adjusted: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
ELSE 'Stock movement: ' || i.name
END as description,
sm.movement_date as timestamp,
sm.created_by as user_id,
CASE
WHEN movement_type = 'WASTE' THEN 'high'
WHEN movement_type = 'ADJUSTMENT' THEN 'medium'
ELSE 'low'
END as impact_level,
sm.id as entity_id,
'stock_movement' as entity_type
FROM stock_movements sm
JOIN ingredients i ON sm.ingredient_id = i.id
WHERE i.tenant_id = :tenant_id
ORDER BY sm.movement_date DESC
LIMIT :limit
""")
result = await self.session.execute(query, {"tenant_id": tenant_id, "limit": limit})
return [
{
"activity_type": row.activity_type,
"description": row.description,
"timestamp": row.timestamp,
"user_id": row.user_id,
"impact_level": row.impact_level,
"entity_id": row.entity_id,
"entity_type": row.entity_type
}
for row in result.fetchall()
]
except Exception as e:
logger.error("Failed to get recent stock movements", error=str(e), tenant_id=str(tenant_id))
raise
async def get_recent_food_safety_alerts(
self,
tenant_id: UUID,
limit: int = 20
) -> List[Dict[str, Any]]:
"""Get recent food safety alerts"""
try:
query = text("""
SELECT
'food_safety_alert' as activity_type,
title as description,
created_at as timestamp,
created_by as user_id,
CASE
WHEN severity = 'critical' THEN 'high'
WHEN severity = 'high' THEN 'medium'
ELSE 'low'
END as impact_level,
id as entity_id,
'food_safety_alert' as entity_type
FROM food_safety_alerts
WHERE tenant_id = :tenant_id
ORDER BY created_at DESC
LIMIT :limit
""")
result = await self.session.execute(query, {"tenant_id": tenant_id, "limit": limit})
return [
{
"activity_type": row.activity_type,
"description": row.description,
"timestamp": row.timestamp,
"user_id": row.user_id,
"impact_level": row.impact_level,
"entity_id": row.entity_id,
"entity_type": row.entity_type
}
for row in result.fetchall()
]
except Exception as e:
logger.error("Failed to get recent food safety alerts", error=str(e), tenant_id=str(tenant_id))
raise
async def get_live_metrics(self, tenant_id: UUID) -> Dict[str, Any]:
"""Get real-time inventory metrics"""
try:
query = text("""
SELECT
COUNT(DISTINCT i.id) as total_ingredients,
COUNT(CASE WHEN s.available_quantity > i.low_stock_threshold THEN 1 END) as in_stock,
COUNT(CASE WHEN s.available_quantity <= i.low_stock_threshold THEN 1 END) as low_stock,
COUNT(CASE WHEN s.available_quantity = 0 THEN 1 END) as out_of_stock,
COALESCE(SUM(s.available_quantity * s.unit_cost), 0) as total_value,
COUNT(CASE WHEN s.expiration_date < NOW() THEN 1 END) as expired_items,
COUNT(CASE WHEN s.expiration_date BETWEEN NOW() AND NOW() + INTERVAL '7 days' THEN 1 END) as expiring_soon
FROM ingredients i
LEFT JOIN stock s ON i.id = s.ingredient_id AND s.is_available = true
WHERE i.tenant_id = :tenant_id AND i.is_active = true
""")
result = await self.session.execute(query, {"tenant_id": tenant_id})
metrics = result.fetchone()
if not metrics:
return {
"total_ingredients": 0,
"in_stock": 0,
"low_stock": 0,
"out_of_stock": 0,
"total_value": 0.0,
"expired_items": 0,
"expiring_soon": 0,
"last_updated": datetime.now().isoformat()
}
return {
"total_ingredients": metrics.total_ingredients,
"in_stock": metrics.in_stock,
"low_stock": metrics.low_stock,
"out_of_stock": metrics.out_of_stock,
"total_value": float(metrics.total_value),
"expired_items": metrics.expired_items,
"expiring_soon": metrics.expiring_soon,
"last_updated": datetime.now().isoformat()
}
except Exception as e:
logger.error("Failed to get live metrics", error=str(e), tenant_id=str(tenant_id))
raise
async def get_stock_status_by_category(
self,
tenant_id: UUID
) -> List[Dict[str, Any]]:
"""Get stock status breakdown by category"""
try:
query = text("""
SELECT
COALESCE(i.ingredient_category::text, i.product_category::text, 'other') as category,
COUNT(DISTINCT i.id) as total_ingredients,
COUNT(CASE WHEN s.available_quantity > i.low_stock_threshold THEN 1 END) as in_stock,
COUNT(CASE WHEN s.available_quantity <= i.low_stock_threshold AND s.available_quantity > 0 THEN 1 END) as low_stock,
COUNT(CASE WHEN COALESCE(s.available_quantity, 0) = 0 THEN 1 END) as out_of_stock,
COALESCE(SUM(s.available_quantity * s.unit_cost), 0) as total_value
FROM ingredients i
LEFT JOIN (
SELECT
ingredient_id,
SUM(available_quantity) as available_quantity,
AVG(unit_cost) as unit_cost
FROM stock
WHERE tenant_id = :tenant_id AND is_available = true
GROUP BY ingredient_id
) s ON i.id = s.ingredient_id
WHERE i.tenant_id = :tenant_id AND i.is_active = true
GROUP BY category
ORDER BY total_value DESC
""")
result = await self.session.execute(query, {"tenant_id": tenant_id})
return [
{
"category": row.category,
"total_ingredients": row.total_ingredients,
"in_stock": row.in_stock,
"low_stock": row.low_stock,
"out_of_stock": row.out_of_stock,
"total_value": float(row.total_value)
}
for row in result.fetchall()
]
except Exception as e:
logger.error("Failed to get stock status by category", error=str(e), tenant_id=str(tenant_id))
raise
async def get_alerts_summary(
self,
tenant_id: UUID,
alert_types: Optional[List[str]] = None,
severities: Optional[List[str]] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None
) -> List[Dict[str, Any]]:
"""Get alerts summary by type and severity with filters"""
try:
# Build query with filters
where_conditions = ["tenant_id = :tenant_id", "status = 'active'"]
params = {"tenant_id": tenant_id}
if alert_types:
where_conditions.append("alert_type = ANY(:alert_types)")
params["alert_types"] = alert_types
if severities:
where_conditions.append("severity = ANY(:severities)")
params["severities"] = severities
if date_from:
where_conditions.append("created_at >= :date_from")
params["date_from"] = date_from
if date_to:
where_conditions.append("created_at <= :date_to")
params["date_to"] = date_to
where_clause = " AND ".join(where_conditions)
query = text(f"""
SELECT
alert_type,
severity,
COUNT(*) as count,
MIN(EXTRACT(EPOCH FROM (NOW() - created_at))/3600)::int as oldest_alert_age_hours,
AVG(CASE WHEN resolved_at IS NOT NULL
THEN EXTRACT(EPOCH FROM (resolved_at - created_at))/3600
ELSE NULL END)::int as avg_resolution_hours
FROM food_safety_alerts
WHERE {where_clause}
GROUP BY alert_type, severity
ORDER BY severity DESC, count DESC
""")
result = await self.session.execute(query, params)
return [
{
"alert_type": row.alert_type,
"severity": row.severity,
"count": row.count,
"oldest_alert_age_hours": row.oldest_alert_age_hours,
"average_resolution_time_hours": row.avg_resolution_hours
}
for row in result.fetchall()
]
except Exception as e:
logger.error("Failed to get alerts summary", error=str(e), tenant_id=str(tenant_id))
raise
async def get_ingredient_stock_levels(self, tenant_id: UUID) -> Dict[str, float]:
"""
Get current stock levels for all ingredients
Args:
tenant_id: Tenant UUID
Returns:
Dictionary mapping ingredient_id to current stock level
"""
try:
stock_query = text("""
SELECT
i.id as ingredient_id,
COALESCE(SUM(s.available_quantity), 0) as current_stock
FROM ingredients i
LEFT JOIN stock s ON i.id = s.ingredient_id AND s.is_available = true
WHERE i.tenant_id = :tenant_id AND i.is_active = true
GROUP BY i.id
""")
result = await self.session.execute(stock_query, {"tenant_id": tenant_id})
stock_levels = {}
for row in result.fetchall():
stock_levels[str(row.ingredient_id)] = float(row.current_stock)
return stock_levels
except Exception as e:
logger.error("Failed to get ingredient stock levels", error=str(e), tenant_id=str(tenant_id))
raise

View File

@@ -0,0 +1,279 @@
# services/inventory/app/repositories/food_safety_repository.py
"""
Food Safety Repository
Data access layer for food safety compliance and monitoring
"""
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime
from sqlalchemy import text, select
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
from app.models.food_safety import (
FoodSafetyCompliance,
FoodSafetyAlert,
TemperatureLog,
ComplianceStatus
)
logger = structlog.get_logger()
class FoodSafetyRepository:
"""Repository for food safety data access"""
def __init__(self, session: AsyncSession):
self.session = session
# ===== COMPLIANCE METHODS =====
async def create_compliance(self, compliance: FoodSafetyCompliance) -> FoodSafetyCompliance:
"""
Create a new compliance record
Args:
compliance: FoodSafetyCompliance instance
Returns:
Created FoodSafetyCompliance instance
"""
self.session.add(compliance)
await self.session.flush()
await self.session.refresh(compliance)
return compliance
async def get_compliance_by_id(
self,
compliance_id: UUID,
tenant_id: UUID
) -> Optional[FoodSafetyCompliance]:
"""
Get compliance record by ID
Args:
compliance_id: Compliance record UUID
tenant_id: Tenant UUID for authorization
Returns:
FoodSafetyCompliance or None
"""
compliance = await self.session.get(FoodSafetyCompliance, compliance_id)
if compliance and compliance.tenant_id == tenant_id:
return compliance
return None
async def update_compliance(
self,
compliance: FoodSafetyCompliance
) -> FoodSafetyCompliance:
"""
Update compliance record
Args:
compliance: FoodSafetyCompliance instance with updates
Returns:
Updated FoodSafetyCompliance instance
"""
await self.session.flush()
await self.session.refresh(compliance)
return compliance
async def get_compliance_stats(self, tenant_id: UUID) -> Dict[str, int]:
"""
Get compliance statistics for dashboard
Args:
tenant_id: Tenant UUID
Returns:
Dictionary with compliance counts by status
"""
try:
query = text("""
SELECT
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_REVIEW' THEN 1 END) as pending_review
FROM food_safety_compliance
WHERE tenant_id = :tenant_id AND is_active = true
""")
result = await self.session.execute(query, {"tenant_id": tenant_id})
row = result.fetchone()
if not row:
return {
"total": 0,
"compliant": 0,
"non_compliant": 0,
"pending_review": 0
}
return {
"total": row.total or 0,
"compliant": row.compliant or 0,
"non_compliant": row.non_compliant or 0,
"pending_review": row.pending_review or 0
}
except Exception as e:
logger.error("Failed to get compliance stats", error=str(e), tenant_id=str(tenant_id))
raise
# ===== TEMPERATURE MONITORING METHODS =====
async def get_temperature_stats(self, tenant_id: UUID) -> Dict[str, Any]:
"""
Get temperature monitoring statistics
Args:
tenant_id: Tenant UUID
Returns:
Dictionary with temperature monitoring stats
"""
try:
query = text("""
SELECT
COUNT(DISTINCT equipment_id) as sensors_online,
COUNT(CASE WHEN NOT is_within_range AND recorded_at > NOW() - INTERVAL '24 hours' THEN 1 END) as violations_24h
FROM temperature_logs
WHERE tenant_id = :tenant_id AND recorded_at > NOW() - INTERVAL '1 hour'
""")
result = await self.session.execute(query, {"tenant_id": tenant_id})
row = result.fetchone()
if not row:
return {
"sensors_online": 0,
"violations_24h": 0
}
return {
"sensors_online": row.sensors_online or 0,
"violations_24h": row.violations_24h or 0
}
except Exception as e:
logger.error("Failed to get temperature stats", error=str(e), tenant_id=str(tenant_id))
raise
# ===== EXPIRATION TRACKING METHODS =====
async def get_expiration_stats(self, tenant_id: UUID) -> Dict[str, int]:
"""
Get expiration tracking statistics
Args:
tenant_id: Tenant UUID
Returns:
Dictionary with expiration counts
"""
try:
query = text("""
SELECT
COUNT(CASE WHEN expiration_date::date = CURRENT_DATE THEN 1 END) as expiring_today,
COUNT(CASE WHEN expiration_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '7 days' THEN 1 END) as expiring_week,
COUNT(CASE WHEN expiration_date < CURRENT_DATE AND is_available THEN 1 END) as expired_requiring_action
FROM stock s
JOIN ingredients i ON s.ingredient_id = i.id
WHERE i.tenant_id = :tenant_id AND s.is_available = true
""")
result = await self.session.execute(query, {"tenant_id": tenant_id})
row = result.fetchone()
if not row:
return {
"expiring_today": 0,
"expiring_week": 0,
"expired_requiring_action": 0
}
return {
"expiring_today": row.expiring_today or 0,
"expiring_week": row.expiring_week or 0,
"expired_requiring_action": row.expired_requiring_action or 0
}
except Exception as e:
logger.error("Failed to get expiration stats", error=str(e), tenant_id=str(tenant_id))
raise
# ===== ALERT METHODS =====
async def get_alert_stats(self, tenant_id: UUID) -> Dict[str, int]:
"""
Get food safety alert statistics
Args:
tenant_id: Tenant UUID
Returns:
Dictionary with alert counts by severity
"""
try:
query = text("""
SELECT
COUNT(CASE WHEN severity = 'high' OR severity = 'critical' THEN 1 END) as high_risk,
COUNT(CASE WHEN severity = 'critical' THEN 1 END) as critical,
COUNT(CASE WHEN regulatory_action_required = true AND resolved_at IS NULL THEN 1 END) as regulatory_pending
FROM food_safety_alerts
WHERE tenant_id = :tenant_id AND status = 'active'
""")
result = await self.session.execute(query, {"tenant_id": tenant_id})
row = result.fetchone()
if not row:
return {
"high_risk": 0,
"critical": 0,
"regulatory_pending": 0
}
return {
"high_risk": row.high_risk or 0,
"critical": row.critical or 0,
"regulatory_pending": row.regulatory_pending or 0
}
except Exception as e:
logger.error("Failed to get alert stats", error=str(e), tenant_id=str(tenant_id))
raise
# ===== VALIDATION METHODS =====
async def validate_ingredient_exists(
self,
ingredient_id: UUID,
tenant_id: UUID
) -> bool:
"""
Validate that an ingredient exists for a tenant
Args:
ingredient_id: Ingredient UUID
tenant_id: Tenant UUID
Returns:
True if ingredient exists, False otherwise
"""
try:
query = text("""
SELECT id
FROM ingredients
WHERE id = :ingredient_id AND tenant_id = :tenant_id
""")
result = await self.session.execute(query, {
"ingredient_id": ingredient_id,
"tenant_id": tenant_id
})
return result.fetchone() is not None
except Exception as e:
logger.error("Failed to validate ingredient", error=str(e))
raise

View File

@@ -0,0 +1,301 @@
# services/inventory/app/repositories/inventory_alert_repository.py
"""
Inventory Alert Repository
Data access layer for inventory alert detection and analysis
"""
from typing import List, Dict, Any
from uuid import UUID
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
logger = structlog.get_logger()
class InventoryAlertRepository:
"""Repository for inventory alert data access"""
def __init__(self, session: AsyncSession):
self.session = session
async def get_stock_issues(self, tenant_id: UUID) -> List[Dict[str, Any]]:
"""
Get stock level issues with CTE analysis
Returns list of critical, low, and overstock situations
"""
try:
query = text("""
WITH stock_analysis AS (
SELECT
i.id, i.name, i.tenant_id,
COALESCE(SUM(s.current_quantity), 0) as current_stock,
i.low_stock_threshold as minimum_stock,
i.max_stock_level as maximum_stock,
i.reorder_point,
0 as tomorrow_needed,
0 as avg_daily_usage,
7 as lead_time_days,
CASE
WHEN COALESCE(SUM(s.current_quantity), 0) < i.low_stock_threshold THEN 'critical'
WHEN COALESCE(SUM(s.current_quantity), 0) < i.low_stock_threshold * 1.2 THEN 'low'
WHEN i.max_stock_level IS NOT NULL AND COALESCE(SUM(s.current_quantity), 0) > i.max_stock_level THEN 'overstock'
ELSE 'normal'
END as status,
GREATEST(0, i.low_stock_threshold - COALESCE(SUM(s.current_quantity), 0)) as shortage_amount
FROM ingredients i
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true
WHERE i.tenant_id = :tenant_id AND i.is_active = true
GROUP BY i.id, i.name, i.tenant_id, i.low_stock_threshold, i.max_stock_level, i.reorder_point
)
SELECT * FROM stock_analysis WHERE status != 'normal'
ORDER BY
CASE status
WHEN 'critical' THEN 1
WHEN 'low' THEN 2
WHEN 'overstock' THEN 3
END,
shortage_amount DESC
""")
result = await self.session.execute(query, {"tenant_id": tenant_id})
return [dict(row._mapping) for row in result.fetchall()]
except Exception as e:
logger.error("Failed to get stock issues", error=str(e), tenant_id=str(tenant_id))
raise
async def get_expiring_products(self, tenant_id: UUID, days_threshold: int = 7) -> List[Dict[str, Any]]:
"""
Get products expiring soon or already expired
"""
try:
query = text("""
SELECT
i.id as ingredient_id,
i.name as ingredient_name,
s.id as stock_id,
s.batch_number,
s.expiration_date,
s.current_quantity,
i.unit_of_measure,
s.unit_cost,
(s.current_quantity * s.unit_cost) as total_value,
CASE
WHEN s.expiration_date < CURRENT_DATE THEN 'expired'
WHEN s.expiration_date <= CURRENT_DATE + INTERVAL '1 day' THEN 'expires_today'
WHEN s.expiration_date <= CURRENT_DATE + INTERVAL '3 days' THEN 'expires_soon'
ELSE 'warning'
END as urgency,
EXTRACT(DAY FROM (s.expiration_date - CURRENT_DATE)) as days_until_expiry
FROM stock s
JOIN ingredients i ON s.ingredient_id = i.id
WHERE i.tenant_id = :tenant_id
AND s.is_available = true
AND s.expiration_date <= CURRENT_DATE + INTERVAL ':days_threshold days'
ORDER BY s.expiration_date ASC, total_value DESC
""")
result = await self.session.execute(query, {
"tenant_id": tenant_id,
"days_threshold": days_threshold
})
return [dict(row._mapping) for row in result.fetchall()]
except Exception as e:
logger.error("Failed to get expiring products", error=str(e), tenant_id=str(tenant_id))
raise
async def get_temperature_breaches(self, tenant_id: UUID, hours_back: int = 24) -> List[Dict[str, Any]]:
"""
Get temperature monitoring breaches
"""
try:
query = text("""
SELECT
tl.id,
tl.equipment_id,
tl.equipment_name,
tl.storage_type,
tl.temperature_celsius,
tl.min_threshold,
tl.max_threshold,
tl.is_within_range,
tl.recorded_at,
tl.alert_triggered,
EXTRACT(EPOCH FROM (NOW() - tl.recorded_at))/3600 as hours_ago,
CASE
WHEN tl.temperature_celsius < tl.min_threshold
THEN tl.min_threshold - tl.temperature_celsius
WHEN tl.temperature_celsius > tl.max_threshold
THEN tl.temperature_celsius - tl.max_threshold
ELSE 0
END as deviation
FROM temperature_logs tl
WHERE tl.tenant_id = :tenant_id
AND tl.is_within_range = false
AND tl.recorded_at > NOW() - INTERVAL ':hours_back hours'
AND tl.alert_triggered = false
ORDER BY deviation DESC, tl.recorded_at DESC
""")
result = await self.session.execute(query, {
"tenant_id": tenant_id,
"hours_back": hours_back
})
return [dict(row._mapping) for row in result.fetchall()]
except Exception as e:
logger.error("Failed to get temperature breaches", error=str(e), tenant_id=str(tenant_id))
raise
async def mark_temperature_alert_triggered(self, log_id: UUID) -> None:
"""
Mark a temperature log as having triggered an alert
"""
try:
query = text("""
UPDATE temperature_logs
SET alert_triggered = true
WHERE id = :id
""")
await self.session.execute(query, {"id": log_id})
await self.session.commit()
except Exception as e:
logger.error("Failed to mark temperature alert", error=str(e), log_id=str(log_id))
raise
async def get_waste_opportunities(self, tenant_id: UUID) -> List[Dict[str, Any]]:
"""
Identify waste reduction opportunities
"""
try:
query = text("""
WITH waste_analysis AS (
SELECT
i.id as ingredient_id,
i.name as ingredient_name,
i.ingredient_category,
COUNT(sm.id) as waste_incidents,
SUM(sm.quantity) as total_waste_quantity,
SUM(sm.total_cost) as total_waste_cost,
AVG(sm.quantity) as avg_waste_per_incident,
MAX(sm.movement_date) as last_waste_date
FROM stock_movements sm
JOIN ingredients i ON sm.ingredient_id = i.id
WHERE i.tenant_id = :tenant_id
AND sm.movement_type = 'WASTE'
AND sm.movement_date > NOW() - INTERVAL '30 days'
GROUP BY i.id, i.name, i.ingredient_category
HAVING COUNT(sm.id) >= 3 OR SUM(sm.total_cost) > 50
)
SELECT * FROM waste_analysis
ORDER BY total_waste_cost DESC, waste_incidents DESC
LIMIT 20
""")
result = await self.session.execute(query, {"tenant_id": tenant_id})
return [dict(row._mapping) for row in result.fetchall()]
except Exception as e:
logger.error("Failed to get waste opportunities", error=str(e), tenant_id=str(tenant_id))
raise
async def get_reorder_recommendations(self, tenant_id: UUID) -> List[Dict[str, Any]]:
"""
Get ingredients that need reordering based on stock levels and usage
"""
try:
query = text("""
WITH usage_analysis AS (
SELECT
i.id,
i.name,
COALESCE(SUM(s.current_quantity), 0) as current_stock,
i.reorder_point,
i.low_stock_threshold,
COALESCE(SUM(sm.quantity) FILTER (WHERE sm.movement_date > NOW() - INTERVAL '7 days'), 0) / 7 as daily_usage,
i.preferred_supplier_id,
i.standard_order_quantity
FROM ingredients i
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true
LEFT JOIN stock_movements sm ON sm.ingredient_id = i.id
AND sm.movement_type = 'PRODUCTION_USE'
AND sm.movement_date > NOW() - INTERVAL '7 days'
WHERE i.tenant_id = :tenant_id
AND i.is_active = true
GROUP BY i.id, i.name, i.reorder_point, i.low_stock_threshold,
i.preferred_supplier_id, i.standard_order_quantity
)
SELECT *,
CASE
WHEN daily_usage > 0 THEN FLOOR(current_stock / NULLIF(daily_usage, 0))
ELSE 999
END as days_of_stock,
GREATEST(
standard_order_quantity,
CEIL(daily_usage * 14)
) as recommended_order_quantity
FROM usage_analysis
WHERE current_stock <= reorder_point
ORDER BY days_of_stock ASC, current_stock ASC
LIMIT 50
""")
result = await self.session.execute(query, {"tenant_id": tenant_id})
return [dict(row._mapping) for row in result.fetchall()]
except Exception as e:
logger.error("Failed to get reorder recommendations", error=str(e), tenant_id=str(tenant_id))
raise
async def get_active_tenant_ids(self) -> List[UUID]:
"""
Get list of active tenant IDs from ingredients table
"""
try:
query = text("SELECT DISTINCT tenant_id FROM ingredients WHERE is_active = true")
result = await self.session.execute(query)
tenant_ids = []
for row in result.fetchall():
tenant_id = row.tenant_id
# Convert to UUID if it's not already
if isinstance(tenant_id, UUID):
tenant_ids.append(tenant_id)
else:
tenant_ids.append(UUID(str(tenant_id)))
return tenant_ids
except Exception as e:
logger.error("Failed to get active tenant IDs", error=str(e))
raise
async def get_stock_after_order(self, ingredient_id: str, order_quantity: float) -> Dict[str, Any]:
"""
Get stock information after hypothetical order
"""
try:
query = text("""
SELECT i.id, i.name,
COALESCE(SUM(s.current_quantity), 0) as current_stock,
i.low_stock_threshold as minimum_stock,
(COALESCE(SUM(s.current_quantity), 0) - :order_quantity) as remaining
FROM ingredients i
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true
WHERE i.id = :ingredient_id
GROUP BY i.id, i.name, i.low_stock_threshold
""")
result = await self.session.execute(query, {
"ingredient_id": ingredient_id,
"order_quantity": order_quantity
})
row = result.fetchone()
return dict(row._mapping) if row else None
except Exception as e:
logger.error("Failed to get stock after order", error=str(e), ingredient_id=ingredient_id)
raise

View File

@@ -491,4 +491,49 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate,
tenant_id=str(tenant_id),
ingredient_id=str(ingredient_id),
stock_id=str(stock_id))
raise
async def get_inventory_waste_total(
self,
tenant_id: UUID,
start_date: datetime,
end_date: datetime
) -> float:
"""
Get total inventory waste for sustainability reporting
Args:
tenant_id: Tenant UUID
start_date: Start date for period
end_date: End date for period
Returns:
Total waste quantity
"""
try:
from sqlalchemy import text
query = text("""
SELECT COALESCE(SUM(sm.quantity), 0) as total_inventory_waste
FROM stock_movements sm
JOIN ingredients i ON sm.ingredient_id = i.id
WHERE i.tenant_id = :tenant_id
AND sm.movement_type = 'WASTE'
AND sm.movement_date BETWEEN :start_date AND :end_date
""")
result = await self.session.execute(
query,
{
'tenant_id': tenant_id,
'start_date': start_date,
'end_date': end_date
}
)
row = result.fetchone()
return float(row.total_inventory_waste or 0)
except Exception as e:
logger.error("Failed to get inventory waste total", error=str(e), tenant_id=str(tenant_id))
raise