Initial commit - production deployment

This commit is contained in:
2026-01-21 17:17:16 +01:00
commit c23d00dd92
2289 changed files with 638440 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
"""
Inventory Service Models Package
Import all models to ensure they are registered with SQLAlchemy Base.
"""
# Import AuditLog model for this service
from shared.security import create_audit_log_model
from shared.database.base import Base
# Create audit log model for this service
AuditLog = create_audit_log_model(Base)
# Import all models to register them with the Base metadata
from .inventory import (
Ingredient,
Stock,
StockMovement,
ProductTransformation,
StockAlert,
UnitOfMeasure,
IngredientCategory,
ProductCategory,
ProductType,
ProductionStage,
StockMovementType,
)
from .food_safety import (
FoodSafetyCompliance,
TemperatureLog,
FoodSafetyAlert,
FoodSafetyStandard,
ComplianceStatus,
FoodSafetyAlertType,
)
from .stock_receipt import (
StockReceipt,
StockReceiptLineItem,
StockLot,
ReceiptStatus,
)
# List all models for easier access
__all__ = [
# Inventory models
"Ingredient",
"Stock",
"StockMovement",
"ProductTransformation",
"StockAlert",
# Inventory enums
"UnitOfMeasure",
"IngredientCategory",
"ProductCategory",
"ProductType",
"ProductionStage",
"StockMovementType",
# Food safety models
"FoodSafetyCompliance",
"TemperatureLog",
"FoodSafetyAlert",
# Food safety enums
"FoodSafetyStandard",
"ComplianceStatus",
"FoodSafetyAlertType",
# Stock receipt models
"StockReceipt",
"StockReceiptLineItem",
"StockLot",
"ReceiptStatus",
"AuditLog",
]

View File

