# 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 # ===== 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]