Create new services: inventory, recipes, suppliers

This commit is contained in:
Urtzi Alfaro
2025-08-13 17:39:35 +02:00
parent fbe7470ad9
commit 16b8a9d50c
151 changed files with 35799 additions and 857 deletions

View 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"]

View File

View File

View 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)}")

View 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"
)

View 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"
)

View File

View 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()

View 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()

View 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"
)

View 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,
}

View 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

View 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

View 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

View 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]

View 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

View 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
)

View 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()

View 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

View 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()

View 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"}

View File

@@ -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;")

View File

@@ -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;")

View 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