Create new services: inventory, recipes, suppliers

This commit is contained in:
Urtzi Alfaro
2025-08-13 17:39:35 +02:00
parent fbe7470ad9
commit 16b8a9d50c
151 changed files with 35799 additions and 857 deletions

View File

@@ -0,0 +1,239 @@
# services/inventory/app/repositories/ingredient_repository.py
"""
Ingredient Repository using Repository Pattern
"""
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime
from sqlalchemy import select, func, and_, or_, desc, asc
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
from app.models.inventory import Ingredient, Stock
from app.schemas.inventory import IngredientCreate, IngredientUpdate
from shared.database.repository import BaseRepository
logger = structlog.get_logger()
class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, IngredientUpdate]):
"""Repository for ingredient operations"""
def __init__(self, session: AsyncSession):
super().__init__(Ingredient, session)
async def create_ingredient(self, ingredient_data: IngredientCreate, tenant_id: UUID) -> Ingredient:
"""Create a new ingredient"""
try:
# Prepare data
create_data = ingredient_data.model_dump()
create_data['tenant_id'] = tenant_id
# Create record
record = await self.create(create_data)
logger.info(
"Created ingredient",
ingredient_id=record.id,
name=record.name,
category=record.category.value if record.category else None,
tenant_id=tenant_id
)
return record
except Exception as e:
logger.error("Failed to create ingredient", error=str(e), tenant_id=tenant_id)
raise
async def get_ingredients_by_tenant(
self,
tenant_id: UUID,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None
) -> List[Ingredient]:
"""Get ingredients for a tenant with filtering"""
try:
query_filters = {'tenant_id': tenant_id}
if filters:
if filters.get('category'):
query_filters['category'] = filters['category']
if filters.get('is_active') is not None:
query_filters['is_active'] = filters['is_active']
if filters.get('is_perishable') is not None:
query_filters['is_perishable'] = filters['is_perishable']
ingredients = await self.get_multi(
skip=skip,
limit=limit,
filters=query_filters,
order_by='name'
)
return ingredients
except Exception as e:
logger.error("Failed to get ingredients", error=str(e), tenant_id=tenant_id)
raise
async def search_ingredients(
self,
tenant_id: UUID,
search_term: str,
skip: int = 0,
limit: int = 50
) -> List[Ingredient]:
"""Search ingredients by name, sku, or barcode"""
try:
# Add tenant filter to search
query = select(self.model).where(
and_(
self.model.tenant_id == tenant_id,
or_(
self.model.name.ilike(f"%{search_term}%"),
self.model.sku.ilike(f"%{search_term}%"),
self.model.barcode.ilike(f"%{search_term}%"),
self.model.brand.ilike(f"%{search_term}%")
)
)
).offset(skip).limit(limit)
result = await self.session.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Failed to search ingredients", error=str(e), tenant_id=tenant_id)
raise
async def get_low_stock_ingredients(self, tenant_id: UUID) -> List[Dict[str, Any]]:
"""Get ingredients with low stock levels"""
try:
# Query ingredients with their current stock levels
query = select(
Ingredient,
func.coalesce(func.sum(Stock.available_quantity), 0).label('current_stock')
).outerjoin(
Stock, and_(
Stock.ingredient_id == Ingredient.id,
Stock.is_available == True
)
).where(
Ingredient.tenant_id == tenant_id
).group_by(Ingredient.id).having(
func.coalesce(func.sum(Stock.available_quantity), 0) <= Ingredient.low_stock_threshold
)
result = await self.session.execute(query)
results = []
for ingredient, current_stock in result:
results.append({
'ingredient': ingredient,
'current_stock': float(current_stock) if current_stock else 0.0,
'threshold': ingredient.low_stock_threshold,
'needs_reorder': current_stock <= ingredient.reorder_point if current_stock else True
})
return results
except Exception as e:
logger.error("Failed to get low stock ingredients", error=str(e), tenant_id=tenant_id)
raise
async def get_ingredients_needing_reorder(self, tenant_id: UUID) -> List[Dict[str, Any]]:
"""Get ingredients that need reordering"""
try:
query = select(
Ingredient,
func.coalesce(func.sum(Stock.available_quantity), 0).label('current_stock')
).outerjoin(
Stock, and_(
Stock.ingredient_id == Ingredient.id,
Stock.is_available == True
)
).where(
and_(
Ingredient.tenant_id == tenant_id,
Ingredient.is_active == True
)
).group_by(Ingredient.id).having(
func.coalesce(func.sum(Stock.available_quantity), 0) <= Ingredient.reorder_point
)
result = await self.session.execute(query)
results = []
for ingredient, current_stock in result:
results.append({
'ingredient': ingredient,
'current_stock': float(current_stock) if current_stock else 0.0,
'reorder_point': ingredient.reorder_point,
'reorder_quantity': ingredient.reorder_quantity
})
return results
except Exception as e:
logger.error("Failed to get ingredients needing reorder", error=str(e), tenant_id=tenant_id)
raise
async def get_by_sku(self, tenant_id: UUID, sku: str) -> Optional[Ingredient]:
"""Get ingredient by SKU"""
try:
result = await self.session.execute(
select(self.model).where(
and_(
self.model.tenant_id == tenant_id,
self.model.sku == sku
)
)
)
return result.scalar_one_or_none()
except Exception as e:
logger.error("Failed to get ingredient by SKU", error=str(e), sku=sku, tenant_id=tenant_id)
raise
async def get_by_barcode(self, tenant_id: UUID, barcode: str) -> Optional[Ingredient]:
"""Get ingredient by barcode"""
try:
result = await self.session.execute(
select(self.model).where(
and_(
self.model.tenant_id == tenant_id,
self.model.barcode == barcode
)
)
)
return result.scalar_one_or_none()
except Exception as e:
logger.error("Failed to get ingredient by barcode", error=str(e), barcode=barcode, tenant_id=tenant_id)
raise
async def update_last_purchase_price(self, ingredient_id: UUID, price: float) -> Optional[Ingredient]:
"""Update the last purchase price for an ingredient"""
try:
update_data = {'last_purchase_price': price}
return await self.update(ingredient_id, update_data)
except Exception as e:
logger.error("Failed to update last purchase price", error=str(e), ingredient_id=ingredient_id)
raise
async def get_ingredients_by_category(self, tenant_id: UUID, category: str) -> List[Ingredient]:
"""Get all ingredients in a specific category"""
try:
result = await self.session.execute(
select(self.model).where(
and_(
self.model.tenant_id == tenant_id,
self.model.category == category,
self.model.is_active == True
)
).order_by(self.model.name)
)
return result.scalars().all()
except Exception as e:
logger.error("Failed to get ingredients by category", error=str(e), category=category, tenant_id=tenant_id)
raise

