Create new services: inventory, recipes, suppliers
This commit is contained in:
0
services/inventory/app/repositories/__init__.py
Normal file
0
services/inventory/app/repositories/__init__.py
Normal file
239
services/inventory/app/repositories/ingredient_repository.py
Normal file
239
services/inventory/app/repositories/ingredient_repository.py
Normal 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
|
||||
340
services/inventory/app/repositories/stock_movement_repository.py
Normal file
340
services/inventory/app/repositories/stock_movement_repository.py
Normal 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
|
||||
379
services/inventory/app/repositories/stock_repository.py
Normal file
379
services/inventory/app/repositories/stock_repository.py
Normal 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
|
||||
Reference in New Issue
Block a user