Initial commit - production deployment
This commit is contained in:
564
services/inventory/app/models/inventory.py
Normal file
564
services/inventory/app/models/inventory.py
Normal file
@@ -0,0 +1,564 @@
|
||||
# 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 = "KILOGRAMS"
|
||||
GRAMS = "GRAMS"
|
||||
LITERS = "LITERS"
|
||||
MILLILITERS = "MILLILITERS"
|
||||
UNITS = "UNITS"
|
||||
PIECES = "PIECES"
|
||||
PACKAGES = "PACKAGES"
|
||||
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 ProductionStage(enum.Enum):
|
||||
"""Production stages for bakery products"""
|
||||
RAW_INGREDIENT = "raw_ingredient" # Basic ingredients (flour, yeast)
|
||||
PAR_BAKED = "par_baked" # Pre-baked items needing final baking
|
||||
FULLY_BAKED = "fully_baked" # Completed products ready for sale
|
||||
PREPARED_DOUGH = "prepared_dough" # Prepared but unbaked dough
|
||||
FROZEN_PRODUCT = "frozen_product" # Frozen intermediate products
|
||||
|
||||
|
||||
class StockMovementType(enum.Enum):
|
||||
"""Types of inventory movements"""
|
||||
PURCHASE = "PURCHASE"
|
||||
PRODUCTION_USE = "PRODUCTION_USE"
|
||||
TRANSFORMATION = "TRANSFORMATION" # Converting between production stages
|
||||
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
|
||||
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 - now optional to simplify onboarding
|
||||
# These can be configured later based on actual usage patterns
|
||||
low_stock_threshold = Column(Float, nullable=True, default=None)
|
||||
reorder_point = Column(Float, nullable=True, default=None)
|
||||
reorder_quantity = Column(Float, nullable=True, default=None)
|
||||
max_stock_level = Column(Float, nullable=True)
|
||||
|
||||
# Shelf life (critical for finished products) - default values only
|
||||
shelf_life_days = Column(Integer, nullable=True) # Default shelf life - actual per batch
|
||||
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
|
||||
|
||||
# NEW: Local production support (for procurement service integration)
|
||||
produced_locally = Column(Boolean, default=False, nullable=False) # If true, ingredient is produced in-house
|
||||
recipe_id = Column(UUID(as_uuid=True), nullable=True) # Links to recipe for BOM explosion
|
||||
|
||||
# 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'),
|
||||
)
|
||||
|
||||
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,
|
||||
'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,
|
||||
'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 if self.is_active is not None else True,
|
||||
'is_perishable': self.is_perishable if self.is_perishable is not None else False,
|
||||
'allergen_info': self.allergen_info,
|
||||
'nutritional_info': self.nutritional_info,
|
||||
# NEW: Local production support
|
||||
'produced_locally': self.produced_locally if self.produced_locally is not None else False,
|
||||
'recipe_id': str(self.recipe_id) if self.recipe_id else None,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else datetime.now(timezone.utc).isoformat(),
|
||||
'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)
|
||||
|
||||
# Supplier association
|
||||
supplier_id = Column(UUID(as_uuid=True), nullable=True, 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)
|
||||
|
||||
# Production stage tracking
|
||||
production_stage = Column(SQLEnum('raw_ingredient', 'par_baked', 'fully_baked', 'prepared_dough', 'frozen_product', name='productionstage', create_type=False), nullable=False, default='raw_ingredient', index=True)
|
||||
transformation_reference = Column(String(100), nullable=True, index=True) # Links related transformations
|
||||
|
||||
# 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)
|
||||
|
||||
# Stage-specific expiration tracking
|
||||
original_expiration_date = Column(DateTime(timezone=True), nullable=True) # Original batch expiration (for par-baked)
|
||||
transformation_date = Column(DateTime(timezone=True), nullable=True) # When product was transformed
|
||||
final_expiration_date = Column(DateTime(timezone=True), nullable=True) # Final product expiration after transformation
|
||||
|
||||
# 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)
|
||||
|
||||
# Batch-specific storage requirements
|
||||
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_days = Column(Integer, nullable=True) # Batch-specific shelf life
|
||||
storage_instructions = Column(Text, nullable=True) # Batch-specific instructions
|
||||
|
||||
# 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'),
|
||||
Index('idx_stock_production_stage', 'tenant_id', 'production_stage', 'is_available'),
|
||||
Index('idx_stock_transformation', 'tenant_id', 'transformation_reference'),
|
||||
Index('idx_stock_final_expiration', 'tenant_id', 'final_expiration_date', '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),
|
||||
'supplier_id': str(self.supplier_id) if self.supplier_id else None,
|
||||
'batch_number': self.batch_number,
|
||||
'lot_number': self.lot_number,
|
||||
'supplier_batch_ref': self.supplier_batch_ref,
|
||||
'production_stage': self.production_stage if self.production_stage else None,
|
||||
'transformation_reference': self.transformation_reference,
|
||||
'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,
|
||||
'original_expiration_date': self.original_expiration_date.isoformat() if self.original_expiration_date else None,
|
||||
'transformation_date': self.transformation_date.isoformat() if self.transformation_date else None,
|
||||
'final_expiration_date': self.final_expiration_date.isoformat() if self.final_expiration_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,
|
||||
'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,
|
||||
'storage_instructions': self.storage_instructions,
|
||||
'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, name='stockmovementtype', create_type=False), 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 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 ProductTransformation(Base):
|
||||
"""Track product transformations (e.g., par-baked to fully baked)"""
|
||||
__tablename__ = "product_transformations"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
|
||||
# Transformation details
|
||||
transformation_reference = Column(String(100), nullable=False, index=True)
|
||||
source_ingredient_id = Column(UUID(as_uuid=True), ForeignKey('ingredients.id'), nullable=False)
|
||||
target_ingredient_id = Column(UUID(as_uuid=True), ForeignKey('ingredients.id'), nullable=False)
|
||||
|
||||
# Stage transformation
|
||||
source_stage = Column(SQLEnum('raw_ingredient', 'par_baked', 'fully_baked', 'prepared_dough', 'frozen_product', name='productionstage', create_type=False), nullable=False)
|
||||
target_stage = Column(SQLEnum('raw_ingredient', 'par_baked', 'fully_baked', 'prepared_dough', 'frozen_product', name='productionstage', create_type=False), nullable=False)
|
||||
|
||||
# Quantities and conversion
|
||||
source_quantity = Column(Float, nullable=False) # Input quantity
|
||||
target_quantity = Column(Float, nullable=False) # Output quantity
|
||||
conversion_ratio = Column(Float, nullable=False, default=1.0) # target/source ratio
|
||||
|
||||
# Expiration logic
|
||||
expiration_calculation_method = Column(String(50), nullable=False, default="days_from_transformation") # days_from_transformation, preserve_original
|
||||
expiration_days_offset = Column(Integer, nullable=True) # Days from transformation date
|
||||
|
||||
# Process tracking
|
||||
transformation_date = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
process_notes = Column(Text, nullable=True)
|
||||
performed_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
|
||||
# Batch tracking
|
||||
source_batch_numbers = Column(Text, nullable=True) # JSON array of source batch numbers
|
||||
target_batch_number = Column(String(100), nullable=True)
|
||||
|
||||
# Status
|
||||
is_completed = Column(Boolean, default=True)
|
||||
is_reversed = Column(Boolean, default=False)
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
created_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_transformations_tenant_date', 'tenant_id', 'transformation_date'),
|
||||
Index('idx_transformations_reference', 'transformation_reference'),
|
||||
Index('idx_transformations_source', 'tenant_id', 'source_ingredient_id'),
|
||||
Index('idx_transformations_target', 'tenant_id', 'target_ingredient_id'),
|
||||
Index('idx_transformations_stages', 'source_stage', 'target_stage'),
|
||||
)
|
||||
|
||||
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),
|
||||
'transformation_reference': self.transformation_reference,
|
||||
'source_ingredient_id': str(self.source_ingredient_id),
|
||||
'target_ingredient_id': str(self.target_ingredient_id),
|
||||
'source_stage': self.source_stage if self.source_stage else None,
|
||||
'target_stage': self.target_stage if self.target_stage else None,
|
||||
'source_quantity': self.source_quantity,
|
||||
'target_quantity': self.target_quantity,
|
||||
'conversion_ratio': self.conversion_ratio,
|
||||
'expiration_calculation_method': self.expiration_calculation_method,
|
||||
'expiration_days_offset': self.expiration_days_offset,
|
||||
'transformation_date': self.transformation_date.isoformat() if self.transformation_date else None,
|
||||
'process_notes': self.process_notes,
|
||||
'performed_by': str(self.performed_by) if self.performed_by else None,
|
||||
'source_batch_numbers': self.source_batch_numbers,
|
||||
'target_batch_number': self.target_batch_number,
|
||||
'is_completed': self.is_completed,
|
||||
'is_reversed': self.is_reversed,
|
||||
'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,
|
||||
}
|
||||
Reference in New Issue
Block a user