Improve the inventory page

This commit is contained in:
Urtzi Alfaro
2025-09-17 16:06:30 +02:00
parent 7aa26d51d3
commit dcb3ce441b
39 changed files with 5852 additions and 1762 deletions

View File

@@ -64,6 +64,15 @@ class ProductType(enum.Enum):
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"
@@ -73,6 +82,7 @@ class StockMovementType(enum.Enum):
TRANSFER = "transfer"
RETURN = "return"
INITIAL_STOCK = "initial_stock"
TRANSFORMATION = "transformation" # Converting between production stages
class Ingredient(Base):
@@ -227,16 +237,20 @@ class Ingredient(Base):
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)
# Production stage tracking
production_stage = Column(String(20), 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
@@ -246,6 +260,11 @@ class Stock(Base):
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)
@@ -276,6 +295,9 @@ class Stock(Base):
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]:
@@ -287,12 +309,17 @@ class Stock(Base):
'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,
@@ -375,6 +402,83 @@ class StockMovement(Base):
}
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(String(20), nullable=False)
target_stage = Column(String(20), 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"