View File

@@ -0,0 +1,340 @@
# services/inventory/app/repositories/stock_movement_repository.py
"""
Stock Movement 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
from app.models.inventory import StockMovement, Ingredient, StockMovementType
from app.schemas.inventory import StockMovementCreate
from shared.database.repository import BaseRepository
logger = structlog.get_logger()
class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate, dict]):
"""Repository for stock movement operations"""
def __init__(self, session: AsyncSession):
super().__init__(StockMovement, session)
async def create_movement(
self,
movement_data: StockMovementCreate,
tenant_id: UUID,
created_by: Optional[UUID] = None
) -> StockMovement:
"""Create a new stock movement record"""
try:
# Prepare data
create_data = movement_data.model_dump()
create_data['tenant_id'] = tenant_id
create_data['created_by'] = created_by
# Set movement date if not provided
if not create_data.get('movement_date'):
create_data['movement_date'] = datetime.now()
# Calculate total cost if unit cost provided
if create_data.get('unit_cost') and create_data.get('quantity'):
create_data['total_cost'] = create_data['unit_cost'] * create_data['quantity']
# Create record
record = await self.create(create_data)
logger.info(
"Created stock movement",
movement_id=record.id,
ingredient_id=record.ingredient_id,
movement_type=record.movement_type.value if record.movement_type else None,
quantity=record.quantity,
tenant_id=tenant_id
)
return record
except Exception as e:
logger.error("Failed to create stock movement", error=str(e), tenant_id=tenant_id)
raise
async def get_movements_by_ingredient(
self,
tenant_id: UUID,
ingredient_id: UUID,
skip: int = 0,
limit: int = 100,
days_back: Optional[int] = None
) -> List[StockMovement]:
"""Get stock movements for a specific ingredient"""
try:
query = select(self.model).where(
and_(
self.model.tenant_id == tenant_id,
self.model.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.movement_date >= start_date)
query = query.order_by(desc(self.model.movement_date)).offset(skip).limit(limit)
result = await self.session.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Failed to get movements by ingredient", error=str(e), ingredient_id=ingredient_id)
raise
async def get_movements_by_type(
self,
tenant_id: UUID,
movement_type: StockMovementType,
skip: int = 0,
limit: int = 100,
days_back: Optional[int] = None
) -> List[StockMovement]:
"""Get stock movements by type"""
try:
query = select(self.model).where(
and_(
self.model.tenant_id == tenant_id,
self.model.movement_type == movement_type
)
)
# Filter by date range if specified
if days_back:
start_date = datetime.now() - timedelta(days=days_back)
query = query.where(self.model.movement_date >= start_date)
query = query.order_by(desc(self.model.movement_date)).offset(skip).limit(limit)
result = await self.session.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Failed to get movements by type", error=str(e), movement_type=movement_type)
raise
async def get_recent_movements(
self,
tenant_id: UUID,
limit: int = 50
) -> List[StockMovement]:
"""Get recent stock movements for dashboard"""
try:
result = await self.session.execute(
select(self.model)
.where(self.model.tenant_id == tenant_id)
.order_by(desc(self.model.movement_date))
.limit(limit)
)
return result.scalars().all()
except Exception as e:
logger.error("Failed to get recent movements", error=str(e), tenant_id=tenant_id)
raise
async def get_movements_by_reference(
self,
tenant_id: UUID,
reference_number: str
) -> List[StockMovement]:
"""Get stock movements by reference number (e.g., purchase order)"""
try:
result = await self.session.execute(
select(self.model).where(
and_(
self.model.tenant_id == tenant_id,
self.model.reference_number == reference_number
)
).order_by(desc(self.model.movement_date))
)
return result.scalars().all()
except Exception as e:
logger.error("Failed to get movements by reference", error=str(e), reference_number=reference_number)
raise
async def get_movement_summary_by_period(
self,
tenant_id: UUID,
days_back: int = 30
) -> Dict[str, Any]:
"""Get movement summary for specified period"""
try:
start_date = datetime.now() - timedelta(days=days_back)
# Get movement counts by type
result = await self.session.execute(
select(
self.model.movement_type,
func.count(self.model.id).label('count'),
func.coalesce(func.sum(self.model.quantity), 0).label('total_quantity'),
func.coalesce(func.sum(self.model.total_cost), 0).label('total_cost')
).where(
and_(
self.model.tenant_id == tenant_id,
self.model.movement_date >= start_date
)
).group_by(self.model.movement_type)
)
summary = {}
for row in result:
movement_type = row.movement_type.value if row.movement_type else "unknown"
summary[movement_type] = {
'count': row.count,
'total_quantity': float(row.total_quantity),
'total_cost': float(row.total_cost) if row.total_cost else 0.0
}
# Get total movements count
total_result = await self.session.execute(
select(func.count(self.model.id)).where(
and_(
self.model.tenant_id == tenant_id,
self.model.movement_date >= start_date
)
)
)
summary['total_movements'] = total_result.scalar() or 0
summary['period_days'] = days_back
return summary
except Exception as e:
logger.error("Failed to get movement summary", error=str(e), tenant_id=tenant_id)
raise
async def get_waste_movements(
self,
tenant_id: UUID,
days_back: Optional[int] = None,
skip: int = 0,
limit: int = 100
) -> List[StockMovement]:
"""Get waste-related movements"""
try:
query = select(self.model).where(
and_(
self.model.tenant_id == tenant_id,
self.model.movement_type == StockMovementType.WASTE
)
)
if days_back:
start_date = datetime.now() - timedelta(days=days_back)
query = query.where(self.model.movement_date >= start_date)
query = query.order_by(desc(self.model.movement_date)).offset(skip).limit(limit)
result = await self.session.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Failed to get waste movements", error=str(e), tenant_id=tenant_id)
raise
async def get_purchase_movements(
self,
tenant_id: UUID,
days_back: Optional[int] = None,
skip: int = 0,
limit: int = 100
) -> List[StockMovement]:
"""Get purchase-related movements"""
try:
query = select(self.model).where(
and_(
self.model.tenant_id == tenant_id,
self.model.movement_type == StockMovementType.PURCHASE
)
)
if days_back:
start_date = datetime.now() - timedelta(days=days_back)
query = query.where(self.model.movement_date >= start_date)
query = query.order_by(desc(self.model.movement_date)).offset(skip).limit(limit)
result = await self.session.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Failed to get purchase movements", error=str(e), tenant_id=tenant_id)
raise
async def calculate_ingredient_usage(
self,
tenant_id: UUID,
ingredient_id: UUID,
days_back: int = 30
) -> Dict[str, float]:
"""Calculate ingredient usage statistics"""
try:
start_date = datetime.now() - timedelta(days=days_back)
# Get production usage
production_result = await self.session.execute(
select(func.coalesce(func.sum(self.model.quantity), 0)).where(
and_(
self.model.tenant_id == tenant_id,
self.model.ingredient_id == ingredient_id,
self.model.movement_type == StockMovementType.PRODUCTION_USE,
self.model.movement_date >= start_date
)
)
)
# Get waste quantity
waste_result = await self.session.execute(
select(func.coalesce(func.sum(self.model.quantity), 0)).where(
and_(
self.model.tenant_id == tenant_id,
self.model.ingredient_id == ingredient_id,
self.model.movement_type == StockMovementType.WASTE,
self.model.movement_date >= start_date
)
)
)
# Get purchases
purchase_result = await self.session.execute(
select(func.coalesce(func.sum(self.model.quantity), 0)).where(
and_(
self.model.tenant_id == tenant_id,
self.model.ingredient_id == ingredient_id,
self.model.movement_type == StockMovementType.PURCHASE,
self.model.movement_date >= start_date
)
)
)
production_usage = float(production_result.scalar() or 0)
waste_quantity = float(waste_result.scalar() or 0)
purchase_quantity = float(purchase_result.scalar() or 0)
# Calculate usage rate per day
usage_per_day = production_usage / days_back if days_back > 0 else 0
waste_percentage = (waste_quantity / purchase_quantity * 100) if purchase_quantity > 0 else 0
return {
'production_usage': production_usage,
'waste_quantity': waste_quantity,
'purchase_quantity': purchase_quantity,
'usage_per_day': usage_per_day,
'waste_percentage': waste_percentage,
'period_days': days_back
}
except Exception as e:
logger.error("Failed to calculate ingredient usage", error=str(e), ingredient_id=ingredient_id)
raise

View File

@@ -0,0 +1,379 @@
# services/inventory/app/repositories/stock_repository.py
"""
Stock Repository using Repository Pattern
"""
from typing import List, Optional, Dict, Any, Tuple
from uuid import UUID
from datetime import datetime, timedelta
from sqlalchemy import select, func, and_, or_, desc, asc, update
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
from app.models.inventory import Stock, Ingredient
from app.schemas.inventory import StockCreate, StockUpdate
from shared.database.repository import BaseRepository
logger = structlog.get_logger()
class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate]):
"""Repository for stock operations"""
def __init__(self, session: AsyncSession):
super().__init__(Stock, session)
async def create_stock_entry(self, stock_data: StockCreate, tenant_id: UUID) -> Stock:
"""Create a new stock entry"""
try:
# Prepare data
create_data = stock_data.model_dump()
create_data['tenant_id'] = tenant_id
# Calculate available quantity
available_qty = create_data['current_quantity'] - create_data.get('reserved_quantity', 0)
create_data['available_quantity'] = max(0, available_qty)
# Calculate total cost if unit cost provided
if create_data.get('unit_cost') and create_data.get('current_quantity'):
create_data['total_cost'] = create_data['unit_cost'] * create_data['current_quantity']
# Create record
record = await self.create(create_data)
logger.info(
"Created stock entry",
stock_id=record.id,
ingredient_id=record.ingredient_id,
quantity=record.current_quantity,
tenant_id=tenant_id
)
return record
except Exception as e:
logger.error("Failed to create stock entry", error=str(e), tenant_id=tenant_id)
raise
async def get_stock_by_ingredient(
self,
tenant_id: UUID,
ingredient_id: UUID,
include_unavailable: bool = False
) -> List[Stock]:
"""Get all stock entries for a specific ingredient"""
try:
query = select(self.model).where(
and_(
self.model.tenant_id == tenant_id,
self.model.ingredient_id == ingredient_id
)
)
if not include_unavailable:
query = query.where(self.model.is_available == True)
query = query.order_by(asc(self.model.expiration_date))
result = await self.session.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Failed to get stock by ingredient", error=str(e), ingredient_id=ingredient_id)
raise
async def get_total_stock_by_ingredient(self, tenant_id: UUID, ingredient_id: UUID) -> Dict[str, float]:
"""Get total stock quantities for an ingredient"""
try:
result = await self.session.execute(
select(
func.coalesce(func.sum(Stock.current_quantity), 0).label('total_quantity'),
func.coalesce(func.sum(Stock.reserved_quantity), 0).label('total_reserved'),
func.coalesce(func.sum(Stock.available_quantity), 0).label('total_available')
).where(
and_(
Stock.tenant_id == tenant_id,
Stock.ingredient_id == ingredient_id,
Stock.is_available == True
)
)
)
row = result.first()
return {
'total_quantity': float(row.total_quantity) if row.total_quantity else 0.0,
'total_reserved': float(row.total_reserved) if row.total_reserved else 0.0,
'total_available': float(row.total_available) if row.total_available else 0.0
}
except Exception as e:
logger.error("Failed to get total stock", error=str(e), ingredient_id=ingredient_id)
raise
async def get_expiring_stock(
self,
tenant_id: UUID,
days_ahead: int = 7
) -> List[Tuple[Stock, Ingredient]]:
"""Get stock items expiring within specified days"""
try:
expiry_date = datetime.now() + timedelta(days=days_ahead)
result = await self.session.execute(
select(Stock, Ingredient)
.join(Ingredient, Stock.ingredient_id == Ingredient.id)
.where(
and_(
Stock.tenant_id == tenant_id,
Stock.is_available == True,
Stock.expiration_date.isnot(None),
Stock.expiration_date <= expiry_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"""
try:
current_date = datetime.now()
result = await self.session.execute(
select(Stock, Ingredient)
.join(Ingredient, Stock.ingredient_id == Ingredient.id)
.where(
and_(
Stock.tenant_id == tenant_id,
Stock.is_available == True,
Stock.expiration_date.isnot(None),
Stock.expiration_date < current_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,
quantity: float,
fifo: bool = True
) -> List[Dict[str, Any]]:
"""Reserve stock using FIFO/LIFO method"""
try:
# Get available stock ordered by expiration date
order_clause = asc(Stock.expiration_date) if fifo else desc(Stock.expiration_date)
result = await self.session.execute(
select(Stock).where(
and_(
Stock.tenant_id == tenant_id,
Stock.ingredient_id == ingredient_id,
Stock.is_available == True,
Stock.available_quantity > 0
)
).order_by(order_clause)
)
stock_items = result.scalars().all()
reservations = []
remaining_qty = quantity
for stock_item in stock_items:
if remaining_qty <= 0:
break
available = stock_item.available_quantity
to_reserve = min(remaining_qty, available)
# Update stock reservation
new_reserved = stock_item.reserved_quantity + to_reserve
new_available = stock_item.current_quantity - new_reserved
await self.session.execute(
update(Stock)
.where(Stock.id == stock_item.id)
.values(
reserved_quantity=new_reserved,
available_quantity=new_available
)
)
reservations.append({
'stock_id': stock_item.id,
'reserved_quantity': to_reserve,
'batch_number': stock_item.batch_number,
'expiration_date': stock_item.expiration_date
})
remaining_qty -= to_reserve
if remaining_qty > 0:
logger.warning(
"Insufficient stock for reservation",
ingredient_id=ingredient_id,
requested=quantity,
unfulfilled=remaining_qty
)
return reservations
except Exception as e:
logger.error("Failed to reserve stock", error=str(e), ingredient_id=ingredient_id)
raise
async def release_stock_reservation(
self,
stock_id: UUID,
quantity: float
) -> Optional[Stock]:
"""Release reserved stock"""
try:
stock_item = await self.get_by_id(stock_id)
if not stock_item:
return None
# Calculate new quantities
new_reserved = max(0, stock_item.reserved_quantity - quantity)
new_available = stock_item.current_quantity - new_reserved
# Update stock
await self.session.execute(
update(Stock)
.where(Stock.id == stock_id)
.values(
reserved_quantity=new_reserved,
available_quantity=new_available
)
)
# Refresh and return updated stock
await self.session.refresh(stock_item)
return stock_item
except Exception as e:
logger.error("Failed to release stock reservation", error=str(e), stock_id=stock_id)
raise
async def consume_stock(
self,
stock_id: UUID,
quantity: float,
from_reserved: bool = True
) -> Optional[Stock]:
"""Consume stock (reduce current quantity)"""
try:
stock_item = await self.get_by_id(stock_id)
if not stock_item:
return None
if from_reserved:
# Reduce from reserved quantity
new_reserved = max(0, stock_item.reserved_quantity - quantity)
new_current = max(0, stock_item.current_quantity - quantity)
new_available = new_current - new_reserved
else:
# Reduce from available quantity
new_current = max(0, stock_item.current_quantity - quantity)
new_available = max(0, stock_item.available_quantity - quantity)
new_reserved = stock_item.reserved_quantity
# Update stock
await self.session.execute(
update(Stock)
.where(Stock.id == stock_id)
.values(
current_quantity=new_current,
reserved_quantity=new_reserved,
available_quantity=new_available,
is_available=new_current > 0
)
)
# Refresh and return updated stock
await self.session.refresh(stock_item)
return stock_item
except Exception as e:
logger.error("Failed to consume stock", error=str(e), stock_id=stock_id)
raise
async def get_stock_summary_by_tenant(self, tenant_id: UUID) -> Dict[str, Any]:
"""Get stock summary for tenant dashboard"""
try:
# Total stock value and counts
result = await self.session.execute(
select(
func.count(Stock.id).label('total_stock_items'),
func.coalesce(func.sum(Stock.total_cost), 0).label('total_stock_value'),
func.count(func.distinct(Stock.ingredient_id)).label('unique_ingredients'),
func.sum(
func.case(
(Stock.expiration_date < datetime.now(), 1),
else_=0
)
).label('expired_items'),
func.sum(
func.case(
(and_(Stock.expiration_date.isnot(None),
Stock.expiration_date <= datetime.now() + timedelta(days=7)), 1),
else_=0
)
).label('expiring_soon_items')
).where(
and_(
Stock.tenant_id == tenant_id,
Stock.is_available == True
)
)
)
summary = result.first()
return {
'total_stock_items': summary.total_stock_items or 0,
'total_stock_value': float(summary.total_stock_value) if summary.total_stock_value else 0.0,
'unique_ingredients': summary.unique_ingredients or 0,
'expired_items': summary.expired_items or 0,
'expiring_soon_items': summary.expiring_soon_items or 0
}
except Exception as e:
logger.error("Failed to get stock summary", error=str(e), tenant_id=tenant_id)
raise
async def mark_expired_stock(self, tenant_id: UUID) -> int:
"""Mark expired stock items as expired"""
try:
current_date = datetime.now()
result = await self.session.execute(
update(Stock)
.where(
and_(
Stock.tenant_id == tenant_id,
Stock.expiration_date < current_date,
Stock.is_expired == False
)
)
.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)
return expired_count
except Exception as e:
logger.error("Failed to mark expired stock", error=str(e), tenant_id=tenant_id)
raise