Improve the frontend and fix TODOs

This commit is contained in:
Urtzi Alfaro
2025-10-24 13:05:04 +02:00
parent 07c33fa578
commit 61376b7a9f
100 changed files with 8284 additions and 3419 deletions

View File

@@ -5,7 +5,7 @@ Service-to-service endpoint for cloning inventory data with date adjustment
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import select, func
import structlog
import uuid
from datetime import datetime, timezone
@@ -18,7 +18,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from app.core.database import get_db
from app.models.inventory import Ingredient, Stock
from app.models.inventory import Ingredient, Stock, StockMovement
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
logger = structlog.get_logger()
@@ -83,15 +83,49 @@ async def clone_demo_data(
base_uuid = uuid.UUID(base_tenant_id)
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Check if data already exists for this virtual tenant (idempotency)
existing_check = await db.execute(
select(Ingredient).where(Ingredient.tenant_id == virtual_uuid).limit(1)
)
existing_ingredient = existing_check.scalars().first()
if existing_ingredient:
logger.warning(
"Data already exists for virtual tenant - cleaning before re-clone",
virtual_tenant_id=virtual_tenant_id,
base_tenant_id=base_tenant_id
)
# Clean up existing data first to ensure fresh clone
from sqlalchemy import delete
await db.execute(
delete(StockMovement).where(StockMovement.tenant_id == virtual_uuid)
)
await db.execute(
delete(Stock).where(Stock.tenant_id == virtual_uuid)
)
await db.execute(
delete(Ingredient).where(Ingredient.tenant_id == virtual_uuid)
)
await db.commit()
logger.info(
"Existing data cleaned, proceeding with fresh clone",
virtual_tenant_id=virtual_tenant_id
)
# Track cloning statistics
stats = {
"ingredients": 0,
"stock_batches": 0,
"stock_movements": 0,
"alerts_generated": 0
}
# Mapping from base ingredient ID to virtual ingredient ID
ingredient_id_mapping = {}
# Mapping from base stock ID to virtual stock ID
stock_id_mapping = {}
# Clone Ingredients
result = await db.execute(
@@ -213,9 +247,11 @@ async def clone_demo_data(
BASE_REFERENCE_DATE
) or session_created_at
# Create new stock batch
# Create new stock batch with new ID
new_stock_id = uuid.uuid4()
new_stock = Stock(
id=uuid.uuid4(),
id=new_stock_id,
tenant_id=virtual_uuid,
ingredient_id=new_ingredient_id,
supplier_id=stock.supplier_id,
@@ -250,6 +286,72 @@ async def clone_demo_data(
db.add(new_stock)
stats["stock_batches"] += 1
# Store mapping for movement cloning
stock_id_mapping[stock.id] = new_stock_id
await db.flush() # Ensure stock is persisted before movements
# Clone Stock Movements with date adjustment
result = await db.execute(
select(StockMovement).where(StockMovement.tenant_id == base_uuid)
)
base_movements = result.scalars().all()
logger.info(
"Found stock movements to clone",
count=len(base_movements),
base_tenant=str(base_uuid)
)
for movement in base_movements:
# Map ingredient ID and stock ID
new_ingredient_id = ingredient_id_mapping.get(movement.ingredient_id)
new_stock_id = stock_id_mapping.get(movement.stock_id) if movement.stock_id else None
if not new_ingredient_id:
logger.warning(
"Movement references non-existent ingredient, skipping",
movement_id=str(movement.id),
ingredient_id=str(movement.ingredient_id)
)
continue
# Adjust movement date relative to session creation
adjusted_movement_date = adjust_date_for_demo(
movement.movement_date,
session_created_at,
BASE_REFERENCE_DATE
) or session_created_at
adjusted_created_at = adjust_date_for_demo(
movement.created_at,
session_created_at,
BASE_REFERENCE_DATE
) or session_created_at
# Create new stock movement
new_movement = StockMovement(
id=uuid.uuid4(),
tenant_id=virtual_uuid,
ingredient_id=new_ingredient_id,
stock_id=new_stock_id,
movement_type=movement.movement_type,
quantity=movement.quantity,
unit_cost=movement.unit_cost,
total_cost=movement.total_cost,
quantity_before=movement.quantity_before,
quantity_after=movement.quantity_after,
reference_number=movement.reference_number,
supplier_id=movement.supplier_id,
notes=movement.notes,
reason_code=movement.reason_code,
movement_date=adjusted_movement_date,
created_at=adjusted_created_at,
created_by=movement.created_by
)
db.add(new_movement)
stats["stock_movements"] += 1
# Commit all changes
await db.commit()
@@ -312,3 +414,104 @@ async def clone_health_check(_: bool = Depends(verify_internal_api_key)):
"clone_endpoint": "available",
"version": "2.0.0"
}
@router.delete("/tenant/{virtual_tenant_id}")
async def delete_demo_data(
virtual_tenant_id: str,
db: AsyncSession = Depends(get_db),
_: bool = Depends(verify_internal_api_key)
):
"""
Delete all inventory data for a virtual demo tenant
Called by demo session cleanup service to remove ephemeral data
when demo sessions expire or are destroyed.
Args:
virtual_tenant_id: Virtual tenant UUID to delete
Returns:
Deletion status and count of records deleted
"""
from sqlalchemy import delete
logger.info(
"Deleting inventory data for virtual tenant",
virtual_tenant_id=virtual_tenant_id
)
start_time = datetime.now(timezone.utc)
try:
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Count records before deletion for reporting
stock_count = await db.scalar(
select(func.count(Stock.id)).where(Stock.tenant_id == virtual_uuid)
)
ingredient_count = await db.scalar(
select(func.count(Ingredient.id)).where(Ingredient.tenant_id == virtual_uuid)
)
movement_count = await db.scalar(
select(func.count(StockMovement.id)).where(StockMovement.tenant_id == virtual_uuid)
)
# Delete in correct order to respect foreign key constraints
# 1. Delete StockMovements (references Stock)
await db.execute(
delete(StockMovement).where(StockMovement.tenant_id == virtual_uuid)
)
# 2. Delete Stock batches (references Ingredient)
await db.execute(
delete(Stock).where(Stock.tenant_id == virtual_uuid)
)
# 3. Delete Ingredients
await db.execute(
delete(Ingredient).where(Ingredient.tenant_id == virtual_uuid)
)
await db.commit()
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info(
"Inventory data deleted successfully",
virtual_tenant_id=virtual_tenant_id,
stocks_deleted=stock_count,
ingredients_deleted=ingredient_count,
movements_deleted=movement_count,
duration_ms=duration_ms
)
return {
"service": "inventory",
"status": "deleted",
"virtual_tenant_id": virtual_tenant_id,
"records_deleted": {
"stock_batches": stock_count,
"ingredients": ingredient_count,
"stock_movements": movement_count,
"total": stock_count + ingredient_count + movement_count
},
"duration_ms": duration_ms
}
except ValueError as e:
logger.error("Invalid UUID format", error=str(e))
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
except Exception as e:
logger.error(
"Failed to delete inventory data",
virtual_tenant_id=virtual_tenant_id,
error=str(e),
exc_info=True
)
await db.rollback()
raise HTTPException(
status_code=500,
detail=f"Failed to delete inventory data: {str(e)}"
)

View File

@@ -100,6 +100,106 @@ async def get_stock(
)
# ===== STOCK MOVEMENTS ROUTES (must come before stock/{stock_id} route) =====
@router.get(
route_builder.build_base_route("stock/movements"),
response_model=List[StockMovementResponse]
)
async def get_stock_movements(
tenant_id: UUID = Path(..., description="Tenant ID"),
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
ingredient_id: Optional[str] = Query(None, description="Filter by ingredient"),
movement_type: Optional[str] = Query(None, description="Filter by movement type"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Get stock movements with filtering"""
logger.info("Stock movements endpoint called",
tenant_id=str(tenant_id),
ingredient_id=ingredient_id,
skip=skip,
limit=limit,
movement_type=movement_type)
# Validate and convert ingredient_id if provided
ingredient_uuid = None
if ingredient_id:
try:
ingredient_uuid = UUID(ingredient_id)
logger.info("Ingredient ID validated", ingredient_id=str(ingredient_uuid))
except (ValueError, AttributeError) as e:
logger.error("Invalid ingredient_id format",
ingredient_id=ingredient_id,
error=str(e))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid ingredient_id format: {ingredient_id}. Must be a valid UUID."
)
try:
service = InventoryService()
movements = await service.get_stock_movements(
tenant_id, skip, limit, ingredient_uuid, movement_type
)
logger.info("Successfully retrieved stock movements",
count=len(movements),
tenant_id=str(tenant_id))
return movements
except ValueError as e:
logger.error("Validation error in stock movements",
error=str(e),
tenant_id=str(tenant_id),
ingredient_id=ingredient_id)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
logger.error("Failed to get stock movements",
error=str(e),
error_type=type(e).__name__,
tenant_id=str(tenant_id),
ingredient_id=ingredient_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get stock movements: {str(e)}"
)
@router.post(
route_builder.build_base_route("stock/movements"),
response_model=StockMovementResponse,
status_code=status.HTTP_201_CREATED
)
@require_user_role(['admin', 'owner', 'member'])
async def create_stock_movement(
movement_data: StockMovementCreate,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Create stock movement record"""
try:
user_id = get_current_user_id(current_user)
service = InventoryService()
movement = await service.create_stock_movement(movement_data, tenant_id, user_id)
return movement
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create stock movement"
)
# ===== STOCK DETAIL ROUTES (must come after stock/movements routes) =====
@router.get(
route_builder.build_resource_detail_route("stock", "stock_id"),
response_model=StockResponse
@@ -199,68 +299,3 @@ async def delete_stock(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete stock entry"
)
@router.get(
route_builder.build_base_route("stock/movements"),
response_model=List[StockMovementResponse]
)
async def get_stock_movements(
tenant_id: UUID = Path(..., description="Tenant ID"),
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
ingredient_id: Optional[UUID] = Query(None, description="Filter by ingredient"),
movement_type: Optional[str] = Query(None, description="Filter by movement type"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Get stock movements with filtering"""
logger.info("API endpoint reached!",
tenant_id=tenant_id,
ingredient_id=ingredient_id,
skip=skip,
limit=limit)
try:
service = InventoryService()
movements = await service.get_stock_movements(
tenant_id, skip, limit, ingredient_id, movement_type
)
logger.info("Returning movements", count=len(movements))
return movements
except Exception as e:
logger.error("Failed to get stock movements", error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get stock movements"
)
@router.post(
route_builder.build_base_route("stock/movements"),
response_model=StockMovementResponse,
status_code=status.HTTP_201_CREATED
)
@require_user_role(['admin', 'owner', 'member'])
async def create_stock_movement(
movement_data: StockMovementCreate,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Create stock movement record"""
try:
user_id = get_current_user_id(current_user)
service = InventoryService()
movement = await service.create_stock_movement(movement_data, tenant_id, user_id)
return movement
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create stock movement"
)

View File

@@ -77,12 +77,12 @@ class StockMovementType(enum.Enum):
"""Types of inventory movements"""
PURCHASE = "PURCHASE"
PRODUCTION_USE = "PRODUCTION_USE"
TRANSFORMATION = "TRANSFORMATION" # Converting between production stages
ADJUSTMENT = "ADJUSTMENT"
WASTE = "WASTE"
TRANSFER = "TRANSFER"
RETURN = "RETURN"
INITIAL_STOCK = "INITIAL_STOCK"
TRANSFORMATION = "TRANSFORMATION" # Converting between production stages
class Ingredient(Base):
@@ -348,7 +348,7 @@ class StockMovement(Base):
stock_id = Column(UUID(as_uuid=True), ForeignKey('stock.id'), nullable=True, index=True)
# Movement details
movement_type = Column(SQLEnum('PURCHASE', 'PRODUCTION_USE', 'ADJUSTMENT', 'WASTE', 'TRANSFER', 'RETURN', 'INITIAL_STOCK', name='stockmovementtype', create_type=False), nullable=False, index=True)
movement_type = Column(SQLEnum(StockMovementType, name='stockmovementtype', create_type=False), nullable=False, index=True)
quantity = Column(Float, nullable=False)
unit_cost = Column(Numeric(10, 2), nullable=True)
total_cost = Column(Numeric(10, 2), nullable=True)
@@ -553,4 +553,4 @@ class StockAlert(Base):
'resolution_notes': self.resolution_notes,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
}

View File

@@ -7,7 +7,7 @@ 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
from sqlalchemy import select, func, and_, or_, desc, asc, update, exists
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
@@ -400,12 +400,33 @@ class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate], BatchCoun
)
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
'expiring_soon_items': expiring_count,
'out_of_stock_count': out_of_stock_count
}
except Exception as e:

View File

@@ -109,6 +109,16 @@ class GrantProgramEligibility(BaseModel):
eligible: bool = Field(description="Whether eligible for this grant")
confidence: str = Field(description="Confidence level: high, medium, low")
requirements_met: bool = Field(description="Whether requirements are met")
funding_eur: float = Field(description="Available funding in euros")
deadline: str = Field(description="Application deadline")
program_type: str = Field(description="Type: grant, loan, or certification")
sector_specific: Optional[str] = Field(None, description="Sector if specific: bakery, retail, etc.")
class SpainCompliance(BaseModel):
"""Spain-specific legal compliance"""
law_1_2025: bool = Field(description="Compliance with Spanish Law 1/2025 on food waste")
circular_economy_strategy: bool = Field(description="Aligned with Spanish Circular Economy Strategy")
class GrantReadiness(BaseModel):
@@ -116,6 +126,7 @@ class GrantReadiness(BaseModel):
overall_readiness_percentage: float = Field(description="Overall readiness percentage")
grant_programs: Dict[str, GrantProgramEligibility] = Field(description="Eligibility by program")
recommended_applications: List[str] = Field(description="Recommended grant programs to apply for")
spain_compliance: SpainCompliance = Field(description="Spain-specific compliance status")
class SustainabilityMetrics(BaseModel):

View File

@@ -100,6 +100,9 @@ class DashboardService:
stock_value_trend = await self._get_stock_value_trend(db, tenant_id, days=30)
alert_trend = await dashboard_repo.get_alert_trend(tenant_id, days=30)
# Get stock summary for total stock items
stock_summary = await repos['stock_repo'].get_stock_summary_by_tenant(tenant_id)
# Recent activity
recent_activity = await self.get_recent_activity(db, tenant_id, limit=10)
@@ -108,7 +111,7 @@ class DashboardService:
total_ingredients=inventory_summary.total_ingredients,
active_ingredients=inventory_summary.total_ingredients, # Assuming all are active
total_stock_value=inventory_summary.total_stock_value,
total_stock_items=await self._get_total_stock_items(db, tenant_id),
total_stock_items=stock_summary.get('total_stock_items', 0),
# Stock status breakdown
in_stock_items=await self._get_in_stock_count(db, tenant_id),
@@ -872,6 +875,201 @@ class DashboardService:
"temperature_compliance_rate": Decimal("100")
}
async def _get_in_stock_count(self, db, tenant_id: UUID) -> int:
"""Get count of items currently in stock"""
try:
repos = self._get_repositories(db)
stock_repo = repos['stock_repo']
# Get stock summary and extract in-stock count
stock_summary = await stock_repo.get_stock_summary_by_tenant(tenant_id)
return stock_summary.get('in_stock_items', 0)
except Exception as e:
logger.error("Failed to get in-stock count", error=str(e))
return 0
async def _get_ingredient_metrics(self, db, tenant_id: UUID) -> Dict[str, Any]:
"""Get ingredient metrics for business model analysis"""
try:
repos = self._get_repositories(db)
ingredient_repo = repos['ingredient_repo']
# Get all ingredients for the tenant
ingredients = await ingredient_repo.get_ingredients_by_tenant(tenant_id, limit=1000)
if not ingredients:
return {
"total_types": 0,
"avg_stock": 0.0,
"finished_product_ratio": 0.0,
"supplier_count": 0
}
# Calculate metrics
total_types = len(ingredients)
# Calculate average stock per ingredient
total_stock = sum(float(i.current_stock_level or 0) for i in ingredients)
avg_stock = total_stock / total_types if total_types > 0 else 0
# Calculate finished product ratio
finished_products = len([i for i in ingredients if hasattr(i, 'product_type') and i.product_type and i.product_type.value == 'finished_product'])
finished_ratio = finished_products / total_types if total_types > 0 else 0
# Estimate supplier diversity (simplified)
supplier_count = len(set(str(i.supplier_id) for i in ingredients if hasattr(i, 'supplier_id') and i.supplier_id)) or 1
return {
"total_types": total_types,
"avg_stock": avg_stock,
"finished_product_ratio": finished_ratio,
"supplier_count": supplier_count
}
except Exception as e:
logger.error("Failed to get ingredient metrics", error=str(e))
return {
"total_types": 0,
"avg_stock": 0.0,
"finished_product_ratio": 0.0,
"supplier_count": 0
}
async def _analyze_operational_patterns(self, db, tenant_id: UUID) -> Dict[str, Any]:
"""Analyze operational patterns for business model insights"""
try:
repos = self._get_repositories(db)
# Get ingredients to analyze patterns
ingredients = await repos['ingredient_repo'].get_ingredients_by_tenant(tenant_id, limit=1000)
if not ingredients:
return {
"order_frequency": "unknown",
"seasonal_variation": "low",
"bulk_indicator": "unknown",
"scale_indicator": "small"
}
# Analyze order frequency based on reorder patterns
frequent_reorders = len([i for i in ingredients if hasattr(i, 'reorder_frequency') and i.reorder_frequency and i.reorder_frequency > 5])
infrequent_reorders = len([i for i in ingredients if hasattr(i, 'reorder_frequency') and i.reorder_frequency and i.reorder_frequency <= 2])
if frequent_reorders > len(ingredients) * 0.3:
order_frequency = "high"
elif infrequent_reorders > len(ingredients) * 0.4:
order_frequency = "low"
else:
order_frequency = "moderate"
# Analyze seasonal variation (simplified estimation)
seasonal_variation = "moderate" # Default assumption for bakery business
# Analyze bulk purchasing indicator
bulk_items = len([i for i in ingredients if hasattr(i, 'bulk_order_quantity') and i.bulk_order_quantity and i.bulk_order_quantity > 100])
if bulk_items > len(ingredients) * 0.2:
bulk_indicator = "high"
elif bulk_items < len(ingredients) * 0.05:
bulk_indicator = "low"
else:
bulk_indicator = "moderate"
# Analyze production scale
total_ingredients = len(ingredients)
if total_ingredients > 500:
scale_indicator = "large"
elif total_ingredients > 100:
scale_indicator = "medium"
else:
scale_indicator = "small"
return {
"order_frequency": order_frequency,
"seasonal_variation": seasonal_variation,
"bulk_indicator": bulk_indicator,
"scale_indicator": scale_indicator
}
except Exception as e:
logger.error("Failed to analyze operational patterns", error=str(e))
return {
"order_frequency": "unknown",
"seasonal_variation": "low",
"bulk_indicator": "unknown",
"scale_indicator": "small"
}
async def _generate_model_recommendations(
self,
model: str,
ingredient_metrics: Dict[str, Any],
operational_patterns: Dict[str, Any]
) -> Dict[str, Any]:
"""Generate business model specific recommendations"""
try:
recommendations = {
"specific": [],
"optimization": []
}
# Model-specific recommendations
if model == "central_bakery":
recommendations["specific"].extend([
"Optimize distribution network for multi-location delivery",
"Implement centralized procurement for bulk discounts",
"Standardize recipes across all production facilities"
])
if operational_patterns.get("scale_indicator") == "large":
recommendations["optimization"].extend([
"Automate inter-facility transfers",
"Implement predictive demand forecasting",
"Optimize fleet routing for distribution"
])
elif model == "individual_bakery":
recommendations["specific"].extend([
"Focus on local sourcing to reduce costs",
"Implement just-in-time production scheduling",
"Optimize single-location workflow efficiency"
])
recommendations["optimization"].extend([
"Reduce waste through better portion control",
"Implement daily production planning",
"Optimize oven scheduling for energy efficiency"
])
elif model == "mixed":
recommendations["specific"].extend([
"Balance centralized and decentralized operations",
"Implement hybrid sourcing strategy",
"Maintain flexibility in production planning"
])
recommendations["optimization"].extend([
"Optimize batch sizes for efficiency",
"Implement cross-training for staff flexibility",
"Balance inventory across multiple locations"
])
# Generic recommendations based on metrics
if ingredient_metrics.get("finished_product_ratio", 0) > 0.5:
recommendations["optimization"].append("Focus on finished product quality control")
if operational_patterns.get("order_frequency") == "high":
recommendations["optimization"].append("Streamline ordering process with automated reordering")
return recommendations
except Exception as e:
logger.error("Failed to generate model recommendations", error=str(e))
return {
"specific": ["Review business model configuration"],
"optimization": ["Analyze operational data for insights"]
}
async def _analyze_inventory_performance(self, db, tenant_id: UUID, days_back: int) -> Dict[str, Any]:
"""Analyze overall inventory performance metrics using real data"""
try:

View File

@@ -412,11 +412,11 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
for rec in recommendations:
await self._generate_stock_recommendation(tenant_id, rec)
except Exception as e:
logger.error("Error generating recommendations for tenant",
tenant_id=str(tenant_id),
error=str(e))
except Exception as e:
logger.error("Error generating recommendations for tenant",
tenant_id=str(tenant_id),
error=str(e))
except Exception as e:
logger.error("Inventory recommendations failed", error=str(e))
self._errors_count += 1
@@ -510,11 +510,11 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
for waste in waste_data:
await self._generate_waste_recommendation(tenant_id, waste)
except Exception as e:
logger.error("Error generating waste recommendations",
tenant_id=str(tenant_id),
error=str(e))
except Exception as e:
logger.error("Error generating waste recommendations",
tenant_id=str(tenant_id),
error=str(e))
except Exception as e:
logger.error("Waste reduction recommendations failed", error=str(e))
self._errors_count += 1
@@ -885,4 +885,4 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
except Exception as e:
logger.error("Error generating expired batch summary alert",
tenant_id=str(tenant_id),
error=str(e))
error=str(e))

