428 lines
20 KiB
Python
428 lines
20 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"""
|
|
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,
|
|
} |