Improve the inventory page 3

This commit is contained in:
Urtzi Alfaro
2025-09-18 08:06:32 +02:00
parent dcb3ce441b
commit ae77a0e1c5
31 changed files with 2376 additions and 1774 deletions

View File

@@ -6,15 +6,18 @@ Implements hybrid detection patterns for critical stock issues and optimization
import asyncio
import json
import uuid
from typing import List, Dict, Any, Optional
from uuid import UUID
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
import structlog
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import text
from shared.alerts.base_service import BaseAlertService, AlertServiceMixin
from shared.alerts.templates import format_item_message
from app.repositories.stock_repository import StockRepository
from app.repositories.stock_movement_repository import StockMovementRepository
logger = structlog.get_logger()
@@ -70,6 +73,15 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
misfire_grace_time=300,
max_instances=1
)
# Expired batch detection - daily at 6:00 AM (alerts and automated processing)
self.scheduler.add_job(
self.check_and_process_expired_batches,
CronTrigger(hour=6, minute=0), # Daily at 6:00 AM
id='expired_batch_processing',
misfire_grace_time=1800, # 30 minute grace time
max_instances=1
)
logger.info("Inventory alert schedules configured",
service=self.config.SERVICE_NAME)
@@ -770,4 +782,193 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
logger.error("Error getting stock after order",
ingredient_id=ingredient_id,
error=str(e))
return None
return None
async def check_and_process_expired_batches(self):
"""Daily check and automated processing of expired stock batches"""
try:
self._checks_performed += 1
# Use existing method to get active tenants from ingredients table
tenants = await self.get_active_tenants()
if not tenants:
logger.info("No active tenants found")
return
total_processed = 0
for tenant_id in tenants:
try:
# Get expired batches for each tenant
async with self.db_manager.get_background_session() as session:
stock_repo = StockRepository(session)
expired_batches = await stock_repo.get_expired_batches_for_processing(tenant_id)
if expired_batches:
processed_count = await self._process_expired_batches_for_tenant(tenant_id, expired_batches)
total_processed += processed_count
except Exception as e:
logger.error("Error processing expired batches for tenant",
tenant_id=str(tenant_id),
error=str(e))
logger.info("Expired batch processing completed",
total_processed=total_processed,
tenants_processed=len(tenants))
except Exception as e:
logger.error("Expired batch processing failed", error=str(e))
self._errors_count += 1
async def _process_expired_batches_for_tenant(self, tenant_id: UUID, batches: List[tuple]) -> int:
"""Process expired batches for a specific tenant"""
processed_count = 0
processed_batches = []
try:
for stock, ingredient in batches:
try:
# Process each batch individually with its own transaction
await self._process_single_expired_batch(tenant_id, stock, ingredient)
processed_count += 1
processed_batches.append((stock, ingredient))
except Exception as e:
logger.error("Error processing individual expired batch",
tenant_id=str(tenant_id),
stock_id=str(stock.id),
batch_number=stock.batch_number,
error=str(e))
# Generate summary alert for the tenant if any batches were processed
if processed_count > 0:
await self._generate_expired_batch_summary_alert(tenant_id, processed_batches)
except Exception as e:
logger.error("Error processing expired batches for tenant",
tenant_id=str(tenant_id),
error=str(e))
return processed_count
async def _process_single_expired_batch(self, tenant_id: UUID, stock, ingredient):
"""Process a single expired batch: mark as expired, create waste movement, update stock"""
async with self.db_manager.get_background_session() as session:
async with session.begin(): # Use transaction for consistency
try:
stock_repo = StockRepository(session)
movement_repo = StockMovementRepository(session)
# Calculate effective expiration date
effective_expiration_date = stock.final_expiration_date or stock.expiration_date
# 1. Mark the stock batch as expired
await stock_repo.mark_batch_as_expired(stock.id, tenant_id)
# 2. Create waste stock movement
await movement_repo.create_automatic_waste_movement(
tenant_id=tenant_id,
ingredient_id=stock.ingredient_id,
stock_id=stock.id,
quantity=stock.current_quantity,
unit_cost=float(stock.unit_cost) if stock.unit_cost else None,
batch_number=stock.batch_number,
expiration_date=effective_expiration_date,
created_by=None # Automatic system operation
)
# 3. Update the stock quantity to 0 (moved to waste)
await stock_repo.update_stock_to_zero(stock.id, tenant_id)
# Calculate days expired
days_expired = (datetime.now().date() - effective_expiration_date.date()).days if effective_expiration_date else 0
logger.info("Expired batch processed successfully",
tenant_id=str(tenant_id),
stock_id=str(stock.id),
ingredient_name=ingredient.name,
batch_number=stock.batch_number,
quantity_wasted=stock.current_quantity,
days_expired=days_expired)
except Exception as e:
logger.error("Error in expired batch transaction",
stock_id=str(stock.id),
error=str(e))
raise # Re-raise to trigger rollback
async def _generate_expired_batch_summary_alert(self, tenant_id: UUID, processed_batches: List[tuple]):
"""Generate summary alert for automatically processed expired batches"""
try:
total_batches = len(processed_batches)
total_quantity = sum(float(stock.current_quantity) for stock, ingredient in processed_batches)
# Get the most affected ingredients (top 3)
ingredient_summary = {}
for stock, ingredient in processed_batches:
ingredient_name = ingredient.name
if ingredient_name not in ingredient_summary:
ingredient_summary[ingredient_name] = {
'quantity': 0,
'batches': 0,
'unit': ingredient.unit_of_measure.value if ingredient.unit_of_measure else 'kg'
}
ingredient_summary[ingredient_name]['quantity'] += float(stock.current_quantity)
ingredient_summary[ingredient_name]['batches'] += 1
# Sort by quantity and get top 3
top_ingredients = sorted(ingredient_summary.items(),
key=lambda x: x[1]['quantity'],
reverse=True)[:3]
# Build ingredient list for message
ingredient_list = []
for name, info in top_ingredients:
ingredient_list.append(f"{name} ({info['quantity']:.1f}{info['unit']}, {info['batches']} lote{'s' if info['batches'] > 1 else ''})")
remaining_count = total_batches - sum(info['batches'] for _, info in top_ingredients)
if remaining_count > 0:
ingredient_list.append(f"y {remaining_count} lote{'s' if remaining_count > 1 else ''} más")
# Create alert message
title = f"🗑️ Lotes Caducados Procesados Automáticamente"
message = (
f"Se han procesado automáticamente {total_batches} lote{'s' if total_batches > 1 else ''} "
f"caducado{'s' if total_batches > 1 else ''} ({total_quantity:.1f}kg total) y se ha{'n' if total_batches > 1 else ''} "
f"movido automáticamente a desperdicio:\n\n"
f"{chr(10).join(ingredient_list)}\n\n"
f"Los lotes han sido marcados como no disponibles y se han generado los movimientos de desperdicio correspondientes."
)
await self.publish_item(tenant_id, {
'type': 'expired_batches_auto_processed',
'severity': 'medium',
'title': title,
'message': message,
'actions': [
'Revisar movimientos de desperdicio',
'Analizar causas de caducidad',
'Ajustar niveles de stock',
'Revisar rotación de inventario'
],
'metadata': {
'total_batches_processed': total_batches,
'total_quantity_wasted': total_quantity,
'processing_date': datetime.now(timezone.utc).isoformat(),
'affected_ingredients': [
{
'name': name,
'quantity_wasted': info['quantity'],
'batches_count': info['batches'],
'unit': info['unit']
} for name, info in ingredient_summary.items()
],
'automation_source': 'daily_expired_batch_check'
}
}, item_type='alert')
except Exception as e:
logger.error("Error generating expired batch summary alert",
tenant_id=str(tenant_id),
error=str(e))

View File

@@ -20,6 +20,7 @@ from app.schemas.inventory import (
)
from app.core.database import get_db_transaction
from shared.database.exceptions import DatabaseError
from shared.utils.batch_generator import BatchNumberGenerator, create_fallback_batch_number
logger = structlog.get_logger()
@@ -237,7 +238,21 @@ class InventoryService:
ingredient = await ingredient_repo.get_by_id(UUID(stock_data.ingredient_id))
if not ingredient or ingredient.tenant_id != tenant_id:
raise ValueError("Ingredient not found")
# Generate batch number if not provided
if not stock_data.batch_number:
try:
batch_generator = BatchNumberGenerator(stock_repo)
stock_data.batch_number = await batch_generator.generate_batch_number(
tenant_id=str(tenant_id),
prefix="INV"
)
logger.info("Generated batch number", batch_number=stock_data.batch_number)
except Exception as e:
# Fallback to a simple batch number if generation fails
stock_data.batch_number = create_fallback_batch_number("INV")
logger.warning("Used fallback batch number", batch_number=stock_data.batch_number, error=str(e))
# Create stock entry
stock = await stock_repo.create_stock_entry(stock_data, tenant_id)