View File

@@ -419,15 +419,36 @@ class InventoryService:
) -> List[StockMovementResponse]:
"""Get stock movements with filtering"""
logger.info("📈 Getting stock movements",
tenant_id=tenant_id,
ingredient_id=ingredient_id,
tenant_id=str(tenant_id),
ingredient_id=str(ingredient_id) if ingredient_id else None,
skip=skip,
limit=limit)
limit=limit,
movement_type=movement_type)
try:
async with get_db_transaction() as db:
movement_repo = StockMovementRepository(db)
ingredient_repo = IngredientRepository(db)
# Validate ingredient exists if filtering by ingredient
if ingredient_id:
ingredient = await ingredient_repo.get_by_id(ingredient_id)
if not ingredient:
logger.warning("Ingredient not found for movements query",
ingredient_id=str(ingredient_id),
tenant_id=str(tenant_id))
raise ValueError(f"Ingredient {ingredient_id} not found")
if ingredient.tenant_id != tenant_id:
logger.error("Ingredient does not belong to tenant",
ingredient_id=str(ingredient_id),
ingredient_tenant=str(ingredient.tenant_id),
requested_tenant=str(tenant_id))
raise ValueError(f"Ingredient {ingredient_id} does not belong to tenant {tenant_id}")
logger.info("Ingredient validated for movements query",
ingredient_name=ingredient.name,
ingredient_id=str(ingredient_id))
# Get filtered movements
movements = await movement_repo.get_movements(
tenant_id=tenant_id,
@@ -454,8 +475,14 @@ class InventoryService:
logger.info("✅ Returning movements", response_count=len(responses))
return responses
except ValueError:
# Re-raise validation errors as-is
raise
except Exception as e:
logger.error("❌ Failed to get stock movements", error=str(e), tenant_id=tenant_id)
logger.error("❌ Failed to get stock movements",
error=str(e),
error_type=type(e).__name__,
tenant_id=str(tenant_id))
raise
# ===== ALERTS AND NOTIFICATIONS =====
@@ -577,7 +604,7 @@ class InventoryService:
low_stock_alerts=len(low_stock_items),
expiring_soon_items=len(expiring_items),
expired_items=len(expired_items),
out_of_stock_items=0, # TODO: Calculate this
out_of_stock_items=stock_summary.get('out_of_stock_count', 0),
stock_by_category=stock_by_category,
recent_movements=recent_activity.get('total_movements', 0),
recent_purchases=recent_activity.get('purchase', {}).get('count', 0),

View File

@@ -16,6 +16,7 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.repositories.stock_movement_repository import StockMovementRepository
from app.repositories.inventory_alert_repository import InventoryAlertRepository
from shared.clients.production_client import create_production_client
logger = structlog.get_logger()
@@ -170,6 +171,13 @@ class SustainabilityService:
'damaged_inventory': inventory_waste * 0.3, # Estimate: 30% damaged
}
# Get waste incidents from inventory alert repository
alert_repo = InventoryAlertRepository(db)
waste_opportunities = await alert_repo.get_waste_opportunities(tenant_id)
# Sum up all waste incidents for the period
total_waste_incidents = sum(item['waste_incidents'] for item in waste_opportunities) if waste_opportunities else 0
return {
'total_waste_kg': total_waste,
'production_waste_kg': production_waste + defect_waste,
@@ -177,7 +185,7 @@ class SustainabilityService:
'waste_percentage': waste_percentage,
'total_production_kg': total_production,
'waste_by_reason': waste_by_reason,
'waste_incidents': int(inv_data.waste_incidents or 0)
'waste_incidents': total_waste_incidents
}
except Exception as e:
@@ -492,29 +500,54 @@ class SustainabilityService:
return areas
def _assess_grant_readiness(self, sdg_compliance: Dict[str, Any]) -> Dict[str, Any]:
"""Assess readiness for various grant programs"""
"""
Assess readiness for EU grant programs accessible to Spanish bakeries and retail.
Based on 2025 research and Spain's Law 1/2025 on food waste prevention.
"""
reduction = sdg_compliance['sdg_12_3']['reduction_achieved']
grants = {
'eu_horizon_europe': {
'eligible': reduction >= 30,
'confidence': 'high' if reduction >= 50 else 'medium' if reduction >= 30 else 'low',
'requirements_met': reduction >= 30
},
'eu_farm_to_fork': {
'eligible': reduction >= 20,
'confidence': 'high' if reduction >= 40 else 'medium' if reduction >= 20 else 'low',
'requirements_met': reduction >= 20
},
'national_circular_economy': {
'life_circular_economy': {
'eligible': reduction >= 15,
'confidence': 'high' if reduction >= 25 else 'medium' if reduction >= 15 else 'low',
'requirements_met': reduction >= 15
'requirements_met': reduction >= 15,
'funding_eur': 73_000_000, # €73M available for circular economy
'deadline': '2025-09-23',
'program_type': 'grant'
},
'horizon_europe_cluster_6': {
'eligible': reduction >= 20,
'confidence': 'high' if reduction >= 35 else 'medium' if reduction >= 20 else 'low',
'requirements_met': reduction >= 20,
'funding_eur': 880_000_000, # €880M+ annually for food systems
'deadline': 'rolling_2025',
'program_type': 'grant'
},
'fedima_sustainability_grant': {
'eligible': reduction >= 15,
'confidence': 'high' if reduction >= 20 else 'medium' if reduction >= 15 else 'low',
'requirements_met': reduction >= 15,
'funding_eur': 20_000, # €20k bi-annual
'deadline': '2025-06-30',
'program_type': 'grant',
'sector_specific': 'bakery'
},
'eit_food_retail': {
'eligible': reduction >= 20,
'confidence': 'high' if reduction >= 30 else 'medium' if reduction >= 20 else 'low',
'requirements_met': reduction >= 20,
'funding_eur': 45_000, # €15-45k range
'deadline': 'rolling',
'program_type': 'grant',
'sector_specific': 'retail'
},
'un_sdg_certified': {
'eligible': reduction >= 50,
'confidence': 'high' if reduction >= 50 else 'low',
'requirements_met': reduction >= 50
'requirements_met': reduction >= 50,
'funding_eur': 0, # Certification, not funding
'deadline': 'ongoing',
'program_type': 'certification'
}
}
@@ -525,7 +558,11 @@ class SustainabilityService:
'grant_programs': grants,
'recommended_applications': [
name for name, details in grants.items() if details['eligible']
]
],
'spain_compliance': {
'law_1_2025': True, # Spanish food waste prevention law
'circular_economy_strategy': True # Spanish Circular Economy Strategy
}
}
async def export_grant_report(

View File

@@ -368,7 +368,7 @@ def upgrade() -> None:
sa.Column('tenant_id', sa.UUID(), nullable=False),
sa.Column('ingredient_id', sa.UUID(), nullable=False),
sa.Column('stock_id', sa.UUID(), nullable=True),
sa.Column('movement_type', sa.Enum('PURCHASE', 'PRODUCTION_USE', 'ADJUSTMENT', 'WASTE', 'TRANSFER', 'RETURN', 'INITIAL_STOCK', name='stockmovementtype'), nullable=False),
sa.Column('movement_type', sa.Enum('PURCHASE', 'PRODUCTION_USE', 'TRANSFORMATION', 'ADJUSTMENT', 'WASTE', 'TRANSFER', 'RETURN', 'INITIAL_STOCK', name='stockmovementtype'), nullable=False),
sa.Column('quantity', sa.Float(), nullable=False),
sa.Column('unit_cost', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('total_cost', sa.Numeric(precision=10, scale=2), nullable=True),

View File

@@ -34,7 +34,7 @@ from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
import structlog
from app.models.inventory import Ingredient, Stock
from app.models.inventory import Ingredient, Stock, StockMovement, StockMovementType
# Configure logging
structlog.configure(
@@ -220,6 +220,438 @@ async def create_stock_batches_for_ingredient(
return stocks
async def create_waste_movements_for_tenant(
db: AsyncSession,
tenant_id: uuid.UUID,
base_date: datetime
) -> list:
"""
Create realistic waste stock movements for the past 30 days
Args:
db: Database session
tenant_id: UUID of the tenant
base_date: Base reference date for movement calculations
Returns:
List of created StockMovement instances
"""
# Get all stock for this tenant (including expired)
result = await db.execute(
select(Stock, Ingredient).join(
Ingredient, Stock.ingredient_id == Ingredient.id
).where(
Stock.tenant_id == tenant_id
)
)
stock_items = result.all()
if not stock_items:
return []
movements = []
waste_reasons = [
("spoilage", 0.40), # 40% of waste is spoilage
("expired", 0.30), # 30% is expiration
("damage", 0.20), # 20% is damage
("contamination", 0.10) # 10% is contamination
]
# Create waste movements for expired stock
for stock, ingredient in stock_items:
if stock.is_expired and stock.current_quantity > 0:
# Create waste movement for expired stock
waste_quantity = stock.current_quantity
movement_date = stock.expiration_date + timedelta(days=random.randint(1, 3))
movement = StockMovement(
id=uuid.uuid4(),
tenant_id=tenant_id,
ingredient_id=ingredient.id,
stock_id=stock.id,
movement_type=StockMovementType.WASTE,
quantity=waste_quantity,
unit_cost=stock.unit_cost,
total_cost=stock.unit_cost * Decimal(str(waste_quantity)) if stock.unit_cost else None,
reason_code="expired",
notes=f"Lote {stock.batch_number} caducado - movimiento automático de desperdicio",
reference_number=f"WASTE-EXP-{stock.batch_number}",
movement_date=movement_date,
created_at=movement_date,
created_by=None # System-generated
)
movements.append(movement)
# Create additional random waste movements for the past 30 days
# to show waste patterns from spoilage, damage, etc.
num_waste_movements = random.randint(8, 15) # 8-15 waste incidents in 30 days
for i in range(num_waste_movements):
# Select random non-expired stock
available_stock = [(s, i) for s, i in stock_items if not s.is_expired and s.current_quantity > 5.0]
if not available_stock:
continue
stock, ingredient = random.choice(available_stock)
# Random date in the past 30 days
days_ago = random.randint(1, 30)
movement_date = base_date - timedelta(days=days_ago)
# Random waste quantity (1-10% of current stock)
waste_percentage = random.uniform(0.01, 0.10)
waste_quantity = round(stock.current_quantity * waste_percentage, 2)
# Select random waste reason
reason, _ = random.choices(
waste_reasons,
weights=[w for _, w in waste_reasons]
)[0]
# Create waste movement
movement = StockMovement(
id=uuid.uuid4(),
tenant_id=tenant_id,
ingredient_id=ingredient.id,
stock_id=stock.id,
movement_type=StockMovementType.WASTE,
quantity=waste_quantity,
unit_cost=stock.unit_cost,
total_cost=stock.unit_cost * Decimal(str(waste_quantity)) if stock.unit_cost else None,
reason_code=reason,
notes=f"Desperdicio de {ingredient.name} por {reason}",
reference_number=f"WASTE-{reason.upper()}-{i+1:03d}",
movement_date=movement_date,
created_at=movement_date,
created_by=None # System-generated
)
movements.append(movement)
return movements
async def create_purchase_movements_for_stock(
db: AsyncSession,
tenant_id: uuid.UUID,
base_date: datetime
) -> list:
"""
Create PURCHASE movements for all stock batches
Each stock batch should have a corresponding PURCHASE movement
representing when it was received from the supplier.
Args:
db: Database session
tenant_id: UUID of the tenant
base_date: Base reference date for movement calculations
Returns:
List of created StockMovement instances
"""
# Get all stock for this tenant
result = await db.execute(
select(Stock, Ingredient).join(
Ingredient, Stock.ingredient_id == Ingredient.id
).where(
Stock.tenant_id == tenant_id
)
)
stock_items = result.all()
if not stock_items:
return []
movements = []
for stock, ingredient in stock_items:
# Create PURCHASE movement for each stock batch
# Movement date is the received date of the stock
movement_date = stock.received_date
movement = StockMovement(
id=uuid.uuid4(),
tenant_id=tenant_id,
ingredient_id=ingredient.id,
stock_id=stock.id,
movement_type=StockMovementType.PURCHASE,
quantity=stock.current_quantity + stock.reserved_quantity, # Total received
unit_cost=stock.unit_cost,
total_cost=stock.total_cost,
quantity_before=0.0, # Was zero before purchase
quantity_after=stock.current_quantity + stock.reserved_quantity,
reference_number=f"PO-{movement_date.strftime('%Y%m')}-{random.randint(1000, 9999)}",
supplier_id=stock.supplier_id,
notes=f"Compra de {ingredient.name} - Lote {stock.batch_number}",
movement_date=movement_date,
created_at=movement_date,
created_by=None # System-generated
)
movements.append(movement)
return movements
async def create_production_use_movements(
db: AsyncSession,
tenant_id: uuid.UUID,
base_date: datetime
) -> list:
"""
Create realistic PRODUCTION_USE movements for the past 30 days
Simulates ingredients being consumed in production runs.
Args:
db: Database session
tenant_id: UUID of the tenant
base_date: Base reference date for movement calculations
Returns:
List of created StockMovement instances
"""
# Get all available stock for this tenant
result = await db.execute(
select(Stock, Ingredient).join(
Ingredient, Stock.ingredient_id == Ingredient.id
).where(
Stock.tenant_id == tenant_id,
Stock.is_available == True,
Stock.current_quantity > 10.0 # Only use stock with sufficient quantity
)
)
stock_items = result.all()
if not stock_items:
return []
movements = []
# Create 15-25 production use movements spread over 30 days
num_production_runs = random.randint(15, 25)
production_types = [
("Pan Rústico", 20.0, 50.0), # 20-50 kg flour
("Pan de Molde", 15.0, 40.0),
("Croissants", 10.0, 30.0),
("Baguettes", 25.0, 60.0),
("Bollería Variada", 12.0, 35.0),
("Pan Integral", 18.0, 45.0)
]
for i in range(num_production_runs):
# Select random stock item
if not stock_items:
break
stock, ingredient = random.choice(stock_items)
# Random date in the past 30 days
days_ago = random.randint(1, 30)
movement_date = base_date - timedelta(days=days_ago)
# Random production type and quantity
production_name, min_qty, max_qty = random.choice(production_types)
# Production quantity (5-20% of current stock, within min/max range)
use_percentage = random.uniform(0.05, 0.20)
use_quantity = round(min(
stock.current_quantity * use_percentage,
random.uniform(min_qty, max_qty)
), 2)
# Ensure we don't consume more than available
if use_quantity > stock.available_quantity:
use_quantity = round(stock.available_quantity * 0.5, 2)
if use_quantity < 1.0:
continue
# Create production use movement
movement = StockMovement(
id=uuid.uuid4(),
tenant_id=tenant_id,
ingredient_id=ingredient.id,
stock_id=stock.id,
movement_type=StockMovementType.PRODUCTION_USE,
quantity=use_quantity,
unit_cost=stock.unit_cost,
total_cost=stock.unit_cost * Decimal(str(use_quantity)) if stock.unit_cost else None,
quantity_before=stock.current_quantity,
quantity_after=stock.current_quantity - use_quantity,
reference_number=f"PROD-{movement_date.strftime('%Y%m%d')}-{i+1:03d}",
notes=f"Producción de {production_name} - Consumo de {ingredient.name}",
movement_date=movement_date,
created_at=movement_date,
created_by=None # System-generated
)
movements.append(movement)
# Update stock quantity for realistic simulation (don't commit, just for calculation)
stock.current_quantity -= use_quantity
stock.available_quantity -= use_quantity
return movements
async def create_adjustment_movements(
db: AsyncSession,
tenant_id: uuid.UUID,
base_date: datetime
) -> list:
"""
Create inventory ADJUSTMENT movements
Represents inventory counts and corrections.
Args:
db: Database session
tenant_id: UUID of the tenant
base_date: Base reference date for movement calculations
Returns:
List of created StockMovement instances
"""
# Get all stock for this tenant
result = await db.execute(
select(Stock, Ingredient).join(
Ingredient, Stock.ingredient_id == Ingredient.id
).where(
Stock.tenant_id == tenant_id,
Stock.current_quantity > 5.0
)
)
stock_items = result.all()
if not stock_items:
return []
movements = []
adjustment_reasons = [
("inventory_count", "Conteo de inventario mensual"),
("correction", "Corrección de entrada incorrecta"),
("shrinkage", "Ajuste por merma natural"),
("reconciliation", "Reconciliación de stock")
]
# Create 3-5 adjustment movements
num_adjustments = random.randint(3, 5)
for i in range(num_adjustments):
if not stock_items:
break
stock, ingredient = random.choice(stock_items)
# Random date in the past 30 days
days_ago = random.randint(5, 30)
movement_date = base_date - timedelta(days=days_ago)
# Random adjustment (±5% of current stock)
adjustment_percentage = random.uniform(-0.05, 0.05)
adjustment_quantity = round(stock.current_quantity * adjustment_percentage, 2)
if abs(adjustment_quantity) < 0.1:
continue
reason_code, reason_note = random.choice(adjustment_reasons)
# Create adjustment movement
movement = StockMovement(
id=uuid.uuid4(),
tenant_id=tenant_id,
ingredient_id=ingredient.id,
stock_id=stock.id,
movement_type=StockMovementType.ADJUSTMENT,
quantity=abs(adjustment_quantity),
unit_cost=stock.unit_cost,
total_cost=stock.unit_cost * Decimal(str(abs(adjustment_quantity))) if stock.unit_cost else None,
quantity_before=stock.current_quantity,
quantity_after=stock.current_quantity + adjustment_quantity,
reference_number=f"ADJ-{movement_date.strftime('%Y%m%d')}-{i+1:03d}",
reason_code=reason_code,
notes=f"{reason_note} - {ingredient.name}: {'+' if adjustment_quantity > 0 else ''}{adjustment_quantity:.2f} {ingredient.unit_of_measure.value}",
movement_date=movement_date,
created_at=movement_date,
created_by=None # System-generated
)
movements.append(movement)
return movements
async def create_initial_stock_movements(
db: AsyncSession,
tenant_id: uuid.UUID,
base_date: datetime
) -> list:
"""
Create INITIAL_STOCK movements for opening inventory
Represents the initial inventory when the system was set up.
Args:
db: Database session
tenant_id: UUID of the tenant
base_date: Base reference date for movement calculations
Returns:
List of created StockMovement instances
"""
# Get all stock for this tenant
result = await db.execute(
select(Stock, Ingredient).join(
Ingredient, Stock.ingredient_id == Ingredient.id
).where(
Stock.tenant_id == tenant_id
)
)
stock_items = result.all()
if not stock_items:
return []
movements = []
# Create initial stock for 20% of ingredients (opening inventory)
# Date is 60-90 days before base_date
initial_stock_date = base_date - timedelta(days=random.randint(60, 90))
# Select 20% of stock items randomly
num_initial = max(1, int(len(stock_items) * 0.20))
initial_stock_items = random.sample(stock_items, num_initial)
for stock, ingredient in initial_stock_items:
# Initial quantity (50-80% of current quantity)
initial_quantity = round(stock.current_quantity * random.uniform(0.5, 0.8), 2)
if initial_quantity < 1.0:
continue
# Create initial stock movement
movement = StockMovement(
id=uuid.uuid4(),
tenant_id=tenant_id,
ingredient_id=ingredient.id,
stock_id=stock.id,
movement_type=StockMovementType.INITIAL_STOCK,
quantity=initial_quantity,
unit_cost=stock.unit_cost,
total_cost=stock.unit_cost * Decimal(str(initial_quantity)) if stock.unit_cost else None,
quantity_before=0.0,
quantity_after=initial_quantity,
reference_number=f"INIT-{initial_stock_date.strftime('%Y%m%d')}",
notes=f"Inventario inicial de {ingredient.name}",
movement_date=initial_stock_date,
created_at=initial_stock_date,
created_by=None # System-generated
)
movements.append(movement)
return movements
async def seed_stock_for_tenant(
db: AsyncSession,
tenant_id: uuid.UUID,
@@ -244,6 +676,37 @@ async def seed_stock_for_tenant(
logger.info(f"Base Reference Date: {base_date.isoformat()}")
logger.info("" * 80)
# Check if stock already exists for this tenant (idempotency)
existing_stock_check = await db.execute(
select(Stock).where(Stock.tenant_id == tenant_id).limit(1)
)
existing_stock = existing_stock_check.scalars().first()
if existing_stock:
logger.warning(f"Stock already exists for tenant {tenant_id} - skipping to prevent duplicates")
# Count existing stock for reporting
stock_count_result = await db.execute(
select(Stock).where(Stock.tenant_id == tenant_id)
)
existing_stocks = stock_count_result.scalars().all()
return {
"tenant_id": str(tenant_id),
"tenant_name": tenant_name,
"stock_created": 0,
"ingredients_processed": 0,
"skipped": True,
"existing_stock_count": len(existing_stocks),
"expired_count": 0,
"expiring_soon_count": 0,
"movements_created": 0,
"purchase_movements": 0,
"initial_movements": 0,
"production_movements": 0,
"adjustment_movements": 0,
"waste_movements": 0
}
# Get all ingredients for this tenant
result = await db.execute(
select(Ingredient).where(
@@ -282,12 +745,62 @@ async def seed_stock_for_tenant(
logger.debug(f" ✅ Created {len(stocks)} stock batches for: {ingredient.name}")
# Commit all changes
# Commit stock changes
await db.commit()
# Create all types of stock movements
logger.info(f" 📦 Creating stock movements...")
# 1. Create PURCHASE movements (for all stock received)
logger.info(f" 💰 Creating purchase movements...")
purchase_movements = await create_purchase_movements_for_stock(db, tenant_id, base_date)
for movement in purchase_movements:
db.add(movement)
# 2. Create INITIAL_STOCK movements (opening inventory)
logger.info(f" 📋 Creating initial stock movements...")
initial_movements = await create_initial_stock_movements(db, tenant_id, base_date)
for movement in initial_movements:
db.add(movement)
# 3. Create PRODUCTION_USE movements (ingredients consumed)
logger.info(f" 🍞 Creating production use movements...")
production_movements = await create_production_use_movements(db, tenant_id, base_date)
for movement in production_movements:
db.add(movement)
# 4. Create ADJUSTMENT movements (inventory corrections)
logger.info(f" 🔧 Creating adjustment movements...")
adjustment_movements = await create_adjustment_movements(db, tenant_id, base_date)
for movement in adjustment_movements:
db.add(movement)
# 5. Create WASTE movements (spoilage, expiration, etc.)
logger.info(f" 🗑️ Creating waste movements...")
waste_movements = await create_waste_movements_for_tenant(db, tenant_id, base_date)
for movement in waste_movements:
db.add(movement)
# Commit all movements
await db.commit()
total_movements = (
len(purchase_movements) +
len(initial_movements) +
len(production_movements) +
len(adjustment_movements) +
len(waste_movements)
)
logger.info(f" 📊 Total Stock Batches Created: {total_stock_created}")
logger.info(f" ⚠️ Expired Batches: {expired_count}")
logger.info(f" 🔔 Expiring Soon (≤3 days): {expiring_soon_count}")
logger.info(f" 📝 Stock Movements Created: {total_movements}")
logger.info(f" 💰 Purchase: {len(purchase_movements)}")
logger.info(f" 📋 Initial Stock: {len(initial_movements)}")
logger.info(f" 🍞 Production Use: {len(production_movements)}")
logger.info(f" 🔧 Adjustments: {len(adjustment_movements)}")
logger.info(f" 🗑️ Waste: {len(waste_movements)}")
logger.info("")
return {
@@ -296,7 +809,13 @@ async def seed_stock_for_tenant(
"stock_created": total_stock_created,
"ingredients_processed": len(ingredients),
"expired_count": expired_count,
"expiring_soon_count": expiring_soon_count
"expiring_soon_count": expiring_soon_count,
"movements_created": total_movements,
"purchase_movements": len(purchase_movements),
"initial_movements": len(initial_movements),
"production_movements": len(production_movements),
"adjustment_movements": len(adjustment_movements),
"waste_movements": len(waste_movements)
}
@@ -339,6 +858,7 @@ async def seed_stock(db: AsyncSession):
total_stock = sum(r["stock_created"] for r in results)
total_expired = sum(r["expired_count"] for r in results)
total_expiring_soon = sum(r["expiring_soon_count"] for r in results)
total_movements = sum(r.get("movements_created", r.get("waste_movements_created", 0)) for r in results)
logger.info("=" * 80)
logger.info("✅ Demo Stock Seeding Completed")
@@ -350,6 +870,7 @@ async def seed_stock(db: AsyncSession):
"total_stock_created": total_stock,
"total_expired": total_expired,
"total_expiring_soon": total_expiring_soon,
"total_movements_created": total_movements,
"results": results
}
@@ -398,15 +919,18 @@ async def main():
logger.info(f" ✅ Total stock batches: {result['total_stock_created']}")
logger.info(f" ⚠️ Expired batches: {result['total_expired']}")
logger.info(f" 🔔 Expiring soon (≤3 days): {result['total_expiring_soon']}")
logger.info(f" 📝 Total movements: {result['total_movements_created']}")
logger.info("")
# Print per-tenant details
for tenant_result in result['results']:
movements_count = tenant_result.get('movements_created', tenant_result.get('waste_movements_created', 0))
logger.info(
f" {tenant_result['tenant_name']}: "
f"{tenant_result['stock_created']} batches "
f"({tenant_result['expired_count']} expired, "
f"{tenant_result['expiring_soon_count']} expiring soon)"
f"{tenant_result['expiring_soon_count']} expiring soon, "
f"{movements_count} movements)"
)
logger.info("")