Initial commit - production deployment
This commit is contained in:
74
services/inventory/app/models/__init__.py
Normal file
74
services/inventory/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
369
services/inventory/app/models/food_safety.py
Normal file
369
services/inventory/app/models/food_safety.py
Normal 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,
|
||||
}
|
||||
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,
|
||||
}
|
||||
233
services/inventory/app/models/stock_receipt.py
Normal file
233
services/inventory/app/models/stock_receipt.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user