629 lines
24 KiB
Python
629 lines
24 KiB
Python
# services/inventory/app/api/inventory_operations.py
|
|
"""
|
|
Inventory Operations API - Business operations for inventory management
|
|
"""
|
|
|
|
from typing import List, Optional, Dict, Any
|
|
from uuid import UUID, uuid4
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from pydantic import BaseModel, Field
|
|
import structlog
|
|
|
|
from app.core.database import get_db
|
|
from app.services.inventory_service import InventoryService
|
|
from app.services.product_classifier import ProductClassifierService, get_product_classifier
|
|
from shared.auth.decorators import get_current_user_dep
|
|
from shared.auth.access_control import require_user_role
|
|
from shared.routing import RouteBuilder
|
|
|
|
logger = structlog.get_logger()
|
|
route_builder = RouteBuilder('inventory')
|
|
router = APIRouter(tags=["inventory-operations"])
|
|
|
|
|
|
def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> UUID:
|
|
"""Extract user ID from current user context"""
|
|
user_id = current_user.get('user_id')
|
|
if not user_id:
|
|
if current_user.get('type') == 'service':
|
|
return None
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User ID not found in context"
|
|
)
|
|
try:
|
|
return UUID(user_id)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
|
|
# ===== Stock Operations =====
|
|
|
|
@router.post(
|
|
route_builder.build_operations_route("consume-stock"),
|
|
response_model=dict
|
|
)
|
|
@require_user_role(['admin', 'owner', 'member'])
|
|
async def consume_stock(
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
ingredient_id: UUID = Query(..., description="Ingredient ID to consume"),
|
|
quantity: float = Query(..., gt=0, description="Quantity to consume"),
|
|
reference_number: Optional[str] = Query(None, description="Reference number"),
|
|
notes: Optional[str] = Query(None, description="Additional notes"),
|
|
fifo: bool = Query(True, description="Use FIFO method"),
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Consume stock for production"""
|
|
try:
|
|
user_id = get_current_user_id(current_user)
|
|
service = InventoryService()
|
|
consumed_items = await service.consume_stock(
|
|
ingredient_id, quantity, tenant_id, user_id, reference_number, notes, fifo
|
|
)
|
|
return {
|
|
"ingredient_id": str(ingredient_id),
|
|
"total_quantity_consumed": quantity,
|
|
"consumed_items": consumed_items,
|
|
"method": "FIFO" if fifo else "LIFO"
|
|
}
|
|
except ValueError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e)
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to consume stock"
|
|
)
|
|
|
|
|
|
@router.get(
|
|
route_builder.build_operations_route("stock/expiring"),
|
|
response_model=List[dict]
|
|
)
|
|
async def get_expiring_stock(
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
days_ahead: int = Query(7, ge=1, le=365, description="Days ahead to check"),
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get stock items expiring within specified days"""
|
|
try:
|
|
service = InventoryService()
|
|
expiring_items = await service.check_expiration_alerts(tenant_id, days_ahead)
|
|
return expiring_items
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to get expiring stock"
|
|
)
|
|
|
|
|
|
@router.get(
|
|
route_builder.build_operations_route("stock/low-stock"),
|
|
response_model=List[dict]
|
|
)
|
|
async def get_low_stock(
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get ingredients with low stock levels"""
|
|
try:
|
|
service = InventoryService()
|
|
low_stock_items = await service.check_low_stock_alerts(tenant_id)
|
|
return low_stock_items
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to get low stock items"
|
|
)
|
|
|
|
|
|
@router.get(
|
|
route_builder.build_operations_route("stock/summary"),
|
|
response_model=dict
|
|
)
|
|
async def get_stock_summary(
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get stock summary for tenant"""
|
|
try:
|
|
service = InventoryService()
|
|
summary = await service.get_inventory_summary(tenant_id)
|
|
return summary.dict()
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to get stock summary"
|
|
)
|
|
|
|
|
|
# ===== Product Classification Operations =====
|
|
|
|
class ProductClassificationRequest(BaseModel):
|
|
"""Request for single product classification"""
|
|
product_name: str = Field(..., description="Product name to classify")
|
|
sales_volume: float = Field(None, description="Total sales volume for context")
|
|
sales_data: Dict[str, Any] = Field(default_factory=dict, description="Additional sales context")
|
|
|
|
|
|
class BatchClassificationRequest(BaseModel):
|
|
"""Request for batch product classification"""
|
|
products: List[ProductClassificationRequest] = Field(..., description="Products to classify")
|
|
|
|
|
|
class ProductSuggestionResponse(BaseModel):
|
|
"""Response with product classification suggestion"""
|
|
suggestion_id: str
|
|
original_name: str
|
|
suggested_name: str
|
|
product_type: str
|
|
category: str
|
|
unit_of_measure: str
|
|
confidence_score: float
|
|
estimated_shelf_life_days: Optional[int] = None
|
|
requires_refrigeration: bool = False
|
|
requires_freezing: bool = False
|
|
is_seasonal: bool = False
|
|
suggested_supplier: Optional[str] = None
|
|
notes: Optional[str] = None
|
|
|
|
|
|
class BusinessModelAnalysisResponse(BaseModel):
|
|
"""Response with business model analysis"""
|
|
model: str
|
|
confidence: float
|
|
ingredient_count: int
|
|
finished_product_count: int
|
|
ingredient_ratio: float
|
|
recommendations: List[str]
|
|
|
|
|
|
class BatchClassificationResponse(BaseModel):
|
|
"""Response for batch classification"""
|
|
suggestions: List[ProductSuggestionResponse]
|
|
business_model_analysis: BusinessModelAnalysisResponse
|
|
total_products: int
|
|
high_confidence_count: int
|
|
low_confidence_count: int
|
|
|
|
|
|
@router.post(
|
|
route_builder.build_operations_route("classify-product"),
|
|
response_model=ProductSuggestionResponse
|
|
)
|
|
async def classify_single_product(
|
|
request: ProductClassificationRequest,
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
classifier: ProductClassifierService = Depends(get_product_classifier)
|
|
):
|
|
"""Classify a single product for inventory creation"""
|
|
try:
|
|
suggestion = classifier.classify_product(
|
|
request.product_name,
|
|
request.sales_volume
|
|
)
|
|
|
|
response = ProductSuggestionResponse(
|
|
suggestion_id=str(uuid4()),
|
|
original_name=suggestion.original_name,
|
|
suggested_name=suggestion.suggested_name,
|
|
product_type=suggestion.product_type.value,
|
|
category=suggestion.category,
|
|
unit_of_measure=suggestion.unit_of_measure.value,
|
|
confidence_score=suggestion.confidence_score,
|
|
estimated_shelf_life_days=suggestion.estimated_shelf_life_days,
|
|
requires_refrigeration=suggestion.requires_refrigeration,
|
|
requires_freezing=suggestion.requires_freezing,
|
|
is_seasonal=suggestion.is_seasonal,
|
|
suggested_supplier=suggestion.suggested_supplier,
|
|
notes=suggestion.notes
|
|
)
|
|
|
|
logger.info("Classified single product",
|
|
product=request.product_name,
|
|
classification=suggestion.product_type.value,
|
|
confidence=suggestion.confidence_score,
|
|
tenant_id=tenant_id)
|
|
|
|
return response
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to classify product",
|
|
error=str(e), product=request.product_name, tenant_id=tenant_id)
|
|
raise HTTPException(status_code=500, detail=f"Classification failed: {str(e)}")
|
|
|
|
|
|
@router.post(
|
|
route_builder.build_operations_route("classify-products-batch"),
|
|
response_model=BatchClassificationResponse
|
|
)
|
|
async def classify_products_batch(
|
|
request: BatchClassificationRequest,
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
classifier: ProductClassifierService = Depends(get_product_classifier)
|
|
):
|
|
"""Classify multiple products for onboarding automation"""
|
|
try:
|
|
if not request.products:
|
|
raise HTTPException(status_code=400, detail="No products provided for classification")
|
|
|
|
product_names = [p.product_name for p in request.products]
|
|
sales_volumes = {p.product_name: p.sales_volume for p in request.products if p.sales_volume}
|
|
|
|
suggestions = classifier.classify_products_batch(product_names, sales_volumes)
|
|
|
|
suggestion_responses = []
|
|
for suggestion in suggestions:
|
|
suggestion_responses.append(ProductSuggestionResponse(
|
|
suggestion_id=str(uuid4()),
|
|
original_name=suggestion.original_name,
|
|
suggested_name=suggestion.suggested_name,
|
|
product_type=suggestion.product_type.value,
|
|
category=suggestion.category,
|
|
unit_of_measure=suggestion.unit_of_measure.value,
|
|
confidence_score=suggestion.confidence_score,
|
|
estimated_shelf_life_days=suggestion.estimated_shelf_life_days,
|
|
requires_refrigeration=suggestion.requires_refrigeration,
|
|
requires_freezing=suggestion.requires_freezing,
|
|
is_seasonal=suggestion.is_seasonal,
|
|
suggested_supplier=suggestion.suggested_supplier,
|
|
notes=suggestion.notes
|
|
))
|
|
|
|
# Analyze business model
|
|
ingredient_count = sum(1 for s in suggestions if s.product_type.value == 'ingredient')
|
|
finished_count = sum(1 for s in suggestions if s.product_type.value == 'finished_product')
|
|
semi_finished_count = sum(1 for s in suggestions if 'semi' in s.suggested_name.lower() or 'frozen' in s.suggested_name.lower() or 'pre' in s.suggested_name.lower())
|
|
total = len(suggestions)
|
|
ingredient_ratio = ingredient_count / total if total > 0 else 0
|
|
semi_finished_ratio = semi_finished_count / total if total > 0 else 0
|
|
|
|
if ingredient_ratio >= 0.7:
|
|
model = 'individual_bakery'
|
|
elif ingredient_ratio <= 0.2 and semi_finished_ratio >= 0.3:
|
|
model = 'central_baker_satellite'
|
|
elif ingredient_ratio <= 0.3:
|
|
model = 'retail_bakery'
|
|
else:
|
|
model = 'hybrid_bakery'
|
|
|
|
if model == 'individual_bakery':
|
|
confidence = min(ingredient_ratio * 1.2, 0.95)
|
|
elif model == 'central_baker_satellite':
|
|
confidence = min((semi_finished_ratio + (1 - ingredient_ratio)) / 2 * 1.2, 0.95)
|
|
else:
|
|
confidence = max(abs(ingredient_ratio - 0.5) * 2, 0.1)
|
|
|
|
recommendations = {
|
|
'individual_bakery': [
|
|
'Set up raw ingredient inventory management',
|
|
'Configure recipe cost calculation and production planning',
|
|
'Enable supplier relationships for flour, yeast, sugar, etc.',
|
|
'Set up full production workflow with proofing and baking schedules',
|
|
'Enable waste tracking for overproduction'
|
|
],
|
|
'central_baker_satellite': [
|
|
'Configure central baker delivery schedules',
|
|
'Set up semi-finished product inventory (frozen dough, par-baked items)',
|
|
'Enable finish-baking workflow and timing optimization',
|
|
'Track freshness and shelf-life for received products',
|
|
'Focus on customer demand forecasting for final products'
|
|
],
|
|
'retail_bakery': [
|
|
'Set up finished product supplier relationships',
|
|
'Configure delivery schedule tracking',
|
|
'Enable freshness monitoring and expiration management',
|
|
'Focus on sales forecasting and customer preferences'
|
|
],
|
|
'hybrid_bakery': [
|
|
'Configure both ingredient and semi-finished product management',
|
|
'Set up flexible production workflows',
|
|
'Enable both supplier and central baker relationships',
|
|
'Configure multi-tier inventory categories'
|
|
]
|
|
}
|
|
|
|
business_model_analysis = BusinessModelAnalysisResponse(
|
|
model=model,
|
|
confidence=confidence,
|
|
ingredient_count=ingredient_count,
|
|
finished_product_count=finished_count,
|
|
ingredient_ratio=ingredient_ratio,
|
|
recommendations=recommendations.get(model, [])
|
|
)
|
|
|
|
high_confidence_count = sum(1 for s in suggestions if s.confidence_score >= 0.7)
|
|
low_confidence_count = sum(1 for s in suggestions if s.confidence_score < 0.6)
|
|
|
|
response = BatchClassificationResponse(
|
|
suggestions=suggestion_responses,
|
|
business_model_analysis=business_model_analysis,
|
|
total_products=len(suggestions),
|
|
high_confidence_count=high_confidence_count,
|
|
low_confidence_count=low_confidence_count
|
|
)
|
|
|
|
logger.info("Batch classification complete",
|
|
total_products=len(suggestions),
|
|
business_model=model,
|
|
high_confidence=high_confidence_count,
|
|
low_confidence=low_confidence_count,
|
|
tenant_id=tenant_id)
|
|
|
|
return response
|
|
|
|
except Exception as e:
|
|
logger.error("Failed batch classification",
|
|
error=str(e), products_count=len(request.products), tenant_id=tenant_id)
|
|
raise HTTPException(status_code=500, detail=f"Batch classification failed: {str(e)}")
|
|
|
|
|
|
class BatchProductResolutionRequest(BaseModel):
|
|
"""Request for batch product resolution or creation"""
|
|
products: List[Dict[str, Any]] = Field(..., description="Products to resolve or create")
|
|
|
|
|
|
class BatchProductResolutionResponse(BaseModel):
|
|
"""Response with product name to inventory ID mappings"""
|
|
product_mappings: Dict[str, str] = Field(..., description="Product name to inventory product ID mapping")
|
|
created_count: int = Field(..., description="Number of products created")
|
|
resolved_count: int = Field(..., description="Number of existing products resolved")
|
|
failed_count: int = Field(0, description="Number of products that failed")
|
|
|
|
|
|
@router.post(
|
|
route_builder.build_operations_route("resolve-or-create-products-batch"),
|
|
response_model=BatchProductResolutionResponse
|
|
)
|
|
async def resolve_or_create_products_batch(
|
|
request: BatchProductResolutionRequest,
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Resolve or create multiple products in a single optimized operation for sales import"""
|
|
try:
|
|
if not request.products:
|
|
raise HTTPException(status_code=400, detail="No products provided")
|
|
|
|
service = InventoryService()
|
|
product_mappings = {}
|
|
created_count = 0
|
|
resolved_count = 0
|
|
failed_count = 0
|
|
|
|
for product_data in request.products:
|
|
product_name = product_data.get('name', product_data.get('product_name', ''))
|
|
if not product_name:
|
|
failed_count += 1
|
|
continue
|
|
|
|
try:
|
|
existing = await service.search_ingredients_by_name(product_name, tenant_id, db)
|
|
|
|
if existing:
|
|
product_mappings[product_name] = str(existing.id)
|
|
resolved_count += 1
|
|
logger.debug("Resolved existing product", product=product_name, tenant_id=tenant_id)
|
|
else:
|
|
category = product_data.get('category', 'general')
|
|
ingredient_data = {
|
|
'name': product_name,
|
|
'type': 'finished_product',
|
|
'unit': 'unit',
|
|
'current_stock': 0,
|
|
'reorder_point': 0,
|
|
'cost_per_unit': 0,
|
|
'category': category
|
|
}
|
|
|
|
created = await service.create_ingredient_fast(ingredient_data, tenant_id, db)
|
|
product_mappings[product_name] = str(created.id)
|
|
created_count += 1
|
|
logger.debug("Created new product", product=product_name, tenant_id=tenant_id)
|
|
|
|
except Exception as e:
|
|
logger.warning("Failed to resolve/create product",
|
|
product=product_name, error=str(e), tenant_id=tenant_id)
|
|
failed_count += 1
|
|
continue
|
|
|
|
logger.info("Batch product resolution complete",
|
|
total=len(request.products),
|
|
created=created_count,
|
|
resolved=resolved_count,
|
|
failed=failed_count,
|
|
tenant_id=tenant_id)
|
|
|
|
return BatchProductResolutionResponse(
|
|
product_mappings=product_mappings,
|
|
created_count=created_count,
|
|
resolved_count=resolved_count,
|
|
failed_count=failed_count
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error("Batch product resolution failed",
|
|
error=str(e), tenant_id=tenant_id)
|
|
raise HTTPException(status_code=500, detail=f"Batch resolution failed: {str(e)}")
|
|
|
|
|
|
# ================================================================
|
|
# NEW: BATCH API ENDPOINTS FOR ORCHESTRATOR
|
|
# ================================================================
|
|
|
|
class BatchIngredientsRequest(BaseModel):
|
|
"""Request for batch ingredient fetching"""
|
|
ingredient_ids: List[UUID] = Field(..., description="List of ingredient IDs to fetch")
|
|
|
|
|
|
class BatchIngredientsResponse(BaseModel):
|
|
"""Response with ingredient data"""
|
|
ingredients: List[Dict[str, Any]] = Field(..., description="List of ingredient data")
|
|
found_count: int = Field(..., description="Number of ingredients found")
|
|
missing_ids: List[str] = Field(default_factory=list, description="IDs not found")
|
|
|
|
|
|
@router.post(
|
|
route_builder.build_operations_route("ingredients/batch"),
|
|
response_model=BatchIngredientsResponse
|
|
)
|
|
async def get_ingredients_batch(
|
|
request: BatchIngredientsRequest,
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Fetch multiple ingredients in a single request (for Orchestrator).
|
|
|
|
This endpoint reduces N API calls to 1, improving performance when
|
|
the orchestrator needs ingredient data for production/procurement planning.
|
|
"""
|
|
try:
|
|
if not request.ingredient_ids:
|
|
return BatchIngredientsResponse(
|
|
ingredients=[],
|
|
found_count=0,
|
|
missing_ids=[]
|
|
)
|
|
|
|
service = InventoryService()
|
|
ingredients = []
|
|
found_ids = set()
|
|
|
|
for ingredient_id in request.ingredient_ids:
|
|
try:
|
|
ingredient = await service.get_ingredient_by_id(ingredient_id, tenant_id, db)
|
|
if ingredient:
|
|
ingredients.append({
|
|
'id': str(ingredient.id),
|
|
'name': ingredient.name,
|
|
'type': ingredient.type,
|
|
'unit': ingredient.unit,
|
|
'current_stock': float(ingredient.current_stock) if ingredient.current_stock else 0,
|
|
'reorder_point': float(ingredient.reorder_point) if ingredient.reorder_point else 0,
|
|
'cost_per_unit': float(ingredient.cost_per_unit) if ingredient.cost_per_unit else 0,
|
|
'category': ingredient.category,
|
|
'is_active': ingredient.is_active,
|
|
'shelf_life_days': ingredient.shelf_life_days
|
|
})
|
|
found_ids.add(str(ingredient_id))
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Failed to fetch ingredient in batch",
|
|
ingredient_id=str(ingredient_id),
|
|
error=str(e)
|
|
)
|
|
continue
|
|
|
|
missing_ids = [str(id) for id in request.ingredient_ids if str(id) not in found_ids]
|
|
|
|
logger.info(
|
|
"Batch ingredient fetch complete",
|
|
requested=len(request.ingredient_ids),
|
|
found=len(ingredients),
|
|
missing=len(missing_ids),
|
|
tenant_id=str(tenant_id)
|
|
)
|
|
|
|
return BatchIngredientsResponse(
|
|
ingredients=ingredients,
|
|
found_count=len(ingredients),
|
|
missing_ids=missing_ids
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
"Batch ingredient fetch failed",
|
|
error=str(e),
|
|
tenant_id=str(tenant_id)
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Batch ingredient fetch failed: {str(e)}"
|
|
)
|
|
|
|
|
|
class BatchStockLevelsRequest(BaseModel):
|
|
"""Request for batch stock level fetching"""
|
|
ingredient_ids: List[UUID] = Field(..., description="List of ingredient IDs")
|
|
|
|
|
|
class BatchStockLevelsResponse(BaseModel):
|
|
"""Response with stock level data"""
|
|
stock_levels: Dict[str, float] = Field(..., description="Ingredient ID to stock level mapping")
|
|
found_count: int = Field(..., description="Number of stock levels found")
|
|
|
|
|
|
@router.post(
|
|
route_builder.build_operations_route("stock-levels/batch"),
|
|
response_model=BatchStockLevelsResponse
|
|
)
|
|
async def get_stock_levels_batch(
|
|
request: BatchStockLevelsRequest,
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Fetch stock levels for multiple ingredients in a single request.
|
|
|
|
Optimized endpoint for Orchestrator to quickly check inventory levels
|
|
without making individual API calls per ingredient.
|
|
"""
|
|
try:
|
|
if not request.ingredient_ids:
|
|
return BatchStockLevelsResponse(
|
|
stock_levels={},
|
|
found_count=0
|
|
)
|
|
|
|
service = InventoryService()
|
|
stock_levels = {}
|
|
|
|
for ingredient_id in request.ingredient_ids:
|
|
try:
|
|
ingredient = await service.get_ingredient_by_id(ingredient_id, tenant_id, db)
|
|
if ingredient:
|
|
stock_levels[str(ingredient_id)] = float(ingredient.current_stock) if ingredient.current_stock else 0.0
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Failed to fetch stock level in batch",
|
|
ingredient_id=str(ingredient_id),
|
|
error=str(e)
|
|
)
|
|
continue
|
|
|
|
logger.info(
|
|
"Batch stock level fetch complete",
|
|
requested=len(request.ingredient_ids),
|
|
found=len(stock_levels),
|
|
tenant_id=str(tenant_id)
|
|
)
|
|
|
|
return BatchStockLevelsResponse(
|
|
stock_levels=stock_levels,
|
|
found_count=len(stock_levels)
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
"Batch stock level fetch failed",
|
|
error=str(e),
|
|
tenant_id=str(tenant_id)
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Batch stock level fetch failed: {str(e)}"
|
|
)
|