Files
bakery-ia/services/inventory/app/schemas/inventory.py
2025-12-25 18:35:37 +01:00

607 lines
25 KiB
Python

# 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 uuid import UUID
from pydantic import BaseModel, Field, validator
from typing import Generic, TypeVar
from enum import Enum
from app.models.inventory import UnitOfMeasure, IngredientCategory, StockMovementType, ProductType, ProductCategory, ProductionStage
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 and finished products"""
name: str = Field(..., max_length=255, description="Product name")
product_type: ProductType = Field(ProductType.INGREDIENT, description="Type of product (ingredient or finished_product)")
sku: Optional[str] = Field(None, max_length=100, description="SKU code")
barcode: Optional[str] = Field(None, max_length=50, description="Barcode")
category: Optional[str] = Field(None, description="Product category (ingredient or finished product 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
# Note: average_cost is calculated automatically from purchases (not set on create)
# All cost fields are optional - can be added later after onboarding
standard_cost: Optional[Decimal] = Field(None, ge=0, description="Standard/target cost per unit for budgeting")
# Stock management - all optional with sensible defaults for onboarding
# These can be configured later based on actual usage patterns
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")
# Shelf life (default value only - actual per batch)
shelf_life_days: Optional[int] = Field(None, gt=0, description="Default shelf life in days")
# Properties
is_perishable: bool = Field(False, description="Is perishable")
allergen_info: Optional[Dict[str, Any]] = Field(None, description="Allergen information")
# NEW: Local production support
produced_locally: bool = Field(False, description="If true, ingredient is produced in-house")
recipe_id: Optional[str] = Field(None, description="Recipe ID for BOM explosion (if produced locally)")
@validator('reorder_point')
def validate_reorder_point(cls, v, values):
# Only validate if both values are provided and not None
low_stock = values.get('low_stock_threshold')
if v is not None and low_stock is not None:
try:
if v <= low_stock:
raise ValueError('Reorder point must be greater than low stock threshold')
except TypeError:
# Skip validation if comparison fails due to type mismatch
pass
return v
class IngredientUpdate(InventoryBaseSchema):
"""Schema for updating ingredients and finished products"""
name: Optional[str] = Field(None, max_length=255, description="Product name")
product_type: Optional[ProductType] = Field(None, description="Type of product (ingredient or finished_product)")
sku: Optional[str] = Field(None, max_length=100, description="SKU code")
barcode: Optional[str] = Field(None, max_length=50, description="Barcode")
category: Optional[str] = Field(None, description="Product 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")
# Shelf life (default value only - actual per batch)
shelf_life_days: Optional[int] = Field(None, gt=0, description="Default shelf life in days")
# 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")
# NEW: Local production support
produced_locally: Optional[bool] = Field(None, description="If true, ingredient is produced in-house")
recipe_id: Optional[str] = Field(None, description="Recipe ID for BOM explosion (if produced locally)")
class IngredientResponse(InventoryBaseSchema):
"""Schema for ingredient and finished product API responses"""
id: str
tenant_id: str
name: str
product_type: ProductType
sku: Optional[str]
barcode: Optional[str]
category: Optional[str] # Will be populated from ingredient_category or product_category
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: Optional[float] # Now optional
reorder_point: Optional[float] # Now optional
reorder_quantity: Optional[float] # Now optional
max_stock_level: Optional[float]
shelf_life_days: Optional[int] # Default value only
is_active: bool
is_perishable: bool
allergen_info: Optional[Dict[str, Any]]
# NEW: Local production support
produced_locally: bool = False
recipe_id: Optional[str] = None
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
@validator('allergen_info', pre=True)
def validate_allergen_info(cls, v):
"""Convert empty lists or lists to empty dict, handle None"""
if v is None:
return None
if isinstance(v, list):
# If it's an empty list, return None; if it's a non-empty list, convert to dict format
return {"allergens": v} if v else None
if isinstance(v, dict):
return v
# For any other type including invalid ones, return None
return None
# ===== BULK INGREDIENT SCHEMAS =====
class BulkIngredientCreate(InventoryBaseSchema):
"""Schema for bulk creating ingredients"""
ingredients: List[IngredientCreate] = Field(..., description="List of ingredients to create")
class BulkIngredientResult(InventoryBaseSchema):
"""Schema for individual result in bulk operation"""
index: int = Field(..., description="Index of the ingredient in the original request")
success: bool = Field(..., description="Whether the creation succeeded")
ingredient: Optional[IngredientResponse] = Field(None, description="Created ingredient (if successful)")
error: Optional[str] = Field(None, description="Error message (if failed)")
class BulkIngredientResponse(InventoryBaseSchema):
"""Schema for bulk ingredient creation response"""
total_requested: int = Field(..., description="Total number of ingredients requested")
total_created: int = Field(..., description="Number of ingredients successfully created")
total_failed: int = Field(..., description="Number of ingredients that failed")
results: List[BulkIngredientResult] = Field(..., description="Detailed results for each ingredient")
transaction_id: str = Field(..., description="Transaction ID for audit trail")
# ===== STOCK SCHEMAS =====
class StockCreate(InventoryBaseSchema):
"""Schema for creating stock entries"""
ingredient_id: str = Field(..., description="Ingredient ID")
supplier_id: Optional[str] = Field(None, description="Supplier 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")
# Production stage tracking
production_stage: ProductionStage = Field(default=ProductionStage.RAW_INGREDIENT, description="Production stage of the stock")
transformation_reference: Optional[str] = Field(None, max_length=100, description="Transformation reference ID")
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")
# Stage-specific expiration fields
original_expiration_date: Optional[datetime] = Field(None, description="Original batch expiration (for par-baked items)")
transformation_date: Optional[datetime] = Field(None, description="Date when product was transformed")
final_expiration_date: Optional[datetime] = Field(None, description="Final expiration after transformation")
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")
# Batch-specific 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_days: Optional[int] = Field(None, gt=0, description="Batch-specific shelf life in days")
storage_instructions: Optional[str] = Field(None, description="Batch-specific storage instructions")
@validator('ingredient_id')
def validate_ingredient_id(cls, v):
"""Validate ingredient_id is a valid UUID"""
if not v:
raise ValueError("ingredient_id is required")
if isinstance(v, str):
try:
# Validate it's a proper UUID
UUID(v)
return v
except (ValueError, AttributeError) as e:
raise ValueError(f"ingredient_id must be a valid UUID string, got: {v}")
return str(v)
@validator('supplier_id')
def validate_supplier_id(cls, v):
"""Convert empty string to None for optional UUID field"""
if v == '' or (isinstance(v, str) and v.strip() == ''):
return None
return v
@validator('storage_temperature_max')
def validate_temperature_range(cls, v, values):
# Only validate if both values are provided and not None
min_temp = values.get('storage_temperature_min')
if v is not None and min_temp is not None:
try:
if v <= min_temp:
raise ValueError('Max temperature must be greater than min temperature')
except TypeError:
# Skip validation if comparison fails due to type mismatch
pass
return v
class StockUpdate(InventoryBaseSchema):
"""Schema for updating stock entries"""
supplier_id: Optional[str] = Field(None, description="Supplier 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")
# Production stage tracking
production_stage: Optional[ProductionStage] = Field(None, description="Production stage of the stock")
transformation_reference: Optional[str] = Field(None, max_length=100, description="Transformation reference ID")
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")
# Stage-specific expiration fields
original_expiration_date: Optional[datetime] = Field(None, description="Original batch expiration (for par-baked items)")
transformation_date: Optional[datetime] = Field(None, description="Date when product was transformed")
final_expiration_date: Optional[datetime] = Field(None, description="Final expiration after transformation")
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")
# Batch-specific 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_days: Optional[int] = Field(None, gt=0, description="Batch-specific shelf life in days")
storage_instructions: Optional[str] = Field(None, description="Batch-specific storage instructions")
@validator('supplier_id')
def validate_supplier_id(cls, v):
"""Convert empty string to None for optional UUID field"""
if v == '' or (isinstance(v, str) and v.strip() == ''):
return None
return v
class StockResponse(InventoryBaseSchema):
"""Schema for stock API responses"""
id: str
tenant_id: str
ingredient_id: str
supplier_id: Optional[str]
batch_number: Optional[str]
lot_number: Optional[str]
supplier_batch_ref: Optional[str]
# Production stage tracking
production_stage: ProductionStage
transformation_reference: Optional[str]
current_quantity: float
reserved_quantity: float
available_quantity: float
received_date: Optional[datetime]
expiration_date: Optional[datetime]
best_before_date: Optional[datetime]
# Stage-specific expiration fields
original_expiration_date: Optional[datetime]
transformation_date: Optional[datetime]
final_expiration_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
# Batch-specific storage requirements
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]
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")
@validator('ingredient_id')
def validate_ingredient_id(cls, v):
"""Validate ingredient_id is a valid UUID"""
if not v:
raise ValueError("ingredient_id is required")
if isinstance(v, str):
try:
# Validate it's a proper UUID
UUID(v)
return v
except (ValueError, AttributeError) as e:
raise ValueError(f"ingredient_id must be a valid UUID string, got: {v}")
return str(v)
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
# ===== PRODUCT TRANSFORMATION SCHEMAS =====
class ProductTransformationCreate(InventoryBaseSchema):
"""Schema for creating product transformations"""
source_ingredient_id: str = Field(..., description="Source ingredient ID")
target_ingredient_id: str = Field(..., description="Target ingredient ID")
source_stage: ProductionStage = Field(..., description="Source production stage")
target_stage: ProductionStage = Field(..., description="Target production stage")
source_quantity: float = Field(..., gt=0, description="Input quantity")
target_quantity: float = Field(..., gt=0, description="Output quantity")
conversion_ratio: Optional[float] = Field(None, gt=0, description="Conversion ratio (auto-calculated if not provided)")
# Expiration handling
expiration_calculation_method: str = Field("days_from_transformation", description="How to calculate expiration")
expiration_days_offset: Optional[int] = Field(1, description="Days from transformation date for expiration")
# Process details
process_notes: Optional[str] = Field(None, description="Process notes")
target_batch_number: Optional[str] = Field(None, max_length=100, description="Target batch number")
# Source stock selection (optional - if not provided, uses FIFO)
source_stock_ids: Optional[List[str]] = Field(None, description="Specific source stock IDs to transform")
@validator('source_ingredient_id', 'target_ingredient_id')
def validate_ingredient_ids(cls, v):
"""Validate ingredient IDs are valid UUIDs"""
if not v:
raise ValueError("ingredient_id is required")
if isinstance(v, str):
try:
# Validate it's a proper UUID
UUID(v)
return v
except (ValueError, AttributeError) as e:
raise ValueError(f"ingredient_id must be a valid UUID string, got: {v}")
return str(v)
class ProductTransformationResponse(InventoryBaseSchema):
"""Schema for product transformation responses"""
id: str
tenant_id: str
transformation_reference: str
source_ingredient_id: str
target_ingredient_id: str
source_stage: ProductionStage
target_stage: ProductionStage
source_quantity: float
target_quantity: float
conversion_ratio: float
expiration_calculation_method: str
expiration_days_offset: Optional[int]
transformation_date: datetime
process_notes: Optional[str]
performed_by: Optional[str]
source_batch_numbers: Optional[str]
target_batch_number: Optional[str]
is_completed: bool
is_reversed: bool
created_at: datetime
created_by: Optional[str]
# Related data
source_ingredient: Optional[IngredientResponse] = None
target_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
production_stage: Optional[ProductionStage] = None
transformation_reference: 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]