Files
bakery-ia/services/inventory/app/repositories/stock_repository.py
2025-12-13 23:57:54 +01:00

920 lines
36 KiB
Python

# 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 decimal import Decimal
from sqlalchemy import select, func, and_, or_, desc, asc, update, exists
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
from shared.utils.batch_generator import BatchCountProvider
logger = structlog.get_logger()
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
# 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'):
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_stock_by_product(
self,
tenant_id: UUID,
inventory_product_id: UUID,
include_unavailable: bool = False
) -> List[Stock]:
"""
Get all stock entries for a specific product.
Note: inventory_product_id and ingredient_id refer to the same entity.
The 'ingredients' table is used as a unified catalog for both raw ingredients
and finished products, distinguished by the product_type field.
This method is an alias for get_stock_by_ingredient for clarity when called
from contexts that use 'product' terminology (e.g., procurement service).
"""
return await self.get_stock_by_ingredient(
tenant_id=tenant_id,
ingredient_id=inventory_product_id,
include_unavailable=include_unavailable
)
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 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)
.where(
and_(
Stock.tenant_id == tenant_id,
Stock.is_available == True,
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)
)
)
)
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 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)
.where(
and_(
Stock.tenant_id == tenant_id,
Stock.is_available == True,
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)
)
)
)
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 with state-dependent expiration"""
try:
# 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:
# 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'),
func.count(func.distinct(Stock.ingredient_id)).label('unique_ingredients')
).where(
and_(
Stock.tenant_id == tenant_id,
Stock.is_available == True
)
)
)
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
# Count out of stock items (ingredients with no available stock)
out_of_stock_result = await self.session.execute(
select(func.count(Ingredient.id)).where(
and_(
Ingredient.tenant_id == tenant_id,
~exists(
select(1).where(
and_(
Stock.ingredient_id == Ingredient.id,
Stock.tenant_id == tenant_id,
Stock.is_available == True,
Stock.available_quantity > 0
)
)
)
)
)
)
out_of_stock_count = out_of_stock_result.scalar() or 0
return {
'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,
'out_of_stock_count': out_of_stock_count
}
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 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.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 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
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
async def get_expiring_products(self, tenant_id: UUID, days_threshold: int = 7) -> List[Dict[str, Any]]:
"""
Get products expiring soon or already expired
"""
try:
from sqlalchemy import text
query = text("""
SELECT
i.id as ingredient_id,
i.name as ingredient_name,
s.id as stock_id,
s.batch_number,
s.expiration_date,
s.current_quantity,
i.unit_of_measure,
s.unit_cost,
(s.current_quantity * s.unit_cost) as total_value,
CASE
WHEN s.expiration_date < CURRENT_DATE THEN 'expired'
WHEN s.expiration_date <= CURRENT_DATE + INTERVAL '1 day' THEN 'expires_today'
WHEN s.expiration_date <= CURRENT_DATE + INTERVAL '3 days' THEN 'expires_soon'
ELSE 'warning'
END as urgency,
EXTRACT(DAY FROM (s.expiration_date - CURRENT_DATE)) as days_until_expiry
FROM stock s
JOIN ingredients i ON s.ingredient_id = i.id
WHERE i.tenant_id = :tenant_id
AND s.is_available = true
AND s.expiration_date <= CURRENT_DATE + (INTERVAL '1 day' * :days_threshold)
ORDER BY s.expiration_date ASC, total_value DESC
""")
result = await self.session.execute(query, {
"tenant_id": tenant_id,
"days_threshold": days_threshold
})
return [dict(row._mapping) for row in result.fetchall()]
except Exception as e:
logger.error("Failed to get expiring products", error=str(e), tenant_id=str(tenant_id))
raise
async def get_temperature_breaches(self, tenant_id: UUID, hours_back: int = 24) -> List[Dict[str, Any]]:
"""
Get temperature monitoring breaches
"""
try:
from sqlalchemy import text
query = text("""
SELECT
tl.id,
tl.equipment_id,
tl.equipment_name,
tl.storage_type,
tl.temperature_celsius,
tl.min_threshold,
tl.max_threshold,
tl.is_within_range,
tl.recorded_at,
tl.alert_triggered,
EXTRACT(EPOCH FROM (NOW() - tl.recorded_at))/3600 as hours_ago,
CASE
WHEN tl.temperature_celsius < tl.min_threshold
THEN tl.min_threshold - tl.temperature_celsius
WHEN tl.temperature_celsius > tl.max_threshold
THEN tl.temperature_celsius - tl.max_threshold
ELSE 0
END as deviation
FROM temperature_logs tl
WHERE tl.tenant_id = :tenant_id
AND tl.is_within_range = false
AND tl.recorded_at > NOW() - (INTERVAL '1 hour' * :hours_back)
AND tl.alert_triggered = false
ORDER BY deviation DESC, tl.recorded_at DESC
""")
result = await self.session.execute(query, {
"tenant_id": tenant_id,
"hours_back": hours_back
})
return [dict(row._mapping) for row in result.fetchall()]
except Exception as e:
logger.error("Failed to get temperature breaches", error=str(e), tenant_id=str(tenant_id))
raise
async def get_waste_opportunities(self, tenant_id: UUID) -> List[Dict[str, Any]]:
"""
Identify waste reduction opportunities
"""
try:
from sqlalchemy import text
query = text("""
WITH waste_analysis AS (
SELECT
i.id as ingredient_id,
i.name as ingredient_name,
i.ingredient_category,
COUNT(sm.id) as waste_incidents,
SUM(sm.quantity) as total_waste_quantity,
SUM(sm.total_cost) as total_waste_cost,
AVG(sm.quantity) as avg_waste_per_incident,
MAX(sm.movement_date) as last_waste_date
FROM stock_movements sm
JOIN ingredients i ON sm.ingredient_id = i.id
WHERE i.tenant_id = :tenant_id
AND sm.movement_type = 'WASTE'
AND sm.movement_date > NOW() - INTERVAL '30 days'
GROUP BY i.id, i.name, i.ingredient_category
HAVING COUNT(sm.id) >= 3 OR SUM(sm.total_cost) > 50
)
SELECT * FROM waste_analysis
ORDER BY total_waste_cost DESC, waste_incidents DESC
LIMIT 20
""")
result = await self.session.execute(query, {"tenant_id": tenant_id})
return [dict(row._mapping) for row in result.fetchall()]
except Exception as e:
logger.error("Failed to get waste opportunities", error=str(e), tenant_id=str(tenant_id))
raise
async def get_reorder_recommendations(self, tenant_id: UUID) -> List[Dict[str, Any]]:
"""
Get ingredients that need reordering based on stock levels and usage
"""
try:
from sqlalchemy import text
query = text("""
WITH usage_analysis AS (
SELECT
i.id,
i.name,
COALESCE(SUM(s.current_quantity), 0) as current_stock,
i.reorder_point,
i.low_stock_threshold,
COALESCE(SUM(sm.quantity) FILTER (WHERE sm.movement_date > NOW() - INTERVAL '7 days'), 0) / 7 as daily_usage,
i.preferred_supplier_id,
i.standard_order_quantity
FROM ingredients i
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true
LEFT JOIN stock_movements sm ON sm.ingredient_id = i.id
AND sm.movement_type = 'PRODUCTION_USE'
AND sm.movement_date > NOW() - INTERVAL '7 days'
WHERE i.tenant_id = :tenant_id
AND i.is_active = true
GROUP BY i.id, i.name, i.reorder_point, i.low_stock_threshold,
i.preferred_supplier_id, i.standard_order_quantity
)
SELECT *,
CASE
WHEN daily_usage > 0 THEN FLOOR(current_stock / NULLIF(daily_usage, 0))
ELSE 999
END as days_of_stock,
GREATEST(
standard_order_quantity,
CEIL(daily_usage * 14)
) as recommended_order_quantity
FROM usage_analysis
WHERE current_stock <= reorder_point
ORDER BY days_of_stock ASC, current_stock ASC
LIMIT 50
""")
result = await self.session.execute(query, {"tenant_id": tenant_id})
return [dict(row._mapping) for row in result.fetchall()]
except Exception as e:
logger.error("Failed to get reorder recommendations", error=str(e), tenant_id=str(tenant_id))
raise