Improve the inventory page

This commit is contained in:
Urtzi Alfaro
2025-09-17 16:06:30 +02:00
parent 7aa26d51d3
commit dcb3ce441b
39 changed files with 5852 additions and 1762 deletions

View File

@@ -535,6 +535,185 @@ class InventoryService:
logger.error("Failed to get inventory summary", error=str(e), tenant_id=tenant_id)
raise
async def get_stock(
self,
tenant_id: UUID,
skip: int = 0,
limit: int = 100,
ingredient_id: Optional[UUID] = None,
available_only: bool = True
) -> List[StockResponse]:
"""Get stock entries with filtering"""
try:
async with get_db_transaction() as db:
stock_repo = StockRepository(db)
ingredient_repo = IngredientRepository(db)
# Get stock entries
stock_entries = await stock_repo.get_stock_entries(
tenant_id, skip, limit, ingredient_id, available_only
)
responses = []
for stock in stock_entries:
# Get ingredient information
ingredient = await ingredient_repo.get_by_id(stock.ingredient_id)
response = StockResponse(**stock.to_dict())
if ingredient:
response.ingredient = IngredientResponse(**ingredient.to_dict())
responses.append(response)
return responses
except Exception as e:
logger.error("Failed to get stock entries", error=str(e), tenant_id=tenant_id)
raise
# ===== DELETION METHODS =====
async def hard_delete_ingredient(
self,
ingredient_id: UUID,
tenant_id: UUID,
user_id: Optional[UUID] = None
) -> Dict[str, Any]:
"""
Completely delete an ingredient and all associated data.
This includes:
- All stock entries
- All stock movements
- All stock alerts
- The ingredient record itself
Returns a summary of what was deleted.
"""
try:
deletion_summary = {
"ingredient_id": str(ingredient_id),
"deleted_stock_entries": 0,
"deleted_stock_movements": 0,
"deleted_stock_alerts": 0,
"ingredient_name": None,
"success": False
}
async with get_db_transaction() as db:
ingredient_repo = IngredientRepository(db)
stock_repo = StockRepository(db)
movement_repo = StockMovementRepository(db)
# 1. Verify ingredient exists and belongs to tenant
ingredient = await ingredient_repo.get_by_id(ingredient_id)
if not ingredient or ingredient.tenant_id != tenant_id:
raise ValueError(f"Ingredient {ingredient_id} not found or access denied")
deletion_summary["ingredient_name"] = ingredient.name
logger.info(
"Starting hard deletion of ingredient",
ingredient_id=str(ingredient_id),
ingredient_name=ingredient.name,
tenant_id=str(tenant_id)
)
# 2. Delete all stock movements first (due to foreign key constraints)
try:
deleted_movements = await movement_repo.delete_by_ingredient(ingredient_id, tenant_id)
deletion_summary["deleted_stock_movements"] = deleted_movements
logger.info(f"Deleted {deleted_movements} stock movements")
except Exception as e:
logger.warning(f"Error deleting stock movements: {str(e)}")
# Continue with deletion even if this fails
# 3. Delete all stock entries
try:
deleted_stock = await stock_repo.delete_by_ingredient(ingredient_id, tenant_id)
deletion_summary["deleted_stock_entries"] = deleted_stock
logger.info(f"Deleted {deleted_stock} stock entries")
except Exception as e:
logger.warning(f"Error deleting stock entries: {str(e)}")
# Continue with deletion even if this fails
# 4. Delete stock alerts if they exist
try:
# Note: StockAlert deletion would go here if that table exists
# For now, we'll assume this is handled by cascading deletes or doesn't exist
deletion_summary["deleted_stock_alerts"] = 0
except Exception as e:
logger.warning(f"Error deleting stock alerts: {str(e)}")
# 5. Finally, delete the ingredient itself
deleted_ingredient = await ingredient_repo.delete_by_id(ingredient_id, tenant_id)
if not deleted_ingredient:
raise ValueError("Failed to delete ingredient record")
deletion_summary["success"] = True
logger.info(
"Successfully completed hard deletion of ingredient",
ingredient_id=str(ingredient_id),
ingredient_name=ingredient.name,
summary=deletion_summary
)
return deletion_summary
except ValueError:
# Re-raise validation errors
raise
except Exception as e:
logger.error(
"Failed to hard delete ingredient",
ingredient_id=str(ingredient_id),
tenant_id=str(tenant_id),
error=str(e)
)
raise
async def soft_delete_ingredient(
self,
ingredient_id: UUID,
tenant_id: UUID,
user_id: Optional[UUID] = None
) -> IngredientResponse:
"""
Soft delete an ingredient (mark as inactive).
This preserves all associated data for reporting and audit purposes.
"""
try:
async with get_db_transaction() as db:
ingredient_repo = IngredientRepository(db)
# Verify ingredient exists and belongs to tenant
ingredient = await ingredient_repo.get_by_id(ingredient_id)
if not ingredient or ingredient.tenant_id != tenant_id:
raise ValueError(f"Ingredient {ingredient_id} not found or access denied")
# Mark as inactive
update_data = IngredientUpdate(is_active=False)
updated_ingredient = await ingredient_repo.update_ingredient(ingredient_id, update_data)
logger.info(
"Soft deleted ingredient",
ingredient_id=str(ingredient_id),
ingredient_name=ingredient.name,
tenant_id=str(tenant_id)
)
return IngredientResponse(**updated_ingredient.to_dict())
except ValueError:
raise
except Exception as e:
logger.error(
"Failed to soft delete ingredient",
ingredient_id=str(ingredient_id),
tenant_id=str(tenant_id),
error=str(e)
)
raise
# ===== PRIVATE HELPER METHODS =====
async def _validate_ingredient_data(self, ingredient_data: IngredientCreate, tenant_id: UUID):

