Initial commit - production deployment
This commit is contained in:
31
services/tenant/app/models/__init__.py
Normal file
31
services/tenant/app/models/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Tenant 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 .tenants import Tenant, TenantMember, Subscription
|
||||
from .tenant_location import TenantLocation
|
||||
from .coupon import CouponModel, CouponRedemptionModel
|
||||
from .events import Event, EventTemplate
|
||||
|
||||
# List all models for easier access
|
||||
__all__ = [
|
||||
"Tenant",
|
||||
"TenantMember",
|
||||
"Subscription",
|
||||
"TenantLocation",
|
||||
"AuditLog",
|
||||
"CouponModel",
|
||||
"CouponRedemptionModel",
|
||||
"Event",
|
||||
"EventTemplate",
|
||||
]
|
||||
64
services/tenant/app/models/coupon.py
Normal file
64
services/tenant/app/models/coupon.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
SQLAlchemy models for coupon system
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, JSON, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
|
||||
from shared.database import Base
|
||||
|
||||
|
||||
class CouponModel(Base):
|
||||
"""Coupon configuration table"""
|
||||
__tablename__ = "coupons"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
code = Column(String(50), unique=True, nullable=False, index=True)
|
||||
discount_type = Column(String(20), nullable=False) # trial_extension, percentage, fixed_amount
|
||||
discount_value = Column(Integer, nullable=False) # Days/percentage/cents depending on type
|
||||
max_redemptions = Column(Integer, nullable=True) # None = unlimited
|
||||
current_redemptions = Column(Integer, nullable=False, default=0)
|
||||
valid_from = Column(DateTime(timezone=True), nullable=False)
|
||||
valid_until = Column(DateTime(timezone=True), nullable=True) # None = no expiry
|
||||
active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
||||
extra_data = Column(JSON, nullable=True) # Renamed from metadata to avoid SQLAlchemy reserved name
|
||||
|
||||
# Relationships
|
||||
redemptions = relationship("CouponRedemptionModel", back_populates="coupon")
|
||||
|
||||
# Indexes for performance
|
||||
__table_args__ = (
|
||||
Index('idx_coupon_code_active', 'code', 'active'),
|
||||
Index('idx_coupon_valid_dates', 'valid_from', 'valid_until'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Coupon(code='{self.code}', type='{self.discount_type}', value={self.discount_value})>"
|
||||
|
||||
|
||||
class CouponRedemptionModel(Base):
|
||||
"""Coupon redemption history table"""
|
||||
__tablename__ = "coupon_redemptions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(String(255), nullable=False, index=True)
|
||||
coupon_code = Column(String(50), ForeignKey('coupons.code'), nullable=False)
|
||||
redeemed_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
||||
discount_applied = Column(JSON, nullable=False) # Details of discount applied
|
||||
extra_data = Column(JSON, nullable=True) # Renamed from metadata to avoid SQLAlchemy reserved name
|
||||
|
||||
# Relationships
|
||||
coupon = relationship("CouponModel", back_populates="redemptions")
|
||||
|
||||
# Constraints
|
||||
__table_args__ = (
|
||||
Index('idx_redemption_tenant', 'tenant_id'),
|
||||
Index('idx_redemption_coupon', 'coupon_code'),
|
||||
Index('idx_redemption_tenant_coupon', 'tenant_id', 'coupon_code'), # Prevent duplicate redemptions
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<CouponRedemption(tenant_id='{self.tenant_id}', code='{self.coupon_code}')>"
|
||||
136
services/tenant/app/models/events.py
Normal file
136
services/tenant/app/models/events.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Event Calendar Models
|
||||
Database models for tracking local events, promotions, and special occasions
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, Float, Date
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from shared.database.base import Base
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
|
||||
|
||||
class Event(Base):
|
||||
"""
|
||||
Table to track events that affect bakery demand.
|
||||
|
||||
Events include:
|
||||
- Local events (festivals, markets, concerts)
|
||||
- Promotions and sales
|
||||
- Weather events (heat waves, storms)
|
||||
- School holidays and breaks
|
||||
- Special occasions
|
||||
"""
|
||||
__tablename__ = "events"
|
||||
|
||||
# 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)
|
||||
|
||||
# Event information
|
||||
event_name = Column(String(500), nullable=False)
|
||||
event_type = Column(String(100), nullable=False, index=True) # promotion, festival, holiday, weather, school_break, sport_event, etc.
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Date and time
|
||||
event_date = Column(Date, nullable=False, index=True)
|
||||
start_time = Column(DateTime(timezone=True), nullable=True)
|
||||
end_time = Column(DateTime(timezone=True), nullable=True)
|
||||
is_all_day = Column(Boolean, default=True)
|
||||
|
||||
# Impact estimation
|
||||
expected_impact = Column(String(50), nullable=True) # low, medium, high, very_high
|
||||
impact_multiplier = Column(Float, nullable=True) # Expected demand multiplier (e.g., 1.5 = 50% increase)
|
||||
affected_product_categories = Column(String(500), nullable=True) # Comma-separated categories
|
||||
|
||||
# Location
|
||||
location = Column(String(500), nullable=True)
|
||||
is_local = Column(Boolean, default=True) # True if event is near bakery
|
||||
|
||||
# Status
|
||||
is_confirmed = Column(Boolean, default=False)
|
||||
is_recurring = Column(Boolean, default=False)
|
||||
recurrence_pattern = Column(String(200), nullable=True) # e.g., "weekly:monday", "monthly:first_saturday"
|
||||
|
||||
# Actual impact (filled after event)
|
||||
actual_impact_multiplier = Column(Float, nullable=True)
|
||||
actual_sales_increase_percent = Column(Float, nullable=True)
|
||||
|
||||
# Metadata
|
||||
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(String(255), nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"tenant_id": str(self.tenant_id),
|
||||
"event_name": self.event_name,
|
||||
"event_type": self.event_type,
|
||||
"description": self.description,
|
||||
"event_date": self.event_date.isoformat() if self.event_date else None,
|
||||
"start_time": self.start_time.isoformat() if self.start_time else None,
|
||||
"end_time": self.end_time.isoformat() if self.end_time else None,
|
||||
"is_all_day": self.is_all_day,
|
||||
"expected_impact": self.expected_impact,
|
||||
"impact_multiplier": self.impact_multiplier,
|
||||
"affected_product_categories": self.affected_product_categories,
|
||||
"location": self.location,
|
||||
"is_local": self.is_local,
|
||||
"is_confirmed": self.is_confirmed,
|
||||
"is_recurring": self.is_recurring,
|
||||
"recurrence_pattern": self.recurrence_pattern,
|
||||
"actual_impact_multiplier": self.actual_impact_multiplier,
|
||||
"actual_sales_increase_percent": self.actual_sales_increase_percent,
|
||||
"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": self.created_by,
|
||||
"notes": self.notes
|
||||
}
|
||||
|
||||
|
||||
class EventTemplate(Base):
|
||||
"""
|
||||
Template for recurring events.
|
||||
Allows easy creation of events based on patterns.
|
||||
"""
|
||||
__tablename__ = "event_templates"
|
||||
|
||||
# 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)
|
||||
|
||||
# Template information
|
||||
template_name = Column(String(500), nullable=False)
|
||||
event_type = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Default values
|
||||
default_impact = Column(String(50), nullable=True)
|
||||
default_impact_multiplier = Column(Float, nullable=True)
|
||||
default_affected_categories = Column(String(500), nullable=True)
|
||||
|
||||
# Recurrence
|
||||
recurrence_pattern = Column(String(200), nullable=False) # e.g., "weekly:saturday", "monthly:last_sunday"
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Metadata
|
||||
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))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"tenant_id": str(self.tenant_id),
|
||||
"template_name": self.template_name,
|
||||
"event_type": self.event_type,
|
||||
"description": self.description,
|
||||
"default_impact": self.default_impact,
|
||||
"default_impact_multiplier": self.default_impact_multiplier,
|
||||
"default_affected_categories": self.default_affected_categories,
|
||||
"recurrence_pattern": self.recurrence_pattern,
|
||||
"is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
59
services/tenant/app/models/tenant_location.py
Normal file
59
services/tenant/app/models/tenant_location.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Tenant Location Model
|
||||
Represents physical locations for enterprise tenants (central production, retail outlets)
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Float, ForeignKey, Text, Integer, JSON
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
|
||||
from shared.database.base import Base
|
||||
|
||||
|
||||
class TenantLocation(Base):
|
||||
"""TenantLocation model - represents physical locations for enterprise tenants"""
|
||||
__tablename__ = "tenant_locations"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# Location information
|
||||
name = Column(String(200), nullable=False)
|
||||
location_type = Column(String(50), nullable=False) # central_production, retail_outlet
|
||||
address = Column(Text, nullable=False)
|
||||
city = Column(String(100), default="Madrid")
|
||||
postal_code = Column(String(10), nullable=False)
|
||||
latitude = Column(Float, nullable=True)
|
||||
longitude = Column(Float, nullable=True)
|
||||
|
||||
# Location-specific configuration
|
||||
delivery_windows = Column(JSON, nullable=True) # { "monday": "08:00-12:00,14:00-18:00", ... }
|
||||
capacity = Column(Integer, nullable=True) # For production capacity in kg/day or storage capacity
|
||||
max_delivery_radius_km = Column(Float, nullable=True, default=50.0)
|
||||
|
||||
# Operational hours
|
||||
operational_hours = Column(JSON, nullable=True) # { "monday": "06:00-20:00", ... }
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Contact information
|
||||
contact_person = Column(String(200), nullable=True)
|
||||
contact_phone = Column(String(20), nullable=True)
|
||||
contact_email = Column(String(255), nullable=True)
|
||||
|
||||
# Custom delivery scheduling configuration per location
|
||||
delivery_schedule_config = Column(JSON, nullable=True) # { "delivery_days": "Mon,Wed,Fri", "time_window": "07:00-10:00" }
|
||||
|
||||
# Metadata
|
||||
metadata_ = Column(JSON, 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
|
||||
tenant = relationship("Tenant", back_populates="locations")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TenantLocation(id={self.id}, tenant_id={self.tenant_id}, name={self.name}, type={self.location_type})>"
|
||||
370
services/tenant/app/models/tenant_settings.py
Normal file
370
services/tenant/app/models/tenant_settings.py
Normal file
@@ -0,0 +1,370 @@
|
||||
# services/tenant/app/models/tenant_settings.py
|
||||
"""
|
||||
Tenant Settings Model
|
||||
Centralized configuration storage for all tenant-specific operational settings
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, JSON
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
|
||||
from shared.database.base import Base
|
||||
|
||||
|
||||
class TenantSettings(Base):
|
||||
"""
|
||||
Centralized tenant settings model
|
||||
Stores all operational configurations for a tenant across all services
|
||||
"""
|
||||
__tablename__ = "tenant_settings"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
|
||||
|
||||
# Procurement & Auto-Approval Settings (Orders Service)
|
||||
procurement_settings = Column(JSON, nullable=False, default=lambda: {
|
||||
"auto_approve_enabled": True,
|
||||
"auto_approve_threshold_eur": 500.0,
|
||||
"auto_approve_min_supplier_score": 0.80,
|
||||
"require_approval_new_suppliers": True,
|
||||
"require_approval_critical_items": True,
|
||||
"procurement_lead_time_days": 3,
|
||||
"demand_forecast_days": 14,
|
||||
"safety_stock_percentage": 20.0,
|
||||
"po_approval_reminder_hours": 24,
|
||||
"po_critical_escalation_hours": 12,
|
||||
"use_reorder_rules": True,
|
||||
"economic_rounding": True,
|
||||
"respect_storage_limits": True,
|
||||
"use_supplier_minimums": True,
|
||||
"optimize_price_tiers": True
|
||||
})
|
||||
|
||||
# Inventory Management Settings (Inventory Service)
|
||||
inventory_settings = Column(JSON, nullable=False, default=lambda: {
|
||||
"low_stock_threshold": 10,
|
||||
"reorder_point": 20,
|
||||
"reorder_quantity": 50,
|
||||
"expiring_soon_days": 7,
|
||||
"expiration_warning_days": 3,
|
||||
"quality_score_threshold": 8.0,
|
||||
"temperature_monitoring_enabled": True,
|
||||
"refrigeration_temp_min": 1.0,
|
||||
"refrigeration_temp_max": 4.0,
|
||||
"freezer_temp_min": -20.0,
|
||||
"freezer_temp_max": -15.0,
|
||||
"room_temp_min": 18.0,
|
||||
"room_temp_max": 25.0,
|
||||
"temp_deviation_alert_minutes": 15,
|
||||
"critical_temp_deviation_minutes": 5
|
||||
})
|
||||
|
||||
# Production Settings (Production Service)
|
||||
production_settings = Column(JSON, nullable=False, default=lambda: {
|
||||
"planning_horizon_days": 7,
|
||||
"minimum_batch_size": 1.0,
|
||||
"maximum_batch_size": 100.0,
|
||||
"production_buffer_percentage": 10.0,
|
||||
"working_hours_per_day": 12,
|
||||
"max_overtime_hours": 4,
|
||||
"capacity_utilization_target": 0.85,
|
||||
"capacity_warning_threshold": 0.95,
|
||||
"quality_check_enabled": True,
|
||||
"minimum_yield_percentage": 85.0,
|
||||
"quality_score_threshold": 8.0,
|
||||
"schedule_optimization_enabled": True,
|
||||
"prep_time_buffer_minutes": 30,
|
||||
"cleanup_time_buffer_minutes": 15,
|
||||
"labor_cost_per_hour_eur": 15.0,
|
||||
"overhead_cost_percentage": 20.0
|
||||
})
|
||||
|
||||
# Supplier Settings (Suppliers Service)
|
||||
supplier_settings = Column(JSON, nullable=False, default=lambda: {
|
||||
"default_payment_terms_days": 30,
|
||||
"default_delivery_days": 3,
|
||||
"excellent_delivery_rate": 95.0,
|
||||
"good_delivery_rate": 90.0,
|
||||
"excellent_quality_rate": 98.0,
|
||||
"good_quality_rate": 95.0,
|
||||
"critical_delivery_delay_hours": 24,
|
||||
"critical_quality_rejection_rate": 10.0,
|
||||
"high_cost_variance_percentage": 15.0,
|
||||
# Supplier Approval Workflow Settings
|
||||
"require_supplier_approval": True,
|
||||
"auto_approve_for_admin_owner": True,
|
||||
"approval_required_roles": ["member", "viewer"]
|
||||
})
|
||||
|
||||
# POS Integration Settings (POS Service)
|
||||
pos_settings = Column(JSON, nullable=False, default=lambda: {
|
||||
"sync_interval_minutes": 5,
|
||||
"auto_sync_products": True,
|
||||
"auto_sync_transactions": True
|
||||
})
|
||||
|
||||
# Order & Business Rules Settings (Orders Service)
|
||||
order_settings = Column(JSON, nullable=False, default=lambda: {
|
||||
"max_discount_percentage": 50.0,
|
||||
"default_delivery_window_hours": 48,
|
||||
"dynamic_pricing_enabled": False,
|
||||
"discount_enabled": True,
|
||||
"delivery_tracking_enabled": True
|
||||
})
|
||||
|
||||
# Replenishment Planning Settings (Orchestrator Service)
|
||||
replenishment_settings = Column(JSON, nullable=False, default=lambda: {
|
||||
"projection_horizon_days": 7,
|
||||
"service_level": 0.95,
|
||||
"buffer_days": 1,
|
||||
"enable_auto_replenishment": True,
|
||||
"min_order_quantity": 1.0,
|
||||
"max_order_quantity": 1000.0,
|
||||
"demand_forecast_days": 14
|
||||
})
|
||||
|
||||
# Safety Stock Settings (Orchestrator Service)
|
||||
safety_stock_settings = Column(JSON, nullable=False, default=lambda: {
|
||||
"service_level": 0.95,
|
||||
"method": "statistical",
|
||||
"min_safety_stock": 0.0,
|
||||
"max_safety_stock": 100.0,
|
||||
"reorder_point_calculation": "safety_stock_plus_lead_time_demand"
|
||||
})
|
||||
|
||||
# MOQ Aggregation Settings (Orchestrator Service)
|
||||
moq_settings = Column(JSON, nullable=False, default=lambda: {
|
||||
"consolidation_window_days": 7,
|
||||
"allow_early_ordering": True,
|
||||
"enable_batch_optimization": True,
|
||||
"min_batch_size": 1.0,
|
||||
"max_batch_size": 1000.0
|
||||
})
|
||||
|
||||
# Supplier Selection Settings (Orchestrator Service)
|
||||
supplier_selection_settings = Column(JSON, nullable=False, default=lambda: {
|
||||
"price_weight": 0.40,
|
||||
"lead_time_weight": 0.20,
|
||||
"quality_weight": 0.20,
|
||||
"reliability_weight": 0.20,
|
||||
"diversification_threshold": 1000,
|
||||
"max_single_percentage": 0.70,
|
||||
"enable_supplier_score_optimization": True
|
||||
})
|
||||
|
||||
# ML Insights Settings (AI Insights Service)
|
||||
ml_insights_settings = Column(JSON, nullable=False, default=lambda: {
|
||||
# Inventory ML (Safety Stock Optimization)
|
||||
"inventory_lookback_days": 90,
|
||||
"inventory_min_history_days": 30,
|
||||
|
||||
# Production ML (Yield Prediction)
|
||||
"production_lookback_days": 90,
|
||||
"production_min_history_runs": 30,
|
||||
|
||||
# Procurement ML (Supplier Analysis & Price Forecasting)
|
||||
"supplier_analysis_lookback_days": 180,
|
||||
"supplier_analysis_min_orders": 10,
|
||||
"price_forecast_lookback_days": 180,
|
||||
"price_forecast_horizon_days": 30,
|
||||
|
||||
# Forecasting ML (Dynamic Rules)
|
||||
"rules_generation_lookback_days": 90,
|
||||
"rules_generation_min_samples": 10,
|
||||
|
||||
# Global ML Settings
|
||||
"enable_ml_insights": True,
|
||||
"ml_insights_auto_trigger": False,
|
||||
"ml_confidence_threshold": 0.80
|
||||
})
|
||||
|
||||
# Notification Settings (Notification Service)
|
||||
notification_settings = Column(JSON, nullable=False, default=lambda: {
|
||||
# WhatsApp Configuration (Shared Account Model)
|
||||
"whatsapp_enabled": False,
|
||||
"whatsapp_phone_number_id": "", # Meta WhatsApp Phone Number ID (from shared master account)
|
||||
"whatsapp_display_phone_number": "", # Display format for UI (e.g., "+34 612 345 678")
|
||||
"whatsapp_default_language": "es",
|
||||
|
||||
# Email Configuration
|
||||
"email_enabled": True,
|
||||
"email_from_address": "",
|
||||
"email_from_name": "",
|
||||
"email_reply_to": "",
|
||||
|
||||
# Notification Preferences
|
||||
"enable_po_notifications": True,
|
||||
"enable_inventory_alerts": True,
|
||||
"enable_production_alerts": True,
|
||||
"enable_forecast_alerts": True,
|
||||
|
||||
# Notification Channels
|
||||
"po_notification_channels": ["email"], # ["email", "whatsapp"]
|
||||
"inventory_alert_channels": ["email"],
|
||||
"production_alert_channels": ["email"],
|
||||
"forecast_alert_channels": ["email"]
|
||||
})
|
||||
|
||||
# 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), nullable=False)
|
||||
|
||||
# Relationships
|
||||
tenant = relationship("Tenant", backref="settings")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TenantSettings(tenant_id={self.tenant_id})>"
|
||||
|
||||
@staticmethod
|
||||
def get_default_settings() -> dict:
|
||||
"""
|
||||
Get default settings for all categories
|
||||
Returns a dictionary with default values for all setting categories
|
||||
"""
|
||||
return {
|
||||
"procurement_settings": {
|
||||
"auto_approve_enabled": True,
|
||||
"auto_approve_threshold_eur": 500.0,
|
||||
"auto_approve_min_supplier_score": 0.80,
|
||||
"require_approval_new_suppliers": True,
|
||||
"require_approval_critical_items": True,
|
||||
"procurement_lead_time_days": 3,
|
||||
"demand_forecast_days": 14,
|
||||
"safety_stock_percentage": 20.0,
|
||||
"po_approval_reminder_hours": 24,
|
||||
"po_critical_escalation_hours": 12,
|
||||
"use_reorder_rules": True,
|
||||
"economic_rounding": True,
|
||||
"respect_storage_limits": True,
|
||||
"use_supplier_minimums": True,
|
||||
"optimize_price_tiers": True
|
||||
},
|
||||
"inventory_settings": {
|
||||
"low_stock_threshold": 10,
|
||||
"reorder_point": 20,
|
||||
"reorder_quantity": 50,
|
||||
"expiring_soon_days": 7,
|
||||
"expiration_warning_days": 3,
|
||||
"quality_score_threshold": 8.0,
|
||||
"temperature_monitoring_enabled": True,
|
||||
"refrigeration_temp_min": 1.0,
|
||||
"refrigeration_temp_max": 4.0,
|
||||
"freezer_temp_min": -20.0,
|
||||
"freezer_temp_max": -15.0,
|
||||
"room_temp_min": 18.0,
|
||||
"room_temp_max": 25.0,
|
||||
"temp_deviation_alert_minutes": 15,
|
||||
"critical_temp_deviation_minutes": 5
|
||||
},
|
||||
"production_settings": {
|
||||
"planning_horizon_days": 7,
|
||||
"minimum_batch_size": 1.0,
|
||||
"maximum_batch_size": 100.0,
|
||||
"production_buffer_percentage": 10.0,
|
||||
"working_hours_per_day": 12,
|
||||
"max_overtime_hours": 4,
|
||||
"capacity_utilization_target": 0.85,
|
||||
"capacity_warning_threshold": 0.95,
|
||||
"quality_check_enabled": True,
|
||||
"minimum_yield_percentage": 85.0,
|
||||
"quality_score_threshold": 8.0,
|
||||
"schedule_optimization_enabled": True,
|
||||
"prep_time_buffer_minutes": 30,
|
||||
"cleanup_time_buffer_minutes": 15,
|
||||
"labor_cost_per_hour_eur": 15.0,
|
||||
"overhead_cost_percentage": 20.0
|
||||
},
|
||||
"supplier_settings": {
|
||||
"default_payment_terms_days": 30,
|
||||
"default_delivery_days": 3,
|
||||
"excellent_delivery_rate": 95.0,
|
||||
"good_delivery_rate": 90.0,
|
||||
"excellent_quality_rate": 98.0,
|
||||
"good_quality_rate": 95.0,
|
||||
"critical_delivery_delay_hours": 24,
|
||||
"critical_quality_rejection_rate": 10.0,
|
||||
"high_cost_variance_percentage": 15.0,
|
||||
"require_supplier_approval": True,
|
||||
"auto_approve_for_admin_owner": True,
|
||||
"approval_required_roles": ["member", "viewer"]
|
||||
},
|
||||
"pos_settings": {
|
||||
"sync_interval_minutes": 5,
|
||||
"auto_sync_products": True,
|
||||
"auto_sync_transactions": True
|
||||
},
|
||||
"order_settings": {
|
||||
"max_discount_percentage": 50.0,
|
||||
"default_delivery_window_hours": 48,
|
||||
"dynamic_pricing_enabled": False,
|
||||
"discount_enabled": True,
|
||||
"delivery_tracking_enabled": True
|
||||
},
|
||||
"replenishment_settings": {
|
||||
"projection_horizon_days": 7,
|
||||
"service_level": 0.95,
|
||||
"buffer_days": 1,
|
||||
"enable_auto_replenishment": True,
|
||||
"min_order_quantity": 1.0,
|
||||
"max_order_quantity": 1000.0,
|
||||
"demand_forecast_days": 14
|
||||
},
|
||||
"safety_stock_settings": {
|
||||
"service_level": 0.95,
|
||||
"method": "statistical",
|
||||
"min_safety_stock": 0.0,
|
||||
"max_safety_stock": 100.0,
|
||||
"reorder_point_calculation": "safety_stock_plus_lead_time_demand"
|
||||
},
|
||||
"moq_settings": {
|
||||
"consolidation_window_days": 7,
|
||||
"allow_early_ordering": True,
|
||||
"enable_batch_optimization": True,
|
||||
"min_batch_size": 1.0,
|
||||
"max_batch_size": 1000.0
|
||||
},
|
||||
"supplier_selection_settings": {
|
||||
"price_weight": 0.40,
|
||||
"lead_time_weight": 0.20,
|
||||
"quality_weight": 0.20,
|
||||
"reliability_weight": 0.20,
|
||||
"diversification_threshold": 1000,
|
||||
"max_single_percentage": 0.70,
|
||||
"enable_supplier_score_optimization": True
|
||||
},
|
||||
"ml_insights_settings": {
|
||||
"inventory_lookback_days": 90,
|
||||
"inventory_min_history_days": 30,
|
||||
"production_lookback_days": 90,
|
||||
"production_min_history_runs": 30,
|
||||
"supplier_analysis_lookback_days": 180,
|
||||
"supplier_analysis_min_orders": 10,
|
||||
"price_forecast_lookback_days": 180,
|
||||
"price_forecast_horizon_days": 30,
|
||||
"rules_generation_lookback_days": 90,
|
||||
"rules_generation_min_samples": 10,
|
||||
"enable_ml_insights": True,
|
||||
"ml_insights_auto_trigger": False,
|
||||
"ml_confidence_threshold": 0.80
|
||||
},
|
||||
"notification_settings": {
|
||||
"whatsapp_enabled": False,
|
||||
"whatsapp_phone_number_id": "",
|
||||
"whatsapp_display_phone_number": "",
|
||||
"whatsapp_default_language": "es",
|
||||
"email_enabled": True,
|
||||
"email_from_address": "",
|
||||
"email_from_name": "",
|
||||
"email_reply_to": "",
|
||||
"enable_po_notifications": True,
|
||||
"enable_inventory_alerts": True,
|
||||
"enable_production_alerts": True,
|
||||
"enable_forecast_alerts": True,
|
||||
"po_notification_channels": ["email"],
|
||||
"inventory_alert_channels": ["email"],
|
||||
"production_alert_channels": ["email"],
|
||||
"forecast_alert_channels": ["email"]
|
||||
}
|
||||
}
|
||||
221
services/tenant/app/models/tenants.py
Normal file
221
services/tenant/app/models/tenants.py
Normal file
@@ -0,0 +1,221 @@
|
||||
# services/tenant/app/models/tenants.py - FIXED VERSION
|
||||
"""
|
||||
Tenant models for bakery management - FIXED
|
||||
Removed cross-service User relationship to eliminate circular dependencies
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Float, ForeignKey, Text, Integer, JSON
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
|
||||
from shared.database.base import Base
|
||||
|
||||
class Tenant(Base):
|
||||
"""Tenant/Bakery model"""
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String(200), nullable=False)
|
||||
subdomain = Column(String(100), unique=True)
|
||||
business_type = Column(String(100), default="bakery")
|
||||
business_model = Column(String(100), default="individual_bakery") # individual_bakery, central_baker_satellite, retail_bakery, hybrid_bakery
|
||||
|
||||
# Location info
|
||||
address = Column(Text, nullable=False)
|
||||
city = Column(String(100), default="Madrid")
|
||||
postal_code = Column(String(10), nullable=False)
|
||||
latitude = Column(Float)
|
||||
longitude = Column(Float)
|
||||
|
||||
# Regional/Localization configuration
|
||||
timezone = Column(String(50), default="Europe/Madrid", nullable=False)
|
||||
currency = Column(String(3), default="EUR", nullable=False) # Currency code: EUR, USD, GBP
|
||||
language = Column(String(5), default="es", nullable=False) # Language code: es, en, eu
|
||||
|
||||
# Contact info
|
||||
phone = Column(String(20))
|
||||
email = Column(String(255))
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Demo account flags
|
||||
is_demo = Column(Boolean, default=False, index=True)
|
||||
is_demo_template = Column(Boolean, default=False, index=True)
|
||||
base_demo_tenant_id = Column(UUID(as_uuid=True), nullable=True, index=True)
|
||||
demo_session_id = Column(String(100), nullable=True, index=True)
|
||||
demo_expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# ML status
|
||||
ml_model_trained = Column(Boolean, default=False)
|
||||
last_training_date = Column(DateTime(timezone=True))
|
||||
|
||||
# Additional metadata (JSON field for flexible data storage)
|
||||
metadata_ = Column(JSON, nullable=True)
|
||||
|
||||
# Ownership (user_id without FK - cross-service reference)
|
||||
owner_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
|
||||
# Enterprise tier hierarchy fields
|
||||
parent_tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=True, index=True)
|
||||
tenant_type = Column(String(50), default="standalone", nullable=False) # standalone, parent, child
|
||||
hierarchy_path = Column(String(500), nullable=True) # Materialized path for queries
|
||||
|
||||
# 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))
|
||||
|
||||
# 3D Secure (3DS) tracking
|
||||
threeds_authentication_required = Column(Boolean, default=False)
|
||||
threeds_authentication_required_at = Column(DateTime(timezone=True), nullable=True)
|
||||
threeds_authentication_completed = Column(Boolean, default=False)
|
||||
threeds_authentication_completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
last_threeds_setup_intent_id = Column(String(255), nullable=True)
|
||||
threeds_action_type = Column(String(100), nullable=True)
|
||||
|
||||
# Relationships - only within tenant service
|
||||
members = relationship("TenantMember", back_populates="tenant", cascade="all, delete-orphan")
|
||||
subscriptions = relationship("Subscription", back_populates="tenant", cascade="all, delete-orphan")
|
||||
locations = relationship("TenantLocation", back_populates="tenant", cascade="all, delete-orphan")
|
||||
child_tenants = relationship("Tenant", back_populates="parent_tenant", remote_side=[id])
|
||||
parent_tenant = relationship("Tenant", back_populates="child_tenants", remote_side=[parent_tenant_id])
|
||||
|
||||
# REMOVED: users relationship - no cross-service SQLAlchemy relationships
|
||||
|
||||
@property
|
||||
def subscription_tier(self):
|
||||
"""
|
||||
Get current subscription tier from active subscription
|
||||
|
||||
Note: This is a computed property that requires subscription relationship to be loaded.
|
||||
For performance-critical operations, use the subscription cache service directly.
|
||||
"""
|
||||
# Find active subscription
|
||||
for subscription in self.subscriptions:
|
||||
if subscription.status == 'active':
|
||||
return subscription.plan
|
||||
return "starter" # Default fallback
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Tenant(id={self.id}, name={self.name})>"
|
||||
|
||||
class TenantMember(Base):
|
||||
"""
|
||||
Tenant membership model for team access.
|
||||
|
||||
This model represents TENANT-SPECIFIC roles, which are distinct from global user roles.
|
||||
|
||||
TENANT ROLES (stored here):
|
||||
- owner: Full control of the tenant, can transfer ownership, manage all aspects
|
||||
- admin: Tenant administrator, can manage team members and most operations
|
||||
- member: Standard team member, regular operational access
|
||||
- viewer: Read-only observer, view-only access to tenant data
|
||||
|
||||
ROLE MAPPING TO GLOBAL ROLES:
|
||||
When users are created through tenant management (pilot phase), their tenant role
|
||||
is mapped to a global user role in the Auth service:
|
||||
- tenant 'admin' → global 'admin' (system-wide admin access)
|
||||
- tenant 'member' → global 'manager' (management-level access)
|
||||
- tenant 'viewer' → global 'user' (basic user access)
|
||||
- tenant 'owner' → No automatic global role (owner is tenant-specific)
|
||||
|
||||
This mapping is implemented in app/api/tenant_members.py lines 68-76.
|
||||
|
||||
Note: user_id is a cross-service reference (no FK) to avoid circular dependencies.
|
||||
User enrichment is handled at the service layer via Auth service calls.
|
||||
"""
|
||||
__tablename__ = "tenant_members"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # No FK - cross-service reference
|
||||
|
||||
# Role and permissions specific to this tenant
|
||||
# Valid values: 'owner', 'admin', 'member', 'viewer', 'network_admin'
|
||||
role = Column(String(50), default="member")
|
||||
permissions = Column(Text) # JSON string of permissions
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
invited_by = Column(UUID(as_uuid=True)) # No FK - cross-service reference
|
||||
invited_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
joined_at = Column(DateTime(timezone=True))
|
||||
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Relationships - only within tenant service
|
||||
tenant = relationship("Tenant", back_populates="members")
|
||||
|
||||
# REMOVED: user relationship - no cross-service SQLAlchemy relationships
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TenantMember(tenant_id={self.tenant_id}, user_id={self.user_id}, role={self.role})>"
|
||||
|
||||
# Additional models for subscriptions, plans, etc.
|
||||
class Subscription(Base):
|
||||
"""Subscription model for tenant billing with tenant linking support"""
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True)
|
||||
|
||||
# User reference for tenant-independent subscriptions
|
||||
user_id = Column(UUID(as_uuid=True), nullable=True, index=True)
|
||||
|
||||
# Tenant linking status
|
||||
is_tenant_linked = Column(Boolean, default=False, nullable=False)
|
||||
tenant_linking_status = Column(String(50), nullable=True) # pending, completed, failed
|
||||
linked_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
plan = Column(String(50), default="starter") # starter, professional, enterprise
|
||||
status = Column(String(50), default="active") # active, pending_cancellation, inactive, suspended, pending_tenant_linking
|
||||
|
||||
# Billing
|
||||
monthly_price = Column(Float, default=0.0)
|
||||
billing_cycle = Column(String(20), default="monthly") # monthly, yearly
|
||||
next_billing_date = Column(DateTime(timezone=True))
|
||||
trial_ends_at = Column(DateTime(timezone=True))
|
||||
cancelled_at = Column(DateTime(timezone=True), nullable=True)
|
||||
cancellation_effective_date = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Payment provider references (generic names for provider-agnostic design)
|
||||
subscription_id = Column(String(255), nullable=True) # Payment provider subscription ID
|
||||
customer_id = Column(String(255), nullable=True) # Payment provider customer ID
|
||||
|
||||
# Limits
|
||||
max_users = Column(Integer, default=5)
|
||||
max_locations = Column(Integer, default=1)
|
||||
max_products = Column(Integer, default=50)
|
||||
|
||||
# Features - Store plan features as JSON
|
||||
features = Column(JSON)
|
||||
|
||||
# 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))
|
||||
|
||||
# 3D Secure (3DS) tracking
|
||||
threeds_authentication_required = Column(Boolean, default=False)
|
||||
threeds_authentication_required_at = Column(DateTime(timezone=True), nullable=True)
|
||||
threeds_authentication_completed = Column(Boolean, default=False)
|
||||
threeds_authentication_completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
last_threeds_setup_intent_id = Column(String(255), nullable=True)
|
||||
threeds_action_type = Column(String(100), nullable=True)
|
||||
|
||||
# Relationships
|
||||
tenant = relationship("Tenant")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Subscription(id={self.id}, tenant_id={self.tenant_id}, user_id={self.user_id}, plan={self.plan}, status={self.status})>"
|
||||
|
||||
def is_pending_tenant_linking(self) -> bool:
|
||||
"""Check if subscription is waiting to be linked to a tenant"""
|
||||
return self.tenant_linking_status == "pending" and not self.is_tenant_linked
|
||||
|
||||
def can_be_linked_to_tenant(self, user_id: str) -> bool:
|
||||
"""Check if subscription can be linked to a tenant by the given user"""
|
||||
return (self.is_pending_tenant_linking() and
|
||||
str(self.user_id) == user_id and
|
||||
self.tenant_id is None)
|
||||
Reference in New Issue
Block a user