Files
bakery-ia/services/inventory/app/repositories/stock_repository.py

705 lines
26 KiB
Python
Raw Normal View History

# 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
2025-09-18 08:06:32 +02:00
from decimal import Decimal
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
2025-09-18 08:06:32 +02:00
from shared.utils.batch_generator import BatchCountProvider
logger = structlog.get_logger()
2025-09-18 08:06:32 +02:00
class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate], BatchCountProvider):
"""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
2025-09-18 08:06:32 +02:00
# Ensure production_stage is properly converted to enum value
if 'production_stage' in create_data:
if hasattr(create_data['production_stage'], 'value'):
create_data['production_stage'] = create_data['production_stage'].value
elif isinstance(create_data['production_stage'], str):
# If it's a string, ensure it's the correct enum value
from app.models.inventory import ProductionStage
try:
enum_obj = ProductionStage[create_data['production_stage']]
create_data['production_stage'] = enum_obj.value
except KeyError:
# If it's already the value, keep it as is
pass
# 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'):
2025-09-18 08:06:32 +02:00
unit_cost = create_data['unit_cost']
current_quantity = Decimal(str(create_data['current_quantity']))
create_data['total_cost'] = unit_cost * 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(
2025-09-17 16:06:30 +02:00
self,
tenant_id: UUID,
days_ahead: int = 7
) -> List[Tuple[Stock, Ingredient]]:
2025-09-17 16:06:30 +02:00
"""Get stock items expiring within specified days using state-dependent expiration logic"""
try:
expiry_date = datetime.now() + timedelta(days=days_ahead)
2025-09-17 16:06:30 +02:00
# 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)
.where(
and_(
Stock.tenant_id == tenant_id,
Stock.is_available == True,
2025-09-17 16:06:30 +02:00
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)
)
)
)
2025-09-17 16:06:30 +02:00
return result.all()
2025-09-17 16:06:30 +02:00
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]]:
2025-09-17 16:06:30 +02:00
"""Get stock items that have expired using state-dependent expiration logic"""
try:
current_date = datetime.now()
2025-09-17 16:06:30 +02:00
# 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)
.where(
and_(
Stock.tenant_id == tenant_id,
Stock.is_available == True,
2025-09-17 16:06:30 +02:00
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)
)
)
)
2025-09-17 16:06:30 +02:00
return result.all()
2025-09-17 16:06:30 +02:00
except Exception as e:
logger.error("Failed to get expired stock", error=str(e), tenant_id=tenant_id)
raise
async def reserve_stock(
2025-09-17 16:06:30 +02:00
self,
tenant_id: UUID,
ingredient_id: UUID,
quantity: float,
fifo: bool = True
) -> List[Dict[str, Any]]:
2025-09-17 16:06:30 +02:00
"""Reserve stock using FIFO/LIFO method with state-dependent expiration"""
try:
2025-09-17 16:06:30 +02:00
# 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_(
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:
2025-09-15 15:31:27 +02:00
# Basic stock summary
basic_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'),
2025-09-15 15:31:27 +02:00
func.count(func.distinct(Stock.ingredient_id)).label('unique_ingredients')
).where(
and_(
Stock.tenant_id == tenant_id,
Stock.is_available == True
)
)
)
2025-09-15 15:31:27 +02:00
basic_summary = basic_result.first()
# Count expired items
expired_result = await self.session.execute(
select(func.count(Stock.id)).where(
and_(
Stock.tenant_id == tenant_id,
Stock.is_available == True,
Stock.expiration_date < datetime.now()
)
)
)
expired_count = expired_result.scalar() or 0
# Count expiring soon items
expiring_result = await self.session.execute(
select(func.count(Stock.id)).where(
and_(
Stock.tenant_id == tenant_id,
Stock.is_available == True,
Stock.expiration_date.isnot(None),
Stock.expiration_date <= datetime.now() + timedelta(days=7)
)
)
)
expiring_count = expiring_result.scalar() or 0
return {
2025-09-15 15:31:27 +02:00
'total_stock_items': basic_summary.total_stock_items or 0,
'total_stock_value': float(basic_summary.total_stock_value) if basic_summary.total_stock_value else 0.0,
'unique_ingredients': basic_summary.unique_ingredients or 0,
'expired_items': expired_count,
'expiring_soon_items': expiring_count
}
2025-09-15 15:31:27 +02:00
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:
2025-09-17 16:06:30 +02:00
"""Mark expired stock items as expired using state-dependent expiration logic"""
try:
current_date = datetime.now()
2025-09-17 16:06:30 +02:00
# 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,
2025-09-17 16:06:30 +02:00
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")
)
2025-09-17 16:06:30 +02:00
expired_count = result.rowcount
2025-09-17 16:06:30 +02:00
logger.info(f"Marked {expired_count} stock items as expired using state-dependent logic", tenant_id=tenant_id)
return expired_count
2025-09-17 16:06:30 +02:00
except Exception as e:
logger.error("Failed to mark expired stock", error=str(e), tenant_id=tenant_id)
2025-09-17 16:06:30 +02:00
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)
)
2025-09-18 08:06:32 +02:00
raise
async def get_daily_batch_count(
self,
tenant_id: str,
date_start: datetime,
date_end: datetime,
prefix: Optional[str] = None
) -> int:
"""Get the count of batches created today for the given tenant"""
try:
conditions = [
Stock.tenant_id == tenant_id,
Stock.created_at >= date_start,
Stock.created_at <= date_end
]
if prefix:
conditions.append(Stock.batch_number.like(f"{prefix}-%"))
stmt = select(func.count(Stock.id)).where(and_(*conditions))
result = await self.session.execute(stmt)
count = result.scalar() or 0
logger.debug(
"Retrieved daily batch count",
tenant_id=tenant_id,
prefix=prefix,
count=count,
date_start=date_start,
date_end=date_end
)
return count
except Exception as e:
logger.error(
"Failed to get daily batch count",
error=str(e),
tenant_id=tenant_id,
prefix=prefix
)
raise
async def get_expired_batches_for_processing(self, tenant_id: UUID) -> List[Tuple[Stock, Ingredient]]:
"""Get expired batches that haven't been processed yet (for automatic processing)"""
try:
current_date = datetime.now()
# Find expired batches that are still available and not yet marked as expired
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.is_expired == False,
Stock.current_quantity > 0,
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(
asc(func.coalesce(Stock.final_expiration_date, Stock.expiration_date))
)
)
expired_batches = result.all()
logger.info("Found expired batches for processing",
tenant_id=str(tenant_id),
count=len(expired_batches))
return expired_batches
except Exception as e:
logger.error("Failed to get expired batches for processing",
error=str(e), tenant_id=tenant_id)
raise
async def mark_batch_as_expired(self, stock_id: UUID, tenant_id: UUID) -> bool:
"""Mark a specific batch as expired and unavailable"""
try:
result = await self.session.execute(
update(Stock)
.where(
and_(
Stock.id == stock_id,
Stock.tenant_id == tenant_id
)
)
.values(
is_expired=True,
is_available=False,
quality_status="expired",
updated_at=datetime.now()
)
)
if result.rowcount > 0:
logger.info("Marked batch as expired",
stock_id=str(stock_id),
tenant_id=str(tenant_id))
return True
else:
logger.warning("No batch found to mark as expired",
stock_id=str(stock_id),
tenant_id=str(tenant_id))
return False
except Exception as e:
logger.error("Failed to mark batch as expired",
error=str(e),
stock_id=str(stock_id),
tenant_id=str(tenant_id))
raise
async def update_stock_to_zero(self, stock_id: UUID, tenant_id: UUID) -> bool:
"""Update stock quantities to zero after moving to waste"""
try:
result = await self.session.execute(
update(Stock)
.where(
and_(
Stock.id == stock_id,
Stock.tenant_id == tenant_id
)
)
.values(
current_quantity=0,
available_quantity=0,
updated_at=datetime.now()
)
)
if result.rowcount > 0:
logger.info("Updated stock quantities to zero",
stock_id=str(stock_id),
tenant_id=str(tenant_id))
return True
else:
logger.warning("No stock found to update to zero",
stock_id=str(stock_id),
tenant_id=str(tenant_id))
return False
except Exception as e:
logger.error("Failed to update stock to zero",
error=str(e),
stock_id=str(stock_id),
tenant_id=str(tenant_id))
raise