Create new services: inventory, recipes, suppliers
This commit is contained in:
33
services/inventory/Dockerfile
Normal file
33
services/inventory/Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
||||
# services/inventory/Dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY services/inventory/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy shared modules first
|
||||
COPY shared/ /app/shared/
|
||||
|
||||
# Copy application code
|
||||
COPY services/inventory/app/ /app/app/
|
||||
|
||||
# Set Python path to include shared modules
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD python -c "import requests; requests.get('http://localhost:8000/health', timeout=5)" || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
services/inventory/app/__init__.py
Normal file
0
services/inventory/app/__init__.py
Normal file
0
services/inventory/app/api/__init__.py
Normal file
0
services/inventory/app/api/__init__.py
Normal file
231
services/inventory/app/api/classification.py
Normal file
231
services/inventory/app/api/classification.py
Normal file
@@ -0,0 +1,231 @@
|
||||
# services/inventory/app/api/classification.py
|
||||
"""
|
||||
Product Classification API Endpoints
|
||||
AI-powered product classification for onboarding automation
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from typing import List, Dict, Any
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
import structlog
|
||||
|
||||
from app.services.product_classifier import ProductClassifierService, get_product_classifier
|
||||
from shared.auth.decorators import get_current_user_dep, get_current_tenant_id_dep
|
||||
|
||||
router = APIRouter(tags=["classification"])
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
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: int = None
|
||||
requires_refrigeration: bool = False
|
||||
requires_freezing: bool = False
|
||||
is_seasonal: bool = False
|
||||
suggested_supplier: str = None
|
||||
notes: str = None
|
||||
|
||||
|
||||
class BusinessModelAnalysisResponse(BaseModel):
|
||||
"""Response with business model analysis"""
|
||||
model: str # production, retail, hybrid
|
||||
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("/tenants/{tenant_id}/inventory/classify-product", response_model=ProductSuggestionResponse)
|
||||
async def classify_single_product(
|
||||
request: ProductClassificationRequest,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
classifier: ProductClassifierService = Depends(get_product_classifier)
|
||||
):
|
||||
"""Classify a single product for inventory creation"""
|
||||
try:
|
||||
# Verify tenant access
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||
|
||||
# Classify the product
|
||||
suggestion = classifier.classify_product(
|
||||
request.product_name,
|
||||
request.sales_volume
|
||||
)
|
||||
|
||||
# Convert to response format
|
||||
response = ProductSuggestionResponse(
|
||||
suggestion_id=str(UUID.uuid4()), # Generate unique ID for tracking
|
||||
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("/tenants/{tenant_id}/inventory/classify-products-batch", response_model=BatchClassificationResponse)
|
||||
async def classify_products_batch(
|
||||
request: BatchClassificationRequest,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
classifier: ProductClassifierService = Depends(get_product_classifier)
|
||||
):
|
||||
"""Classify multiple products for onboarding automation"""
|
||||
try:
|
||||
# Verify tenant access
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||
|
||||
if not request.products:
|
||||
raise HTTPException(status_code=400, detail="No products provided for classification")
|
||||
|
||||
# Extract product names and volumes
|
||||
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}
|
||||
|
||||
# Classify products in batch
|
||||
suggestions = classifier.classify_products_batch(product_names, sales_volumes)
|
||||
|
||||
# Convert suggestions to response format
|
||||
suggestion_responses = []
|
||||
for suggestion in suggestions:
|
||||
suggestion_responses.append(ProductSuggestionResponse(
|
||||
suggestion_id=str(UUID.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')
|
||||
total = len(suggestions)
|
||||
ingredient_ratio = ingredient_count / total if total > 0 else 0
|
||||
|
||||
# Determine business model
|
||||
if ingredient_ratio >= 0.7:
|
||||
model = 'production'
|
||||
elif ingredient_ratio <= 0.3:
|
||||
model = 'retail'
|
||||
else:
|
||||
model = 'hybrid'
|
||||
|
||||
confidence = max(abs(ingredient_ratio - 0.5) * 2, 0.1)
|
||||
|
||||
recommendations = {
|
||||
'production': [
|
||||
'Focus on ingredient inventory management',
|
||||
'Set up recipe cost calculation',
|
||||
'Configure supplier relationships',
|
||||
'Enable production planning features'
|
||||
],
|
||||
'retail': [
|
||||
'Configure central baker relationships',
|
||||
'Set up delivery schedule tracking',
|
||||
'Enable finished product freshness monitoring',
|
||||
'Focus on sales forecasting'
|
||||
],
|
||||
'hybrid': [
|
||||
'Configure both ingredient and finished product management',
|
||||
'Set up flexible inventory categories',
|
||||
'Enable both production and retail features'
|
||||
]
|
||||
}
|
||||
|
||||
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, [])
|
||||
)
|
||||
|
||||
# Count confidence levels
|
||||
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)}")
|
||||
208
services/inventory/app/api/ingredients.py
Normal file
208
services/inventory/app/api/ingredients.py
Normal file
@@ -0,0 +1,208 @@
|
||||
# services/inventory/app/api/ingredients.py
|
||||
"""
|
||||
API endpoints for ingredient management
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.inventory_service import InventoryService
|
||||
from app.schemas.inventory import (
|
||||
IngredientCreate,
|
||||
IngredientUpdate,
|
||||
IngredientResponse,
|
||||
InventoryFilter,
|
||||
PaginatedResponse
|
||||
)
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.tenant_access import verify_tenant_access_dep
|
||||
|
||||
router = APIRouter(prefix="/ingredients", tags=["ingredients"])
|
||||
|
||||
# Helper function to extract user ID from user object
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User ID not found in context"
|
||||
)
|
||||
return UUID(user_id)
|
||||
|
||||
|
||||
@router.post("/", response_model=IngredientResponse)
|
||||
async def create_ingredient(
|
||||
ingredient_data: IngredientCreate,
|
||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
||||
user_id: UUID = Depends(get_current_user_id),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a new ingredient"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
ingredient = await service.create_ingredient(ingredient_data, tenant_id, user_id)
|
||||
return ingredient
|
||||
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 create ingredient"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{ingredient_id}", response_model=IngredientResponse)
|
||||
async def get_ingredient(
|
||||
ingredient_id: UUID,
|
||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get ingredient by ID"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
ingredient = await service.get_ingredient(ingredient_id, tenant_id)
|
||||
|
||||
if not ingredient:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ingredient not found"
|
||||
)
|
||||
|
||||
return ingredient
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get ingredient"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{ingredient_id}", response_model=IngredientResponse)
|
||||
async def update_ingredient(
|
||||
ingredient_id: UUID,
|
||||
ingredient_data: IngredientUpdate,
|
||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update ingredient"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
ingredient = await service.update_ingredient(ingredient_id, ingredient_data, tenant_id)
|
||||
|
||||
if not ingredient:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ingredient not found"
|
||||
)
|
||||
|
||||
return ingredient
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update ingredient"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[IngredientResponse])
|
||||
async def list_ingredients(
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||
category: Optional[str] = Query(None, description="Filter by category"),
|
||||
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||
is_low_stock: Optional[bool] = Query(None, description="Filter by low stock status"),
|
||||
needs_reorder: Optional[bool] = Query(None, description="Filter by reorder needed"),
|
||||
search: Optional[str] = Query(None, description="Search in name, SKU, or barcode"),
|
||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""List ingredients with filtering"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
|
||||
# Build filters
|
||||
filters = {}
|
||||
if category:
|
||||
filters['category'] = category
|
||||
if is_active is not None:
|
||||
filters['is_active'] = is_active
|
||||
if is_low_stock is not None:
|
||||
filters['is_low_stock'] = is_low_stock
|
||||
if needs_reorder is not None:
|
||||
filters['needs_reorder'] = needs_reorder
|
||||
if search:
|
||||
filters['search'] = search
|
||||
|
||||
ingredients = await service.get_ingredients(tenant_id, skip, limit, filters)
|
||||
return ingredients
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to list ingredients"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{ingredient_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_ingredient(
|
||||
ingredient_id: UUID,
|
||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Soft delete ingredient (mark as inactive)"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
ingredient = await service.update_ingredient(
|
||||
ingredient_id,
|
||||
{"is_active": False},
|
||||
tenant_id
|
||||
)
|
||||
|
||||
if not ingredient:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ingredient not found"
|
||||
)
|
||||
|
||||
return None
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete ingredient"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{ingredient_id}/stock", response_model=List[dict])
|
||||
async def get_ingredient_stock(
|
||||
ingredient_id: UUID,
|
||||
include_unavailable: bool = Query(False, description="Include unavailable stock"),
|
||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock entries for an ingredient"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
stock_entries = await service.get_stock_by_ingredient(
|
||||
ingredient_id, tenant_id, include_unavailable
|
||||
)
|
||||
return stock_entries
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get ingredient stock"
|
||||
)
|
||||
167
services/inventory/app/api/stock.py
Normal file
167
services/inventory/app/api/stock.py
Normal file
@@ -0,0 +1,167 @@
|
||||
# services/inventory/app/api/stock.py
|
||||
"""
|
||||
API endpoints for stock management
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.inventory_service import InventoryService
|
||||
from app.schemas.inventory import (
|
||||
StockCreate,
|
||||
StockUpdate,
|
||||
StockResponse,
|
||||
StockMovementCreate,
|
||||
StockMovementResponse,
|
||||
StockFilter
|
||||
)
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.tenant_access import verify_tenant_access_dep
|
||||
|
||||
router = APIRouter(prefix="/stock", tags=["stock"])
|
||||
|
||||
# Helper function to extract user ID from user object
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User ID not found in context"
|
||||
)
|
||||
return UUID(user_id)
|
||||
|
||||
|
||||
@router.post("/", response_model=StockResponse)
|
||||
async def add_stock(
|
||||
stock_data: StockCreate,
|
||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
||||
user_id: UUID = Depends(get_current_user_id),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Add new stock entry"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
stock = await service.add_stock(stock_data, tenant_id, user_id)
|
||||
return stock
|
||||
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 add stock"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/consume")
|
||||
async def consume_stock(
|
||||
ingredient_id: UUID,
|
||||
quantity: float = Query(..., gt=0, description="Quantity to consume"),
|
||||
reference_number: Optional[str] = Query(None, description="Reference number (e.g., production order)"),
|
||||
notes: Optional[str] = Query(None, description="Additional notes"),
|
||||
fifo: bool = Query(True, description="Use FIFO (First In, First Out) method"),
|
||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
||||
user_id: UUID = Depends(get_current_user_id),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Consume stock for production"""
|
||||
try:
|
||||
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("/ingredient/{ingredient_id}", response_model=List[StockResponse])
|
||||
async def get_ingredient_stock(
|
||||
ingredient_id: UUID,
|
||||
include_unavailable: bool = Query(False, description="Include unavailable stock"),
|
||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock entries for an ingredient"""
|
||||
try:
|
||||
service = InventoryService()
|
||||
stock_entries = await service.get_stock_by_ingredient(
|
||||
ingredient_id, tenant_id, include_unavailable
|
||||
)
|
||||
return stock_entries
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get ingredient stock"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/expiring", response_model=List[dict])
|
||||
async def get_expiring_stock(
|
||||
days_ahead: int = Query(7, ge=1, le=365, description="Days ahead to check for expiring items"),
|
||||
tenant_id: UUID = Depends(verify_tenant_access_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("/low-stock", response_model=List[dict])
|
||||
async def get_low_stock(
|
||||
tenant_id: UUID = Depends(verify_tenant_access_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("/summary", response_model=dict)
|
||||
async def get_stock_summary(
|
||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock summary for dashboard"""
|
||||
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"
|
||||
)
|
||||
0
services/inventory/app/core/__init__.py
Normal file
0
services/inventory/app/core/__init__.py
Normal file
67
services/inventory/app/core/config.py
Normal file
67
services/inventory/app/core/config.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# services/inventory/app/core/config.py
|
||||
"""
|
||||
Inventory Service Configuration
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
from pydantic import Field
|
||||
from shared.config.base import BaseServiceSettings
|
||||
|
||||
|
||||
class Settings(BaseServiceSettings):
|
||||
"""Inventory service settings extending base configuration"""
|
||||
|
||||
# Override service-specific settings
|
||||
SERVICE_NAME: str = "inventory-service"
|
||||
VERSION: str = "1.0.0"
|
||||
APP_NAME: str = "Bakery Inventory Service"
|
||||
DESCRIPTION: str = "Inventory and stock management service"
|
||||
|
||||
# API Configuration
|
||||
API_V1_STR: str = "/api/v1"
|
||||
|
||||
# Override database URL to use INVENTORY_DATABASE_URL
|
||||
DATABASE_URL: str = Field(
|
||||
default="postgresql+asyncpg://inventory_user:inventory_pass123@inventory-db:5432/inventory_db",
|
||||
env="INVENTORY_DATABASE_URL"
|
||||
)
|
||||
|
||||
# Inventory-specific Redis database
|
||||
REDIS_DB: int = Field(default=3, env="INVENTORY_REDIS_DB")
|
||||
|
||||
# File upload configuration
|
||||
MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024 # 10MB
|
||||
UPLOAD_PATH: str = Field(default="/tmp/uploads", env="INVENTORY_UPLOAD_PATH")
|
||||
ALLOWED_FILE_EXTENSIONS: List[str] = [".csv", ".xlsx", ".xls", ".png", ".jpg", ".jpeg"]
|
||||
|
||||
# Pagination
|
||||
DEFAULT_PAGE_SIZE: int = 50
|
||||
MAX_PAGE_SIZE: int = 1000
|
||||
|
||||
# Stock validation
|
||||
MIN_QUANTITY: float = 0.0
|
||||
MAX_QUANTITY: float = 100000.0
|
||||
MIN_PRICE: float = 0.01
|
||||
MAX_PRICE: float = 10000.0
|
||||
|
||||
# Inventory-specific cache TTL
|
||||
INVENTORY_CACHE_TTL: int = 180 # 3 minutes for real-time stock
|
||||
INGREDIENT_CACHE_TTL: int = 600 # 10 minutes
|
||||
SUPPLIER_CACHE_TTL: int = 1800 # 30 minutes
|
||||
|
||||
# Low stock thresholds
|
||||
DEFAULT_LOW_STOCK_THRESHOLD: int = 10
|
||||
DEFAULT_REORDER_POINT: int = 20
|
||||
DEFAULT_REORDER_QUANTITY: int = 50
|
||||
|
||||
# Expiration alert thresholds (in days)
|
||||
EXPIRING_SOON_DAYS: int = 7
|
||||
EXPIRED_ALERT_DAYS: int = 1
|
||||
|
||||
# Barcode/QR configuration
|
||||
BARCODE_FORMAT: str = "Code128"
|
||||
QR_CODE_VERSION: int = 1
|
||||
|
||||
|
||||
# Global settings instance
|
||||
settings = Settings()
|
||||
86
services/inventory/app/core/database.py
Normal file
86
services/inventory/app/core/database.py
Normal file
@@ -0,0 +1,86 @@
|
||||
# services/inventory/app/core/database.py
|
||||
"""
|
||||
Inventory Service Database Configuration using shared database manager
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from app.core.config import settings
|
||||
from shared.database.base import DatabaseManager, Base
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Create database manager instance
|
||||
database_manager = DatabaseManager(
|
||||
database_url=settings.DATABASE_URL,
|
||||
service_name="inventory-service",
|
||||
pool_size=settings.DB_POOL_SIZE,
|
||||
max_overflow=settings.DB_MAX_OVERFLOW,
|
||||
pool_recycle=settings.DB_POOL_RECYCLE,
|
||||
echo=settings.DB_ECHO
|
||||
)
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""
|
||||
Database dependency for FastAPI - using shared database manager
|
||||
"""
|
||||
async for session in database_manager.get_db():
|
||||
yield session
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database tables using shared database manager"""
|
||||
try:
|
||||
logger.info("Initializing Inventory Service database...")
|
||||
|
||||
# Import all models to ensure they're registered
|
||||
from app.models import inventory # noqa: F401
|
||||
|
||||
# Create all tables using database manager
|
||||
await database_manager.create_tables(Base.metadata)
|
||||
|
||||
logger.info("Inventory Service database initialized successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize database", error=str(e))
|
||||
raise
|
||||
|
||||
|
||||
async def close_db():
|
||||
"""Close database connections using shared database manager"""
|
||||
try:
|
||||
await database_manager.close_connections()
|
||||
logger.info("Database connections closed")
|
||||
except Exception as e:
|
||||
logger.error("Error closing database connections", error=str(e))
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_db_transaction():
|
||||
"""
|
||||
Context manager for database transactions using shared database manager
|
||||
"""
|
||||
async with database_manager.get_session() as session:
|
||||
try:
|
||||
async with session.begin():
|
||||
yield session
|
||||
except Exception as e:
|
||||
logger.error("Transaction error", error=str(e))
|
||||
raise
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_background_session():
|
||||
"""
|
||||
Context manager for background tasks using shared database manager
|
||||
"""
|
||||
async with database_manager.get_background_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def health_check():
|
||||
"""Database health check using shared database manager"""
|
||||
return await database_manager.health_check()
|
||||
167
services/inventory/app/main.py
Normal file
167
services/inventory/app/main.py
Normal file
@@ -0,0 +1,167 @@
|
||||
# services/inventory/app/main.py
|
||||
"""
|
||||
Inventory Service FastAPI Application
|
||||
"""
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
import structlog
|
||||
|
||||
# Import core modules
|
||||
from app.core.config import settings
|
||||
from app.core.database import init_db, close_db
|
||||
from app.api import ingredients, stock, classification
|
||||
from shared.monitoring.health import router as health_router
|
||||
from shared.monitoring.metrics import setup_metrics_early
|
||||
# Auth decorators are used in endpoints, no global setup needed
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan management"""
|
||||
# Startup
|
||||
logger.info("Starting Inventory Service", version=settings.VERSION)
|
||||
|
||||
try:
|
||||
# Initialize database
|
||||
await init_db()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
# Setup metrics is already done early - no need to do it here
|
||||
logger.info("Metrics setup completed")
|
||||
|
||||
yield
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Startup failed", error=str(e))
|
||||
raise
|
||||
finally:
|
||||
# Shutdown
|
||||
logger.info("Shutting down Inventory Service")
|
||||
try:
|
||||
await close_db()
|
||||
logger.info("Database connections closed")
|
||||
except Exception as e:
|
||||
logger.error("Shutdown error", error=str(e))
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
description=settings.DESCRIPTION,
|
||||
version=settings.VERSION,
|
||||
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
||||
docs_url=f"{settings.API_V1_STR}/docs",
|
||||
redoc_url=f"{settings.API_V1_STR}/redoc",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Setup metrics BEFORE any middleware and BEFORE lifespan
|
||||
metrics_collector = setup_metrics_early(app, "inventory-service")
|
||||
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# Auth is handled via decorators in individual endpoints
|
||||
|
||||
|
||||
# Exception handlers
|
||||
@app.exception_handler(ValueError)
|
||||
async def value_error_handler(request: Request, exc: ValueError):
|
||||
"""Handle validation errors"""
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "Validation Error",
|
||||
"detail": str(exc),
|
||||
"type": "value_error"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
"""Handle general exceptions"""
|
||||
logger.error(
|
||||
"Unhandled exception",
|
||||
error=str(exc),
|
||||
path=request.url.path,
|
||||
method=request.method
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "Internal Server Error",
|
||||
"detail": "An unexpected error occurred",
|
||||
"type": "internal_error"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Include routers
|
||||
app.include_router(health_router, prefix="/health", tags=["health"])
|
||||
app.include_router(ingredients.router, prefix=settings.API_V1_STR)
|
||||
app.include_router(stock.router, prefix=settings.API_V1_STR)
|
||||
app.include_router(classification.router, prefix=settings.API_V1_STR)
|
||||
|
||||
|
||||
# Root endpoint
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint with service information"""
|
||||
return {
|
||||
"service": settings.SERVICE_NAME,
|
||||
"version": settings.VERSION,
|
||||
"description": settings.DESCRIPTION,
|
||||
"status": "running",
|
||||
"docs_url": f"{settings.API_V1_STR}/docs",
|
||||
"health_url": "/health"
|
||||
}
|
||||
|
||||
|
||||
# Service info endpoint
|
||||
@app.get(f"{settings.API_V1_STR}/info")
|
||||
async def service_info():
|
||||
"""Service information endpoint"""
|
||||
return {
|
||||
"service": settings.SERVICE_NAME,
|
||||
"version": settings.VERSION,
|
||||
"description": settings.DESCRIPTION,
|
||||
"api_version": "v1",
|
||||
"environment": settings.ENVIRONMENT,
|
||||
"features": [
|
||||
"ingredient_management",
|
||||
"stock_tracking",
|
||||
"expiration_alerts",
|
||||
"low_stock_alerts",
|
||||
"batch_tracking",
|
||||
"fifo_consumption",
|
||||
"barcode_support"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=os.getenv("RELOAD", "false").lower() == "true",
|
||||
log_level="info"
|
||||
)
|
||||
0
services/inventory/app/models/__init__.py
Normal file
0
services/inventory/app/models/__init__.py
Normal file
428
services/inventory/app/models/inventory.py
Normal file
428
services/inventory/app/models/inventory.py
Normal file
@@ -0,0 +1,428 @@
|
||||
# services/inventory/app/models/inventory.py
|
||||
"""
|
||||
Inventory management models for Inventory Service
|
||||
Comprehensive inventory tracking, ingredient management, and supplier integration
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, Float, Integer, Text, Index, Boolean, Numeric, ForeignKey, Enum as SQLEnum
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
import uuid
|
||||
import enum
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from shared.database.base import Base
|
||||
|
||||
|
||||
class UnitOfMeasure(enum.Enum):
|
||||
"""Standard units of measure for ingredients"""
|
||||
KILOGRAMS = "kg"
|
||||
GRAMS = "g"
|
||||
LITERS = "l"
|
||||
MILLILITERS = "ml"
|
||||
UNITS = "units"
|
||||
PIECES = "pcs"
|
||||
PACKAGES = "pkg"
|
||||
BAGS = "bags"
|
||||
BOXES = "boxes"
|
||||
|
||||
|
||||
class IngredientCategory(enum.Enum):
|
||||
"""Bakery ingredient categories"""
|
||||
FLOUR = "flour"
|
||||
YEAST = "yeast"
|
||||
DAIRY = "dairy"
|
||||
EGGS = "eggs"
|
||||
SUGAR = "sugar"
|
||||
FATS = "fats"
|
||||
SALT = "salt"
|
||||
SPICES = "spices"
|
||||
ADDITIVES = "additives"
|
||||
PACKAGING = "packaging"
|
||||
CLEANING = "cleaning"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class ProductCategory(enum.Enum):
|
||||
"""Finished bakery product categories for retail/distribution model"""
|
||||
BREAD = "bread"
|
||||
CROISSANTS = "croissants"
|
||||
PASTRIES = "pastries"
|
||||
CAKES = "cakes"
|
||||
COOKIES = "cookies"
|
||||
MUFFINS = "muffins"
|
||||
SANDWICHES = "sandwiches"
|
||||
SEASONAL = "seasonal"
|
||||
BEVERAGES = "beverages"
|
||||
OTHER_PRODUCTS = "other_products"
|
||||
|
||||
|
||||
class ProductType(enum.Enum):
|
||||
"""Type of product in inventory"""
|
||||
INGREDIENT = "ingredient" # Raw materials (flour, yeast, etc.)
|
||||
FINISHED_PRODUCT = "finished_product" # Ready-to-sell items (bread, croissants, etc.)
|
||||
|
||||
|
||||
class StockMovementType(enum.Enum):
|
||||
"""Types of inventory movements"""
|
||||
PURCHASE = "purchase"
|
||||
PRODUCTION_USE = "production_use"
|
||||
ADJUSTMENT = "adjustment"
|
||||
WASTE = "waste"
|
||||
TRANSFER = "transfer"
|
||||
RETURN = "return"
|
||||
INITIAL_STOCK = "initial_stock"
|
||||
|
||||
|
||||
class Ingredient(Base):
|
||||
"""Master catalog for ingredients and finished products"""
|
||||
__tablename__ = "ingredients"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
|
||||
# Product identification
|
||||
name = Column(String(255), nullable=False, index=True)
|
||||
sku = Column(String(100), nullable=True, index=True)
|
||||
barcode = Column(String(50), nullable=True, index=True)
|
||||
|
||||
# Product type and categories
|
||||
product_type = Column(SQLEnum(ProductType), nullable=False, default=ProductType.INGREDIENT, index=True)
|
||||
ingredient_category = Column(SQLEnum(IngredientCategory), nullable=True, index=True) # For ingredients
|
||||
product_category = Column(SQLEnum(ProductCategory), nullable=True, index=True) # For finished products
|
||||
subcategory = Column(String(100), nullable=True)
|
||||
|
||||
# Product details
|
||||
description = Column(Text, nullable=True)
|
||||
brand = Column(String(100), nullable=True) # Brand or central baker name
|
||||
supplier_name = Column(String(200), nullable=True) # Central baker or distributor
|
||||
unit_of_measure = Column(SQLEnum(UnitOfMeasure), nullable=False)
|
||||
package_size = Column(Float, nullable=True) # Size per package/unit
|
||||
|
||||
# Pricing and costs
|
||||
average_cost = Column(Numeric(10, 2), nullable=True)
|
||||
last_purchase_price = Column(Numeric(10, 2), nullable=True)
|
||||
standard_cost = Column(Numeric(10, 2), nullable=True)
|
||||
|
||||
# Stock management
|
||||
low_stock_threshold = Column(Float, nullable=False, default=10.0)
|
||||
reorder_point = Column(Float, nullable=False, default=20.0)
|
||||
reorder_quantity = Column(Float, nullable=False, default=50.0)
|
||||
max_stock_level = Column(Float, nullable=True)
|
||||
|
||||
# Storage requirements (applies to both ingredients and finished products)
|
||||
requires_refrigeration = Column(Boolean, default=False)
|
||||
requires_freezing = Column(Boolean, default=False)
|
||||
storage_temperature_min = Column(Float, nullable=True) # Celsius
|
||||
storage_temperature_max = Column(Float, nullable=True) # Celsius
|
||||
storage_humidity_max = Column(Float, nullable=True) # Percentage
|
||||
|
||||
# Shelf life (critical for finished products)
|
||||
shelf_life_days = Column(Integer, nullable=True)
|
||||
display_life_hours = Column(Integer, nullable=True) # How long can be displayed (for fresh products)
|
||||
best_before_hours = Column(Integer, nullable=True) # Hours until best before (for same-day products)
|
||||
storage_instructions = Column(Text, nullable=True)
|
||||
|
||||
# Finished product specific fields
|
||||
central_baker_product_code = Column(String(100), nullable=True) # Central baker's product code
|
||||
delivery_days = Column(String(20), nullable=True) # Days of week delivered (e.g., "Mon,Wed,Fri")
|
||||
minimum_order_quantity = Column(Float, nullable=True) # Minimum order from central baker
|
||||
pack_size = Column(Integer, nullable=True) # How many pieces per pack
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_perishable = Column(Boolean, default=False)
|
||||
allergen_info = Column(JSONB, nullable=True) # JSON array of allergens
|
||||
nutritional_info = Column(JSONB, nullable=True) # Nutritional information for finished products
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
created_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
stock_items = relationship("Stock", back_populates="ingredient", cascade="all, delete-orphan")
|
||||
movement_items = relationship("StockMovement", back_populates="ingredient", cascade="all, delete-orphan")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_ingredients_tenant_name', 'tenant_id', 'name', unique=True),
|
||||
Index('idx_ingredients_tenant_sku', 'tenant_id', 'sku'),
|
||||
Index('idx_ingredients_barcode', 'barcode'),
|
||||
Index('idx_ingredients_product_type', 'tenant_id', 'product_type', 'is_active'),
|
||||
Index('idx_ingredients_ingredient_category', 'tenant_id', 'ingredient_category', 'is_active'),
|
||||
Index('idx_ingredients_product_category', 'tenant_id', 'product_category', 'is_active'),
|
||||
Index('idx_ingredients_stock_levels', 'tenant_id', 'low_stock_threshold', 'reorder_point'),
|
||||
Index('idx_ingredients_central_baker', 'tenant_id', 'supplier_name', 'product_type'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert model to dictionary for API responses"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'tenant_id': str(self.tenant_id),
|
||||
'name': self.name,
|
||||
'sku': self.sku,
|
||||
'barcode': self.barcode,
|
||||
'product_type': self.product_type.value if self.product_type else None,
|
||||
'ingredient_category': self.ingredient_category.value if self.ingredient_category else None,
|
||||
'product_category': self.product_category.value if self.product_category else None,
|
||||
'subcategory': self.subcategory,
|
||||
'description': self.description,
|
||||
'brand': self.brand,
|
||||
'supplier_name': self.supplier_name,
|
||||
'unit_of_measure': self.unit_of_measure.value if self.unit_of_measure else None,
|
||||
'package_size': self.package_size,
|
||||
'average_cost': float(self.average_cost) if self.average_cost else None,
|
||||
'last_purchase_price': float(self.last_purchase_price) if self.last_purchase_price else None,
|
||||
'standard_cost': float(self.standard_cost) if self.standard_cost else None,
|
||||
'low_stock_threshold': self.low_stock_threshold,
|
||||
'reorder_point': self.reorder_point,
|
||||
'reorder_quantity': self.reorder_quantity,
|
||||
'max_stock_level': self.max_stock_level,
|
||||
'requires_refrigeration': self.requires_refrigeration,
|
||||
'requires_freezing': self.requires_freezing,
|
||||
'storage_temperature_min': self.storage_temperature_min,
|
||||
'storage_temperature_max': self.storage_temperature_max,
|
||||
'storage_humidity_max': self.storage_humidity_max,
|
||||
'shelf_life_days': self.shelf_life_days,
|
||||
'display_life_hours': self.display_life_hours,
|
||||
'best_before_hours': self.best_before_hours,
|
||||
'storage_instructions': self.storage_instructions,
|
||||
'central_baker_product_code': self.central_baker_product_code,
|
||||
'delivery_days': self.delivery_days,
|
||||
'minimum_order_quantity': self.minimum_order_quantity,
|
||||
'pack_size': self.pack_size,
|
||||
'is_active': self.is_active,
|
||||
'is_perishable': self.is_perishable,
|
||||
'allergen_info': self.allergen_info,
|
||||
'nutritional_info': self.nutritional_info,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'created_by': str(self.created_by) if self.created_by else None,
|
||||
}
|
||||
|
||||
|
||||
class Stock(Base):
|
||||
"""Current stock levels and batch tracking"""
|
||||
__tablename__ = "stock"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
ingredient_id = Column(UUID(as_uuid=True), ForeignKey('ingredients.id'), nullable=False, index=True)
|
||||
|
||||
# Stock identification
|
||||
batch_number = Column(String(100), nullable=True, index=True)
|
||||
lot_number = Column(String(100), nullable=True, index=True)
|
||||
supplier_batch_ref = Column(String(100), nullable=True)
|
||||
|
||||
# Quantities
|
||||
current_quantity = Column(Float, nullable=False, default=0.0)
|
||||
reserved_quantity = Column(Float, nullable=False, default=0.0) # Reserved for production
|
||||
available_quantity = Column(Float, nullable=False, default=0.0) # current - reserved
|
||||
|
||||
# Dates
|
||||
received_date = Column(DateTime(timezone=True), nullable=True)
|
||||
expiration_date = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||
best_before_date = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Cost tracking
|
||||
unit_cost = Column(Numeric(10, 2), nullable=True)
|
||||
total_cost = Column(Numeric(10, 2), nullable=True)
|
||||
|
||||
# Location
|
||||
storage_location = Column(String(100), nullable=True)
|
||||
warehouse_zone = Column(String(50), nullable=True)
|
||||
shelf_position = Column(String(50), nullable=True)
|
||||
|
||||
# Status
|
||||
is_available = Column(Boolean, default=True)
|
||||
is_expired = Column(Boolean, default=False, index=True)
|
||||
quality_status = Column(String(20), default="good") # good, damaged, expired, quarantined
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Relationships
|
||||
ingredient = relationship("Ingredient", back_populates="stock_items")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_stock_tenant_ingredient', 'tenant_id', 'ingredient_id'),
|
||||
Index('idx_stock_expiration', 'tenant_id', 'expiration_date', 'is_available'),
|
||||
Index('idx_stock_batch', 'tenant_id', 'batch_number'),
|
||||
Index('idx_stock_low_levels', 'tenant_id', 'current_quantity', 'is_available'),
|
||||
Index('idx_stock_quality', 'tenant_id', 'quality_status', 'is_available'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert model to dictionary for API responses"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'tenant_id': str(self.tenant_id),
|
||||
'ingredient_id': str(self.ingredient_id),
|
||||
'batch_number': self.batch_number,
|
||||
'lot_number': self.lot_number,
|
||||
'supplier_batch_ref': self.supplier_batch_ref,
|
||||
'current_quantity': self.current_quantity,
|
||||
'reserved_quantity': self.reserved_quantity,
|
||||
'available_quantity': self.available_quantity,
|
||||
'received_date': self.received_date.isoformat() if self.received_date else None,
|
||||
'expiration_date': self.expiration_date.isoformat() if self.expiration_date else None,
|
||||
'best_before_date': self.best_before_date.isoformat() if self.best_before_date else None,
|
||||
'unit_cost': float(self.unit_cost) if self.unit_cost else None,
|
||||
'total_cost': float(self.total_cost) if self.total_cost else None,
|
||||
'storage_location': self.storage_location,
|
||||
'warehouse_zone': self.warehouse_zone,
|
||||
'shelf_position': self.shelf_position,
|
||||
'is_available': self.is_available,
|
||||
'is_expired': self.is_expired,
|
||||
'quality_status': self.quality_status,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class StockMovement(Base):
|
||||
"""Track all stock movements for audit trail"""
|
||||
__tablename__ = "stock_movements"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
ingredient_id = Column(UUID(as_uuid=True), ForeignKey('ingredients.id'), nullable=False, index=True)
|
||||
stock_id = Column(UUID(as_uuid=True), ForeignKey('stock.id'), nullable=True, index=True)
|
||||
|
||||
# Movement details
|
||||
movement_type = Column(SQLEnum(StockMovementType), nullable=False, index=True)
|
||||
quantity = Column(Float, nullable=False)
|
||||
unit_cost = Column(Numeric(10, 2), nullable=True)
|
||||
total_cost = Column(Numeric(10, 2), nullable=True)
|
||||
|
||||
# Balance tracking
|
||||
quantity_before = Column(Float, nullable=True)
|
||||
quantity_after = Column(Float, nullable=True)
|
||||
|
||||
# References
|
||||
reference_number = Column(String(100), nullable=True, index=True) # PO number, production order, etc.
|
||||
supplier_id = Column(UUID(as_uuid=True), nullable=True, index=True)
|
||||
|
||||
# Additional details
|
||||
notes = Column(Text, nullable=True)
|
||||
reason_code = Column(String(50), nullable=True) # spoilage, damage, theft, etc.
|
||||
|
||||
# Timestamp
|
||||
movement_date = Column(DateTime(timezone=True), nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc), index=True)
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
created_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
ingredient = relationship("Ingredient", back_populates="movement_items")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_movements_tenant_date', 'tenant_id', 'movement_date'),
|
||||
Index('idx_movements_tenant_ingredient', 'tenant_id', 'ingredient_id', 'movement_date'),
|
||||
Index('idx_movements_type', 'tenant_id', 'movement_type', 'movement_date'),
|
||||
Index('idx_movements_reference', 'reference_number'),
|
||||
Index('idx_movements_supplier', 'supplier_id', 'movement_date'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert model to dictionary for API responses"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'tenant_id': str(self.tenant_id),
|
||||
'ingredient_id': str(self.ingredient_id),
|
||||
'stock_id': str(self.stock_id) if self.stock_id else None,
|
||||
'movement_type': self.movement_type.value if self.movement_type else None,
|
||||
'quantity': self.quantity,
|
||||
'unit_cost': float(self.unit_cost) if self.unit_cost else None,
|
||||
'total_cost': float(self.total_cost) if self.total_cost else None,
|
||||
'quantity_before': self.quantity_before,
|
||||
'quantity_after': self.quantity_after,
|
||||
'reference_number': self.reference_number,
|
||||
'supplier_id': str(self.supplier_id) if self.supplier_id else None,
|
||||
'notes': self.notes,
|
||||
'reason_code': self.reason_code,
|
||||
'movement_date': self.movement_date.isoformat() if self.movement_date else None,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'created_by': str(self.created_by) if self.created_by else None,
|
||||
}
|
||||
|
||||
|
||||
class StockAlert(Base):
|
||||
"""Automated stock alerts for low stock, expiration, etc."""
|
||||
__tablename__ = "stock_alerts"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
ingredient_id = Column(UUID(as_uuid=True), ForeignKey('ingredients.id'), nullable=False, index=True)
|
||||
stock_id = Column(UUID(as_uuid=True), ForeignKey('stock.id'), nullable=True, index=True)
|
||||
|
||||
# Alert details
|
||||
alert_type = Column(String(50), nullable=False, index=True) # low_stock, expiring_soon, expired, reorder
|
||||
severity = Column(String(20), nullable=False, default="medium") # low, medium, high, critical
|
||||
title = Column(String(255), nullable=False)
|
||||
message = Column(Text, nullable=False)
|
||||
|
||||
# Alert data
|
||||
current_quantity = Column(Float, nullable=True)
|
||||
threshold_value = Column(Float, nullable=True)
|
||||
expiration_date = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_acknowledged = Column(Boolean, default=False)
|
||||
acknowledged_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
acknowledged_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Resolution
|
||||
is_resolved = Column(Boolean, default=False)
|
||||
resolved_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
resolved_at = Column(DateTime(timezone=True), nullable=True)
|
||||
resolution_notes = Column(Text, nullable=True)
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_alerts_tenant_active', 'tenant_id', 'is_active', 'created_at'),
|
||||
Index('idx_alerts_type_severity', 'alert_type', 'severity', 'is_active'),
|
||||
Index('idx_alerts_ingredient', 'ingredient_id', 'is_active'),
|
||||
Index('idx_alerts_unresolved', 'tenant_id', 'is_resolved', 'is_active'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert model to dictionary for API responses"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'tenant_id': str(self.tenant_id),
|
||||
'ingredient_id': str(self.ingredient_id),
|
||||
'stock_id': str(self.stock_id) if self.stock_id else None,
|
||||
'alert_type': self.alert_type,
|
||||
'severity': self.severity,
|
||||
'title': self.title,
|
||||
'message': self.message,
|
||||
'current_quantity': self.current_quantity,
|
||||
'threshold_value': self.threshold_value,
|
||||
'expiration_date': self.expiration_date.isoformat() if self.expiration_date else None,
|
||||
'is_active': self.is_active,
|
||||
'is_acknowledged': self.is_acknowledged,
|
||||
'acknowledged_by': str(self.acknowledged_by) if self.acknowledged_by else None,
|
||||
'acknowledged_at': self.acknowledged_at.isoformat() if self.acknowledged_at else None,
|
||||
'is_resolved': self.is_resolved,
|
||||
'resolved_by': str(self.resolved_by) if self.resolved_by else None,
|
||||
'resolved_at': self.resolved_at.isoformat() if self.resolved_at else None,
|
||||
'resolution_notes': self.resolution_notes,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
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
|
||||
0
services/inventory/app/schemas/__init__.py
Normal file
0
services/inventory/app/schemas/__init__.py
Normal file
390
services/inventory/app/schemas/inventory.py
Normal file
390
services/inventory/app/schemas/inventory.py
Normal file
@@ -0,0 +1,390 @@
|
||||
# services/inventory/app/schemas/inventory.py
|
||||
"""
|
||||
Pydantic schemas for inventory API requests and responses
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import Generic, TypeVar
|
||||
from enum import Enum
|
||||
|
||||
from app.models.inventory import UnitOfMeasure, IngredientCategory, StockMovementType
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
# ===== BASE SCHEMAS =====
|
||||
|
||||
class InventoryBaseSchema(BaseModel):
|
||||
"""Base schema for inventory models"""
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
use_enum_values = True
|
||||
json_encoders = {
|
||||
datetime: lambda v: v.isoformat() if v else None,
|
||||
Decimal: lambda v: float(v) if v else None
|
||||
}
|
||||
|
||||
|
||||
# ===== INGREDIENT SCHEMAS =====
|
||||
|
||||
class IngredientCreate(InventoryBaseSchema):
|
||||
"""Schema for creating ingredients"""
|
||||
name: str = Field(..., max_length=255, description="Ingredient name")
|
||||
sku: Optional[str] = Field(None, max_length=100, description="SKU code")
|
||||
barcode: Optional[str] = Field(None, max_length=50, description="Barcode")
|
||||
category: IngredientCategory = Field(..., description="Ingredient category")
|
||||
subcategory: Optional[str] = Field(None, max_length=100, description="Subcategory")
|
||||
description: Optional[str] = Field(None, description="Ingredient description")
|
||||
brand: Optional[str] = Field(None, max_length=100, description="Brand name")
|
||||
unit_of_measure: UnitOfMeasure = Field(..., description="Unit of measure")
|
||||
package_size: Optional[float] = Field(None, gt=0, description="Package size")
|
||||
|
||||
# Pricing
|
||||
average_cost: Optional[Decimal] = Field(None, ge=0, description="Average cost per unit")
|
||||
standard_cost: Optional[Decimal] = Field(None, ge=0, description="Standard cost per unit")
|
||||
|
||||
# Stock management
|
||||
low_stock_threshold: float = Field(10.0, ge=0, description="Low stock alert threshold")
|
||||
reorder_point: float = Field(20.0, ge=0, description="Reorder point")
|
||||
reorder_quantity: float = Field(50.0, gt=0, description="Default reorder quantity")
|
||||
max_stock_level: Optional[float] = Field(None, gt=0, description="Maximum stock level")
|
||||
|
||||
# Storage requirements
|
||||
requires_refrigeration: bool = Field(False, description="Requires refrigeration")
|
||||
requires_freezing: bool = Field(False, description="Requires freezing")
|
||||
storage_temperature_min: Optional[float] = Field(None, description="Min storage temperature (°C)")
|
||||
storage_temperature_max: Optional[float] = Field(None, description="Max storage temperature (°C)")
|
||||
storage_humidity_max: Optional[float] = Field(None, ge=0, le=100, description="Max humidity (%)")
|
||||
|
||||
# Shelf life
|
||||
shelf_life_days: Optional[int] = Field(None, gt=0, description="Shelf life in days")
|
||||
storage_instructions: Optional[str] = Field(None, description="Storage instructions")
|
||||
|
||||
# Properties
|
||||
is_perishable: bool = Field(False, description="Is perishable")
|
||||
allergen_info: Optional[Dict[str, Any]] = Field(None, description="Allergen information")
|
||||
|
||||
@validator('storage_temperature_max')
|
||||
def validate_temperature_range(cls, v, values):
|
||||
if v is not None and 'storage_temperature_min' in values and values['storage_temperature_min'] is not None:
|
||||
if v <= values['storage_temperature_min']:
|
||||
raise ValueError('Max temperature must be greater than min temperature')
|
||||
return v
|
||||
|
||||
@validator('reorder_point')
|
||||
def validate_reorder_point(cls, v, values):
|
||||
if 'low_stock_threshold' in values and v <= values['low_stock_threshold']:
|
||||
raise ValueError('Reorder point must be greater than low stock threshold')
|
||||
return v
|
||||
|
||||
|
||||
class IngredientUpdate(InventoryBaseSchema):
|
||||
"""Schema for updating ingredients"""
|
||||
name: Optional[str] = Field(None, max_length=255, description="Ingredient name")
|
||||
sku: Optional[str] = Field(None, max_length=100, description="SKU code")
|
||||
barcode: Optional[str] = Field(None, max_length=50, description="Barcode")
|
||||
category: Optional[IngredientCategory] = Field(None, description="Ingredient category")
|
||||
subcategory: Optional[str] = Field(None, max_length=100, description="Subcategory")
|
||||
description: Optional[str] = Field(None, description="Ingredient description")
|
||||
brand: Optional[str] = Field(None, max_length=100, description="Brand name")
|
||||
unit_of_measure: Optional[UnitOfMeasure] = Field(None, description="Unit of measure")
|
||||
package_size: Optional[float] = Field(None, gt=0, description="Package size")
|
||||
|
||||
# Pricing
|
||||
average_cost: Optional[Decimal] = Field(None, ge=0, description="Average cost per unit")
|
||||
standard_cost: Optional[Decimal] = Field(None, ge=0, description="Standard cost per unit")
|
||||
|
||||
# Stock management
|
||||
low_stock_threshold: Optional[float] = Field(None, ge=0, description="Low stock alert threshold")
|
||||
reorder_point: Optional[float] = Field(None, ge=0, description="Reorder point")
|
||||
reorder_quantity: Optional[float] = Field(None, gt=0, description="Default reorder quantity")
|
||||
max_stock_level: Optional[float] = Field(None, gt=0, description="Maximum stock level")
|
||||
|
||||
# Storage requirements
|
||||
requires_refrigeration: Optional[bool] = Field(None, description="Requires refrigeration")
|
||||
requires_freezing: Optional[bool] = Field(None, description="Requires freezing")
|
||||
storage_temperature_min: Optional[float] = Field(None, description="Min storage temperature (°C)")
|
||||
storage_temperature_max: Optional[float] = Field(None, description="Max storage temperature (°C)")
|
||||
storage_humidity_max: Optional[float] = Field(None, ge=0, le=100, description="Max humidity (%)")
|
||||
|
||||
# Shelf life
|
||||
shelf_life_days: Optional[int] = Field(None, gt=0, description="Shelf life in days")
|
||||
storage_instructions: Optional[str] = Field(None, description="Storage instructions")
|
||||
|
||||
# Properties
|
||||
is_active: Optional[bool] = Field(None, description="Is active")
|
||||
is_perishable: Optional[bool] = Field(None, description="Is perishable")
|
||||
allergen_info: Optional[Dict[str, Any]] = Field(None, description="Allergen information")
|
||||
|
||||
|
||||
class IngredientResponse(InventoryBaseSchema):
|
||||
"""Schema for ingredient API responses"""
|
||||
id: str
|
||||
tenant_id: str
|
||||
name: str
|
||||
sku: Optional[str]
|
||||
barcode: Optional[str]
|
||||
category: IngredientCategory
|
||||
subcategory: Optional[str]
|
||||
description: Optional[str]
|
||||
brand: Optional[str]
|
||||
unit_of_measure: UnitOfMeasure
|
||||
package_size: Optional[float]
|
||||
average_cost: Optional[float]
|
||||
last_purchase_price: Optional[float]
|
||||
standard_cost: Optional[float]
|
||||
low_stock_threshold: float
|
||||
reorder_point: float
|
||||
reorder_quantity: float
|
||||
max_stock_level: Optional[float]
|
||||
requires_refrigeration: bool
|
||||
requires_freezing: bool
|
||||
storage_temperature_min: Optional[float]
|
||||
storage_temperature_max: Optional[float]
|
||||
storage_humidity_max: Optional[float]
|
||||
shelf_life_days: Optional[int]
|
||||
storage_instructions: Optional[str]
|
||||
is_active: bool
|
||||
is_perishable: bool
|
||||
allergen_info: Optional[Dict[str, Any]]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: Optional[str]
|
||||
|
||||
# Computed fields
|
||||
current_stock: Optional[float] = None
|
||||
is_low_stock: Optional[bool] = None
|
||||
needs_reorder: Optional[bool] = None
|
||||
|
||||
|
||||
# ===== STOCK SCHEMAS =====
|
||||
|
||||
class StockCreate(InventoryBaseSchema):
|
||||
"""Schema for creating stock entries"""
|
||||
ingredient_id: str = Field(..., description="Ingredient ID")
|
||||
batch_number: Optional[str] = Field(None, max_length=100, description="Batch number")
|
||||
lot_number: Optional[str] = Field(None, max_length=100, description="Lot number")
|
||||
supplier_batch_ref: Optional[str] = Field(None, max_length=100, description="Supplier batch reference")
|
||||
|
||||
current_quantity: float = Field(..., ge=0, description="Current quantity")
|
||||
received_date: Optional[datetime] = Field(None, description="Date received")
|
||||
expiration_date: Optional[datetime] = Field(None, description="Expiration date")
|
||||
best_before_date: Optional[datetime] = Field(None, description="Best before date")
|
||||
|
||||
unit_cost: Optional[Decimal] = Field(None, ge=0, description="Unit cost")
|
||||
storage_location: Optional[str] = Field(None, max_length=100, description="Storage location")
|
||||
warehouse_zone: Optional[str] = Field(None, max_length=50, description="Warehouse zone")
|
||||
shelf_position: Optional[str] = Field(None, max_length=50, description="Shelf position")
|
||||
|
||||
quality_status: str = Field("good", description="Quality status")
|
||||
|
||||
|
||||
class StockUpdate(InventoryBaseSchema):
|
||||
"""Schema for updating stock entries"""
|
||||
batch_number: Optional[str] = Field(None, max_length=100, description="Batch number")
|
||||
lot_number: Optional[str] = Field(None, max_length=100, description="Lot number")
|
||||
supplier_batch_ref: Optional[str] = Field(None, max_length=100, description="Supplier batch reference")
|
||||
|
||||
current_quantity: Optional[float] = Field(None, ge=0, description="Current quantity")
|
||||
reserved_quantity: Optional[float] = Field(None, ge=0, description="Reserved quantity")
|
||||
received_date: Optional[datetime] = Field(None, description="Date received")
|
||||
expiration_date: Optional[datetime] = Field(None, description="Expiration date")
|
||||
best_before_date: Optional[datetime] = Field(None, description="Best before date")
|
||||
|
||||
unit_cost: Optional[Decimal] = Field(None, ge=0, description="Unit cost")
|
||||
storage_location: Optional[str] = Field(None, max_length=100, description="Storage location")
|
||||
warehouse_zone: Optional[str] = Field(None, max_length=50, description="Warehouse zone")
|
||||
shelf_position: Optional[str] = Field(None, max_length=50, description="Shelf position")
|
||||
|
||||
is_available: Optional[bool] = Field(None, description="Is available")
|
||||
quality_status: Optional[str] = Field(None, description="Quality status")
|
||||
|
||||
|
||||
class StockResponse(InventoryBaseSchema):
|
||||
"""Schema for stock API responses"""
|
||||
id: str
|
||||
tenant_id: str
|
||||
ingredient_id: str
|
||||
batch_number: Optional[str]
|
||||
lot_number: Optional[str]
|
||||
supplier_batch_ref: Optional[str]
|
||||
current_quantity: float
|
||||
reserved_quantity: float
|
||||
available_quantity: float
|
||||
received_date: Optional[datetime]
|
||||
expiration_date: Optional[datetime]
|
||||
best_before_date: Optional[datetime]
|
||||
unit_cost: Optional[float]
|
||||
total_cost: Optional[float]
|
||||
storage_location: Optional[str]
|
||||
warehouse_zone: Optional[str]
|
||||
shelf_position: Optional[str]
|
||||
is_available: bool
|
||||
is_expired: bool
|
||||
quality_status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
# Related data
|
||||
ingredient: Optional[IngredientResponse] = None
|
||||
|
||||
|
||||
# ===== STOCK MOVEMENT SCHEMAS =====
|
||||
|
||||
class StockMovementCreate(InventoryBaseSchema):
|
||||
"""Schema for creating stock movements"""
|
||||
ingredient_id: str = Field(..., description="Ingredient ID")
|
||||
stock_id: Optional[str] = Field(None, description="Stock ID")
|
||||
movement_type: StockMovementType = Field(..., description="Movement type")
|
||||
quantity: float = Field(..., description="Quantity moved")
|
||||
|
||||
unit_cost: Optional[Decimal] = Field(None, ge=0, description="Unit cost")
|
||||
reference_number: Optional[str] = Field(None, max_length=100, description="Reference number")
|
||||
supplier_id: Optional[str] = Field(None, description="Supplier ID")
|
||||
|
||||
notes: Optional[str] = Field(None, description="Movement notes")
|
||||
reason_code: Optional[str] = Field(None, max_length=50, description="Reason code")
|
||||
movement_date: Optional[datetime] = Field(None, description="Movement date")
|
||||
|
||||
|
||||
class StockMovementResponse(InventoryBaseSchema):
|
||||
"""Schema for stock movement API responses"""
|
||||
id: str
|
||||
tenant_id: str
|
||||
ingredient_id: str
|
||||
stock_id: Optional[str]
|
||||
movement_type: StockMovementType
|
||||
quantity: float
|
||||
unit_cost: Optional[float]
|
||||
total_cost: Optional[float]
|
||||
quantity_before: Optional[float]
|
||||
quantity_after: Optional[float]
|
||||
reference_number: Optional[str]
|
||||
supplier_id: Optional[str]
|
||||
notes: Optional[str]
|
||||
reason_code: Optional[str]
|
||||
movement_date: datetime
|
||||
created_at: datetime
|
||||
created_by: Optional[str]
|
||||
|
||||
# Related data
|
||||
ingredient: Optional[IngredientResponse] = None
|
||||
|
||||
|
||||
# ===== ALERT SCHEMAS =====
|
||||
|
||||
class StockAlertResponse(InventoryBaseSchema):
|
||||
"""Schema for stock alert API responses"""
|
||||
id: str
|
||||
tenant_id: str
|
||||
ingredient_id: str
|
||||
stock_id: Optional[str]
|
||||
alert_type: str
|
||||
severity: str
|
||||
title: str
|
||||
message: str
|
||||
current_quantity: Optional[float]
|
||||
threshold_value: Optional[float]
|
||||
expiration_date: Optional[datetime]
|
||||
is_active: bool
|
||||
is_acknowledged: bool
|
||||
acknowledged_by: Optional[str]
|
||||
acknowledged_at: Optional[datetime]
|
||||
is_resolved: bool
|
||||
resolved_by: Optional[str]
|
||||
resolved_at: Optional[datetime]
|
||||
resolution_notes: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
# Related data
|
||||
ingredient: Optional[IngredientResponse] = None
|
||||
|
||||
|
||||
# ===== DASHBOARD AND SUMMARY SCHEMAS =====
|
||||
|
||||
class InventorySummary(InventoryBaseSchema):
|
||||
"""Inventory dashboard summary"""
|
||||
total_ingredients: int
|
||||
total_stock_value: float
|
||||
low_stock_alerts: int
|
||||
expiring_soon_items: int
|
||||
expired_items: int
|
||||
out_of_stock_items: int
|
||||
|
||||
# By category
|
||||
stock_by_category: Dict[str, Dict[str, Any]]
|
||||
|
||||
# Recent activity
|
||||
recent_movements: int
|
||||
recent_purchases: int
|
||||
recent_waste: int
|
||||
|
||||
|
||||
class StockLevelSummary(InventoryBaseSchema):
|
||||
"""Stock level summary for an ingredient"""
|
||||
ingredient_id: str
|
||||
ingredient_name: str
|
||||
unit_of_measure: str
|
||||
total_quantity: float
|
||||
available_quantity: float
|
||||
reserved_quantity: float
|
||||
|
||||
# Status indicators
|
||||
is_low_stock: bool
|
||||
needs_reorder: bool
|
||||
has_expired_stock: bool
|
||||
|
||||
# Batch information
|
||||
total_batches: int
|
||||
oldest_batch_date: Optional[datetime]
|
||||
newest_batch_date: Optional[datetime]
|
||||
next_expiration_date: Optional[datetime]
|
||||
|
||||
# Cost information
|
||||
average_unit_cost: Optional[float]
|
||||
total_stock_value: Optional[float]
|
||||
|
||||
|
||||
# ===== REQUEST/RESPONSE WRAPPER SCHEMAS =====
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
"""Generic paginated response"""
|
||||
items: List[T]
|
||||
total: int
|
||||
page: int
|
||||
size: int
|
||||
pages: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class InventoryFilter(BaseModel):
|
||||
"""Inventory filtering parameters"""
|
||||
category: Optional[IngredientCategory] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_low_stock: Optional[bool] = None
|
||||
needs_reorder: Optional[bool] = None
|
||||
search: Optional[str] = None
|
||||
|
||||
|
||||
class StockFilter(BaseModel):
|
||||
"""Stock filtering parameters"""
|
||||
ingredient_id: Optional[str] = None
|
||||
is_available: Optional[bool] = None
|
||||
is_expired: Optional[bool] = None
|
||||
expiring_within_days: Optional[int] = None
|
||||
storage_location: Optional[str] = None
|
||||
quality_status: Optional[str] = None
|
||||
|
||||
|
||||
# Type aliases for paginated responses
|
||||
IngredientListResponse = PaginatedResponse[IngredientResponse]
|
||||
StockListResponse = PaginatedResponse[StockResponse]
|
||||
StockMovementListResponse = PaginatedResponse[StockMovementResponse]
|
||||
StockAlertListResponse = PaginatedResponse[StockAlertResponse]
|
||||
0
services/inventory/app/services/__init__.py
Normal file
0
services/inventory/app/services/__init__.py
Normal file
469
services/inventory/app/services/inventory_service.py
Normal file
469
services/inventory/app/services/inventory_service.py
Normal file
@@ -0,0 +1,469 @@
|
||||
# services/inventory/app/services/inventory_service.py
|
||||
"""
|
||||
Inventory Service - Business Logic Layer
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timedelta
|
||||
import structlog
|
||||
|
||||
from app.models.inventory import Ingredient, Stock, StockMovement, StockAlert, StockMovementType
|
||||
from app.repositories.ingredient_repository import IngredientRepository
|
||||
from app.repositories.stock_repository import StockRepository
|
||||
from app.repositories.stock_movement_repository import StockMovementRepository
|
||||
from app.schemas.inventory import (
|
||||
IngredientCreate, IngredientUpdate, IngredientResponse,
|
||||
StockCreate, StockUpdate, StockResponse,
|
||||
StockMovementCreate, StockMovementResponse,
|
||||
InventorySummary, StockLevelSummary
|
||||
)
|
||||
from app.core.database import get_db_transaction
|
||||
from shared.database.exceptions import DatabaseError
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class InventoryService:
|
||||
"""Service layer for inventory operations"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
# ===== INGREDIENT MANAGEMENT =====
|
||||
|
||||
async def create_ingredient(
|
||||
self,
|
||||
ingredient_data: IngredientCreate,
|
||||
tenant_id: UUID,
|
||||
user_id: Optional[UUID] = None
|
||||
) -> IngredientResponse:
|
||||
"""Create a new ingredient with business validation"""
|
||||
try:
|
||||
# Business validation
|
||||
await self._validate_ingredient_data(ingredient_data, tenant_id)
|
||||
|
||||
async with get_db_transaction() as db:
|
||||
repository = IngredientRepository(db)
|
||||
|
||||
# Check for duplicates
|
||||
if ingredient_data.sku:
|
||||
existing = await repository.get_by_sku(tenant_id, ingredient_data.sku)
|
||||
if existing:
|
||||
raise ValueError(f"Ingredient with SKU '{ingredient_data.sku}' already exists")
|
||||
|
||||
if ingredient_data.barcode:
|
||||
existing = await repository.get_by_barcode(tenant_id, ingredient_data.barcode)
|
||||
if existing:
|
||||
raise ValueError(f"Ingredient with barcode '{ingredient_data.barcode}' already exists")
|
||||
|
||||
# Create ingredient
|
||||
ingredient = await repository.create_ingredient(ingredient_data, tenant_id)
|
||||
|
||||
# Convert to response schema
|
||||
response = IngredientResponse(**ingredient.to_dict())
|
||||
|
||||
# Add computed fields
|
||||
response.current_stock = 0.0
|
||||
response.is_low_stock = True
|
||||
response.needs_reorder = True
|
||||
|
||||
logger.info("Ingredient created successfully", ingredient_id=ingredient.id, name=ingredient.name)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create ingredient", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_ingredient(self, ingredient_id: UUID, tenant_id: UUID) -> Optional[IngredientResponse]:
|
||||
"""Get ingredient by ID with stock information"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
ingredient_repo = IngredientRepository(db)
|
||||
stock_repo = StockRepository(db)
|
||||
|
||||
ingredient = await ingredient_repo.get_by_id(ingredient_id)
|
||||
if not ingredient or ingredient.tenant_id != tenant_id:
|
||||
return None
|
||||
|
||||
# Get current stock levels
|
||||
stock_totals = await stock_repo.get_total_stock_by_ingredient(tenant_id, ingredient_id)
|
||||
|
||||
# Convert to response schema
|
||||
response = IngredientResponse(**ingredient.to_dict())
|
||||
response.current_stock = stock_totals['total_available']
|
||||
response.is_low_stock = stock_totals['total_available'] <= ingredient.low_stock_threshold
|
||||
response.needs_reorder = stock_totals['total_available'] <= ingredient.reorder_point
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get ingredient", error=str(e), ingredient_id=ingredient_id)
|
||||
raise
|
||||
|
||||
async def update_ingredient(
|
||||
self,
|
||||
ingredient_id: UUID,
|
||||
ingredient_data: IngredientUpdate,
|
||||
tenant_id: UUID
|
||||
) -> Optional[IngredientResponse]:
|
||||
"""Update ingredient with business validation"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
ingredient_repo = IngredientRepository(db)
|
||||
stock_repo = StockRepository(db)
|
||||
|
||||
# Check if ingredient exists and belongs to tenant
|
||||
existing = await ingredient_repo.get_by_id(ingredient_id)
|
||||
if not existing or existing.tenant_id != tenant_id:
|
||||
return None
|
||||
|
||||
# Validate unique constraints
|
||||
if ingredient_data.sku and ingredient_data.sku != existing.sku:
|
||||
sku_check = await ingredient_repo.get_by_sku(tenant_id, ingredient_data.sku)
|
||||
if sku_check and sku_check.id != ingredient_id:
|
||||
raise ValueError(f"Ingredient with SKU '{ingredient_data.sku}' already exists")
|
||||
|
||||
if ingredient_data.barcode and ingredient_data.barcode != existing.barcode:
|
||||
barcode_check = await ingredient_repo.get_by_barcode(tenant_id, ingredient_data.barcode)
|
||||
if barcode_check and barcode_check.id != ingredient_id:
|
||||
raise ValueError(f"Ingredient with barcode '{ingredient_data.barcode}' already exists")
|
||||
|
||||
# Update ingredient
|
||||
updated_ingredient = await ingredient_repo.update(ingredient_id, ingredient_data)
|
||||
if not updated_ingredient:
|
||||
return None
|
||||
|
||||
# Get current stock levels
|
||||
stock_totals = await stock_repo.get_total_stock_by_ingredient(tenant_id, ingredient_id)
|
||||
|
||||
# Convert to response schema
|
||||
response = IngredientResponse(**updated_ingredient.to_dict())
|
||||
response.current_stock = stock_totals['total_available']
|
||||
response.is_low_stock = stock_totals['total_available'] <= updated_ingredient.low_stock_threshold
|
||||
response.needs_reorder = stock_totals['total_available'] <= updated_ingredient.reorder_point
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to update ingredient", error=str(e), ingredient_id=ingredient_id)
|
||||
raise
|
||||
|
||||
async def get_ingredients(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> List[IngredientResponse]:
|
||||
"""Get ingredients with filtering and stock information"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
ingredient_repo = IngredientRepository(db)
|
||||
stock_repo = StockRepository(db)
|
||||
|
||||
# Get ingredients
|
||||
ingredients = await ingredient_repo.get_ingredients_by_tenant(
|
||||
tenant_id, skip, limit, filters
|
||||
)
|
||||
|
||||
responses = []
|
||||
for ingredient in ingredients:
|
||||
# Get current stock levels
|
||||
stock_totals = await stock_repo.get_total_stock_by_ingredient(tenant_id, ingredient.id)
|
||||
|
||||
# Convert to response schema
|
||||
response = IngredientResponse(**ingredient.to_dict())
|
||||
response.current_stock = stock_totals['total_available']
|
||||
response.is_low_stock = stock_totals['total_available'] <= ingredient.low_stock_threshold
|
||||
response.needs_reorder = stock_totals['total_available'] <= ingredient.reorder_point
|
||||
|
||||
responses.append(response)
|
||||
|
||||
return responses
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get ingredients", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
# ===== STOCK MANAGEMENT =====
|
||||
|
||||
async def add_stock(
|
||||
self,
|
||||
stock_data: StockCreate,
|
||||
tenant_id: UUID,
|
||||
user_id: Optional[UUID] = None
|
||||
) -> StockResponse:
|
||||
"""Add new stock with automatic movement tracking"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
ingredient_repo = IngredientRepository(db)
|
||||
stock_repo = StockRepository(db)
|
||||
movement_repo = StockMovementRepository(db)
|
||||
|
||||
# Validate ingredient exists
|
||||
ingredient = await ingredient_repo.get_by_id(UUID(stock_data.ingredient_id))
|
||||
if not ingredient or ingredient.tenant_id != tenant_id:
|
||||
raise ValueError("Ingredient not found")
|
||||
|
||||
# Create stock entry
|
||||
stock = await stock_repo.create_stock_entry(stock_data, tenant_id)
|
||||
|
||||
# Create stock movement record
|
||||
movement_data = StockMovementCreate(
|
||||
ingredient_id=stock_data.ingredient_id,
|
||||
stock_id=str(stock.id),
|
||||
movement_type=StockMovementType.PURCHASE,
|
||||
quantity=stock_data.current_quantity,
|
||||
unit_cost=stock_data.unit_cost,
|
||||
notes=f"Initial stock entry - Batch: {stock_data.batch_number or 'N/A'}"
|
||||
)
|
||||
await movement_repo.create_movement(movement_data, tenant_id, user_id)
|
||||
|
||||
# Update ingredient's last purchase price
|
||||
if stock_data.unit_cost:
|
||||
await ingredient_repo.update_last_purchase_price(
|
||||
UUID(stock_data.ingredient_id),
|
||||
float(stock_data.unit_cost)
|
||||
)
|
||||
|
||||
# Convert to response schema
|
||||
response = StockResponse(**stock.to_dict())
|
||||
response.ingredient = IngredientResponse(**ingredient.to_dict())
|
||||
|
||||
logger.info("Stock added successfully", stock_id=stock.id, quantity=stock.current_quantity)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to add stock", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def consume_stock(
|
||||
self,
|
||||
ingredient_id: UUID,
|
||||
quantity: float,
|
||||
tenant_id: UUID,
|
||||
user_id: Optional[UUID] = None,
|
||||
reference_number: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
fifo: bool = True
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Consume stock using FIFO/LIFO with movement tracking"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
ingredient_repo = IngredientRepository(db)
|
||||
stock_repo = StockRepository(db)
|
||||
movement_repo = StockMovementRepository(db)
|
||||
|
||||
# Validate ingredient
|
||||
ingredient = await ingredient_repo.get_by_id(ingredient_id)
|
||||
if not ingredient or ingredient.tenant_id != tenant_id:
|
||||
raise ValueError("Ingredient not found")
|
||||
|
||||
# Reserve stock first
|
||||
reservations = await stock_repo.reserve_stock(tenant_id, ingredient_id, quantity, fifo)
|
||||
|
||||
if not reservations:
|
||||
raise ValueError("Insufficient stock available")
|
||||
|
||||
consumed_items = []
|
||||
for reservation in reservations:
|
||||
stock_id = UUID(reservation['stock_id'])
|
||||
reserved_qty = reservation['reserved_quantity']
|
||||
|
||||
# Consume from reserved stock
|
||||
consumed_stock = await stock_repo.consume_stock(stock_id, reserved_qty, from_reserved=True)
|
||||
|
||||
# Create movement record
|
||||
movement_data = StockMovementCreate(
|
||||
ingredient_id=str(ingredient_id),
|
||||
stock_id=str(stock_id),
|
||||
movement_type=StockMovementType.PRODUCTION_USE,
|
||||
quantity=reserved_qty,
|
||||
reference_number=reference_number,
|
||||
notes=notes or f"Stock consumption - Batch: {reservation.get('batch_number', 'N/A')}"
|
||||
)
|
||||
await movement_repo.create_movement(movement_data, tenant_id, user_id)
|
||||
|
||||
consumed_items.append({
|
||||
'stock_id': str(stock_id),
|
||||
'quantity_consumed': reserved_qty,
|
||||
'batch_number': reservation.get('batch_number'),
|
||||
'expiration_date': reservation.get('expiration_date')
|
||||
})
|
||||
|
||||
logger.info(
|
||||
"Stock consumed successfully",
|
||||
ingredient_id=ingredient_id,
|
||||
total_quantity=quantity,
|
||||
items_consumed=len(consumed_items)
|
||||
)
|
||||
return consumed_items
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to consume stock", error=str(e), ingredient_id=ingredient_id)
|
||||
raise
|
||||
|
||||
async def get_stock_by_ingredient(
|
||||
self,
|
||||
ingredient_id: UUID,
|
||||
tenant_id: UUID,
|
||||
include_unavailable: bool = False
|
||||
) -> List[StockResponse]:
|
||||
"""Get all stock entries for an ingredient"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
ingredient_repo = IngredientRepository(db)
|
||||
stock_repo = StockRepository(db)
|
||||
|
||||
# Validate ingredient
|
||||
ingredient = await ingredient_repo.get_by_id(ingredient_id)
|
||||
if not ingredient or ingredient.tenant_id != tenant_id:
|
||||
return []
|
||||
|
||||
# Get stock entries
|
||||
stock_entries = await stock_repo.get_stock_by_ingredient(
|
||||
tenant_id, ingredient_id, include_unavailable
|
||||
)
|
||||
|
||||
responses = []
|
||||
for stock in stock_entries:
|
||||
response = StockResponse(**stock.to_dict())
|
||||
response.ingredient = IngredientResponse(**ingredient.to_dict())
|
||||
responses.append(response)
|
||||
|
||||
return responses
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get stock by ingredient", error=str(e), ingredient_id=ingredient_id)
|
||||
raise
|
||||
|
||||
# ===== ALERTS AND NOTIFICATIONS =====
|
||||
|
||||
async def check_low_stock_alerts(self, tenant_id: UUID) -> List[Dict[str, Any]]:
|
||||
"""Check for ingredients with low stock levels"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
ingredient_repo = IngredientRepository(db)
|
||||
|
||||
low_stock_items = await ingredient_repo.get_low_stock_ingredients(tenant_id)
|
||||
|
||||
alerts = []
|
||||
for item in low_stock_items:
|
||||
ingredient = item['ingredient']
|
||||
alerts.append({
|
||||
'ingredient_id': str(ingredient.id),
|
||||
'ingredient_name': ingredient.name,
|
||||
'current_stock': item['current_stock'],
|
||||
'threshold': item['threshold'],
|
||||
'needs_reorder': item['needs_reorder'],
|
||||
'alert_type': 'reorder_needed' if item['needs_reorder'] else 'low_stock',
|
||||
'severity': 'high' if item['needs_reorder'] else 'medium'
|
||||
})
|
||||
|
||||
return alerts
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to check low stock alerts", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def check_expiration_alerts(self, tenant_id: UUID, days_ahead: int = 7) -> List[Dict[str, Any]]:
|
||||
"""Check for stock items expiring soon"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
stock_repo = StockRepository(db)
|
||||
|
||||
expiring_items = await stock_repo.get_expiring_stock(tenant_id, days_ahead)
|
||||
|
||||
alerts = []
|
||||
for stock, ingredient in expiring_items:
|
||||
days_to_expiry = (stock.expiration_date - datetime.now()).days if stock.expiration_date else None
|
||||
|
||||
alerts.append({
|
||||
'stock_id': str(stock.id),
|
||||
'ingredient_id': str(ingredient.id),
|
||||
'ingredient_name': ingredient.name,
|
||||
'batch_number': stock.batch_number,
|
||||
'quantity': stock.available_quantity,
|
||||
'expiration_date': stock.expiration_date,
|
||||
'days_to_expiry': days_to_expiry,
|
||||
'alert_type': 'expiring_soon',
|
||||
'severity': 'critical' if days_to_expiry and days_to_expiry <= 1 else 'high'
|
||||
})
|
||||
|
||||
return alerts
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to check expiration alerts", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
# ===== DASHBOARD AND ANALYTICS =====
|
||||
|
||||
async def get_inventory_summary(self, tenant_id: UUID) -> InventorySummary:
|
||||
"""Get inventory summary for dashboard"""
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
ingredient_repo = IngredientRepository(db)
|
||||
stock_repo = StockRepository(db)
|
||||
movement_repo = StockMovementRepository(db)
|
||||
|
||||
# Get basic counts
|
||||
total_ingredients_result = await ingredient_repo.count({'tenant_id': tenant_id, 'is_active': True})
|
||||
|
||||
# Get stock summary
|
||||
stock_summary = await stock_repo.get_stock_summary_by_tenant(tenant_id)
|
||||
|
||||
# Get low stock and expiring items
|
||||
low_stock_items = await ingredient_repo.get_low_stock_ingredients(tenant_id)
|
||||
expiring_items = await stock_repo.get_expiring_stock(tenant_id, 7)
|
||||
expired_items = await stock_repo.get_expired_stock(tenant_id)
|
||||
|
||||
# Get recent activity
|
||||
recent_activity = await movement_repo.get_movement_summary_by_period(tenant_id, 7)
|
||||
|
||||
# Build category breakdown
|
||||
stock_by_category = {}
|
||||
ingredients = await ingredient_repo.get_ingredients_by_tenant(tenant_id, 0, 1000)
|
||||
|
||||
for ingredient in ingredients:
|
||||
category = ingredient.category.value if ingredient.category else 'other'
|
||||
if category not in stock_by_category:
|
||||
stock_by_category[category] = {
|
||||
'count': 0,
|
||||
'total_value': 0.0,
|
||||
'low_stock_items': 0
|
||||
}
|
||||
|
||||
stock_by_category[category]['count'] += 1
|
||||
# Additional calculations would go here
|
||||
|
||||
return InventorySummary(
|
||||
total_ingredients=total_ingredients_result,
|
||||
total_stock_value=stock_summary['total_stock_value'],
|
||||
low_stock_alerts=len(low_stock_items),
|
||||
expiring_soon_items=len(expiring_items),
|
||||
expired_items=len(expired_items),
|
||||
out_of_stock_items=0, # TODO: Calculate this
|
||||
stock_by_category=stock_by_category,
|
||||
recent_movements=recent_activity.get('total_movements', 0),
|
||||
recent_purchases=recent_activity.get('purchase', {}).get('count', 0),
|
||||
recent_waste=recent_activity.get('waste', {}).get('count', 0)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get inventory summary", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
# ===== PRIVATE HELPER METHODS =====
|
||||
|
||||
async def _validate_ingredient_data(self, ingredient_data: IngredientCreate, tenant_id: UUID):
|
||||
"""Validate ingredient data for business rules"""
|
||||
# Add business validation logic here
|
||||
if ingredient_data.reorder_point <= ingredient_data.low_stock_threshold:
|
||||
raise ValueError("Reorder point must be greater than low stock threshold")
|
||||
|
||||
if ingredient_data.requires_freezing and ingredient_data.requires_refrigeration:
|
||||
raise ValueError("Item cannot require both freezing and refrigeration")
|
||||
|
||||
# Add more validations as needed
|
||||
pass
|
||||
244
services/inventory/app/services/messaging.py
Normal file
244
services/inventory/app/services/messaging.py
Normal file
@@ -0,0 +1,244 @@
|
||||
# services/inventory/app/services/messaging.py
|
||||
"""
|
||||
Messaging service for inventory events
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
|
||||
from shared.messaging.rabbitmq import MessagePublisher
|
||||
from shared.messaging.events import (
|
||||
EVENT_TYPES,
|
||||
InventoryEvent,
|
||||
StockAlertEvent,
|
||||
StockMovementEvent
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class InventoryMessagingService:
|
||||
"""Service for publishing inventory-related events"""
|
||||
|
||||
def __init__(self):
|
||||
self.publisher = MessagePublisher()
|
||||
|
||||
async def publish_ingredient_created(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
ingredient_data: Dict[str, Any]
|
||||
):
|
||||
"""Publish ingredient creation event"""
|
||||
try:
|
||||
event = InventoryEvent(
|
||||
event_type=EVENT_TYPES.INVENTORY.INGREDIENT_CREATED,
|
||||
tenant_id=str(tenant_id),
|
||||
ingredient_id=str(ingredient_id),
|
||||
data=ingredient_data
|
||||
)
|
||||
|
||||
await self.publisher.publish_event(
|
||||
routing_key="inventory.ingredient.created",
|
||||
event=event
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Published ingredient created event",
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to publish ingredient created event",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id
|
||||
)
|
||||
|
||||
async def publish_stock_added(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
stock_id: UUID,
|
||||
quantity: float,
|
||||
batch_number: Optional[str] = None
|
||||
):
|
||||
"""Publish stock addition event"""
|
||||
try:
|
||||
movement_event = StockMovementEvent(
|
||||
event_type=EVENT_TYPES.INVENTORY.STOCK_ADDED,
|
||||
tenant_id=str(tenant_id),
|
||||
ingredient_id=str(ingredient_id),
|
||||
stock_id=str(stock_id),
|
||||
quantity=quantity,
|
||||
movement_type="purchase",
|
||||
data={
|
||||
"batch_number": batch_number,
|
||||
"movement_type": "purchase"
|
||||
}
|
||||
)
|
||||
|
||||
await self.publisher.publish_event(
|
||||
routing_key="inventory.stock.added",
|
||||
event=movement_event
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Published stock added event",
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
quantity=quantity
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to publish stock added event",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id
|
||||
)
|
||||
|
||||
async def publish_stock_consumed(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
consumed_items: list,
|
||||
total_quantity: float,
|
||||
reference_number: Optional[str] = None
|
||||
):
|
||||
"""Publish stock consumption event"""
|
||||
try:
|
||||
for item in consumed_items:
|
||||
movement_event = StockMovementEvent(
|
||||
event_type=EVENT_TYPES.INVENTORY.STOCK_CONSUMED,
|
||||
tenant_id=str(tenant_id),
|
||||
ingredient_id=str(ingredient_id),
|
||||
stock_id=item['stock_id'],
|
||||
quantity=item['quantity_consumed'],
|
||||
movement_type="production_use",
|
||||
data={
|
||||
"batch_number": item.get('batch_number'),
|
||||
"reference_number": reference_number,
|
||||
"movement_type": "production_use"
|
||||
}
|
||||
)
|
||||
|
||||
await self.publisher.publish_event(
|
||||
routing_key="inventory.stock.consumed",
|
||||
event=movement_event
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Published stock consumed events",
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
total_quantity=total_quantity,
|
||||
items_count=len(consumed_items)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to publish stock consumed event",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id
|
||||
)
|
||||
|
||||
async def publish_low_stock_alert(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
ingredient_name: str,
|
||||
current_stock: float,
|
||||
threshold: float,
|
||||
needs_reorder: bool = False
|
||||
):
|
||||
"""Publish low stock alert event"""
|
||||
try:
|
||||
alert_event = StockAlertEvent(
|
||||
event_type=EVENT_TYPES.INVENTORY.LOW_STOCK_ALERT,
|
||||
tenant_id=str(tenant_id),
|
||||
ingredient_id=str(ingredient_id),
|
||||
alert_type="low_stock" if not needs_reorder else "reorder_needed",
|
||||
severity="medium" if not needs_reorder else "high",
|
||||
data={
|
||||
"ingredient_name": ingredient_name,
|
||||
"current_stock": current_stock,
|
||||
"threshold": threshold,
|
||||
"needs_reorder": needs_reorder
|
||||
}
|
||||
)
|
||||
|
||||
await self.publisher.publish_event(
|
||||
routing_key="inventory.alerts.low_stock",
|
||||
event=alert_event
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Published low stock alert",
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
current_stock=current_stock
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to publish low stock alert",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id
|
||||
)
|
||||
|
||||
async def publish_expiration_alert(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
stock_id: UUID,
|
||||
ingredient_name: str,
|
||||
batch_number: Optional[str],
|
||||
expiration_date: str,
|
||||
days_to_expiry: int,
|
||||
quantity: float
|
||||
):
|
||||
"""Publish expiration alert event"""
|
||||
try:
|
||||
severity = "critical" if days_to_expiry <= 1 else "high"
|
||||
|
||||
alert_event = StockAlertEvent(
|
||||
event_type=EVENT_TYPES.INVENTORY.EXPIRATION_ALERT,
|
||||
tenant_id=str(tenant_id),
|
||||
ingredient_id=str(ingredient_id),
|
||||
alert_type="expiring_soon",
|
||||
severity=severity,
|
||||
data={
|
||||
"stock_id": str(stock_id),
|
||||
"ingredient_name": ingredient_name,
|
||||
"batch_number": batch_number,
|
||||
"expiration_date": expiration_date,
|
||||
"days_to_expiry": days_to_expiry,
|
||||
"quantity": quantity
|
||||
}
|
||||
)
|
||||
|
||||
await self.publisher.publish_event(
|
||||
routing_key="inventory.alerts.expiration",
|
||||
event=alert_event
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Published expiration alert",
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
days_to_expiry=days_to_expiry
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to publish expiration alert",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id
|
||||
)
|
||||
467
services/inventory/app/services/product_classifier.py
Normal file
467
services/inventory/app/services/product_classifier.py
Normal file
@@ -0,0 +1,467 @@
|
||||
# services/inventory/app/services/product_classifier.py
|
||||
"""
|
||||
AI Product Classification Service
|
||||
Automatically classifies products from sales data during onboarding
|
||||
"""
|
||||
|
||||
import re
|
||||
import structlog
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.models.inventory import ProductType, IngredientCategory, ProductCategory, UnitOfMeasure
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProductSuggestion:
|
||||
"""Suggested inventory item from sales data analysis"""
|
||||
original_name: str
|
||||
suggested_name: str
|
||||
product_type: ProductType
|
||||
category: str # ingredient_category or product_category
|
||||
unit_of_measure: UnitOfMeasure
|
||||
confidence_score: float # 0.0 to 1.0
|
||||
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 ProductClassifierService:
|
||||
"""AI-powered product classification for onboarding automation"""
|
||||
|
||||
def __init__(self):
|
||||
self._load_classification_rules()
|
||||
|
||||
def _load_classification_rules(self):
|
||||
"""Load classification patterns and rules"""
|
||||
|
||||
# Ingredient patterns with high confidence
|
||||
self.ingredient_patterns = {
|
||||
IngredientCategory.FLOUR: {
|
||||
'patterns': [
|
||||
r'harina', r'flour', r'trigo', r'wheat', r'integral', r'whole.*wheat',
|
||||
r'centeno', r'rye', r'avena', r'oat', r'maiz', r'corn'
|
||||
],
|
||||
'unit': UnitOfMeasure.KILOGRAMS,
|
||||
'shelf_life': 365,
|
||||
'supplier_hints': ['molinos', 'harinera', 'mill']
|
||||
},
|
||||
IngredientCategory.YEAST: {
|
||||
'patterns': [
|
||||
r'levadura', r'yeast', r'fermento', r'baker.*yeast', r'instant.*yeast'
|
||||
],
|
||||
'unit': UnitOfMeasure.GRAMS,
|
||||
'shelf_life': 730,
|
||||
'refrigeration': True
|
||||
},
|
||||
IngredientCategory.DAIRY: {
|
||||
'patterns': [
|
||||
r'leche', r'milk', r'nata', r'cream', r'mantequilla', r'butter',
|
||||
r'queso', r'cheese', r'yogur', r'yogurt'
|
||||
],
|
||||
'unit': UnitOfMeasure.LITERS,
|
||||
'shelf_life': 7,
|
||||
'refrigeration': True
|
||||
},
|
||||
IngredientCategory.EGGS: {
|
||||
'patterns': [
|
||||
r'huevo', r'egg', r'clara', r'white', r'yema', r'yolk'
|
||||
],
|
||||
'unit': UnitOfMeasure.UNITS,
|
||||
'shelf_life': 28,
|
||||
'refrigeration': True
|
||||
},
|
||||
IngredientCategory.SUGAR: {
|
||||
'patterns': [
|
||||
r'azucar', r'sugar', r'edulcorante', r'sweetener', r'miel', r'honey',
|
||||
r'jarabe', r'syrup', r'mascabado', r'brown.*sugar'
|
||||
],
|
||||
'unit': UnitOfMeasure.KILOGRAMS,
|
||||
'shelf_life': 730
|
||||
},
|
||||
IngredientCategory.FATS: {
|
||||
'patterns': [
|
||||
r'aceite', r'oil', r'grasa', r'fat', r'margarina', r'margarine',
|
||||
r'manteca', r'lard', r'oliva', r'olive'
|
||||
],
|
||||
'unit': UnitOfMeasure.LITERS,
|
||||
'shelf_life': 365
|
||||
},
|
||||
IngredientCategory.SALT: {
|
||||
'patterns': [
|
||||
r'sal', r'salt', r'sodium', r'sodio'
|
||||
],
|
||||
'unit': UnitOfMeasure.KILOGRAMS,
|
||||
'shelf_life': 1825 # 5 years
|
||||
},
|
||||
IngredientCategory.SPICES: {
|
||||
'patterns': [
|
||||
r'canela', r'cinnamon', r'vainilla', r'vanilla', r'cacao', r'cocoa',
|
||||
r'chocolate', r'anis', r'anise', r'cardamomo', r'cardamom',
|
||||
r'jengibre', r'ginger', r'nuez.*moscada', r'nutmeg'
|
||||
],
|
||||
'unit': UnitOfMeasure.GRAMS,
|
||||
'shelf_life': 730
|
||||
},
|
||||
IngredientCategory.ADDITIVES: {
|
||||
'patterns': [
|
||||
r'polvo.*hornear', r'baking.*powder', r'bicarbonato', r'soda',
|
||||
r'cremor.*tartaro', r'cream.*tartar', r'lecitina', r'lecithin',
|
||||
r'conservante', r'preservative', r'emulsificante', r'emulsifier'
|
||||
],
|
||||
'unit': UnitOfMeasure.GRAMS,
|
||||
'shelf_life': 730
|
||||
},
|
||||
IngredientCategory.PACKAGING: {
|
||||
'patterns': [
|
||||
r'bolsa', r'bag', r'envase', r'container', r'papel', r'paper',
|
||||
r'plastico', r'plastic', r'carton', r'cardboard'
|
||||
],
|
||||
'unit': UnitOfMeasure.UNITS,
|
||||
'shelf_life': 1825
|
||||
}
|
||||
}
|
||||
|
||||
# Finished product patterns
|
||||
self.product_patterns = {
|
||||
ProductCategory.BREAD: {
|
||||
'patterns': [
|
||||
r'pan\b', r'bread', r'baguette', r'hogaza', r'loaf', r'molde',
|
||||
r'integral', r'whole.*grain', r'centeno', r'rye.*bread'
|
||||
],
|
||||
'unit': UnitOfMeasure.UNITS,
|
||||
'shelf_life': 3,
|
||||
'display_life': 24 # hours
|
||||
},
|
||||
ProductCategory.CROISSANTS: {
|
||||
'patterns': [
|
||||
r'croissant', r'cruasan', r'napolitana', r'palmera', r'palmier'
|
||||
],
|
||||
'unit': UnitOfMeasure.UNITS,
|
||||
'shelf_life': 2,
|
||||
'display_life': 12
|
||||
},
|
||||
ProductCategory.PASTRIES: {
|
||||
'patterns': [
|
||||
r'pastel', r'pastry', r'hojaldre', r'puff.*pastry', r'empanada',
|
||||
r'milhojas', r'napoleon', r'eclair', r'profiterol'
|
||||
],
|
||||
'unit': UnitOfMeasure.UNITS,
|
||||
'shelf_life': 2,
|
||||
'display_life': 24,
|
||||
'refrigeration': True
|
||||
},
|
||||
ProductCategory.CAKES: {
|
||||
'patterns': [
|
||||
r'tarta', r'cake', r'bizcocho', r'sponge', r'cheesecake',
|
||||
r'tiramisu', r'mousse', r'torta'
|
||||
],
|
||||
'unit': UnitOfMeasure.UNITS,
|
||||
'shelf_life': 3,
|
||||
'refrigeration': True
|
||||
},
|
||||
ProductCategory.COOKIES: {
|
||||
'patterns': [
|
||||
r'galleta', r'cookie', r'biscuit', r'mantecada', r'madeleine'
|
||||
],
|
||||
'unit': UnitOfMeasure.UNITS,
|
||||
'shelf_life': 14
|
||||
},
|
||||
ProductCategory.MUFFINS: {
|
||||
'patterns': [
|
||||
r'muffin', r'magdalena', r'cupcake', r'fairy.*cake'
|
||||
],
|
||||
'unit': UnitOfMeasure.UNITS,
|
||||
'shelf_life': 3
|
||||
},
|
||||
ProductCategory.SANDWICHES: {
|
||||
'patterns': [
|
||||
r'sandwich', r'bocadillo', r'tostada', r'toast', r'bagel'
|
||||
],
|
||||
'unit': UnitOfMeasure.UNITS,
|
||||
'shelf_life': 1,
|
||||
'display_life': 6,
|
||||
'refrigeration': True
|
||||
},
|
||||
ProductCategory.BEVERAGES: {
|
||||
'patterns': [
|
||||
r'cafe', r'coffee', r'te\b', r'tea', r'chocolate.*caliente',
|
||||
r'hot.*chocolate', r'zumo', r'juice', r'batido', r'smoothie'
|
||||
],
|
||||
'unit': UnitOfMeasure.UNITS,
|
||||
'shelf_life': 1
|
||||
}
|
||||
}
|
||||
|
||||
# Seasonal indicators
|
||||
self.seasonal_patterns = {
|
||||
'christmas': [r'navidad', r'christmas', r'turron', r'polvoron', r'roscon'],
|
||||
'easter': [r'pascua', r'easter', r'mona', r'torrija'],
|
||||
'summer': [r'helado', r'ice.*cream', r'granizado', r'sorbete']
|
||||
}
|
||||
|
||||
def classify_product(self, product_name: str, sales_volume: Optional[float] = None) -> ProductSuggestion:
|
||||
"""Classify a single product name into inventory suggestion"""
|
||||
|
||||
# Normalize product name for analysis
|
||||
normalized_name = self._normalize_name(product_name)
|
||||
|
||||
# Try to classify as ingredient first
|
||||
ingredient_result = self._classify_as_ingredient(normalized_name, product_name)
|
||||
if ingredient_result and ingredient_result.confidence_score >= 0.7:
|
||||
return ingredient_result
|
||||
|
||||
# Try to classify as finished product
|
||||
product_result = self._classify_as_finished_product(normalized_name, product_name)
|
||||
if product_result:
|
||||
return product_result
|
||||
|
||||
# Fallback: create generic finished product with low confidence
|
||||
return self._create_fallback_suggestion(product_name, normalized_name)
|
||||
|
||||
def classify_products_batch(self, product_names: List[str],
|
||||
sales_volumes: Optional[Dict[str, float]] = None) -> List[ProductSuggestion]:
|
||||
"""Classify multiple products and detect business model"""
|
||||
|
||||
suggestions = []
|
||||
for name in product_names:
|
||||
volume = sales_volumes.get(name) if sales_volumes else None
|
||||
suggestion = self.classify_product(name, volume)
|
||||
suggestions.append(suggestion)
|
||||
|
||||
# Analyze business model based on classification results
|
||||
self._analyze_business_model(suggestions)
|
||||
|
||||
return suggestions
|
||||
|
||||
def _normalize_name(self, name: str) -> str:
|
||||
"""Normalize product name for pattern matching"""
|
||||
if not name:
|
||||
return ""
|
||||
|
||||
# Convert to lowercase
|
||||
normalized = name.lower().strip()
|
||||
|
||||
# Remove common prefixes/suffixes
|
||||
prefixes_to_remove = ['el ', 'la ', 'los ', 'las ', 'un ', 'una ']
|
||||
for prefix in prefixes_to_remove:
|
||||
if normalized.startswith(prefix):
|
||||
normalized = normalized[len(prefix):]
|
||||
|
||||
# Remove special characters but keep spaces and accents
|
||||
normalized = re.sub(r'[^\w\sáéíóúñü]', ' ', normalized)
|
||||
|
||||
# Normalize multiple spaces
|
||||
normalized = re.sub(r'\s+', ' ', normalized).strip()
|
||||
|
||||
return normalized
|
||||
|
||||
def _classify_as_ingredient(self, normalized_name: str, original_name: str) -> Optional[ProductSuggestion]:
|
||||
"""Try to classify as ingredient"""
|
||||
|
||||
best_match = None
|
||||
best_score = 0.0
|
||||
|
||||
for category, config in self.ingredient_patterns.items():
|
||||
for pattern in config['patterns']:
|
||||
if re.search(pattern, normalized_name, re.IGNORECASE):
|
||||
# Calculate confidence based on pattern specificity
|
||||
score = self._calculate_confidence_score(pattern, normalized_name)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_match = (category, config)
|
||||
|
||||
if best_match and best_score >= 0.6:
|
||||
category, config = best_match
|
||||
|
||||
return ProductSuggestion(
|
||||
original_name=original_name,
|
||||
suggested_name=self._suggest_clean_name(original_name, normalized_name),
|
||||
product_type=ProductType.INGREDIENT,
|
||||
category=category.value,
|
||||
unit_of_measure=config['unit'],
|
||||
confidence_score=best_score,
|
||||
estimated_shelf_life_days=config.get('shelf_life'),
|
||||
requires_refrigeration=config.get('refrigeration', False),
|
||||
requires_freezing=config.get('freezing', False),
|
||||
suggested_supplier=self._suggest_supplier(normalized_name, config.get('supplier_hints', [])),
|
||||
notes=f"Auto-classified as {category.value} ingredient"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _classify_as_finished_product(self, normalized_name: str, original_name: str) -> Optional[ProductSuggestion]:
|
||||
"""Try to classify as finished product"""
|
||||
|
||||
best_match = None
|
||||
best_score = 0.0
|
||||
|
||||
for category, config in self.product_patterns.items():
|
||||
for pattern in config['patterns']:
|
||||
if re.search(pattern, normalized_name, re.IGNORECASE):
|
||||
score = self._calculate_confidence_score(pattern, normalized_name)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_match = (category, config)
|
||||
|
||||
if best_match:
|
||||
category, config = best_match
|
||||
|
||||
# Check if seasonal
|
||||
is_seasonal = self._is_seasonal_product(normalized_name)
|
||||
|
||||
return ProductSuggestion(
|
||||
original_name=original_name,
|
||||
suggested_name=self._suggest_clean_name(original_name, normalized_name),
|
||||
product_type=ProductType.FINISHED_PRODUCT,
|
||||
category=category.value,
|
||||
unit_of_measure=config['unit'],
|
||||
confidence_score=best_score,
|
||||
estimated_shelf_life_days=config.get('shelf_life'),
|
||||
requires_refrigeration=config.get('refrigeration', False),
|
||||
requires_freezing=config.get('freezing', False),
|
||||
is_seasonal=is_seasonal,
|
||||
notes=f"Auto-classified as {category.value}"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _create_fallback_suggestion(self, original_name: str, normalized_name: str) -> ProductSuggestion:
|
||||
"""Create a fallback suggestion for unclassified products"""
|
||||
|
||||
return ProductSuggestion(
|
||||
original_name=original_name,
|
||||
suggested_name=self._suggest_clean_name(original_name, normalized_name),
|
||||
product_type=ProductType.FINISHED_PRODUCT,
|
||||
category=ProductCategory.OTHER_PRODUCTS.value,
|
||||
unit_of_measure=UnitOfMeasure.UNITS,
|
||||
confidence_score=0.3,
|
||||
estimated_shelf_life_days=3,
|
||||
notes="Needs manual classification - defaulted to finished product"
|
||||
)
|
||||
|
||||
def _calculate_confidence_score(self, pattern: str, normalized_name: str) -> float:
|
||||
"""Calculate confidence score for pattern match"""
|
||||
|
||||
# Base score for match
|
||||
base_score = 0.8
|
||||
|
||||
# Boost score for exact matches
|
||||
if pattern.lower() == normalized_name:
|
||||
return 0.95
|
||||
|
||||
# Boost score for word boundary matches
|
||||
if re.search(r'\b' + pattern + r'\b', normalized_name, re.IGNORECASE):
|
||||
base_score += 0.1
|
||||
|
||||
# Reduce score for partial matches
|
||||
if len(pattern) < len(normalized_name) / 2:
|
||||
base_score -= 0.2
|
||||
|
||||
return min(0.95, max(0.3, base_score))
|
||||
|
||||
def _suggest_clean_name(self, original_name: str, normalized_name: str) -> str:
|
||||
"""Suggest a cleaned version of the product name"""
|
||||
|
||||
# Capitalize properly
|
||||
words = original_name.split()
|
||||
cleaned = []
|
||||
|
||||
for word in words:
|
||||
if len(word) > 0:
|
||||
# Keep original casing for abbreviations
|
||||
if word.isupper() and len(word) <= 3:
|
||||
cleaned.append(word)
|
||||
else:
|
||||
cleaned.append(word.capitalize())
|
||||
|
||||
return ' '.join(cleaned)
|
||||
|
||||
def _suggest_supplier(self, normalized_name: str, supplier_hints: List[str]) -> Optional[str]:
|
||||
"""Suggest potential supplier based on product type"""
|
||||
|
||||
for hint in supplier_hints:
|
||||
if hint in normalized_name:
|
||||
return f"Suggested: {hint.title()}"
|
||||
|
||||
return None
|
||||
|
||||
def _is_seasonal_product(self, normalized_name: str) -> bool:
|
||||
"""Check if product appears to be seasonal"""
|
||||
|
||||
for season, patterns in self.seasonal_patterns.items():
|
||||
for pattern in patterns:
|
||||
if re.search(pattern, normalized_name, re.IGNORECASE):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _analyze_business_model(self, suggestions: List[ProductSuggestion]) -> Dict[str, Any]:
|
||||
"""Analyze business model based on product classifications"""
|
||||
|
||||
ingredient_count = sum(1 for s in suggestions if s.product_type == ProductType.INGREDIENT)
|
||||
finished_count = sum(1 for s in suggestions if s.product_type == ProductType.FINISHED_PRODUCT)
|
||||
total = len(suggestions)
|
||||
|
||||
if total == 0:
|
||||
return {"model": "unknown", "confidence": 0.0}
|
||||
|
||||
ingredient_ratio = ingredient_count / total
|
||||
|
||||
if ingredient_ratio >= 0.7:
|
||||
model = "production" # Production bakery
|
||||
elif ingredient_ratio <= 0.3:
|
||||
model = "retail" # Retail/Distribution bakery
|
||||
else:
|
||||
model = "hybrid" # Mixed model
|
||||
|
||||
confidence = max(abs(ingredient_ratio - 0.5) * 2, 0.1)
|
||||
|
||||
logger.info("Business model analysis",
|
||||
model=model, confidence=confidence,
|
||||
ingredient_count=ingredient_count,
|
||||
finished_count=finished_count)
|
||||
|
||||
return {
|
||||
"model": model,
|
||||
"confidence": confidence,
|
||||
"ingredient_ratio": ingredient_ratio,
|
||||
"recommendations": self._get_model_recommendations(model)
|
||||
}
|
||||
|
||||
def _get_model_recommendations(self, model: str) -> List[str]:
|
||||
"""Get recommendations based on detected business model"""
|
||||
|
||||
recommendations = {
|
||||
"production": [
|
||||
"Focus on ingredient inventory management",
|
||||
"Set up recipe cost calculation",
|
||||
"Configure supplier relationships",
|
||||
"Enable production planning features"
|
||||
],
|
||||
"retail": [
|
||||
"Configure central baker relationships",
|
||||
"Set up delivery schedule tracking",
|
||||
"Enable finished product freshness monitoring",
|
||||
"Focus on sales forecasting"
|
||||
],
|
||||
"hybrid": [
|
||||
"Configure both ingredient and finished product management",
|
||||
"Set up flexible inventory categories",
|
||||
"Enable both production and retail features"
|
||||
]
|
||||
}
|
||||
|
||||
return recommendations.get(model, [])
|
||||
|
||||
|
||||
# Dependency injection
|
||||
def get_product_classifier() -> ProductClassifierService:
|
||||
"""Get product classifier service instance"""
|
||||
return ProductClassifierService()
|
||||
93
services/inventory/migrations/alembic.ini
Normal file
93
services/inventory/migrations/alembic.ini
Normal file
@@ -0,0 +1,93 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = migrations
|
||||
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python-dateutil library that can be
|
||||
# installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version number format
|
||||
# Uses Alembic datetime format
|
||||
version_num_format = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d
|
||||
|
||||
# version name format
|
||||
version_path_separator = /
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = postgresql+asyncpg://inventory_user:inventory_pass123@inventory-db:5432/inventory_db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
109
services/inventory/migrations/env.py
Normal file
109
services/inventory/migrations/env.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Alembic environment configuration for Inventory Service
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Add the app directory to the path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
# Import models to ensure they're registered
|
||||
from app.models.inventory import * # noqa
|
||||
from shared.database.base import Base
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Set the SQLAlchemy URL from environment variable if available
|
||||
database_url = os.getenv('INVENTORY_DATABASE_URL')
|
||||
if database_url:
|
||||
config.set_main_option('sqlalchemy.url', database_url)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
"""Run migrations with database connection"""
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""Run migrations in async mode"""
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
24
services/inventory/migrations/script.py.mako
Normal file
24
services/inventory/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Initial inventory tables
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2025-01-15 10:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '001'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create enum types
|
||||
op.execute("""
|
||||
CREATE TYPE unitofmeasure AS ENUM (
|
||||
'kg', 'g', 'l', 'ml', 'units', 'pcs', 'pkg', 'bags', 'boxes'
|
||||
);
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE TYPE ingredientcategory AS ENUM (
|
||||
'flour', 'yeast', 'dairy', 'eggs', 'sugar', 'fats', 'salt',
|
||||
'spices', 'additives', 'packaging', 'cleaning', 'other'
|
||||
);
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE TYPE stockmovementtype AS ENUM (
|
||||
'purchase', 'production_use', 'adjustment', 'waste',
|
||||
'transfer', 'return', 'initial_stock'
|
||||
);
|
||||
""")
|
||||
|
||||
# Create ingredients table
|
||||
op.create_table(
|
||||
'ingredients',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('sku', sa.String(100), nullable=True),
|
||||
sa.Column('barcode', sa.String(50), nullable=True),
|
||||
sa.Column('category', sa.Enum('flour', 'yeast', 'dairy', 'eggs', 'sugar', 'fats', 'salt', 'spices', 'additives', 'packaging', 'cleaning', 'other', name='ingredientcategory'), nullable=False),
|
||||
sa.Column('subcategory', sa.String(100), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('brand', sa.String(100), nullable=True),
|
||||
sa.Column('unit_of_measure', sa.Enum('kg', 'g', 'l', 'ml', 'units', 'pcs', 'pkg', 'bags', 'boxes', name='unitofmeasure'), nullable=False),
|
||||
sa.Column('package_size', sa.Float(), nullable=True),
|
||||
sa.Column('average_cost', sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column('last_purchase_price', sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column('standard_cost', sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column('low_stock_threshold', sa.Float(), nullable=False, server_default='10.0'),
|
||||
sa.Column('reorder_point', sa.Float(), nullable=False, server_default='20.0'),
|
||||
sa.Column('reorder_quantity', sa.Float(), nullable=False, server_default='50.0'),
|
||||
sa.Column('max_stock_level', sa.Float(), nullable=True),
|
||||
sa.Column('requires_refrigeration', sa.Boolean(), nullable=True, server_default='false'),
|
||||
sa.Column('requires_freezing', sa.Boolean(), nullable=True, server_default='false'),
|
||||
sa.Column('storage_temperature_min', sa.Float(), nullable=True),
|
||||
sa.Column('storage_temperature_max', sa.Float(), nullable=True),
|
||||
sa.Column('storage_humidity_max', sa.Float(), nullable=True),
|
||||
sa.Column('shelf_life_days', sa.Integer(), nullable=True),
|
||||
sa.Column('storage_instructions', sa.Text(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True, server_default='true'),
|
||||
sa.Column('is_perishable', sa.Boolean(), nullable=True, server_default='false'),
|
||||
sa.Column('allergen_info', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create stock table
|
||||
op.create_table(
|
||||
'stock',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('ingredient_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('batch_number', sa.String(100), nullable=True),
|
||||
sa.Column('lot_number', sa.String(100), nullable=True),
|
||||
sa.Column('supplier_batch_ref', sa.String(100), nullable=True),
|
||||
sa.Column('current_quantity', sa.Float(), nullable=False, server_default='0.0'),
|
||||
sa.Column('reserved_quantity', sa.Float(), nullable=False, server_default='0.0'),
|
||||
sa.Column('available_quantity', sa.Float(), nullable=False, server_default='0.0'),
|
||||
sa.Column('received_date', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('expiration_date', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('best_before_date', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('unit_cost', sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column('total_cost', sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column('storage_location', sa.String(100), nullable=True),
|
||||
sa.Column('warehouse_zone', sa.String(50), nullable=True),
|
||||
sa.Column('shelf_position', sa.String(50), nullable=True),
|
||||
sa.Column('is_available', sa.Boolean(), nullable=True, server_default='true'),
|
||||
sa.Column('is_expired', sa.Boolean(), nullable=True, server_default='false'),
|
||||
sa.Column('quality_status', sa.String(20), nullable=True, server_default='good'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['ingredient_id'], ['ingredients.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create stock_movements table
|
||||
op.create_table(
|
||||
'stock_movements',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('ingredient_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('stock_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('movement_type', sa.Enum('purchase', 'production_use', 'adjustment', 'waste', 'transfer', 'return', 'initial_stock', name='stockmovementtype'), nullable=False),
|
||||
sa.Column('quantity', sa.Float(), nullable=False),
|
||||
sa.Column('unit_cost', sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column('total_cost', sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column('quantity_before', sa.Float(), nullable=True),
|
||||
sa.Column('quantity_after', sa.Float(), nullable=True),
|
||||
sa.Column('reference_number', sa.String(100), nullable=True),
|
||||
sa.Column('supplier_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('reason_code', sa.String(50), nullable=True),
|
||||
sa.Column('movement_date', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['ingredient_id'], ['ingredients.id'], ),
|
||||
sa.ForeignKeyConstraint(['stock_id'], ['stock.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create stock_alerts table
|
||||
op.create_table(
|
||||
'stock_alerts',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('ingredient_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('stock_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('alert_type', sa.String(50), nullable=False),
|
||||
sa.Column('severity', sa.String(20), nullable=False, server_default='medium'),
|
||||
sa.Column('title', sa.String(255), nullable=False),
|
||||
sa.Column('message', sa.Text(), nullable=False),
|
||||
sa.Column('current_quantity', sa.Float(), nullable=True),
|
||||
sa.Column('threshold_value', sa.Float(), nullable=True),
|
||||
sa.Column('expiration_date', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True, server_default='true'),
|
||||
sa.Column('is_acknowledged', sa.Boolean(), nullable=True, server_default='false'),
|
||||
sa.Column('acknowledged_by', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('acknowledged_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('is_resolved', sa.Boolean(), nullable=True, server_default='false'),
|
||||
sa.Column('resolved_by', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('resolution_notes', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['ingredient_id'], ['ingredients.id'], ),
|
||||
sa.ForeignKeyConstraint(['stock_id'], ['stock.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create indexes for ingredients table
|
||||
op.create_index('idx_ingredients_tenant_name', 'ingredients', ['tenant_id', 'name'], unique=True)
|
||||
op.create_index('idx_ingredients_tenant_sku', 'ingredients', ['tenant_id', 'sku'])
|
||||
op.create_index('idx_ingredients_barcode', 'ingredients', ['barcode'])
|
||||
op.create_index('idx_ingredients_category', 'ingredients', ['tenant_id', 'category', 'is_active'])
|
||||
op.create_index('idx_ingredients_stock_levels', 'ingredients', ['tenant_id', 'low_stock_threshold', 'reorder_point'])
|
||||
|
||||
# Create indexes for stock table
|
||||
op.create_index('idx_stock_tenant_ingredient', 'stock', ['tenant_id', 'ingredient_id'])
|
||||
op.create_index('idx_stock_expiration', 'stock', ['tenant_id', 'expiration_date', 'is_available'])
|
||||
op.create_index('idx_stock_batch', 'stock', ['tenant_id', 'batch_number'])
|
||||
op.create_index('idx_stock_low_levels', 'stock', ['tenant_id', 'current_quantity', 'is_available'])
|
||||
op.create_index('idx_stock_quality', 'stock', ['tenant_id', 'quality_status', 'is_available'])
|
||||
|
||||
# Create indexes for stock_movements table
|
||||
op.create_index('idx_movements_tenant_date', 'stock_movements', ['tenant_id', 'movement_date'])
|
||||
op.create_index('idx_movements_tenant_ingredient', 'stock_movements', ['tenant_id', 'ingredient_id', 'movement_date'])
|
||||
op.create_index('idx_movements_type', 'stock_movements', ['tenant_id', 'movement_type', 'movement_date'])
|
||||
op.create_index('idx_movements_reference', 'stock_movements', ['reference_number'])
|
||||
op.create_index('idx_movements_supplier', 'stock_movements', ['supplier_id', 'movement_date'])
|
||||
|
||||
# Create indexes for stock_alerts table
|
||||
op.create_index('idx_alerts_tenant_active', 'stock_alerts', ['tenant_id', 'is_active', 'created_at'])
|
||||
op.create_index('idx_alerts_type_severity', 'stock_alerts', ['alert_type', 'severity', 'is_active'])
|
||||
op.create_index('idx_alerts_ingredient', 'stock_alerts', ['ingredient_id', 'is_active'])
|
||||
op.create_index('idx_alerts_unresolved', 'stock_alerts', ['tenant_id', 'is_resolved', 'is_active'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop indexes
|
||||
op.drop_index('idx_alerts_unresolved', table_name='stock_alerts')
|
||||
op.drop_index('idx_alerts_ingredient', table_name='stock_alerts')
|
||||
op.drop_index('idx_alerts_type_severity', table_name='stock_alerts')
|
||||
op.drop_index('idx_alerts_tenant_active', table_name='stock_alerts')
|
||||
|
||||
op.drop_index('idx_movements_supplier', table_name='stock_movements')
|
||||
op.drop_index('idx_movements_reference', table_name='stock_movements')
|
||||
op.drop_index('idx_movements_type', table_name='stock_movements')
|
||||
op.drop_index('idx_movements_tenant_ingredient', table_name='stock_movements')
|
||||
op.drop_index('idx_movements_tenant_date', table_name='stock_movements')
|
||||
|
||||
op.drop_index('idx_stock_quality', table_name='stock')
|
||||
op.drop_index('idx_stock_low_levels', table_name='stock')
|
||||
op.drop_index('idx_stock_batch', table_name='stock')
|
||||
op.drop_index('idx_stock_expiration', table_name='stock')
|
||||
op.drop_index('idx_stock_tenant_ingredient', table_name='stock')
|
||||
|
||||
op.drop_index('idx_ingredients_stock_levels', table_name='ingredients')
|
||||
op.drop_index('idx_ingredients_category', table_name='ingredients')
|
||||
op.drop_index('idx_ingredients_barcode', table_name='ingredients')
|
||||
op.drop_index('idx_ingredients_tenant_sku', table_name='ingredients')
|
||||
op.drop_index('idx_ingredients_tenant_name', table_name='ingredients')
|
||||
|
||||
# Drop tables
|
||||
op.drop_table('stock_alerts')
|
||||
op.drop_table('stock_movements')
|
||||
op.drop_table('stock')
|
||||
op.drop_table('ingredients')
|
||||
|
||||
# Drop enum types
|
||||
op.execute("DROP TYPE stockmovementtype;")
|
||||
op.execute("DROP TYPE ingredientcategory;")
|
||||
op.execute("DROP TYPE unitofmeasure;")
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Add finished products support
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2025-01-15 10:30:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '002'
|
||||
down_revision = '001'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create new enum types for finished products
|
||||
op.execute("""
|
||||
CREATE TYPE producttype AS ENUM (
|
||||
'ingredient', 'finished_product'
|
||||
);
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE TYPE productcategory AS ENUM (
|
||||
'bread', 'croissants', 'pastries', 'cakes', 'cookies',
|
||||
'muffins', 'sandwiches', 'seasonal', 'beverages', 'other_products'
|
||||
);
|
||||
""")
|
||||
|
||||
# Add new columns to ingredients table
|
||||
op.add_column('ingredients', sa.Column('product_type',
|
||||
sa.Enum('ingredient', 'finished_product', name='producttype'),
|
||||
nullable=False, server_default='ingredient'))
|
||||
|
||||
op.add_column('ingredients', sa.Column('product_category',
|
||||
sa.Enum('bread', 'croissants', 'pastries', 'cakes', 'cookies', 'muffins', 'sandwiches', 'seasonal', 'beverages', 'other_products', name='productcategory'),
|
||||
nullable=True))
|
||||
|
||||
# Rename existing category column to ingredient_category
|
||||
op.alter_column('ingredients', 'category', new_column_name='ingredient_category')
|
||||
|
||||
# Add finished product specific columns
|
||||
op.add_column('ingredients', sa.Column('supplier_name', sa.String(200), nullable=True))
|
||||
op.add_column('ingredients', sa.Column('display_life_hours', sa.Integer(), nullable=True))
|
||||
op.add_column('ingredients', sa.Column('best_before_hours', sa.Integer(), nullable=True))
|
||||
op.add_column('ingredients', sa.Column('central_baker_product_code', sa.String(100), nullable=True))
|
||||
op.add_column('ingredients', sa.Column('delivery_days', sa.String(20), nullable=True))
|
||||
op.add_column('ingredients', sa.Column('minimum_order_quantity', sa.Float(), nullable=True))
|
||||
op.add_column('ingredients', sa.Column('pack_size', sa.Integer(), nullable=True))
|
||||
op.add_column('ingredients', sa.Column('nutritional_info', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
||||
|
||||
# Update existing indexes and create new ones
|
||||
op.drop_index('idx_ingredients_category', table_name='ingredients')
|
||||
|
||||
# Create new indexes for enhanced functionality
|
||||
op.create_index('idx_ingredients_product_type', 'ingredients', ['tenant_id', 'product_type', 'is_active'])
|
||||
op.create_index('idx_ingredients_ingredient_category', 'ingredients', ['tenant_id', 'ingredient_category', 'is_active'])
|
||||
op.create_index('idx_ingredients_product_category', 'ingredients', ['tenant_id', 'product_category', 'is_active'])
|
||||
op.create_index('idx_ingredients_central_baker', 'ingredients', ['tenant_id', 'supplier_name', 'product_type'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop new indexes
|
||||
op.drop_index('idx_ingredients_central_baker', table_name='ingredients')
|
||||
op.drop_index('idx_ingredients_product_category', table_name='ingredients')
|
||||
op.drop_index('idx_ingredients_ingredient_category', table_name='ingredients')
|
||||
op.drop_index('idx_ingredients_product_type', table_name='ingredients')
|
||||
|
||||
# Remove finished product specific columns
|
||||
op.drop_column('ingredients', 'nutritional_info')
|
||||
op.drop_column('ingredients', 'pack_size')
|
||||
op.drop_column('ingredients', 'minimum_order_quantity')
|
||||
op.drop_column('ingredients', 'delivery_days')
|
||||
op.drop_column('ingredients', 'central_baker_product_code')
|
||||
op.drop_column('ingredients', 'best_before_hours')
|
||||
op.drop_column('ingredients', 'display_life_hours')
|
||||
op.drop_column('ingredients', 'supplier_name')
|
||||
|
||||
# Remove new columns
|
||||
op.drop_column('ingredients', 'product_category')
|
||||
op.drop_column('ingredients', 'product_type')
|
||||
|
||||
# Rename ingredient_category back to category
|
||||
op.alter_column('ingredients', 'ingredient_category', new_column_name='category')
|
||||
|
||||
# Recreate original category index
|
||||
op.create_index('idx_ingredients_category', 'ingredients', ['tenant_id', 'category', 'is_active'])
|
||||
|
||||
# Drop new enum types
|
||||
op.execute("DROP TYPE productcategory;")
|
||||
op.execute("DROP TYPE producttype;")
|
||||
41
services/inventory/requirements.txt
Normal file
41
services/inventory/requirements.txt
Normal file
@@ -0,0 +1,41 @@
|
||||
# services/inventory/requirements.txt
|
||||
# FastAPI and web framework
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
|
||||
# Database
|
||||
sqlalchemy==2.0.23
|
||||
psycopg2-binary==2.9.9
|
||||
asyncpg==0.29.0
|
||||
aiosqlite==0.19.0
|
||||
alembic==1.12.1
|
||||
|
||||
# Data processing
|
||||
pandas==2.1.3
|
||||
numpy==1.25.2
|
||||
|
||||
# HTTP clients
|
||||
httpx==0.25.2
|
||||
aiofiles==23.2.0
|
||||
|
||||
# Validation and serialization
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.0.3
|
||||
|
||||
# Authentication and security
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
|
||||
# Logging and monitoring
|
||||
structlog==23.2.0
|
||||
prometheus-client==0.19.0
|
||||
|
||||
# Message queues
|
||||
aio-pika==9.3.1
|
||||
|
||||
# Additional for inventory management
|
||||
python-barcode==0.15.1
|
||||
qrcode[pil]==7.4.2
|
||||
|
||||
# Development
|
||||
python-multipart==0.0.6
|
||||
Reference in New Issue
Block a user