demo seed change

This commit is contained in:
Urtzi Alfaro
2025-12-13 23:57:54 +01:00
parent f3688dfb04
commit ff830a3415
299 changed files with 20328 additions and 19485 deletions

0
shared/utils/__init__.py Normal file → Executable file
View File

0
shared/utils/batch_generator.py Normal file → Executable file
View File

0
shared/utils/circuit_breaker.py Normal file → Executable file
View File

0
shared/utils/city_normalization.py Normal file → Executable file
View File

465
shared/utils/demo_dates.py Normal file → Executable file
View File

@@ -1,18 +1,37 @@
"""
Demo Date Offset Utilities
Provides functions for adjusting dates during demo session cloning
to ensure all temporal data is relative to the demo session creation time
Demo Date Utilities for Temporal Determinism
Adjusts dates from seed data to be relative to demo session creation time
"""
from datetime import datetime, timezone, timedelta
from typing import Optional
import pytz
# Fixed base reference date for all demo data
# This is the "day 0" that all seed data is defined relative to
BASE_REFERENCE_DATE = datetime(2025, 1, 15, 6, 0, 0, tzinfo=timezone.utc)
# Base reference date for all demo seed data
# All seed scripts should use this as the "logical seed date"
# IMPORTANT: This should be set to approximately the current date to ensure demo data appears current
# Updated to December 1, 2025 to align with current date
BASE_REFERENCE_DATE = datetime(2025, 12, 1, 6, 0, 0, tzinfo=timezone.utc)
def get_base_reference_date(session_created_at: Optional[datetime] = None) -> datetime:
"""
Get the base reference date for demo data.
If session_created_at is provided, calculate relative to it.
Otherwise, use current time (for backwards compatibility with seed scripts).
Returns:
Base reference date at 6 AM UTC
"""
if session_created_at:
if session_created_at.tzinfo is None:
session_created_at = session_created_at.replace(tzinfo=timezone.utc)
# Reference is session creation time at 6 AM that day
return session_created_at.replace(
hour=6, minute=0, second=0, microsecond=0
)
# Fallback for seed scripts: use today at 6 AM
now = datetime.now(timezone.utc)
return now.replace(hour=6, minute=0, second=0, microsecond=0)
def adjust_date_for_demo(
@@ -21,31 +40,14 @@ def adjust_date_for_demo(
base_reference_date: datetime = BASE_REFERENCE_DATE
) -> Optional[datetime]:
"""
Adjust a date from seed data to be relative to demo session creation time
This ensures that demo data appears fresh and relevant regardless of when
the demo session is created. For example, expiration dates that were "15 days
from seed date" will become "15 days from session creation date".
Args:
original_date: The original date from the seed data (or None)
session_created_at: When the demo session was created
base_reference_date: The logical date when seed data was created (default: 2025-01-15)
Returns:
Adjusted date relative to session creation, or None if original_date was None
Adjust a date from seed data to be relative to demo session creation time.
Example:
# Seed data created on 2025-01-15
# Stock expiration: 2025-01-30 (15 days from seed date)
# Demo session created: 2025-10-16
# Result: 2025-10-31 (15 days from session date)
>>> original = datetime(2025, 1, 30, 12, 0, tzinfo=timezone.utc)
>>> session = datetime(2025, 10, 16, 10, 0, tzinfo=timezone.utc)
>>> adjusted = adjust_date_for_demo(original, session)
>>> print(adjusted)
2025-10-31 10:00:00+00:00
# Seed data created on 2025-12-13 06:00
# Stock expiration: 2025-12-28 06:00 (15 days from seed date)
# Demo session created: 2025-12-16 10:00
# Base reference: 2025-12-16 06:00
# Result: 2025-12-31 10:00 (15 days from session date)
"""
if original_date is None:
return None
@@ -65,148 +67,281 @@ def adjust_date_for_demo(
return session_created_at + offset
def adjust_date_relative_to_now(
days_offset: int,
hours_offset: int = 0,
reference_time: Optional[datetime] = None
def calculate_edge_case_times(session_created_at: datetime) -> dict:
"""
Calculate deterministic edge case times for demo sessions.
These times are designed to always create specific demo scenarios:
- One late delivery (should have arrived hours ago)
- One overdue production batch (should have started hours ago)
- One in-progress batch (started recently)
- One upcoming batch (starts soon)
- One arriving-soon delivery (arrives in a few hours)
Returns:
{
'late_delivery_expected': session - 4h,
'overdue_batch_planned_start': session - 2h,
'in_progress_batch_actual_start': session - 1h45m,
'upcoming_batch_planned_start': session + 1h30m,
'arriving_soon_delivery_expected': session + 2h30m,
'evening_batch_planned_start': today 17:00,
'tomorrow_morning_planned_start': tomorrow 05:00
}
"""
if session_created_at.tzinfo is None:
session_created_at = session_created_at.replace(tzinfo=timezone.utc)
# Calculate today at 6 AM (base reference)
base_reference = get_base_reference_date(session_created_at)
# Calculate tomorrow at 6 AM
tomorrow_base = base_reference + timedelta(days=1)
return {
'late_delivery_expected': session_created_at - timedelta(hours=4),
'overdue_batch_planned_start': session_created_at - timedelta(hours=2),
'in_progress_batch_actual_start': session_created_at - timedelta(hours=1, minutes=45),
'upcoming_batch_planned_start': session_created_at + timedelta(hours=1, minutes=30),
'arriving_soon_delivery_expected': session_created_at + timedelta(hours=2, minutes=30),
'evening_batch_planned_start': base_reference.replace(hour=17, minute=0, second=0, microsecond=0),
'tomorrow_morning_planned_start': tomorrow_base.replace(hour=5, minute=0, second=0, microsecond=0)
}
def ensure_future_time(
target_time: datetime,
reference_time: datetime,
min_hours_ahead: float = 1.0
) -> datetime:
"""
Create a date relative to now (or a reference time) with specified offset
Useful for creating dates during cloning without needing to store seed dates.
Args:
days_offset: Number of days to add (negative for past dates)
hours_offset: Number of hours to add (negative for past times)
reference_time: Reference datetime (defaults to now)
Returns:
Calculated datetime
Example:
>>> # Create a date 7 days in the future
>>> future = adjust_date_relative_to_now(days_offset=7)
>>> # Create a date 3 days in the past
>>> past = adjust_date_relative_to_now(days_offset=-3)
Ensure a target time is in the future relative to reference time.
If target_time is in the past or too close to reference_time,
shift it forward by at least min_hours_ahead.
"""
if reference_time is None:
reference_time = datetime.now(timezone.utc)
elif reference_time.tzinfo is None:
if target_time.tzinfo is None:
target_time = target_time.replace(tzinfo=timezone.utc)
if reference_time.tzinfo is None:
reference_time = reference_time.replace(tzinfo=timezone.utc)
return reference_time + timedelta(days=days_offset, hours=hours_offset)
time_diff = (target_time - reference_time).total_seconds() / 3600
if time_diff < min_hours_ahead:
# Shift forward to ensure minimum hours ahead
return reference_time + timedelta(hours=min_hours_ahead)
return target_time
def calculate_expiration_date(
received_date: datetime,
shelf_life_days: int
) -> datetime:
"""
Calculate expiration date based on received date and shelf life
Args:
received_date: When the product was received
shelf_life_days: Number of days until expiration
Returns:
Calculated expiration datetime
"""
if received_date.tzinfo is None:
received_date = received_date.replace(tzinfo=timezone.utc)
return received_date + timedelta(days=shelf_life_days)
def get_days_until_expiration(
expiration_date: datetime,
reference_date: Optional[datetime] = None
) -> int:
"""
Calculate number of days until expiration
Args:
expiration_date: The expiration datetime
reference_date: Reference datetime (defaults to now)
Returns:
Number of days until expiration (negative if already expired)
"""
if reference_date is None:
reference_date = datetime.now(timezone.utc)
elif reference_date.tzinfo is None:
reference_date = reference_date.replace(tzinfo=timezone.utc)
if expiration_date.tzinfo is None:
expiration_date = expiration_date.replace(tzinfo=timezone.utc)
delta = expiration_date - reference_date
return delta.days
def is_expiring_soon(
expiration_date: datetime,
threshold_days: int = 3,
reference_date: Optional[datetime] = None
) -> bool:
"""
Check if a product is expiring soon
Args:
expiration_date: The expiration datetime
threshold_days: Number of days to consider as "soon" (default: 3)
reference_date: Reference datetime (defaults to now)
Returns:
True if expiring within threshold_days, False otherwise
"""
days_until = get_days_until_expiration(expiration_date, reference_date)
return 0 <= days_until <= threshold_days
def is_expired(
expiration_date: datetime,
reference_date: Optional[datetime] = None
) -> bool:
"""
Check if a product is expired
Args:
expiration_date: The expiration datetime
reference_date: Reference datetime (defaults to now)
Returns:
True if expired, False otherwise
"""
days_until = get_days_until_expiration(expiration_date, reference_date)
return days_until < 0
def adjust_multiple_dates(
dates_dict: dict,
def resolve_time_marker(
time_marker: str,
session_created_at: datetime,
base_reference_date: datetime = BASE_REFERENCE_DATE
) -> dict:
) -> datetime:
"""
Adjust multiple dates in a dictionary
Resolve time markers like "BASE_TS + 1h30m" to actual datetimes.
Supports markers in the format:
- "BASE_TS + XhYm" (e.g., "BASE_TS + 1h30m")
- "BASE_TS - XhYm" (e.g., "BASE_TS - 2h")
- "BASE_TS + Xd" (e.g., "BASE_TS + 2d")
- "BASE_TS - Xd" (e.g., "BASE_TS - 1d")
Args:
dates_dict: Dictionary with datetime values to adjust
session_created_at: When the demo session was created
base_reference_date: The logical date when seed data was created
time_marker: Time marker string to resolve
session_created_at: Demo session creation time
base_reference_date: Base reference date for calculation
Returns:
Dictionary with adjusted dates (preserves None values)
Example:
>>> dates = {
... 'expiration_date': datetime(2025, 1, 30, tzinfo=timezone.utc),
... 'received_date': datetime(2025, 1, 15, tzinfo=timezone.utc),
... 'optional_date': None
... }
>>> session = datetime(2025, 10, 16, tzinfo=timezone.utc)
>>> adjusted = adjust_multiple_dates(dates, session)
Resolved datetime adjusted for demo session
Raises:
ValueError: If time_marker format is invalid
Examples:
>>> resolve_time_marker("BASE_TS + 1h30m", session_time)
>>> # Returns session_created_at + 1h30m
>>> resolve_time_marker("BASE_TS - 2h", session_time)
>>> # Returns session_created_at - 2h
"""
return {
key: adjust_date_for_demo(value, session_created_at, base_reference_date)
for key, value in dates_dict.items()
}
if not time_marker or not time_marker.startswith("BASE_TS"):
raise ValueError(f"Invalid time marker format: {time_marker}")
# Extract the offset part
offset_part = time_marker[7:].strip() # Remove "BASE_TS "
if not offset_part:
# Just "BASE_TS" - return session_created_at
return session_created_at
# Parse operator and value
operator = offset_part[0]
value_part = offset_part[1:].strip()
if operator not in ['+', '-']:
raise ValueError(f"Invalid operator in time marker: {time_marker}")
# Parse time components
days = 0
hours = 0
minutes = 0
if 'd' in value_part:
# Handle days
day_part, rest = value_part.split('d', 1)
days = int(day_part)
value_part = rest
if 'h' in value_part:
# Handle hours
hour_part, rest = value_part.split('h', 1)
hours = int(hour_part)
value_part = rest
if 'm' in value_part:
# Handle minutes
minute_part = value_part.split('m', 1)[0]
minutes = int(minute_part)
# Calculate offset
offset = timedelta(days=days, hours=hours, minutes=minutes)
if operator == '+':
return session_created_at + offset
else:
return session_created_at - offset
def shift_to_session_time(
original_offset_days: int,
original_hour: int,
original_minute: int,
session_created_at: datetime,
base_reference: Optional[datetime] = None
) -> datetime:
"""
Shift a time from seed data to demo session time with same-day preservation.
Ensures that:
1. Items scheduled for "today" (offset_days=0) remain on the same day as session creation
2. Future items stay in the future, past items stay in the past
3. Times don't shift to invalid moments (e.g., past times for pending items)
Examples:
# Session created at noon, item originally scheduled for morning
>>> session = datetime(2025, 12, 12, 12, 0, tzinfo=timezone.utc)
>>> result = shift_to_session_time(0, 6, 0, session) # Today at 06:00
>>> # Returns today at 13:00 (shifted forward to stay in future)
# Session created at noon, item originally scheduled for evening
>>> result = shift_to_session_time(0, 18, 0, session) # Today at 18:00
>>> # Returns today at 18:00 (already in future)
"""
if session_created_at.tzinfo is None:
session_created_at = session_created_at.replace(tzinfo=timezone.utc)
if base_reference is None:
base_reference = get_base_reference_date(session_created_at)
# Calculate original time
original_time = base_reference.replace(
hour=original_hour,
minute=original_minute,
second=0,
microsecond=0
) + timedelta(days=original_offset_days)
# Calculate offset from base reference
offset = original_time - base_reference
# Apply offset to session creation date
new_time = session_created_at + offset
# Ensure the time is in the future for pending items
if original_offset_days >= 0: # Future or today
new_time = ensure_future_time(new_time, session_created_at, min_hours_ahead=0.5)
return new_time
def get_working_hours_time(
target_date: datetime,
hours_from_start: float = 2.0
) -> datetime:
"""
Get a time within working hours (8 AM - 6 PM) for a given date.
Args:
target_date: The date to calculate time for
hours_from_start: Hours from working day start (8 AM)
Returns:
Datetime within working hours
"""
if target_date.tzinfo is None:
target_date = target_date.replace(tzinfo=timezone.utc)
# Working hours: 8 AM - 6 PM (10 hours)
working_start = target_date.replace(hour=8, minute=0, second=0, microsecond=0)
working_end = target_date.replace(hour=18, minute=0, second=0, microsecond=0)
# Calculate time within working hours
result_time = working_start + timedelta(hours=hours_from_start)
# Ensure it's within working hours
if result_time > working_end:
result_time = working_end
return result_time
def get_next_workday(date: datetime) -> datetime:
"""
Get the next workday (Monday-Friday), skipping weekends.
If date is Friday, returns Monday.
If date is Saturday, returns Monday.
Otherwise returns next day.
"""
if date.tzinfo is None:
date = date.replace(tzinfo=timezone.utc)
next_day = date + timedelta(days=1)
# Skip weekends
while next_day.weekday() >= 5: # 5=Saturday, 6=Sunday
next_day += timedelta(days=1)
return next_day
def get_previous_workday(date: datetime) -> datetime:
"""
Get the previous workday (Monday-Friday), skipping weekends.
If date is Monday, returns Friday.
If date is Sunday, returns Friday.
Otherwise returns previous day.
"""
if date.tzinfo is None:
date = date.replace(tzinfo=timezone.utc)
prev_day = date - timedelta(days=1)
# Skip weekends
while prev_day.weekday() >= 5: # 5=Saturday, 6=Sunday
prev_day -= timedelta(days=1)
return prev_day
def format_iso_with_timezone(dt: datetime) -> str:
"""
Format datetime as ISO 8601 with timezone, replacing Z with +00:00 for compatibility.
"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
iso_string = dt.isoformat()
return iso_string.replace('+00:00', 'Z') if iso_string.endswith('+00:00') else iso_string

View File

@@ -0,0 +1,113 @@
"""
Demo ID Transformer Utility
Provides XOR-based ID transformation for creating unique but deterministic
IDs across different demo tenants while maintaining cross-service consistency.
This ensures that:
1. Same base ID + same tenant ID = same transformed ID (deterministic)
2. Different tenant IDs = different transformed IDs (isolation)
3. Cross-service relationships are preserved (consistency)
"""
import uuid
from typing import Union
def transform_id(base_id: Union[str, uuid.UUID], tenant_id: Union[str, uuid.UUID]) -> uuid.UUID:
"""
Transform a base ID using XOR with tenant ID to create unique but deterministic IDs.
Args:
base_id: Original UUID (string or UUID object)
tenant_id: Tenant UUID (string or UUID object)
Returns:
Transformed UUID that is unique to this tenant but deterministic
Example:
>>> base_uuid = UUID('10000000-0000-0000-0000-000000000001')
>>> tenant_uuid = UUID('a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6')
>>> transform_id(base_uuid, tenant_uuid)
# Returns deterministic UUID based on XOR of the two
"""
# Convert inputs to UUID objects if they aren't already
if isinstance(base_id, str):
base_uuid = uuid.UUID(base_id)
else:
base_uuid = base_id
if isinstance(tenant_id, str):
tenant_uuid = uuid.UUID(tenant_id)
else:
tenant_uuid = tenant_id
# Convert UUIDs to 16-byte arrays
base_bytes = base_uuid.bytes
tenant_bytes = tenant_uuid.bytes
# Apply XOR transformation
transformed_bytes = bytes(b1 ^ b2 for b1, b2 in zip(base_bytes, tenant_bytes))
# Create new UUID from transformed bytes
transformed_uuid = uuid.UUID(bytes=transformed_bytes)
return transformed_uuid
def generate_deterministic_uuid_from_string(input_string: str, tenant_id: Union[str, uuid.UUID]) -> uuid.UUID:
"""
Generate a deterministic UUID from a string input and tenant ID.
Useful for transforming non-UUID identifiers (like SKUs) into UUIDs
while maintaining determinism across services.
Args:
input_string: String identifier (e.g., SKU, product code)
tenant_id: Tenant UUID for isolation
Returns:
Deterministic UUID based on the input string and tenant
"""
if isinstance(tenant_id, str):
tenant_uuid = uuid.UUID(tenant_id)
else:
tenant_uuid = tenant_id
# Create a combined string for hashing
combined = f"{input_string}-{tenant_uuid}"
# Use SHA-256 hash to create deterministic UUID
import hashlib
hash_obj = hashlib.sha256(combined.encode('utf-8'))
# Use first 16 bytes for UUID v5 namespace
hash_bytes = hash_obj.digest()[:16]
# Create UUID v5 using a standard namespace
namespace_uuid = uuid.NAMESPACE_DNS # Using DNS namespace as base
deterministic_uuid = uuid.uuid5(namespace_uuid, combined)
return deterministic_uuid
# Utility functions for common transformations
def transform_ingredient_id(base_ingredient_id: Union[str, uuid.UUID], tenant_id: Union[str, uuid.UUID]) -> uuid.UUID:
"""Transform an ingredient ID for a specific tenant"""
return transform_id(base_ingredient_id, tenant_id)
def transform_recipe_id(base_recipe_id: Union[str, uuid.UUID], tenant_id: Union[str, uuid.UUID]) -> uuid.UUID:
"""Transform a recipe ID for a specific tenant"""
return transform_id(base_recipe_id, tenant_id)
def transform_supplier_id(base_supplier_id: Union[str, uuid.UUID], tenant_id: Union[str, uuid.UUID]) -> uuid.UUID:
"""Transform a supplier ID for a specific tenant"""
return transform_id(base_supplier_id, tenant_id)
def transform_production_batch_id(base_batch_id: Union[str, uuid.UUID], tenant_id: Union[str, uuid.UUID]) -> uuid.UUID:
"""Transform a production batch ID for a specific tenant"""
return transform_id(base_batch_id, tenant_id)

0
shared/utils/optimization.py Normal file → Executable file
View File

0
shared/utils/saga_pattern.py Normal file → Executable file
View File

View File

@@ -0,0 +1,79 @@
"""
Seed Data Path Utilities
Provides functions to locate seed data files for demo data creation
"""
from pathlib import Path
import os
def get_seed_data_path(profile: str, filename: str, child_profile: str = None) -> Path:
"""
Get the path to a seed data file, searching in multiple locations.
Args:
profile: Demo profile (professional/enterprise)
filename: Seed data filename
child_profile: Optional child profile for enterprise demos
Returns:
Path to the seed data file
Raises:
FileNotFoundError: If seed data file cannot be found in any location
"""
# Search locations in order of priority
search_locations = []
# 1. First check in shared/demo/fixtures (new location)
if child_profile:
# Enterprise child profile
search_locations.append(
Path(__file__).parent.parent / "demo" / "fixtures" / profile / child_profile / filename
)
else:
# Regular profile
search_locations.append(
Path(__file__).parent.parent / "demo" / "fixtures" / profile / filename
)
# 2. Check in infrastructure/seed-data (old location)
if child_profile:
search_locations.append(
Path(__file__).parent.parent.parent / "infrastructure" / "seed-data" / profile / "children" / f"{child_profile}.json"
)
else:
search_locations.append(
Path(__file__).parent.parent.parent / "infrastructure" / "seed-data" / profile / filename
)
# 3. Check in infrastructure/seed-data with alternative paths
if profile == "enterprise" and not child_profile:
search_locations.append(
Path(__file__).parent.parent.parent / "infrastructure" / "seed-data" / profile / "parent" / filename
)
# Also check the shared/demo/fixtures/enterprise/parent directory
search_locations.append(
Path(__file__).parent.parent / "demo" / "fixtures" / profile / "parent" / filename
)
# Find the first existing file
for file_path in search_locations:
if file_path.exists():
return file_path
# If no file found, raise an error with all searched locations
searched_paths = "\n".join([str(p) for p in search_locations])
raise FileNotFoundError(
f"Seed data file not found: {filename}\n"
f"Profile: {profile}\n"
f"Child profile: {child_profile}\n"
f"Searched locations:\n{searched_paths}"
)
def get_demo_fixture_path(profile: str, filename: str, child_profile: str = None) -> Path:
"""
Alternative function name for backward compatibility
"""
return get_seed_data_path(profile, filename, child_profile)

0
shared/utils/tenant_settings_client.py Normal file → Executable file
View File

0
shared/utils/time_series_utils.py Normal file → Executable file
View File

0
shared/utils/validation.py Normal file → Executable file
View File