Files
bakery-ia/services/inventory/app/models/inventory.py
2025-09-15 15:31:27 +02:00

447 lines
21 KiB
Python

# 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"""
# Map to response schema format - use appropriate category based on product type
category = None
if self.product_type == ProductType.FINISHED_PRODUCT and self.product_category:
# For finished products, use product_category
category = self.product_category.value
elif self.product_type == ProductType.INGREDIENT and self.ingredient_category:
# For ingredients, use ingredient_category
category = self.ingredient_category.value
elif self.ingredient_category and self.ingredient_category != IngredientCategory.OTHER:
# If ingredient_category is set and not 'OTHER', use it
category = self.ingredient_category.value
elif self.product_category:
# Fall back to product_category if available
category = self.product_category.value
else:
# Final fallback
category = "other"
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,
'category': category, # Map to what IngredientResponse expects
'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,
}