View File

@@ -0,0 +1,332 @@
# services/inventory/app/services/transformation_service.py
"""
Product Transformation Service - Business Logic Layer
"""
from typing import List, Optional, Dict, Any, Tuple
from uuid import UUID
from datetime import datetime, timedelta
import structlog
import json
from app.models.inventory import ProductTransformation, Stock, StockMovement, StockMovementType, ProductionStage
from app.repositories.transformation_repository import TransformationRepository
from app.repositories.ingredient_repository import IngredientRepository
from app.repositories.stock_repository import StockRepository
from app.repositories.stock_movement_repository import StockMovementRepository
from app.schemas.inventory import (
ProductTransformationCreate, ProductTransformationResponse,
StockCreate, StockMovementCreate,
IngredientResponse
)
from app.core.database import get_db_transaction
from shared.database.exceptions import DatabaseError
logger = structlog.get_logger()
class TransformationService:
"""Service layer for product transformation operations"""
def __init__(self):
pass
async def create_transformation(
self,
transformation_data: ProductTransformationCreate,
tenant_id: UUID,
user_id: Optional[UUID] = None
) -> ProductTransformationResponse:
"""Create a product transformation with stock movements"""
try:
async with get_db_transaction() as db:
transformation_repo = TransformationRepository(db)
ingredient_repo = IngredientRepository(db)
stock_repo = StockRepository(db)
movement_repo = StockMovementRepository(db)
# Validate ingredients exist
source_ingredient = await ingredient_repo.get_by_id(UUID(transformation_data.source_ingredient_id))
target_ingredient = await ingredient_repo.get_by_id(UUID(transformation_data.target_ingredient_id))
if not source_ingredient or source_ingredient.tenant_id != tenant_id:
raise ValueError("Source ingredient not found")
if not target_ingredient or target_ingredient.tenant_id != tenant_id:
raise ValueError("Target ingredient not found")
# Reserve source stock using FIFO by default
source_reservations = await stock_repo.reserve_stock(
tenant_id,
UUID(transformation_data.source_ingredient_id),
transformation_data.source_quantity,
fifo=True
)
if not source_reservations:
raise ValueError(f"Insufficient stock available for transformation. Required: {transformation_data.source_quantity}")
# Create transformation record
source_batch_numbers = [res.get('batch_number') for res in source_reservations if res.get('batch_number')]
transformation = await transformation_repo.create_transformation(
transformation_data,
tenant_id,
user_id,
source_batch_numbers
)
# Calculate expiration date for target product
target_expiration_date = self._calculate_target_expiration(
transformation_data.expiration_calculation_method,
transformation_data.expiration_days_offset,
source_reservations
)
# Consume source stock and create movements
consumed_items = []
for reservation in source_reservations:
stock_id = UUID(reservation['stock_id'])
reserved_qty = reservation['reserved_quantity']
# Consume from reserved stock
await stock_repo.consume_stock(stock_id, reserved_qty, from_reserved=True)
# Create movement record
movement_data = StockMovementCreate(
ingredient_id=transformation_data.source_ingredient_id,
stock_id=str(stock_id),
movement_type=StockMovementType.TRANSFORMATION,
quantity=reserved_qty,
reference_number=transformation.transformation_reference,
notes=f"Transformation: {transformation_data.source_stage.value}{transformation_data.target_stage.value}"
)
await movement_repo.create_movement(movement_data, tenant_id, user_id)
consumed_items.append({
'stock_id': str(stock_id),
'quantity_consumed': reserved_qty,
'batch_number': reservation.get('batch_number')
})
# Create target stock entry
target_stock_data = StockCreate(
ingredient_id=transformation_data.target_ingredient_id,
production_stage=transformation_data.target_stage,
transformation_reference=transformation.transformation_reference,
current_quantity=transformation_data.target_quantity,
batch_number=transformation_data.target_batch_number or f"TRANS-{transformation.transformation_reference}",
expiration_date=target_expiration_date['expiration_date'],
original_expiration_date=target_expiration_date.get('original_expiration_date'),
transformation_date=transformation.transformation_date,
final_expiration_date=target_expiration_date['expiration_date'],
unit_cost=self._calculate_target_unit_cost(consumed_items, transformation_data.target_quantity),
quality_status="good"
)
target_stock = await stock_repo.create_stock_entry(target_stock_data, tenant_id)
# Create target stock movement
target_movement_data = StockMovementCreate(
ingredient_id=transformation_data.target_ingredient_id,
stock_id=str(target_stock.id),
movement_type=StockMovementType.TRANSFORMATION,
quantity=transformation_data.target_quantity,
reference_number=transformation.transformation_reference,
notes=f"Transformation result: {transformation_data.source_stage.value}{transformation_data.target_stage.value}"
)
await movement_repo.create_movement(target_movement_data, tenant_id, user_id)
# Convert to response schema
response = ProductTransformationResponse(**transformation.to_dict())
response.source_ingredient = IngredientResponse(**source_ingredient.to_dict())
response.target_ingredient = IngredientResponse(**target_ingredient.to_dict())
logger.info(
"Transformation completed successfully",
transformation_id=transformation.id,
reference=transformation.transformation_reference,
source_quantity=transformation_data.source_quantity,
target_quantity=transformation_data.target_quantity
)
return response
except Exception as e:
logger.error("Failed to create transformation", error=str(e), tenant_id=tenant_id)
raise
async def get_transformation(
self,
transformation_id: UUID,
tenant_id: UUID
) -> Optional[ProductTransformationResponse]:
"""Get transformation by ID"""
try:
async with get_db_transaction() as db:
transformation_repo = TransformationRepository(db)
ingredient_repo = IngredientRepository(db)
transformation = await transformation_repo.get_by_id(transformation_id)
if not transformation or transformation.tenant_id != tenant_id:
return None
# Get related ingredients
source_ingredient = await ingredient_repo.get_by_id(transformation.source_ingredient_id)
target_ingredient = await ingredient_repo.get_by_id(transformation.target_ingredient_id)
response = ProductTransformationResponse(**transformation.to_dict())
if source_ingredient:
response.source_ingredient = IngredientResponse(**source_ingredient.to_dict())
if target_ingredient:
response.target_ingredient = IngredientResponse(**target_ingredient.to_dict())
return response
except Exception as e:
logger.error("Failed to get transformation", error=str(e), transformation_id=transformation_id)
raise
async def get_transformations(
self,
tenant_id: UUID,
skip: int = 0,
limit: int = 100,
ingredient_id: Optional[UUID] = None,
source_stage: Optional[ProductionStage] = None,
target_stage: Optional[ProductionStage] = None,
days_back: Optional[int] = None
) -> List[ProductTransformationResponse]:
"""Get transformations with filtering"""
try:
async with get_db_transaction() as db:
transformation_repo = TransformationRepository(db)
ingredient_repo = IngredientRepository(db)
if ingredient_id:
# Get transformations where ingredient is either source or target
source_transformations = await transformation_repo.get_transformations_by_ingredient(
tenant_id, ingredient_id, is_source=True, skip=0, limit=limit//2, days_back=days_back
)
target_transformations = await transformation_repo.get_transformations_by_ingredient(
tenant_id, ingredient_id, is_source=False, skip=0, limit=limit//2, days_back=days_back
)
transformations = source_transformations + target_transformations
# Remove duplicates and sort by date
unique_transformations = {t.id: t for t in transformations}.values()
transformations = sorted(unique_transformations, key=lambda x: x.transformation_date, reverse=True)
transformations = transformations[skip:skip+limit]
else:
transformations = await transformation_repo.get_transformations_by_stage(
tenant_id, source_stage, target_stage, skip, limit, days_back
)
responses = []
for transformation in transformations:
# Get related ingredients
source_ingredient = await ingredient_repo.get_by_id(transformation.source_ingredient_id)
target_ingredient = await ingredient_repo.get_by_id(transformation.target_ingredient_id)
response = ProductTransformationResponse(**transformation.to_dict())
if source_ingredient:
response.source_ingredient = IngredientResponse(**source_ingredient.to_dict())
if target_ingredient:
response.target_ingredient = IngredientResponse(**target_ingredient.to_dict())
responses.append(response)
return responses
except Exception as e:
logger.error("Failed to get transformations", error=str(e), tenant_id=tenant_id)
raise
def _calculate_target_expiration(
self,
calculation_method: str,
expiration_days_offset: Optional[int],
source_reservations: List[Dict[str, Any]]
) -> Dict[str, Optional[datetime]]:
"""Calculate expiration date for target product"""
current_time = datetime.now()
if calculation_method == "days_from_transformation":
# Calculate expiration based on transformation date + offset
if expiration_days_offset:
expiration_date = current_time + timedelta(days=expiration_days_offset)
else:
expiration_date = current_time + timedelta(days=1) # Default 1 day for fresh baked goods
# Use earliest source expiration as original
original_expiration = None
if source_reservations:
source_expirations = [res.get('expiration_date') for res in source_reservations if res.get('expiration_date')]
if source_expirations:
original_expiration = min(source_expirations)
return {
'expiration_date': expiration_date,
'original_expiration_date': original_expiration
}
elif calculation_method == "preserve_original":
# Use the earliest expiration date from source stock
if source_reservations:
source_expirations = [res.get('expiration_date') for res in source_reservations if res.get('expiration_date')]
if source_expirations:
expiration_date = min(source_expirations)
return {
'expiration_date': expiration_date,
'original_expiration_date': expiration_date
}
# Fallback to default
return {
'expiration_date': current_time + timedelta(days=7),
'original_expiration_date': None
}
else:
# Default fallback
return {
'expiration_date': current_time + timedelta(days=1),
'original_expiration_date': None
}
def _calculate_target_unit_cost(
self,
consumed_items: List[Dict[str, Any]],
target_quantity: float
) -> Optional[float]:
"""Calculate unit cost for target product based on consumed items"""
# This is a simplified calculation - in reality you'd want to consider
# additional costs like labor, energy, etc.
total_source_cost = 0.0
total_source_quantity = 0.0
for item in consumed_items:
quantity = item.get('quantity_consumed', 0)
# Note: In a real implementation, you'd fetch the unit cost from the stock items
# For now, we'll use a placeholder
total_source_quantity += quantity
if total_source_quantity > 0 and target_quantity > 0:
# Simple cost transfer based on quantity ratio
return total_source_cost / target_quantity
return None
async def get_transformation_summary(
self,
tenant_id: UUID,
days_back: int = 30
) -> Dict[str, Any]:
"""Get transformation summary for dashboard"""
try:
async with get_db_transaction() as db:
transformation_repo = TransformationRepository(db)
summary = await transformation_repo.get_transformation_summary_by_period(tenant_id, days_back)
return summary
except Exception as e:
logger.error("Failed to get transformation summary", error=str(e), tenant_id=tenant_id)
raise