@@ -0,0 +1,369 @@
# ================================================================
# services/inventory/app/models/food_safety.py
# ================================================================
"""
Food safety and compliance models for Inventory Service
"""
import uuid
import enum
from datetime import datetime
from typing import Dict, Any, Optional
from sqlalchemy import Column, String, DateTime, Float, Integer, Text, Boolean, Numeric, ForeignKey, Enum as SQLEnum
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from shared.database.base import Base
class FoodSafetyStandard(enum.Enum):
"""Food safety standards and certifications"""
HACCP = "haccp"
FDA = "fda"
USDA = "usda"
FSMA = "fsma"
SQF = "sqf"
BRC = "brc"
IFS = "ifs"
ISO22000 = "iso22000"
ORGANIC = "organic"
NON_GMO = "non_gmo"
ALLERGEN_FREE = "allergen_free"
KOSHER = "kosher"
HALAL = "halal"
class ComplianceStatus(enum.Enum):
"""Compliance status for food safety requirements"""
COMPLIANT = "compliant"
NON_COMPLIANT = "non_compliant"
PENDING_REVIEW = "pending_review"
EXPIRED = "expired"
WARNING = "warning"
class FoodSafetyAlertType(enum.Enum):
"""Types of food safety alerts"""
TEMPERATURE_VIOLATION = "temperature_violation"
EXPIRATION_WARNING = "expiration_warning"
EXPIRED_PRODUCT = "expired_product"
CONTAMINATION_RISK = "contamination_risk"
ALLERGEN_CROSS_CONTAMINATION = "allergen_cross_contamination"
STORAGE_VIOLATION = "storage_violation"
QUALITY_DEGRADATION = "quality_degradation"
RECALL_NOTICE = "recall_notice"
CERTIFICATION_EXPIRY = "certification_expiry"
SUPPLIER_COMPLIANCE_ISSUE = "supplier_compliance_issue"
class FoodSafetyCompliance(Base):
"""Food safety compliance tracking for ingredients and products"""
__tablename__ = "food_safety_compliance"
# Primary identification
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)
# Compliance standard
standard = Column(SQLEnum(FoodSafetyStandard), nullable=False, index=True)
compliance_status = Column(SQLEnum(ComplianceStatus), nullable=False, default=ComplianceStatus.PENDING_REVIEW)
# Certification details
certification_number = Column(String(100), nullable=True)
certifying_body = Column(String(200), nullable=True)
certification_date = Column(DateTime(timezone=True), nullable=True)
expiration_date = Column(DateTime(timezone=True), nullable=True, index=True)
# Compliance requirements
requirements = Column(JSONB, nullable=True) # Specific requirements for this standard
compliance_notes = Column(Text, nullable=True)
documentation_url = Column(String(500), nullable=True)
# Audit information
last_audit_date = Column(DateTime(timezone=True), nullable=True)
next_audit_date = Column(DateTime(timezone=True), nullable=True, index=True)
auditor_name = Column(String(200), nullable=True)
audit_score = Column(Float, nullable=True) # 0-100 score
# Risk assessment
risk_level = Column(String(20), nullable=False, default="medium") # low, medium, high, critical
risk_factors = Column(JSONB, nullable=True) # List of identified risk factors
mitigation_measures = Column(JSONB, nullable=True) # Implemented mitigation measures
# Status tracking
is_active = Column(Boolean, nullable=False, default=True)
requires_monitoring = Column(Boolean, nullable=False, default=True)
monitoring_frequency_days = Column(Integer, nullable=True) # How often to check compliance
# Audit fields
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
created_by = Column(UUID(as_uuid=True), nullable=True)
updated_by = Column(UUID(as_uuid=True), nullable=True)
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),
'standard': self.standard.value if self.standard else None,
'compliance_status': self.compliance_status.value if self.compliance_status else None,
'certification_number': self.certification_number,
'certifying_body': self.certifying_body,
'certification_date': self.certification_date.isoformat() if self.certification_date else None,
'expiration_date': self.expiration_date.isoformat() if self.expiration_date else None,
'requirements': self.requirements,
'compliance_notes': self.compliance_notes,
'documentation_url': self.documentation_url,
'last_audit_date': self.last_audit_date.isoformat() if self.last_audit_date else None,
'next_audit_date': self.next_audit_date.isoformat() if self.next_audit_date else None,
'auditor_name': self.auditor_name,
'audit_score': self.audit_score,
'risk_level': self.risk_level,
'risk_factors': self.risk_factors,
'mitigation_measures': self.mitigation_measures,
'is_active': self.is_active,
'requires_monitoring': self.requires_monitoring,
'monitoring_frequency_days': self.monitoring_frequency_days,
'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,
'updated_by': str(self.updated_by) if self.updated_by else None,
}
class TemperatureLog(Base):
"""Temperature monitoring logs for storage areas"""
__tablename__ = "temperature_logs"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Location information
storage_location = Column(String(100), nullable=False, index=True)
warehouse_zone = Column(String(50), nullable=True)
equipment_id = Column(String(100), nullable=True) # Freezer/refrigerator ID
# Temperature readings
temperature_celsius = Column(Float, nullable=False)
humidity_percentage = Column(Float, nullable=True)
target_temperature_min = Column(Float, nullable=True)
target_temperature_max = Column(Float, nullable=True)
# Status and alerts
is_within_range = Column(Boolean, nullable=False, default=True)
alert_triggered = Column(Boolean, nullable=False, default=False)
deviation_minutes = Column(Integer, nullable=True) # How long outside range
# Measurement details
measurement_method = Column(String(50), nullable=False, default="manual") # manual, automatic, sensor
device_id = Column(String(100), nullable=True)
calibration_date = Column(DateTime(timezone=True), nullable=True)
# Timestamp
recorded_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
# Audit fields
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
recorded_by = Column(UUID(as_uuid=True), nullable=True)
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),
'storage_location': self.storage_location,
'warehouse_zone': self.warehouse_zone,
'equipment_id': self.equipment_id,
'temperature_celsius': self.temperature_celsius,
'humidity_percentage': self.humidity_percentage,
'target_temperature_min': self.target_temperature_min,
'target_temperature_max': self.target_temperature_max,
'is_within_range': self.is_within_range,
'alert_triggered': self.alert_triggered,
'deviation_minutes': self.deviation_minutes,
'measurement_method': self.measurement_method,
'device_id': self.device_id,
'calibration_date': self.calibration_date.isoformat() if self.calibration_date else None,
'recorded_at': self.recorded_at.isoformat() if self.recorded_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'recorded_by': str(self.recorded_by) if self.recorded_by else None,
}
class FoodSafetyAlert(Base):
"""Food safety alerts and notifications"""
__tablename__ = "food_safety_alerts"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
alert_code = Column(String(50), nullable=False, index=True)
# Alert classification
alert_type = Column(SQLEnum(FoodSafetyAlertType), nullable=False, index=True)
severity = Column(String(20), nullable=False, default="medium", index=True) # low, medium, high, critical
risk_level = Column(String(20), nullable=False, default="medium")
# Source information
source_entity_type = Column(String(50), nullable=False) # ingredient, stock, temperature_log, compliance
source_entity_id = Column(UUID(as_uuid=True), nullable=False, index=True)
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredients.id"), nullable=True, index=True)
stock_id = Column(UUID(as_uuid=True), ForeignKey("stock.id"), nullable=True, index=True)
# Alert content
title = Column(String(200), nullable=False)
description = Column(Text, nullable=False)
detailed_message = Column(Text, nullable=True)
# Regulatory and compliance context
regulatory_requirement = Column(String(100), nullable=True)
compliance_standard = Column(SQLEnum(FoodSafetyStandard), nullable=True)
regulatory_action_required = Column(Boolean, nullable=False, default=False)
# Alert conditions and triggers
trigger_condition = Column(String(200), nullable=True)
threshold_value = Column(Numeric(15, 4), nullable=True)
actual_value = Column(Numeric(15, 4), nullable=True)
# Context data
alert_data = Column(JSONB, nullable=True) # Additional context-specific data
environmental_factors = Column(JSONB, nullable=True) # Temperature, humidity, etc.
affected_products = Column(JSONB, nullable=True) # List of affected product IDs
# Risk assessment
public_health_risk = Column(Boolean, nullable=False, default=False)
business_impact = Column(Text, nullable=True)
estimated_loss = Column(Numeric(12, 2), nullable=True)
# Alert status and lifecycle
status = Column(String(50), nullable=False, default="active", index=True)
# Status values: active, acknowledged, investigating, resolved, dismissed, escalated
alert_state = Column(String(50), nullable=False, default="new") # new, escalated, recurring
# Response and resolution
immediate_actions_taken = Column(JSONB, nullable=True) # Actions taken immediately
investigation_notes = Column(Text, nullable=True)
resolution_action = Column(String(200), nullable=True)
resolution_notes = Column(Text, nullable=True)
corrective_actions = Column(JSONB, nullable=True) # List of corrective actions
preventive_measures = Column(JSONB, nullable=True) # Preventive measures implemented
# Timing and escalation
first_occurred_at = Column(DateTime(timezone=True), nullable=False, index=True)
last_occurred_at = Column(DateTime(timezone=True), nullable=False)
acknowledged_at = Column(DateTime(timezone=True), nullable=True)
resolved_at = Column(DateTime(timezone=True), nullable=True)
escalation_deadline = Column(DateTime(timezone=True), nullable=True)
# Occurrence tracking
occurrence_count = Column(Integer, nullable=False, default=1)
is_recurring = Column(Boolean, nullable=False, default=False)
recurrence_pattern = Column(String(100), nullable=True)
# Responsibility and assignment
assigned_to = Column(UUID(as_uuid=True), nullable=True)
assigned_role = Column(String(50), nullable=True) # food_safety_manager, quality_assurance, etc.
escalated_to = Column(UUID(as_uuid=True), nullable=True)
escalation_level = Column(Integer, nullable=False, default=0)
# Notification tracking
notification_sent = Column(Boolean, nullable=False, default=False)
notification_methods = Column(JSONB, nullable=True) # [email, sms, whatsapp, dashboard]
notification_recipients = Column(JSONB, nullable=True) # List of recipients
regulatory_notification_required = Column(Boolean, nullable=False, default=False)
regulatory_notification_sent = Column(Boolean, nullable=False, default=False)
# Documentation and audit trail
documentation = Column(JSONB, nullable=True) # Links to documentation, photos, etc.
audit_trail = Column(JSONB, nullable=True) # Changes and actions taken
external_reference = Column(String(100), nullable=True) # External system reference
# Performance tracking
detection_time = Column(DateTime(timezone=True), nullable=True) # When issue was detected
response_time_minutes = Column(Integer, nullable=True) # Time to acknowledge
resolution_time_minutes = Column(Integer, nullable=True) # Time to resolve
# Quality and feedback
alert_accuracy = Column(Boolean, nullable=True) # Was this a valid alert?
false_positive = Column(Boolean, nullable=False, default=False)
feedback_notes = Column(Text, nullable=True)
# Audit fields
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
created_by = Column(UUID(as_uuid=True), nullable=True)
updated_by = Column(UUID(as_uuid=True), nullable=True)
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),
'alert_code': self.alert_code,
'alert_type': self.alert_type.value if self.alert_type else None,
'severity': self.severity,
'risk_level': self.risk_level,
'source_entity_type': self.source_entity_type,
'source_entity_id': str(self.source_entity_id),
'ingredient_id': str(self.ingredient_id) if self.ingredient_id else None,
'stock_id': str(self.stock_id) if self.stock_id else None,
'title': self.title,
'description': self.description,
'detailed_message': self.detailed_message,
'regulatory_requirement': self.regulatory_requirement,
'compliance_standard': self.compliance_standard.value if self.compliance_standard else None,
'regulatory_action_required': self.regulatory_action_required,
'trigger_condition': self.trigger_condition,
'threshold_value': float(self.threshold_value) if self.threshold_value else None,
'actual_value': float(self.actual_value) if self.actual_value else None,
'alert_data': self.alert_data,
'environmental_factors': self.environmental_factors,
'affected_products': self.affected_products,
'public_health_risk': self.public_health_risk,
'business_impact': self.business_impact,
'estimated_loss': float(self.estimated_loss) if self.estimated_loss else None,
'status': self.status,
'alert_state': self.alert_state,
'immediate_actions_taken': self.immediate_actions_taken,
'investigation_notes': self.investigation_notes,
'resolution_action': self.resolution_action,
'resolution_notes': self.resolution_notes,
'corrective_actions': self.corrective_actions,
'preventive_measures': self.preventive_measures,
'first_occurred_at': self.first_occurred_at.isoformat() if self.first_occurred_at else None,
'last_occurred_at': self.last_occurred_at.isoformat() if self.last_occurred_at else None,
'acknowledged_at': self.acknowledged_at.isoformat() if self.acknowledged_at else None,
'resolved_at': self.resolved_at.isoformat() if self.resolved_at else None,
'escalation_deadline': self.escalation_deadline.isoformat() if self.escalation_deadline else None,
'occurrence_count': self.occurrence_count,
'is_recurring': self.is_recurring,
'recurrence_pattern': self.recurrence_pattern,
'assigned_to': str(self.assigned_to) if self.assigned_to else None,
'assigned_role': self.assigned_role,
'escalated_to': str(self.escalated_to) if self.escalated_to else None,
'escalation_level': self.escalation_level,
'notification_sent': self.notification_sent,
'notification_methods': self.notification_methods,
'notification_recipients': self.notification_recipients,
'regulatory_notification_required': self.regulatory_notification_required,
'regulatory_notification_sent': self.regulatory_notification_sent,
'documentation': self.documentation,
'audit_trail': self.audit_trail,
'external_reference': self.external_reference,
'detection_time': self.detection_time.isoformat() if self.detection_time else None,
'response_time_minutes': self.response_time_minutes,
'resolution_time_minutes': self.resolution_time_minutes,
'alert_accuracy': self.alert_accuracy,
'false_positive': self.false_positive,
'feedback_notes': self.feedback_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,
'created_by': str(self.created_by) if self.created_by else None,
'updated_by': str(self.updated_by) if self.updated_by else None,
}

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

