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