Initial commit - production deployment
This commit is contained in:
0
services/inventory/app/repositories/__init__.py
Normal file
0
services/inventory/app/repositories/__init__.py
Normal file
464
services/inventory/app/repositories/dashboard_repository.py
Normal file
464
services/inventory/app/repositories/dashboard_repository.py
Normal 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
|
||||
298
services/inventory/app/repositories/food_safety_repository.py
Normal file
298
services/inventory/app/repositories/food_safety_repository.py
Normal file
@@ -0,0 +1,298 @@
|
||||
# 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
|
||||
|
||||
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:
|
||||
await self.session.rollback()
|
||||
logger.error("Failed to mark temperature alert", error=str(e), log_id=str(log_id))
|
||||
raise
|
||||
668
services/inventory/app/repositories/ingredient_repository.py
Normal file
668
services/inventory/app/repositories/ingredient_repository.py
Normal file
@@ -0,0 +1,668 @@
|
||||
# services/inventory/app/repositories/ingredient_repository.py
|
||||
"""
|
||||
Ingredient Repository using Repository Pattern
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from sqlalchemy import select, func, and_, or_, desc, asc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
|
||||
from app.models.inventory import Ingredient, Stock
|
||||
from app.schemas.inventory import IngredientCreate, IngredientUpdate
|
||||
from shared.database.repository import BaseRepository
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, IngredientUpdate]):
|
||||
"""Repository for ingredient operations"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
super().__init__(Ingredient, session)
|
||||
|
||||
async def create_ingredient(self, ingredient_data: IngredientCreate, tenant_id: UUID) -> Ingredient:
|
||||
"""Create a new ingredient"""
|
||||
try:
|
||||
# Prepare data and map schema fields to model fields
|
||||
create_data = ingredient_data.model_dump()
|
||||
create_data['tenant_id'] = tenant_id
|
||||
|
||||
# Handle product_type enum conversion
|
||||
product_type_value = create_data.get('product_type')
|
||||
|
||||
# Log warning if product_type is missing (should be provided by frontend)
|
||||
if not product_type_value:
|
||||
logger.warning(
|
||||
"product_type not provided, defaulting to 'ingredient'",
|
||||
ingredient_name=create_data.get('name'),
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
product_type_value = 'INGREDIENT'
|
||||
|
||||
if 'product_type' in create_data:
|
||||
from app.models.inventory import ProductType
|
||||
try:
|
||||
# Convert string to enum object
|
||||
if isinstance(product_type_value, str):
|
||||
for enum_member in ProductType:
|
||||
if enum_member.value == product_type_value or enum_member.name == product_type_value:
|
||||
create_data['product_type'] = enum_member
|
||||
break
|
||||
else:
|
||||
# If not found, default to INGREDIENT
|
||||
logger.warning(
|
||||
"Invalid product_type value, defaulting to INGREDIENT",
|
||||
invalid_value=product_type_value,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
create_data['product_type'] = ProductType.INGREDIENT
|
||||
# If it's already an enum, keep it
|
||||
except Exception as e:
|
||||
# Fallback to INGREDIENT if any issues
|
||||
logger.error(
|
||||
"Error converting product_type to enum, defaulting to INGREDIENT",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
create_data['product_type'] = ProductType.INGREDIENT
|
||||
|
||||
# Handle category mapping based on product type
|
||||
if 'category' in create_data:
|
||||
category_value = create_data.pop('category')
|
||||
|
||||
if product_type_value == 'FINISHED_PRODUCT':
|
||||
# Map to product_category for finished products
|
||||
from app.models.inventory import ProductCategory
|
||||
if category_value:
|
||||
try:
|
||||
# Find the enum member by value
|
||||
for enum_member in ProductCategory:
|
||||
if enum_member.value == category_value:
|
||||
create_data['product_category'] = enum_member
|
||||
break
|
||||
else:
|
||||
# If not found, default to OTHER
|
||||
create_data['product_category'] = ProductCategory.OTHER_PRODUCTS
|
||||
except Exception:
|
||||
# Fallback to OTHER if any issues
|
||||
create_data['product_category'] = ProductCategory.OTHER_PRODUCTS
|
||||
else:
|
||||
# Map to ingredient_category for ingredients
|
||||
from app.models.inventory import IngredientCategory
|
||||
if category_value:
|
||||
try:
|
||||
# Find the enum member by value
|
||||
for enum_member in IngredientCategory:
|
||||
if enum_member.value == category_value:
|
||||
create_data['ingredient_category'] = enum_member
|
||||
break
|
||||
else:
|
||||
# If not found, default to OTHER
|
||||
create_data['ingredient_category'] = IngredientCategory.OTHER
|
||||
except Exception:
|
||||
# Fallback to OTHER if any issues
|
||||
create_data['ingredient_category'] = IngredientCategory.OTHER
|
||||
|
||||
# Convert unit_of_measure string to enum object
|
||||
if 'unit_of_measure' in create_data:
|
||||
unit_value = create_data['unit_of_measure']
|
||||
from app.models.inventory import UnitOfMeasure
|
||||
try:
|
||||
# Find the enum member by value
|
||||
for enum_member in UnitOfMeasure:
|
||||
if enum_member.value == unit_value:
|
||||
create_data['unit_of_measure'] = enum_member
|
||||
break
|
||||
else:
|
||||
# If not found, default to UNITS
|
||||
create_data['unit_of_measure'] = UnitOfMeasure.UNITS
|
||||
except Exception:
|
||||
# Fallback to UNITS if any issues
|
||||
create_data['unit_of_measure'] = UnitOfMeasure.UNITS
|
||||
|
||||
# Create record
|
||||
record = await self.create(create_data)
|
||||
logger.info(
|
||||
"Created ingredient",
|
||||
ingredient_id=record.id,
|
||||
name=record.name,
|
||||
ingredient_category=record.ingredient_category.value if record.ingredient_category else None,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
return record
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create ingredient", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def update(self, record_id: Any, obj_in: IngredientUpdate, **kwargs) -> Optional[Ingredient]:
|
||||
"""Override update to handle product_type and category enum conversions"""
|
||||
try:
|
||||
# Prepare data and map schema fields to model fields
|
||||
update_data = obj_in.model_dump(exclude_unset=True)
|
||||
|
||||
# Handle product_type enum conversion
|
||||
if 'product_type' in update_data:
|
||||
product_type_value = update_data['product_type']
|
||||
from app.models.inventory import ProductType
|
||||
try:
|
||||
# Convert string to enum object
|
||||
if isinstance(product_type_value, str):
|
||||
for enum_member in ProductType:
|
||||
if enum_member.value == product_type_value or enum_member.name == product_type_value:
|
||||
update_data['product_type'] = enum_member
|
||||
break
|
||||
else:
|
||||
# If not found, keep original value (don't update)
|
||||
del update_data['product_type']
|
||||
# If it's already an enum, keep it
|
||||
except Exception:
|
||||
# Remove invalid product_type to avoid update
|
||||
del update_data['product_type']
|
||||
|
||||
# Handle category mapping based on product type
|
||||
if 'category' in update_data:
|
||||
category_value = update_data.pop('category')
|
||||
product_type_value = update_data.get('product_type', 'INGREDIENT')
|
||||
|
||||
# Get current product if we need to determine type
|
||||
if 'product_type' not in update_data:
|
||||
current_record = await self.get_by_id(record_id)
|
||||
if current_record:
|
||||
product_type_value = current_record.product_type.value if current_record.product_type else 'INGREDIENT'
|
||||
|
||||
if product_type_value == 'FINISHED_PRODUCT':
|
||||
# Map to product_category for finished products
|
||||
from app.models.inventory import ProductCategory
|
||||
if category_value:
|
||||
try:
|
||||
for enum_member in ProductCategory:
|
||||
if enum_member.value == category_value:
|
||||
update_data['product_category'] = enum_member
|
||||
# Clear ingredient_category when setting product_category
|
||||
update_data['ingredient_category'] = None
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# Map to ingredient_category for ingredients
|
||||
from app.models.inventory import IngredientCategory
|
||||
if category_value:
|
||||
try:
|
||||
for enum_member in IngredientCategory:
|
||||
if enum_member.value == category_value:
|
||||
update_data['ingredient_category'] = enum_member
|
||||
# Clear product_category when setting ingredient_category
|
||||
update_data['product_category'] = None
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Handle unit_of_measure enum conversion
|
||||
if 'unit_of_measure' in update_data:
|
||||
unit_value = update_data['unit_of_measure']
|
||||
from app.models.inventory import UnitOfMeasure
|
||||
try:
|
||||
if isinstance(unit_value, str):
|
||||
for enum_member in UnitOfMeasure:
|
||||
if enum_member.value == unit_value:
|
||||
update_data['unit_of_measure'] = enum_member
|
||||
break
|
||||
else:
|
||||
# If not found, keep original value
|
||||
del update_data['unit_of_measure']
|
||||
except Exception:
|
||||
del update_data['unit_of_measure']
|
||||
|
||||
# Call parent update method
|
||||
return await super().update(record_id, update_data, **kwargs)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to update ingredient", error=str(e), record_id=record_id)
|
||||
raise
|
||||
|
||||
async def get_ingredients_by_tenant(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> List[Ingredient]:
|
||||
"""Get ingredients for a tenant with filtering"""
|
||||
try:
|
||||
# Handle search filter separately since it requires special query logic
|
||||
if filters and filters.get('search'):
|
||||
search_term = filters['search']
|
||||
logger.info(f"Searching ingredients with term: '{search_term}'", tenant_id=tenant_id)
|
||||
return await self.search_ingredients(tenant_id, search_term, skip, limit)
|
||||
|
||||
# Handle other filters with standard multi-get
|
||||
query_filters = {'tenant_id': tenant_id}
|
||||
if filters:
|
||||
if filters.get('category'):
|
||||
query_filters['category'] = filters['category']
|
||||
if filters.get('product_type'):
|
||||
# Convert string to enum object
|
||||
from app.models.inventory import ProductType
|
||||
product_type_value = filters['product_type']
|
||||
try:
|
||||
# Find the enum member by value
|
||||
for enum_member in ProductType:
|
||||
if enum_member.value == product_type_value:
|
||||
query_filters['product_type'] = enum_member
|
||||
break
|
||||
else:
|
||||
# If not found, skip this filter
|
||||
logger.warning(f"Invalid product_type value: {product_type_value}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error converting product_type: {e}")
|
||||
# Skip invalid product_type filter
|
||||
if filters.get('is_active') is not None:
|
||||
query_filters['is_active'] = filters['is_active']
|
||||
if filters.get('is_perishable') is not None:
|
||||
query_filters['is_perishable'] = filters['is_perishable']
|
||||
|
||||
ingredients = await self.get_multi(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
filters=query_filters,
|
||||
order_by='name'
|
||||
)
|
||||
return ingredients
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get ingredients", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def search_ingredients(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
search_term: str,
|
||||
skip: int = 0,
|
||||
limit: int = 50
|
||||
) -> List[Ingredient]:
|
||||
"""Search ingredients by name, sku, or barcode"""
|
||||
try:
|
||||
# Add tenant filter to search
|
||||
query = select(self.model).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
or_(
|
||||
self.model.name.ilike(f"%{search_term}%"),
|
||||
self.model.sku.ilike(f"%{search_term}%"),
|
||||
self.model.barcode.ilike(f"%{search_term}%"),
|
||||
self.model.brand.ilike(f"%{search_term}%")
|
||||
)
|
||||
)
|
||||
).offset(skip).limit(limit)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to search ingredients", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_low_stock_ingredients(self, tenant_id: UUID) -> List[Dict[str, Any]]:
|
||||
"""Get ingredients with low stock levels"""
|
||||
try:
|
||||
# Query ingredients with their current stock levels
|
||||
query = select(
|
||||
Ingredient,
|
||||
func.coalesce(func.sum(Stock.available_quantity), 0).label('current_stock')
|
||||
).outerjoin(
|
||||
Stock, and_(
|
||||
Stock.ingredient_id == Ingredient.id,
|
||||
Stock.is_available == True
|
||||
)
|
||||
).where(
|
||||
Ingredient.tenant_id == tenant_id
|
||||
).group_by(Ingredient.id).having(
|
||||
func.coalesce(func.sum(Stock.available_quantity), 0) <= Ingredient.low_stock_threshold
|
||||
)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
results = []
|
||||
|
||||
for ingredient, current_stock in result:
|
||||
results.append({
|
||||
'ingredient': ingredient,
|
||||
'current_stock': float(current_stock) if current_stock else 0.0,
|
||||
'threshold': ingredient.low_stock_threshold,
|
||||
'needs_reorder': (
|
||||
current_stock <= ingredient.reorder_point
|
||||
if current_stock and ingredient.reorder_point is not None else True
|
||||
)
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get low stock ingredients", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_ingredients_needing_reorder(self, tenant_id: UUID) -> List[Dict[str, Any]]:
|
||||
"""Get ingredients that need reordering"""
|
||||
try:
|
||||
query = select(
|
||||
Ingredient,
|
||||
func.coalesce(func.sum(Stock.available_quantity), 0).label('current_stock')
|
||||
).outerjoin(
|
||||
Stock, and_(
|
||||
Stock.ingredient_id == Ingredient.id,
|
||||
Stock.is_available == True
|
||||
)
|
||||
).where(
|
||||
and_(
|
||||
Ingredient.tenant_id == tenant_id,
|
||||
Ingredient.is_active == True
|
||||
)
|
||||
).group_by(Ingredient.id).having(
|
||||
func.coalesce(func.sum(Stock.available_quantity), 0) <= Ingredient.reorder_point
|
||||
)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
results = []
|
||||
|
||||
for ingredient, current_stock in result:
|
||||
results.append({
|
||||
'ingredient': ingredient,
|
||||
'current_stock': float(current_stock) if current_stock else 0.0,
|
||||
'reorder_point': ingredient.reorder_point,
|
||||
'reorder_quantity': ingredient.reorder_quantity
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get ingredients needing reorder", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_by_sku(self, tenant_id: UUID, sku: str) -> Optional[Ingredient]:
|
||||
"""Get ingredient by SKU"""
|
||||
try:
|
||||
result = await self.session.execute(
|
||||
select(self.model).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.sku == sku
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get ingredient by SKU", error=str(e), sku=sku, tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_by_barcode(self, tenant_id: UUID, barcode: str) -> Optional[Ingredient]:
|
||||
"""Get ingredient by barcode"""
|
||||
try:
|
||||
result = await self.session.execute(
|
||||
select(self.model).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.barcode == barcode
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get ingredient by barcode", error=str(e), barcode=barcode, tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def update_last_purchase_price(self, ingredient_id: UUID, price: float) -> Optional[Ingredient]:
|
||||
"""Update the last purchase price for an ingredient"""
|
||||
try:
|
||||
from app.schemas.inventory import IngredientUpdate
|
||||
update_data = IngredientUpdate(last_purchase_price=price)
|
||||
return await self.update(ingredient_id, update_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to update last purchase price", error=str(e), ingredient_id=ingredient_id)
|
||||
raise
|
||||
|
||||
async def update_weighted_average_cost(
|
||||
self,
|
||||
ingredient_id: UUID,
|
||||
current_stock_quantity: float,
|
||||
new_purchase_quantity: float,
|
||||
new_unit_cost: float
|
||||
) -> Optional[Ingredient]:
|
||||
"""
|
||||
Update the average cost using weighted average calculation.
|
||||
|
||||
Formula:
|
||||
new_average_cost = (current_stock_qty × current_avg_cost + new_qty × new_cost) / (current_stock_qty + new_qty)
|
||||
|
||||
Args:
|
||||
ingredient_id: ID of the ingredient
|
||||
current_stock_quantity: Current stock quantity before this purchase
|
||||
new_purchase_quantity: Quantity being purchased
|
||||
new_unit_cost: Unit cost of the new purchase
|
||||
|
||||
Returns:
|
||||
Updated ingredient or None if not found
|
||||
"""
|
||||
try:
|
||||
# Get current ingredient data
|
||||
ingredient = await self.get_by_id(ingredient_id)
|
||||
if not ingredient:
|
||||
logger.warning("Ingredient not found for average cost update", ingredient_id=ingredient_id)
|
||||
return None
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
# Get current average cost (default to new cost if not set)
|
||||
current_avg_cost = float(ingredient.average_cost) if ingredient.average_cost else float(new_unit_cost)
|
||||
|
||||
# Calculate weighted average
|
||||
# If no current stock, just use the new purchase price
|
||||
if current_stock_quantity <= 0:
|
||||
new_average_cost = Decimal(str(new_unit_cost))
|
||||
else:
|
||||
# Weighted average formula
|
||||
total_cost = (current_stock_quantity * current_avg_cost) + (new_purchase_quantity * new_unit_cost)
|
||||
total_quantity = current_stock_quantity + new_purchase_quantity
|
||||
new_average_cost = Decimal(str(total_cost / total_quantity))
|
||||
|
||||
# Update the ingredient
|
||||
from app.schemas.inventory import IngredientUpdate
|
||||
update_data = IngredientUpdate(average_cost=new_average_cost)
|
||||
updated_ingredient = await self.update(ingredient_id, update_data)
|
||||
|
||||
logger.info(
|
||||
"Updated weighted average cost",
|
||||
ingredient_id=ingredient_id,
|
||||
old_average_cost=current_avg_cost,
|
||||
new_average_cost=float(new_average_cost),
|
||||
current_stock_qty=current_stock_quantity,
|
||||
new_purchase_qty=new_purchase_quantity,
|
||||
new_unit_cost=new_unit_cost
|
||||
)
|
||||
|
||||
return updated_ingredient
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to update weighted average cost",
|
||||
error=str(e),
|
||||
ingredient_id=ingredient_id
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_ingredients_by_category(self, tenant_id: UUID, category: str) -> List[Ingredient]:
|
||||
"""Get all ingredients in a specific category"""
|
||||
try:
|
||||
result = await self.session.execute(
|
||||
select(self.model).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.category == category,
|
||||
self.model.is_active == True
|
||||
)
|
||||
).order_by(self.model.name)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get ingredients by category", error=str(e), category=category, tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def delete_by_id(self, ingredient_id: UUID, tenant_id: UUID) -> bool:
|
||||
"""Hard delete an ingredient by ID"""
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
|
||||
# Delete the ingredient
|
||||
stmt = delete(self.model).where(
|
||||
and_(
|
||||
self.model.id == ingredient_id,
|
||||
self.model.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
|
||||
result = await self.session.execute(stmt)
|
||||
await self.session.commit()
|
||||
|
||||
# Return True if a row was deleted
|
||||
return result.rowcount > 0
|
||||
|
||||
except Exception as e:
|
||||
await self.session.rollback()
|
||||
logger.error("Failed to hard delete ingredient", error=str(e), ingredient_id=ingredient_id, tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_active_tenants(self) -> List[UUID]:
|
||||
"""Get list of active tenant IDs from ingredients table"""
|
||||
try:
|
||||
result = await self.session.execute(
|
||||
select(func.distinct(Ingredient.tenant_id))
|
||||
.where(Ingredient.is_active == True)
|
||||
)
|
||||
|
||||
tenant_ids = []
|
||||
for row in result.fetchall():
|
||||
tenant_id = row[0]
|
||||
# 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)))
|
||||
|
||||
logger.info("Retrieved active tenants from ingredients", count=len(tenant_ids))
|
||||
return tenant_ids
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get active tenants from ingredients", error=str(e))
|
||||
return []
|
||||
|
||||
async def get_critical_stock_shortages(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get critical stock shortages across all tenants using CTE analysis.
|
||||
Returns ingredients that are critically low on stock.
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
query = text("""
|
||||
WITH stock_analysis AS (
|
||||
SELECT
|
||||
i.id as ingredient_id,
|
||||
i.name as ingredient_name,
|
||||
i.tenant_id,
|
||||
i.reorder_point,
|
||||
COALESCE(SUM(s.current_quantity), 0) as current_quantity,
|
||||
i.low_stock_threshold,
|
||||
GREATEST(0, i.low_stock_threshold - COALESCE(SUM(s.current_quantity), 0)) as shortage_amount,
|
||||
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'
|
||||
ELSE 'normal'
|
||||
END as status
|
||||
FROM ingredients i
|
||||
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true
|
||||
WHERE i.is_active = true
|
||||
GROUP BY i.id, i.name, i.tenant_id, i.reorder_point, i.low_stock_threshold
|
||||
)
|
||||
SELECT
|
||||
ingredient_id,
|
||||
ingredient_name,
|
||||
tenant_id,
|
||||
current_quantity,
|
||||
reorder_point,
|
||||
shortage_amount
|
||||
FROM stock_analysis
|
||||
WHERE status = 'critical'
|
||||
ORDER BY shortage_amount DESC
|
||||
""")
|
||||
|
||||
result = await self.session.execute(query)
|
||||
rows = result.fetchall()
|
||||
|
||||
shortages = []
|
||||
for row in rows:
|
||||
shortages.append({
|
||||
'ingredient_id': row.ingredient_id,
|
||||
'ingredient_name': row.ingredient_name,
|
||||
'tenant_id': row.tenant_id,
|
||||
'current_quantity': float(row.current_quantity) if row.current_quantity else 0,
|
||||
'required_quantity': float(row.reorder_point) if row.reorder_point else 0,
|
||||
'shortage_amount': float(row.shortage_amount) if row.shortage_amount else 0
|
||||
})
|
||||
|
||||
return shortages
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get critical stock shortages", error=str(e))
|
||||
raise
|
||||
|
||||
async def get_stock_issues(self, tenant_id: UUID) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get stock level issues with CTE analysis for a specific tenant
|
||||
Returns list of critical, low, and overstock situations
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
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
|
||||
557
services/inventory/app/repositories/stock_movement_repository.py
Normal file
557
services/inventory/app/repositories/stock_movement_repository.py
Normal file
@@ -0,0 +1,557 @@
|
||||
# services/inventory/app/repositories/stock_movement_repository.py
|
||||
"""
|
||||
Stock Movement Repository using Repository Pattern
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from sqlalchemy import select, func, and_, or_, desc, asc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
|
||||
from app.models.inventory import StockMovement, Ingredient, StockMovementType
|
||||
from app.schemas.inventory import StockMovementCreate
|
||||
from shared.database.repository import BaseRepository
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate, dict]):
|
||||
"""Repository for stock movement operations"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
super().__init__(StockMovement, session)
|
||||
|
||||
async def create_movement(
|
||||
self,
|
||||
movement_data: StockMovementCreate,
|
||||
tenant_id: UUID,
|
||||
created_by: Optional[UUID] = None,
|
||||
quantity_before: Optional[float] = None,
|
||||
quantity_after: Optional[float] = None
|
||||
) -> StockMovement:
|
||||
"""Create a new stock movement record"""
|
||||
try:
|
||||
# Prepare data
|
||||
create_data = movement_data.model_dump()
|
||||
create_data['tenant_id'] = tenant_id
|
||||
create_data['created_by'] = created_by
|
||||
|
||||
# Add quantity_before and quantity_after if provided
|
||||
if quantity_before is not None:
|
||||
create_data['quantity_before'] = quantity_before
|
||||
if quantity_after is not None:
|
||||
create_data['quantity_after'] = quantity_after
|
||||
|
||||
# Ensure movement_type is properly converted to enum value
|
||||
if 'movement_type' in create_data:
|
||||
movement_type = create_data['movement_type']
|
||||
if hasattr(movement_type, 'value'):
|
||||
# It's an enum object, use its value
|
||||
create_data['movement_type'] = movement_type.value
|
||||
elif isinstance(movement_type, str):
|
||||
# It's already a string, ensure it's uppercase for database
|
||||
create_data['movement_type'] = movement_type.upper()
|
||||
|
||||
# Set movement date if not provided
|
||||
if not create_data.get('movement_date'):
|
||||
create_data['movement_date'] = datetime.now()
|
||||
|
||||
# Calculate total cost if unit cost provided
|
||||
if create_data.get('unit_cost') and create_data.get('quantity'):
|
||||
unit_cost = create_data['unit_cost']
|
||||
quantity = Decimal(str(create_data['quantity']))
|
||||
create_data['total_cost'] = unit_cost * quantity
|
||||
|
||||
# Create record
|
||||
record = await self.create(create_data)
|
||||
logger.info(
|
||||
"Created stock movement",
|
||||
movement_id=record.id,
|
||||
ingredient_id=record.ingredient_id,
|
||||
movement_type=record.movement_type if record.movement_type else None,
|
||||
quantity=record.quantity,
|
||||
quantity_before=record.quantity_before,
|
||||
quantity_after=record.quantity_after,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
return record
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create stock movement", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_movements_by_ingredient(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
days_back: Optional[int] = None
|
||||
) -> List[StockMovement]:
|
||||
"""Get stock movements for a specific ingredient"""
|
||||
try:
|
||||
query = select(self.model).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.ingredient_id == ingredient_id
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by date range if specified
|
||||
if days_back:
|
||||
start_date = datetime.now() - timedelta(days=days_back)
|
||||
query = query.where(self.model.movement_date >= start_date)
|
||||
|
||||
query = query.order_by(desc(self.model.movement_date)).offset(skip).limit(limit)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get movements by ingredient", error=str(e), ingredient_id=ingredient_id)
|
||||
raise
|
||||
|
||||
async def get_movements_by_type(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
movement_type: StockMovementType,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
days_back: Optional[int] = None
|
||||
) -> List[StockMovement]:
|
||||
"""Get stock movements by type"""
|
||||
try:
|
||||
query = select(self.model).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.movement_type == movement_type
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by date range if specified
|
||||
if days_back:
|
||||
start_date = datetime.now() - timedelta(days=days_back)
|
||||
query = query.where(self.model.movement_date >= start_date)
|
||||
|
||||
query = query.order_by(desc(self.model.movement_date)).offset(skip).limit(limit)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get movements by type", error=str(e), movement_type=movement_type)
|
||||
raise
|
||||
|
||||
async def get_recent_movements(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
limit: int = 50
|
||||
) -> List[StockMovement]:
|
||||
"""Get recent stock movements for dashboard"""
|
||||
try:
|
||||
result = await self.session.execute(
|
||||
select(self.model)
|
||||
.where(self.model.tenant_id == tenant_id)
|
||||
.order_by(desc(self.model.movement_date))
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get recent movements", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_movements(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
ingredient_id: Optional[UUID] = None,
|
||||
movement_type: Optional[str] = None
|
||||
) -> List[StockMovement]:
|
||||
"""Get stock movements with filtering"""
|
||||
logger.info("🔍 Repository getting movements",
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
skip=skip,
|
||||
limit=limit)
|
||||
try:
|
||||
query = select(self.model).where(self.model.tenant_id == tenant_id)
|
||||
|
||||
# Add filters
|
||||
if ingredient_id:
|
||||
query = query.where(self.model.ingredient_id == ingredient_id)
|
||||
logger.info("🎯 Filtering by ingredient_id", ingredient_id=ingredient_id)
|
||||
|
||||
if movement_type:
|
||||
# Convert string to enum
|
||||
try:
|
||||
movement_type_enum = StockMovementType(movement_type)
|
||||
query = query.where(self.model.movement_type == movement_type_enum)
|
||||
logger.info("🏷️ Filtering by movement_type", movement_type=movement_type)
|
||||
except ValueError:
|
||||
logger.warning("⚠️ Invalid movement type", movement_type=movement_type)
|
||||
# Invalid movement type, skip filter
|
||||
pass
|
||||
|
||||
# Order by date (newest first) and apply pagination
|
||||
query = query.order_by(desc(self.model.movement_date)).offset(skip).limit(limit)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
movements = result.scalars().all()
|
||||
|
||||
logger.info("🔢 Repository found movements", count=len(movements))
|
||||
return movements
|
||||
|
||||
except Exception as e:
|
||||
logger.error("❌ Repository failed to get movements", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_movements_by_reference(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
reference_number: str
|
||||
) -> List[StockMovement]:
|
||||
"""Get stock movements by reference number (e.g., purchase order)"""
|
||||
try:
|
||||
result = await self.session.execute(
|
||||
select(self.model).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.reference_number == reference_number
|
||||
)
|
||||
).order_by(desc(self.model.movement_date))
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get movements by reference", error=str(e), reference_number=reference_number)
|
||||
raise
|
||||
|
||||
async def get_movement_summary_by_period(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
days_back: int = 30
|
||||
) -> Dict[str, Any]:
|
||||
"""Get movement summary for specified period"""
|
||||
try:
|
||||
start_date = datetime.now() - timedelta(days=days_back)
|
||||
|
||||
# Get movement counts by type
|
||||
result = await self.session.execute(
|
||||
select(
|
||||
self.model.movement_type,
|
||||
func.count(self.model.id).label('count'),
|
||||
func.coalesce(func.sum(self.model.quantity), 0).label('total_quantity'),
|
||||
func.coalesce(func.sum(self.model.total_cost), 0).label('total_cost')
|
||||
).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.movement_date >= start_date
|
||||
)
|
||||
).group_by(self.model.movement_type)
|
||||
)
|
||||
|
||||
summary = {}
|
||||
for row in result:
|
||||
movement_type = row.movement_type if row.movement_type else "unknown"
|
||||
summary[movement_type] = {
|
||||
'count': row.count,
|
||||
'total_quantity': float(row.total_quantity),
|
||||
'total_cost': float(row.total_cost) if row.total_cost else 0.0
|
||||
}
|
||||
|
||||
# Get total movements count
|
||||
total_result = await self.session.execute(
|
||||
select(func.count(self.model.id)).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.movement_date >= start_date
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
summary['total_movements'] = total_result.scalar() or 0
|
||||
summary['period_days'] = days_back
|
||||
|
||||
return summary
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get movement summary", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_waste_movements(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
days_back: Optional[int] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100
|
||||
) -> List[StockMovement]:
|
||||
"""Get waste-related movements"""
|
||||
try:
|
||||
query = select(self.model).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.movement_type == StockMovementType.WASTE
|
||||
)
|
||||
)
|
||||
|
||||
# Prefer explicit date range over days_back
|
||||
if start_date and end_date:
|
||||
query = query.where(
|
||||
and_(
|
||||
self.model.movement_date >= start_date,
|
||||
self.model.movement_date <= end_date
|
||||
)
|
||||
)
|
||||
elif days_back:
|
||||
calculated_start = datetime.now() - timedelta(days=days_back)
|
||||
query = query.where(self.model.movement_date >= calculated_start)
|
||||
|
||||
query = query.order_by(desc(self.model.movement_date)).offset(skip).limit(limit)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get waste movements", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_purchase_movements(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
days_back: Optional[int] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100
|
||||
) -> List[StockMovement]:
|
||||
"""Get purchase-related movements"""
|
||||
try:
|
||||
query = select(self.model).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.movement_type == StockMovementType.PURCHASE
|
||||
)
|
||||
)
|
||||
|
||||
if days_back:
|
||||
start_date = datetime.now() - timedelta(days=days_back)
|
||||
query = query.where(self.model.movement_date >= start_date)
|
||||
|
||||
query = query.order_by(desc(self.model.movement_date)).offset(skip).limit(limit)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get purchase movements", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def calculate_ingredient_usage(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
days_back: int = 30
|
||||
) -> Dict[str, float]:
|
||||
"""Calculate ingredient usage statistics"""
|
||||
try:
|
||||
start_date = datetime.now() - timedelta(days=days_back)
|
||||
|
||||
# Get production usage
|
||||
production_result = await self.session.execute(
|
||||
select(func.coalesce(func.sum(self.model.quantity), 0)).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.ingredient_id == ingredient_id,
|
||||
self.model.movement_type == StockMovementType.PRODUCTION_USE,
|
||||
self.model.movement_date >= start_date
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Get waste quantity
|
||||
waste_result = await self.session.execute(
|
||||
select(func.coalesce(func.sum(self.model.quantity), 0)).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.ingredient_id == ingredient_id,
|
||||
self.model.movement_type == StockMovementType.WASTE,
|
||||
self.model.movement_date >= start_date
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Get purchases
|
||||
purchase_result = await self.session.execute(
|
||||
select(func.coalesce(func.sum(self.model.quantity), 0)).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.ingredient_id == ingredient_id,
|
||||
self.model.movement_type == StockMovementType.PURCHASE,
|
||||
self.model.movement_date >= start_date
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
production_usage = float(production_result.scalar() or 0)
|
||||
waste_quantity = float(waste_result.scalar() or 0)
|
||||
purchase_quantity = float(purchase_result.scalar() or 0)
|
||||
|
||||
# Calculate usage rate per day
|
||||
usage_per_day = production_usage / days_back if days_back > 0 else 0
|
||||
waste_percentage = (waste_quantity / purchase_quantity * 100) if purchase_quantity > 0 else 0
|
||||
|
||||
return {
|
||||
'production_usage': production_usage,
|
||||
'waste_quantity': waste_quantity,
|
||||
'purchase_quantity': purchase_quantity,
|
||||
'usage_per_day': usage_per_day,
|
||||
'waste_percentage': waste_percentage,
|
||||
'period_days': days_back
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to calculate ingredient usage", error=str(e), ingredient_id=ingredient_id)
|
||||
raise
|
||||
|
||||
async def delete_by_ingredient(self, ingredient_id: UUID, tenant_id: UUID) -> int:
|
||||
"""Delete all stock movements for a specific ingredient"""
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
from app.models.inventory import StockMovement
|
||||
|
||||
stmt = delete(StockMovement).where(
|
||||
and_(
|
||||
StockMovement.ingredient_id == ingredient_id,
|
||||
StockMovement.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
|
||||
result = await self.session.execute(stmt)
|
||||
deleted_count = result.rowcount
|
||||
|
||||
logger.info(
|
||||
"Deleted stock movements for ingredient",
|
||||
ingredient_id=str(ingredient_id),
|
||||
tenant_id=str(tenant_id),
|
||||
deleted_count=deleted_count
|
||||
)
|
||||
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to delete stock movements for ingredient",
|
||||
error=str(e),
|
||||
ingredient_id=str(ingredient_id),
|
||||
tenant_id=str(tenant_id)
|
||||
)
|
||||
raise
|
||||
|
||||
async def create_automatic_waste_movement(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
stock_id: UUID,
|
||||
quantity: float,
|
||||
unit_cost: Optional[float],
|
||||
batch_number: Optional[str],
|
||||
expiration_date: datetime,
|
||||
created_by: Optional[UUID] = None
|
||||
) -> StockMovement:
|
||||
"""Create an automatic waste movement for expired batches"""
|
||||
try:
|
||||
# Calculate total cost
|
||||
total_cost = None
|
||||
if unit_cost and quantity:
|
||||
total_cost = Decimal(str(unit_cost)) * Decimal(str(quantity))
|
||||
|
||||
# Generate reference number
|
||||
reference_number = f"AUTO-EXPIRE-{batch_number or stock_id}"
|
||||
|
||||
# Create movement data (without quantity_before/quantity_after - these will be calculated by the caller)
|
||||
movement_data = {
|
||||
'tenant_id': tenant_id,
|
||||
'ingredient_id': ingredient_id,
|
||||
'stock_id': stock_id,
|
||||
'movement_type': StockMovementType.WASTE.value,
|
||||
'quantity': quantity,
|
||||
'unit_cost': Decimal(str(unit_cost)) if unit_cost else None,
|
||||
'total_cost': total_cost,
|
||||
'reference_number': reference_number,
|
||||
'reason_code': 'expired',
|
||||
'notes': f"Lote automáticamente marcado como caducado. Vencimiento: {expiration_date.strftime('%Y-%m-%d')}",
|
||||
'movement_date': datetime.now(),
|
||||
'created_by': created_by
|
||||
}
|
||||
|
||||
# Create the movement record
|
||||
movement = await self.create(movement_data)
|
||||
|
||||
logger.info("Created automatic waste movement for expired batch",
|
||||
movement_id=str(movement.id),
|
||||
tenant_id=str(tenant_id),
|
||||
ingredient_id=str(ingredient_id),
|
||||
stock_id=str(stock_id),
|
||||
quantity=quantity,
|
||||
batch_number=batch_number,
|
||||
reference_number=reference_number)
|
||||
|
||||
return movement
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create automatic waste movement",
|
||||
error=str(e),
|
||||
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
|
||||
920
services/inventory/app/repositories/stock_repository.py
Normal file
920
services/inventory/app/repositories/stock_repository.py
Normal file
@@ -0,0 +1,920 @@
|
||||
# services/inventory/app/repositories/stock_repository.py
|
||||
"""
|
||||
Stock Repository using Repository Pattern
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from sqlalchemy import select, func, and_, or_, desc, asc, update, exists
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
|
||||
from app.models.inventory import Stock, Ingredient
|
||||
from app.schemas.inventory import StockCreate, StockUpdate
|
||||
from shared.database.repository import BaseRepository
|
||||
from shared.utils.batch_generator import BatchCountProvider
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate], BatchCountProvider):
|
||||
"""Repository for stock operations"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
super().__init__(Stock, session)
|
||||
|
||||
async def create_stock_entry(self, stock_data: StockCreate, tenant_id: UUID) -> Stock:
|
||||
"""Create a new stock entry"""
|
||||
try:
|
||||
# Prepare data
|
||||
create_data = stock_data.model_dump()
|
||||
create_data['tenant_id'] = tenant_id
|
||||
|
||||
# Ensure production_stage is properly converted to enum value
|
||||
if 'production_stage' in create_data:
|
||||
if hasattr(create_data['production_stage'], 'value'):
|
||||
create_data['production_stage'] = create_data['production_stage'].value
|
||||
elif isinstance(create_data['production_stage'], str):
|
||||
# If it's a string, ensure it's the correct enum value
|
||||
from app.models.inventory import ProductionStage
|
||||
try:
|
||||
enum_obj = ProductionStage[create_data['production_stage']]
|
||||
create_data['production_stage'] = enum_obj.value
|
||||
except KeyError:
|
||||
# If it's already the value, keep it as is
|
||||
pass
|
||||
|
||||
# Calculate available quantity
|
||||
available_qty = create_data['current_quantity'] - create_data.get('reserved_quantity', 0)
|
||||
create_data['available_quantity'] = max(0, available_qty)
|
||||
|
||||
# Calculate total cost if unit cost provided
|
||||
if create_data.get('unit_cost') and create_data.get('current_quantity'):
|
||||
unit_cost = create_data['unit_cost']
|
||||
current_quantity = Decimal(str(create_data['current_quantity']))
|
||||
create_data['total_cost'] = unit_cost * current_quantity
|
||||
|
||||
# Create record
|
||||
record = await self.create(create_data)
|
||||
logger.info(
|
||||
"Created stock entry",
|
||||
stock_id=record.id,
|
||||
ingredient_id=record.ingredient_id,
|
||||
quantity=record.current_quantity,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
return record
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create stock entry", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_stock_by_ingredient(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
include_unavailable: bool = False
|
||||
) -> List[Stock]:
|
||||
"""Get all stock entries for a specific ingredient"""
|
||||
try:
|
||||
query = select(self.model).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.ingredient_id == ingredient_id
|
||||
)
|
||||
)
|
||||
|
||||
if not include_unavailable:
|
||||
query = query.where(self.model.is_available == True)
|
||||
|
||||
query = query.order_by(asc(self.model.expiration_date))
|
||||
|
||||
result = await self.session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get stock by ingredient", error=str(e), ingredient_id=ingredient_id)
|
||||
raise
|
||||
|
||||
async def get_stock_by_product(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
inventory_product_id: UUID,
|
||||
include_unavailable: bool = False
|
||||
) -> List[Stock]:
|
||||
"""
|
||||
Get all stock entries for a specific product.
|
||||
|
||||
Note: inventory_product_id and ingredient_id refer to the same entity.
|
||||
The 'ingredients' table is used as a unified catalog for both raw ingredients
|
||||
and finished products, distinguished by the product_type field.
|
||||
|
||||
This method is an alias for get_stock_by_ingredient for clarity when called
|
||||
from contexts that use 'product' terminology (e.g., procurement service).
|
||||
"""
|
||||
return await self.get_stock_by_ingredient(
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=inventory_product_id,
|
||||
include_unavailable=include_unavailable
|
||||
)
|
||||
|
||||
async def get_total_stock_by_ingredient(self, tenant_id: UUID, ingredient_id: UUID) -> Dict[str, float]:
|
||||
"""Get total stock quantities for an ingredient"""
|
||||
try:
|
||||
result = await self.session.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(Stock.current_quantity), 0).label('total_quantity'),
|
||||
func.coalesce(func.sum(Stock.reserved_quantity), 0).label('total_reserved'),
|
||||
func.coalesce(func.sum(Stock.available_quantity), 0).label('total_available')
|
||||
).where(
|
||||
and_(
|
||||
Stock.tenant_id == tenant_id,
|
||||
Stock.ingredient_id == ingredient_id,
|
||||
Stock.is_available == True
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
row = result.first()
|
||||
return {
|
||||
'total_quantity': float(row.total_quantity) if row.total_quantity else 0.0,
|
||||
'total_reserved': float(row.total_reserved) if row.total_reserved else 0.0,
|
||||
'total_available': float(row.total_available) if row.total_available else 0.0
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get total stock", error=str(e), ingredient_id=ingredient_id)
|
||||
raise
|
||||
|
||||
async def get_expiring_stock(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
days_ahead: int = 7
|
||||
) -> List[Tuple[Stock, Ingredient]]:
|
||||
"""Get stock items expiring within specified days using state-dependent expiration logic"""
|
||||
try:
|
||||
expiry_date = datetime.now() + timedelta(days=days_ahead)
|
||||
|
||||
# Use final_expiration_date if available (for transformed products),
|
||||
# otherwise use regular expiration_date
|
||||
result = await self.session.execute(
|
||||
select(Stock, Ingredient)
|
||||
.join(Ingredient, Stock.ingredient_id == Ingredient.id)
|
||||
.where(
|
||||
and_(
|
||||
Stock.tenant_id == tenant_id,
|
||||
Stock.is_available == True,
|
||||
or_(
|
||||
and_(
|
||||
Stock.final_expiration_date.isnot(None),
|
||||
Stock.final_expiration_date <= expiry_date
|
||||
),
|
||||
and_(
|
||||
Stock.final_expiration_date.is_(None),
|
||||
Stock.expiration_date.isnot(None),
|
||||
Stock.expiration_date <= expiry_date
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.order_by(
|
||||
asc(
|
||||
func.coalesce(Stock.final_expiration_date, Stock.expiration_date)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return result.all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get expiring stock", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_expired_stock(self, tenant_id: UUID) -> List[Tuple[Stock, Ingredient]]:
|
||||
"""Get stock items that have expired using state-dependent expiration logic"""
|
||||
try:
|
||||
current_date = datetime.now()
|
||||
|
||||
# Use final_expiration_date if available (for transformed products),
|
||||
# otherwise use regular expiration_date
|
||||
result = await self.session.execute(
|
||||
select(Stock, Ingredient)
|
||||
.join(Ingredient, Stock.ingredient_id == Ingredient.id)
|
||||
.where(
|
||||
and_(
|
||||
Stock.tenant_id == tenant_id,
|
||||
Stock.is_available == True,
|
||||
or_(
|
||||
and_(
|
||||
Stock.final_expiration_date.isnot(None),
|
||||
Stock.final_expiration_date < current_date
|
||||
),
|
||||
and_(
|
||||
Stock.final_expiration_date.is_(None),
|
||||
Stock.expiration_date.isnot(None),
|
||||
Stock.expiration_date < current_date
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.order_by(
|
||||
desc(
|
||||
func.coalesce(Stock.final_expiration_date, Stock.expiration_date)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return result.all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get expired stock", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def reserve_stock(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
quantity: float,
|
||||
fifo: bool = True
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Reserve stock using FIFO/LIFO method with state-dependent expiration"""
|
||||
try:
|
||||
# Order by appropriate expiration date based on transformation status
|
||||
effective_expiration = func.coalesce(Stock.final_expiration_date, Stock.expiration_date)
|
||||
order_clause = asc(effective_expiration) if fifo else desc(effective_expiration)
|
||||
|
||||
result = await self.session.execute(
|
||||
select(Stock).where(
|
||||
and_(
|
||||
Stock.tenant_id == tenant_id,
|
||||
Stock.ingredient_id == ingredient_id,
|
||||
Stock.is_available == True,
|
||||
Stock.available_quantity > 0
|
||||
)
|
||||
).order_by(order_clause)
|
||||
)
|
||||
|
||||
stock_items = result.scalars().all()
|
||||
reservations = []
|
||||
remaining_qty = quantity
|
||||
|
||||
for stock_item in stock_items:
|
||||
if remaining_qty <= 0:
|
||||
break
|
||||
|
||||
available = stock_item.available_quantity
|
||||
to_reserve = min(remaining_qty, available)
|
||||
|
||||
# Update stock reservation
|
||||
new_reserved = stock_item.reserved_quantity + to_reserve
|
||||
new_available = stock_item.current_quantity - new_reserved
|
||||
|
||||
await self.session.execute(
|
||||
update(Stock)
|
||||
.where(Stock.id == stock_item.id)
|
||||
.values(
|
||||
reserved_quantity=new_reserved,
|
||||
available_quantity=new_available
|
||||
)
|
||||
)
|
||||
|
||||
reservations.append({
|
||||
'stock_id': stock_item.id,
|
||||
'reserved_quantity': to_reserve,
|
||||
'batch_number': stock_item.batch_number,
|
||||
'expiration_date': stock_item.expiration_date
|
||||
})
|
||||
|
||||
remaining_qty -= to_reserve
|
||||
|
||||
if remaining_qty > 0:
|
||||
logger.warning(
|
||||
"Insufficient stock for reservation",
|
||||
ingredient_id=ingredient_id,
|
||||
requested=quantity,
|
||||
unfulfilled=remaining_qty
|
||||
)
|
||||
|
||||
return reservations
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to reserve stock", error=str(e), ingredient_id=ingredient_id)
|
||||
raise
|
||||
|
||||
async def release_stock_reservation(
|
||||
self,
|
||||
stock_id: UUID,
|
||||
quantity: float
|
||||
) -> Optional[Stock]:
|
||||
"""Release reserved stock"""
|
||||
try:
|
||||
stock_item = await self.get_by_id(stock_id)
|
||||
if not stock_item:
|
||||
return None
|
||||
|
||||
# Calculate new quantities
|
||||
new_reserved = max(0, stock_item.reserved_quantity - quantity)
|
||||
new_available = stock_item.current_quantity - new_reserved
|
||||
|
||||
# Update stock
|
||||
await self.session.execute(
|
||||
update(Stock)
|
||||
.where(Stock.id == stock_id)
|
||||
.values(
|
||||
reserved_quantity=new_reserved,
|
||||
available_quantity=new_available
|
||||
)
|
||||
)
|
||||
|
||||
# Refresh and return updated stock
|
||||
await self.session.refresh(stock_item)
|
||||
return stock_item
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to release stock reservation", error=str(e), stock_id=stock_id)
|
||||
raise
|
||||
|
||||
async def consume_stock(
|
||||
self,
|
||||
stock_id: UUID,
|
||||
quantity: float,
|
||||
from_reserved: bool = True
|
||||
) -> Optional[Stock]:
|
||||
"""Consume stock (reduce current quantity)"""
|
||||
try:
|
||||
stock_item = await self.get_by_id(stock_id)
|
||||
if not stock_item:
|
||||
return None
|
||||
|
||||
if from_reserved:
|
||||
# Reduce from reserved quantity
|
||||
new_reserved = max(0, stock_item.reserved_quantity - quantity)
|
||||
new_current = max(0, stock_item.current_quantity - quantity)
|
||||
new_available = new_current - new_reserved
|
||||
else:
|
||||
# Reduce from available quantity
|
||||
new_current = max(0, stock_item.current_quantity - quantity)
|
||||
new_available = max(0, stock_item.available_quantity - quantity)
|
||||
new_reserved = stock_item.reserved_quantity
|
||||
|
||||
# Update stock
|
||||
await self.session.execute(
|
||||
update(Stock)
|
||||
.where(Stock.id == stock_id)
|
||||
.values(
|
||||
current_quantity=new_current,
|
||||
reserved_quantity=new_reserved,
|
||||
available_quantity=new_available,
|
||||
is_available=new_current > 0
|
||||
)
|
||||
)
|
||||
|
||||
# Refresh and return updated stock
|
||||
await self.session.refresh(stock_item)
|
||||
return stock_item
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to consume stock", error=str(e), stock_id=stock_id)
|
||||
raise
|
||||
|
||||
async def get_stock_summary_by_tenant(self, tenant_id: UUID) -> Dict[str, Any]:
|
||||
"""Get stock summary for tenant dashboard"""
|
||||
try:
|
||||
# Basic stock summary
|
||||
basic_result = await self.session.execute(
|
||||
select(
|
||||
func.count(Stock.id).label('total_stock_items'),
|
||||
func.coalesce(func.sum(Stock.total_cost), 0).label('total_stock_value'),
|
||||
func.count(func.distinct(Stock.ingredient_id)).label('unique_ingredients')
|
||||
).where(
|
||||
and_(
|
||||
Stock.tenant_id == tenant_id,
|
||||
Stock.is_available == True
|
||||
)
|
||||
)
|
||||
)
|
||||
basic_summary = basic_result.first()
|
||||
|
||||
# Count expired items
|
||||
expired_result = await self.session.execute(
|
||||
select(func.count(Stock.id)).where(
|
||||
and_(
|
||||
Stock.tenant_id == tenant_id,
|
||||
Stock.is_available == True,
|
||||
Stock.expiration_date < datetime.now()
|
||||
)
|
||||
)
|
||||
)
|
||||
expired_count = expired_result.scalar() or 0
|
||||
|
||||
# Count expiring soon items
|
||||
expiring_result = await self.session.execute(
|
||||
select(func.count(Stock.id)).where(
|
||||
and_(
|
||||
Stock.tenant_id == tenant_id,
|
||||
Stock.is_available == True,
|
||||
Stock.expiration_date.isnot(None),
|
||||
Stock.expiration_date <= datetime.now() + timedelta(days=7)
|
||||
)
|
||||
)
|
||||
)
|
||||
expiring_count = expiring_result.scalar() or 0
|
||||
|
||||
# Count out of stock items (ingredients with no available stock)
|
||||
out_of_stock_result = await self.session.execute(
|
||||
select(func.count(Ingredient.id)).where(
|
||||
and_(
|
||||
Ingredient.tenant_id == tenant_id,
|
||||
~exists(
|
||||
select(1).where(
|
||||
and_(
|
||||
Stock.ingredient_id == Ingredient.id,
|
||||
Stock.tenant_id == tenant_id,
|
||||
Stock.is_available == True,
|
||||
Stock.available_quantity > 0
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
out_of_stock_count = out_of_stock_result.scalar() or 0
|
||||
|
||||
return {
|
||||
'total_stock_items': basic_summary.total_stock_items or 0,
|
||||
'total_stock_value': float(basic_summary.total_stock_value) if basic_summary.total_stock_value else 0.0,
|
||||
'unique_ingredients': basic_summary.unique_ingredients or 0,
|
||||
'expired_items': expired_count,
|
||||
'expiring_soon_items': expiring_count,
|
||||
'out_of_stock_count': out_of_stock_count
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get stock summary", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def mark_expired_stock(self, tenant_id: UUID) -> int:
|
||||
"""Mark expired stock items as expired using state-dependent expiration logic"""
|
||||
try:
|
||||
current_date = datetime.now()
|
||||
|
||||
# Mark items as expired based on final_expiration_date or expiration_date
|
||||
result = await self.session.execute(
|
||||
update(Stock)
|
||||
.where(
|
||||
and_(
|
||||
Stock.tenant_id == tenant_id,
|
||||
Stock.is_expired == False,
|
||||
or_(
|
||||
and_(
|
||||
Stock.final_expiration_date.isnot(None),
|
||||
Stock.final_expiration_date < current_date
|
||||
),
|
||||
and_(
|
||||
Stock.final_expiration_date.is_(None),
|
||||
Stock.expiration_date.isnot(None),
|
||||
Stock.expiration_date < current_date
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.values(is_expired=True, quality_status="expired")
|
||||
)
|
||||
|
||||
expired_count = result.rowcount
|
||||
logger.info(f"Marked {expired_count} stock items as expired using state-dependent logic", tenant_id=tenant_id)
|
||||
|
||||
return expired_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to mark expired stock", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_stock_by_production_stage(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
production_stage: 'ProductionStage',
|
||||
ingredient_id: Optional[UUID] = None
|
||||
) -> List['Stock']:
|
||||
"""Get stock items by production stage"""
|
||||
try:
|
||||
conditions = [
|
||||
Stock.tenant_id == tenant_id,
|
||||
Stock.production_stage == production_stage,
|
||||
Stock.is_available == True
|
||||
]
|
||||
|
||||
if ingredient_id:
|
||||
conditions.append(Stock.ingredient_id == ingredient_id)
|
||||
|
||||
result = await self.session.execute(
|
||||
select(Stock)
|
||||
.where(and_(*conditions))
|
||||
.order_by(asc(func.coalesce(Stock.final_expiration_date, Stock.expiration_date)))
|
||||
)
|
||||
|
||||
return result.scalars().all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get stock by production stage", error=str(e), production_stage=production_stage)
|
||||
raise
|
||||
|
||||
async def get_stock_entries(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
ingredient_id: Optional[UUID] = None,
|
||||
available_only: bool = True
|
||||
) -> List[Stock]:
|
||||
"""Get stock entries with filtering and pagination"""
|
||||
try:
|
||||
conditions = [Stock.tenant_id == tenant_id]
|
||||
|
||||
if available_only:
|
||||
conditions.append(Stock.is_available == True)
|
||||
|
||||
if ingredient_id:
|
||||
conditions.append(Stock.ingredient_id == ingredient_id)
|
||||
|
||||
query = (
|
||||
select(Stock)
|
||||
.where(and_(*conditions))
|
||||
.order_by(desc(Stock.created_at))
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get stock entries", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def delete_by_ingredient(self, ingredient_id: UUID, tenant_id: UUID) -> int:
|
||||
"""Delete all stock entries for a specific ingredient"""
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
|
||||
stmt = delete(Stock).where(
|
||||
and_(
|
||||
Stock.ingredient_id == ingredient_id,
|
||||
Stock.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
|
||||
result = await self.session.execute(stmt)
|
||||
deleted_count = result.rowcount
|
||||
|
||||
logger.info(
|
||||
"Deleted stock entries for ingredient",
|
||||
ingredient_id=str(ingredient_id),
|
||||
tenant_id=str(tenant_id),
|
||||
deleted_count=deleted_count
|
||||
)
|
||||
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to delete stock entries for ingredient",
|
||||
error=str(e),
|
||||
ingredient_id=str(ingredient_id),
|
||||
tenant_id=str(tenant_id)
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_daily_batch_count(
|
||||
self,
|
||||
tenant_id: str,
|
||||
date_start: datetime,
|
||||
date_end: datetime,
|
||||
prefix: Optional[str] = None
|
||||
) -> int:
|
||||
"""Get the count of batches created today for the given tenant"""
|
||||
try:
|
||||
conditions = [
|
||||
Stock.tenant_id == tenant_id,
|
||||
Stock.created_at >= date_start,
|
||||
Stock.created_at <= date_end
|
||||
]
|
||||
|
||||
if prefix:
|
||||
conditions.append(Stock.batch_number.like(f"{prefix}-%"))
|
||||
|
||||
stmt = select(func.count(Stock.id)).where(and_(*conditions))
|
||||
result = await self.session.execute(stmt)
|
||||
count = result.scalar() or 0
|
||||
|
||||
logger.debug(
|
||||
"Retrieved daily batch count",
|
||||
tenant_id=tenant_id,
|
||||
prefix=prefix,
|
||||
count=count,
|
||||
date_start=date_start,
|
||||
date_end=date_end
|
||||
)
|
||||
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to get daily batch count",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id,
|
||||
prefix=prefix
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_expired_batches_for_processing(self, tenant_id: UUID) -> List[Tuple[Stock, Ingredient]]:
|
||||
"""Get expired batches that haven't been processed yet (for automatic processing)"""
|
||||
try:
|
||||
current_date = datetime.now()
|
||||
|
||||
# Find expired batches that are still available and not yet marked as expired
|
||||
result = await self.session.execute(
|
||||
select(Stock, Ingredient)
|
||||
.join(Ingredient, Stock.ingredient_id == Ingredient.id)
|
||||
.where(
|
||||
and_(
|
||||
Stock.tenant_id == tenant_id,
|
||||
Stock.is_available == True,
|
||||
Stock.is_expired == False,
|
||||
Stock.current_quantity > 0,
|
||||
or_(
|
||||
and_(
|
||||
Stock.final_expiration_date.isnot(None),
|
||||
Stock.final_expiration_date <= current_date
|
||||
),
|
||||
and_(
|
||||
Stock.final_expiration_date.is_(None),
|
||||
Stock.expiration_date.isnot(None),
|
||||
Stock.expiration_date <= current_date
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.order_by(
|
||||
asc(func.coalesce(Stock.final_expiration_date, Stock.expiration_date))
|
||||
)
|
||||
)
|
||||
|
||||
expired_batches = result.all()
|
||||
logger.info("Found expired batches for processing",
|
||||
tenant_id=str(tenant_id),
|
||||
count=len(expired_batches))
|
||||
|
||||
return expired_batches
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get expired batches for processing",
|
||||
error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def mark_batch_as_expired(self, stock_id: UUID, tenant_id: UUID) -> bool:
|
||||
"""Mark a specific batch as expired and unavailable"""
|
||||
try:
|
||||
result = await self.session.execute(
|
||||
update(Stock)
|
||||
.where(
|
||||
and_(
|
||||
Stock.id == stock_id,
|
||||
Stock.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
.values(
|
||||
is_expired=True,
|
||||
is_available=False,
|
||||
quality_status="expired",
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
)
|
||||
|
||||
if result.rowcount > 0:
|
||||
logger.info("Marked batch as expired",
|
||||
stock_id=str(stock_id),
|
||||
tenant_id=str(tenant_id))
|
||||
return True
|
||||
else:
|
||||
logger.warning("No batch found to mark as expired",
|
||||
stock_id=str(stock_id),
|
||||
tenant_id=str(tenant_id))
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to mark batch as expired",
|
||||
error=str(e),
|
||||
stock_id=str(stock_id),
|
||||
tenant_id=str(tenant_id))
|
||||
raise
|
||||
|
||||
async def update_stock_to_zero(self, stock_id: UUID, tenant_id: UUID) -> bool:
|
||||
"""Update stock quantities to zero after moving to waste"""
|
||||
try:
|
||||
result = await self.session.execute(
|
||||
update(Stock)
|
||||
.where(
|
||||
and_(
|
||||
Stock.id == stock_id,
|
||||
Stock.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
.values(
|
||||
current_quantity=0,
|
||||
available_quantity=0,
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
)
|
||||
|
||||
if result.rowcount > 0:
|
||||
logger.info("Updated stock quantities to zero",
|
||||
stock_id=str(stock_id),
|
||||
tenant_id=str(tenant_id))
|
||||
return True
|
||||
else:
|
||||
logger.warning("No stock found to update to zero",
|
||||
stock_id=str(stock_id),
|
||||
tenant_id=str(tenant_id))
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to update stock to zero",
|
||||
error=str(e),
|
||||
stock_id=str(stock_id),
|
||||
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:
|
||||
from sqlalchemy import text
|
||||
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 '1 day' * :days_threshold)
|
||||
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:
|
||||
from sqlalchemy import text
|
||||
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 '1 hour' * :hours_back)
|
||||
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 get_waste_opportunities(self, tenant_id: UUID) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Identify waste reduction opportunities
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
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:
|
||||
from sqlalchemy import text
|
||||
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
|
||||
257
services/inventory/app/repositories/transformation_repository.py
Normal file
257
services/inventory/app/repositories/transformation_repository.py
Normal file
@@ -0,0 +1,257 @@
|
||||
# services/inventory/app/repositories/transformation_repository.py
|
||||
"""
|
||||
Product Transformation Repository using Repository Pattern
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import select, func, and_, or_, desc, asc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from app.models.inventory import ProductTransformation, Ingredient, ProductionStage
|
||||
from app.schemas.inventory import ProductTransformationCreate
|
||||
from shared.database.repository import BaseRepository
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class TransformationRepository(BaseRepository[ProductTransformation, ProductTransformationCreate, dict]):
|
||||
"""Repository for product transformation operations"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
super().__init__(ProductTransformation, session)
|
||||
|
||||
async def create_transformation(
|
||||
self,
|
||||
transformation_data: ProductTransformationCreate,
|
||||
tenant_id: UUID,
|
||||
created_by: Optional[UUID] = None,
|
||||
source_batch_numbers: Optional[List[str]] = None
|
||||
) -> ProductTransformation:
|
||||
"""Create a new product transformation record"""
|
||||
try:
|
||||
# Generate transformation reference
|
||||
transformation_ref = f"TRANS-{datetime.now().strftime('%Y%m%d')}-{str(uuid.uuid4())[:8].upper()}"
|
||||
|
||||
# Prepare data
|
||||
create_data = transformation_data.model_dump()
|
||||
create_data['tenant_id'] = tenant_id
|
||||
create_data['created_by'] = created_by
|
||||
create_data['transformation_reference'] = transformation_ref
|
||||
|
||||
# Calculate conversion ratio if not provided
|
||||
if not create_data.get('conversion_ratio'):
|
||||
create_data['conversion_ratio'] = create_data['target_quantity'] / create_data['source_quantity']
|
||||
|
||||
# Store source batch numbers as JSON
|
||||
if source_batch_numbers:
|
||||
create_data['source_batch_numbers'] = json.dumps(source_batch_numbers)
|
||||
|
||||
# Create record
|
||||
record = await self.create(create_data)
|
||||
logger.info(
|
||||
"Created product transformation",
|
||||
transformation_id=record.id,
|
||||
reference=record.transformation_reference,
|
||||
source_stage=record.source_stage.value,
|
||||
target_stage=record.target_stage.value,
|
||||
source_quantity=record.source_quantity,
|
||||
target_quantity=record.target_quantity,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
return record
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create transformation", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_transformations_by_ingredient(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
is_source: bool = True,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
days_back: Optional[int] = None
|
||||
) -> List[ProductTransformation]:
|
||||
"""Get transformations for a specific ingredient"""
|
||||
try:
|
||||
if is_source:
|
||||
query = select(self.model).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.source_ingredient_id == ingredient_id
|
||||
)
|
||||
)
|
||||
else:
|
||||
query = select(self.model).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.target_ingredient_id == ingredient_id
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by date range if specified
|
||||
if days_back:
|
||||
start_date = datetime.now() - timedelta(days=days_back)
|
||||
query = query.where(self.model.transformation_date >= start_date)
|
||||
|
||||
query = query.order_by(desc(self.model.transformation_date)).offset(skip).limit(limit)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get transformations by ingredient", error=str(e), ingredient_id=ingredient_id)
|
||||
raise
|
||||
|
||||
async def get_transformations_by_stage(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
source_stage: Optional[ProductionStage] = None,
|
||||
target_stage: Optional[ProductionStage] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
days_back: Optional[int] = None
|
||||
) -> List[ProductTransformation]:
|
||||
"""Get transformations by production stage"""
|
||||
try:
|
||||
conditions = [self.model.tenant_id == tenant_id]
|
||||
|
||||
if source_stage:
|
||||
conditions.append(self.model.source_stage == source_stage)
|
||||
if target_stage:
|
||||
conditions.append(self.model.target_stage == target_stage)
|
||||
|
||||
query = select(self.model).where(and_(*conditions))
|
||||
|
||||
# Filter by date range if specified
|
||||
if days_back:
|
||||
start_date = datetime.now() - timedelta(days=days_back)
|
||||
query = query.where(self.model.transformation_date >= start_date)
|
||||
|
||||
query = query.order_by(desc(self.model.transformation_date)).offset(skip).limit(limit)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get transformations by stage", error=str(e))
|
||||
raise
|
||||
|
||||
async def get_transformation_by_reference(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
transformation_reference: str
|
||||
) -> Optional[ProductTransformation]:
|
||||
"""Get transformation by reference number"""
|
||||
try:
|
||||
result = await self.session.execute(
|
||||
select(self.model).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.transformation_reference == transformation_reference
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get transformation by reference", error=str(e), reference=transformation_reference)
|
||||
raise
|
||||
|
||||
async def get_transformation_summary_by_period(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
days_back: int = 30
|
||||
) -> Dict[str, Any]:
|
||||
"""Get transformation summary for specified period"""
|
||||
try:
|
||||
start_date = datetime.now() - timedelta(days=days_back)
|
||||
|
||||
# Get transformation counts by stage combination
|
||||
result = await self.session.execute(
|
||||
select(
|
||||
self.model.source_stage,
|
||||
self.model.target_stage,
|
||||
func.count(self.model.id).label('count'),
|
||||
func.coalesce(func.sum(self.model.source_quantity), 0).label('total_source_quantity'),
|
||||
func.coalesce(func.sum(self.model.target_quantity), 0).label('total_target_quantity')
|
||||
).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.transformation_date >= start_date
|
||||
)
|
||||
).group_by(self.model.source_stage, self.model.target_stage)
|
||||
)
|
||||
|
||||
summary = {}
|
||||
total_transformations = 0
|
||||
|
||||
for row in result:
|
||||
source_stage = row.source_stage.value if row.source_stage else "unknown"
|
||||
target_stage = row.target_stage.value if row.target_stage else "unknown"
|
||||
|
||||
stage_key = f"{source_stage}_to_{target_stage}"
|
||||
summary[stage_key] = {
|
||||
'count': row.count,
|
||||
'total_source_quantity': float(row.total_source_quantity),
|
||||
'total_target_quantity': float(row.total_target_quantity),
|
||||
'average_conversion_ratio': float(row.total_target_quantity) / float(row.total_source_quantity) if row.total_source_quantity > 0 else 0
|
||||
}
|
||||
total_transformations += row.count
|
||||
|
||||
summary['total_transformations'] = total_transformations
|
||||
summary['period_days'] = days_back
|
||||
|
||||
return summary
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get transformation summary", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def calculate_transformation_efficiency(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
source_ingredient_id: UUID,
|
||||
target_ingredient_id: UUID,
|
||||
days_back: int = 30
|
||||
) -> Dict[str, float]:
|
||||
"""Calculate transformation efficiency between ingredients"""
|
||||
try:
|
||||
start_date = datetime.now() - timedelta(days=days_back)
|
||||
|
||||
result = await self.session.execute(
|
||||
select(
|
||||
func.count(self.model.id).label('transformation_count'),
|
||||
func.coalesce(func.sum(self.model.source_quantity), 0).label('total_source'),
|
||||
func.coalesce(func.sum(self.model.target_quantity), 0).label('total_target'),
|
||||
func.coalesce(func.avg(self.model.conversion_ratio), 0).label('avg_conversion_ratio')
|
||||
).where(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.source_ingredient_id == source_ingredient_id,
|
||||
self.model.target_ingredient_id == target_ingredient_id,
|
||||
self.model.transformation_date >= start_date
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
row = result.first()
|
||||
|
||||
return {
|
||||
'transformation_count': row.transformation_count or 0,
|
||||
'total_source_quantity': float(row.total_source) if row.total_source else 0.0,
|
||||
'total_target_quantity': float(row.total_target) if row.total_target else 0.0,
|
||||
'average_conversion_ratio': float(row.avg_conversion_ratio) if row.avg_conversion_ratio else 0.0,
|
||||
'efficiency_percentage': (float(row.total_target) / float(row.total_source) * 100) if row.total_source and row.total_source > 0 else 0.0,
|
||||
'period_days': days_back
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to calculate transformation efficiency", error=str(e))
|
||||
raise
|
||||
Reference in New Issue
Block a user