Improve the inventory page 3
This commit is contained in:
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user