View File

@@ -0,0 +1,233 @@
"""
Stock Receipt Models for Inventory Service
Lot-level tracking for deliveries with expiration dates
"""
from sqlalchemy import Column, String, DateTime, Float, Integer, Text, Index, Boolean, Numeric, ForeignKey, Enum as SQLEnum, Date
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
import uuid
import enum
from datetime import datetime, timezone, date
from typing import Dict, Any, Optional, List
from decimal import Decimal
from shared.database.base import Base
class ReceiptStatus(enum.Enum):
"""Stock receipt status values"""
DRAFT = "draft"
CONFIRMED = "confirmed"
CANCELLED = "cancelled"
class StockReceipt(Base):
"""
Stock receipt tracking for purchase order deliveries
Captures lot-level details and expiration dates
"""
__tablename__ = "stock_receipts"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Purchase order reference
po_id = Column(UUID(as_uuid=True), nullable=False, index=True)
po_number = Column(String(100), nullable=True) # Denormalized for quick reference
# Receipt details
received_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
received_by_user_id = Column(UUID(as_uuid=True), nullable=True)
# Status
status = Column(
SQLEnum(ReceiptStatus, name='receiptstatus', create_type=True),
nullable=False,
default=ReceiptStatus.DRAFT,
index=True
)
# Supplier information (denormalized)
supplier_id = Column(UUID(as_uuid=True), nullable=True, index=True)
supplier_name = Column(String(255), nullable=True)
# Overall notes
notes = Column(Text, nullable=True)
has_discrepancies = Column(Boolean, default=False, nullable=False)
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at = Column(DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
confirmed_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
line_items = relationship("StockReceiptLineItem", back_populates="receipt", cascade="all, delete-orphan")
__table_args__ = (
Index('idx_stock_receipts_tenant_status', 'tenant_id', 'status'),
Index('idx_stock_receipts_po', 'po_id'),
Index('idx_stock_receipts_received_at', 'tenant_id', 'received_at'),
Index('idx_stock_receipts_supplier', 'supplier_id', 'received_at'),
)
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),
'po_id': str(self.po_id),
'po_number': self.po_number,
'received_at': self.received_at.isoformat() if self.received_at else None,
'received_by_user_id': str(self.received_by_user_id) if self.received_by_user_id else None,
'status': self.status.value if isinstance(self.status, enum.Enum) else self.status,
'supplier_id': str(self.supplier_id) if self.supplier_id else None,
'supplier_name': self.supplier_name,
'notes': self.notes,
'has_discrepancies': self.has_discrepancies,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'confirmed_at': self.confirmed_at.isoformat() if self.confirmed_at else None,
'line_items': [item.to_dict() for item in self.line_items] if self.line_items else [],
}
class StockReceiptLineItem(Base):
"""
Individual line items in a stock receipt
One line item per product, with multiple lots possible
"""
__tablename__ = "stock_receipt_line_items"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
receipt_id = Column(UUID(as_uuid=True), ForeignKey('stock_receipts.id', ondelete='CASCADE'), nullable=False, index=True)
# Product information
ingredient_id = Column(UUID(as_uuid=True), nullable=False, index=True)
ingredient_name = Column(String(255), nullable=True) # Denormalized
# PO line reference
po_line_id = Column(UUID(as_uuid=True), nullable=True)
# Quantities
expected_quantity = Column(Numeric(10, 2), nullable=False)
actual_quantity = Column(Numeric(10, 2), nullable=False)
unit_of_measure = Column(String(20), nullable=False)
# Discrepancy tracking
has_discrepancy = Column(Boolean, default=False, nullable=False)
discrepancy_reason = Column(Text, nullable=True)
# Cost tracking
unit_cost = Column(Numeric(10, 2), nullable=True)
total_cost = Column(Numeric(10, 2), nullable=True)
# Timestamps
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
receipt = relationship("StockReceipt", back_populates="line_items")
lots = relationship("StockLot", back_populates="line_item", cascade="all, delete-orphan")
__table_args__ = (
Index('idx_line_items_receipt', 'receipt_id'),
Index('idx_line_items_ingredient', 'ingredient_id'),
Index('idx_line_items_discrepancy', 'tenant_id', 'has_discrepancy'),
)
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),
'receipt_id': str(self.receipt_id),
'ingredient_id': str(self.ingredient_id),
'ingredient_name': self.ingredient_name,
'po_line_id': str(self.po_line_id) if self.po_line_id else None,
'expected_quantity': float(self.expected_quantity) if self.expected_quantity else None,
'actual_quantity': float(self.actual_quantity) if self.actual_quantity else None,
'unit_of_measure': self.unit_of_measure,
'has_discrepancy': self.has_discrepancy,
'discrepancy_reason': self.discrepancy_reason,
'unit_cost': float(self.unit_cost) if self.unit_cost else None,
'total_cost': float(self.total_cost) if self.total_cost 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 None,
'lots': [lot.to_dict() for lot in self.lots] if self.lots else [],
}
class StockLot(Base):
"""
Individual lots within a line item
Critical for tracking expiration dates when deliveries are split
"""
__tablename__ = "stock_lots"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
line_item_id = Column(UUID(as_uuid=True), ForeignKey('stock_receipt_line_items.id', ondelete='CASCADE'), nullable=False, index=True)
# Links to stock table (created on confirmation)
stock_id = Column(UUID(as_uuid=True), nullable=True, index=True)
# Lot identification
lot_number = Column(String(100), nullable=True)
supplier_lot_number = Column(String(100), nullable=True)
# Quantity for this lot
quantity = Column(Numeric(10, 2), nullable=False)
unit_of_measure = Column(String(20), nullable=False)
# Critical: Expiration tracking
expiration_date = Column(Date, nullable=False, index=True)
best_before_date = Column(Date, nullable=True)
# Storage location
warehouse_location = Column(String(100), nullable=True)
storage_zone = Column(String(50), nullable=True)
# Quality notes
quality_notes = Column(Text, nullable=True)
# Timestamps
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
line_item = relationship("StockReceiptLineItem", back_populates="lots")
__table_args__ = (
Index('idx_lots_line_item', 'line_item_id'),
Index('idx_lots_stock', 'stock_id'),
Index('idx_lots_expiration', 'tenant_id', 'expiration_date'),
Index('idx_lots_lot_number', 'tenant_id', 'lot_number'),
)
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),
'line_item_id': str(self.line_item_id),
'stock_id': str(self.stock_id) if self.stock_id else None,
'lot_number': self.lot_number,
'supplier_lot_number': self.supplier_lot_number,
'quantity': float(self.quantity) if self.quantity else None,
'unit_of_measure': self.unit_of_measure,
'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,
'warehouse_location': self.warehouse_location,
'storage_zone': self.storage_zone,
'quality_notes': self.quality_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,
}