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

@@ -418,4 +418,28 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie
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

View File

@@ -383,4 +383,38 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate,
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

View File

@@ -109,14 +109,16 @@ class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate]):
raise
async def get_expiring_stock(
self,
tenant_id: UUID,
self,
tenant_id: UUID,
days_ahead: int = 7
) -> List[Tuple[Stock, Ingredient]]:
"""Get stock items expiring within specified days"""
"""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)
@@ -124,24 +126,39 @@ class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate]):
and_(
Stock.tenant_id == tenant_id,
Stock.is_available == True,
Stock.expiration_date.isnot(None),
Stock.expiration_date <= expiry_date
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)
)
)
.order_by(asc(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"""
"""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)
@@ -149,31 +166,45 @@ class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate]):
and_(
Stock.tenant_id == tenant_id,
Stock.is_available == True,
Stock.expiration_date.isnot(None),
Stock.expiration_date < current_date
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)
)
)
.order_by(desc(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,
self,
tenant_id: UUID,
ingredient_id: UUID,
quantity: float,
fifo: bool = True
) -> List[Dict[str, Any]]:
"""Reserve stock using FIFO/LIFO method"""
"""Reserve stock using FIFO/LIFO method with state-dependent expiration"""
try:
# Get available stock ordered by expiration date
order_clause = asc(Stock.expiration_date) if fifo else desc(Stock.expiration_date)
# 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_(
@@ -364,27 +395,133 @@ class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate]):
raise
async def mark_expired_stock(self, tenant_id: UUID) -> int:
"""Mark expired stock items as expired"""
"""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.expiration_date < current_date,
Stock.is_expired == False
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", tenant_id=tenant_id)
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

View 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