Initial commit - production deployment

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

View File

@@ -0,0 +1,237 @@
# services/orders/app/api/audit.py
"""
Audit Logs API - Retrieve audit trail for orders service
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
from typing import Optional, Dict, Any
from uuid import UUID
from datetime import datetime
import structlog
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import AuditLog
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import require_user_role
from shared.routing import RouteBuilder
from shared.models.audit_log_schemas import (
AuditLogResponse,
AuditLogListResponse,
AuditLogStatsResponse
)
from app.core.database import database_manager
route_builder = RouteBuilder('orders')
router = APIRouter(tags=["audit-logs"])
logger = structlog.get_logger()
async def get_db():
"""Database session dependency"""
async with database_manager.get_session() as session:
yield session
@router.get(
route_builder.build_base_route("audit-logs"),
response_model=AuditLogListResponse
)
@require_user_role(['admin', 'owner'])
async def get_audit_logs(
tenant_id: UUID = Path(..., description="Tenant ID"),
start_date: Optional[datetime] = Query(None, description="Filter logs from this date"),
end_date: Optional[datetime] = Query(None, description="Filter logs until this date"),
user_id: Optional[UUID] = Query(None, description="Filter by user ID"),
action: Optional[str] = Query(None, description="Filter by action type"),
resource_type: Optional[str] = Query(None, description="Filter by resource type"),
severity: Optional[str] = Query(None, description="Filter by severity level"),
search: Optional[str] = Query(None, description="Search in description field"),
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
offset: int = Query(0, ge=0, description="Number of records to skip"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get audit logs for orders service.
Requires admin or owner role.
"""
try:
logger.info(
"Retrieving audit logs",
tenant_id=tenant_id,
user_id=current_user.get("user_id"),
filters={
"start_date": start_date,
"end_date": end_date,
"action": action,
"resource_type": resource_type,
"severity": severity
}
)
# Build query filters
filters = [AuditLog.tenant_id == tenant_id]
if start_date:
filters.append(AuditLog.created_at >= start_date)
if end_date:
filters.append(AuditLog.created_at <= end_date)
if user_id:
filters.append(AuditLog.user_id == user_id)
if action:
filters.append(AuditLog.action == action)
if resource_type:
filters.append(AuditLog.resource_type == resource_type)
if severity:
filters.append(AuditLog.severity == severity)
if search:
filters.append(AuditLog.description.ilike(f"%{search}%"))
# Count total matching records
count_query = select(func.count()).select_from(AuditLog).where(and_(*filters))
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Fetch paginated results
query = (
select(AuditLog)
.where(and_(*filters))
.order_by(AuditLog.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await db.execute(query)
audit_logs = result.scalars().all()
# Convert to response models
items = [AuditLogResponse.from_orm(log) for log in audit_logs]
logger.info(
"Successfully retrieved audit logs",
tenant_id=tenant_id,
total=total,
returned=len(items)
)
return AuditLogListResponse(
items=items,
total=total,
limit=limit,
offset=offset,
has_more=(offset + len(items)) < total
)
except Exception as e:
logger.error(
"Failed to retrieve audit logs",
error=str(e),
tenant_id=tenant_id
)
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve audit logs: {str(e)}"
)
@router.get(
route_builder.build_base_route("audit-logs/stats"),
response_model=AuditLogStatsResponse
)
@require_user_role(['admin', 'owner'])
async def get_audit_log_stats(
tenant_id: UUID = Path(..., description="Tenant ID"),
start_date: Optional[datetime] = Query(None, description="Filter logs from this date"),
end_date: Optional[datetime] = Query(None, description="Filter logs until this date"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get audit log statistics for orders service.
Requires admin or owner role.
"""
try:
logger.info(
"Retrieving audit log statistics",
tenant_id=tenant_id,
user_id=current_user.get("user_id")
)
# Build base filters
filters = [AuditLog.tenant_id == tenant_id]
if start_date:
filters.append(AuditLog.created_at >= start_date)
if end_date:
filters.append(AuditLog.created_at <= end_date)
# Total events
count_query = select(func.count()).select_from(AuditLog).where(and_(*filters))
total_result = await db.execute(count_query)
total_events = total_result.scalar() or 0
# Events by action
action_query = (
select(AuditLog.action, func.count().label('count'))
.where(and_(*filters))
.group_by(AuditLog.action)
)
action_result = await db.execute(action_query)
events_by_action = {row.action: row.count for row in action_result}
# Events by severity
severity_query = (
select(AuditLog.severity, func.count().label('count'))
.where(and_(*filters))
.group_by(AuditLog.severity)
)
severity_result = await db.execute(severity_query)
events_by_severity = {row.severity: row.count for row in severity_result}
# Events by resource type
resource_query = (
select(AuditLog.resource_type, func.count().label('count'))
.where(and_(*filters))
.group_by(AuditLog.resource_type)
)
resource_result = await db.execute(resource_query)
events_by_resource_type = {row.resource_type: row.count for row in resource_result}
# Date range
date_range_query = (
select(
func.min(AuditLog.created_at).label('min_date'),
func.max(AuditLog.created_at).label('max_date')
)
.where(and_(*filters))
)
date_result = await db.execute(date_range_query)
date_row = date_result.one()
logger.info(
"Successfully retrieved audit log statistics",
tenant_id=tenant_id,
total_events=total_events
)
return AuditLogStatsResponse(
total_events=total_events,
events_by_action=events_by_action,
events_by_severity=events_by_severity,
events_by_resource_type=events_by_resource_type,
date_range={
"min": date_row.min_date,
"max": date_row.max_date
}
)
except Exception as e:
logger.error(
"Failed to retrieve audit log statistics",
error=str(e),
tenant_id=tenant_id
)
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve audit log statistics: {str(e)}"
)

View File

@@ -0,0 +1,322 @@
# ================================================================
# services/orders/app/api/customers.py
# ================================================================
"""
Customers API endpoints - ATOMIC CRUD operations
"""
from typing import List
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
import structlog
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import require_user_role
from shared.routing import RouteBuilder
from shared.security import create_audit_logger, AuditSeverity, AuditAction
from app.core.database import get_db
from app.services.orders_service import OrdersService
from app.models import AuditLog
from app.schemas.order_schemas import (
CustomerCreate,
CustomerUpdate,
CustomerResponse
)
logger = structlog.get_logger()
audit_logger = create_audit_logger("orders-service", AuditLog)
# Create route builder for consistent URL structure
route_builder = RouteBuilder('orders')
router = APIRouter()
# ===== Dependency Injection =====
async def get_orders_service(db = Depends(get_db)) -> OrdersService:
"""Get orders service with dependencies"""
from app.repositories.order_repository import (
OrderRepository,
CustomerRepository,
OrderItemRepository,
OrderStatusHistoryRepository
)
from shared.clients import (
get_inventory_client,
get_production_client,
get_sales_client
)
return OrdersService(
order_repo=OrderRepository(),
customer_repo=CustomerRepository(),
order_item_repo=OrderItemRepository(),
status_history_repo=OrderStatusHistoryRepository(),
inventory_client=get_inventory_client(),
production_client=get_production_client(),
sales_client=get_sales_client()
)
# ===== Customer CRUD Endpoints =====
@router.post(
route_builder.build_base_route("customers"),
response_model=CustomerResponse,
status_code=status.HTTP_201_CREATED
)
@require_user_role(['admin', 'owner', 'member'])
async def create_customer(
customer_data: CustomerCreate,
tenant_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
orders_service: OrdersService = Depends(get_orders_service),
db = Depends(get_db)
):
"""Create a new customer"""
try:
# Check if customer code already exists
existing_customer = await orders_service.customer_repo.get_by_customer_code(
db, customer_data.customer_code, tenant_id
)
if existing_customer:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Customer code already exists"
)
# Extract user ID safely
user_id = current_user.get("user_id")
if not user_id:
logger.error("User ID not found in current_user context", current_user_keys=list(current_user.keys()))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User authentication error"
)
customer = await orders_service.customer_repo.create(
db,
obj_in=customer_data,
created_by=UUID(user_id),
tenant_id=tenant_id
)
# Commit the transaction to persist changes
await db.commit()
logger.info("Customer created successfully",
customer_id=str(customer.id),
customer_code=customer.customer_code)
return CustomerResponse.from_orm(customer)
except HTTPException:
raise
except Exception as e:
logger.error("Error creating customer", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create customer"
)
@router.get(
route_builder.build_base_route("customers"),
response_model=List[CustomerResponse]
)
async def get_customers(
tenant_id: UUID = Path(...),
active_only: bool = Query(True, description="Filter for active customers only"),
skip: int = Query(0, ge=0, description="Number of customers to skip"),
limit: int = Query(100, ge=1, le=1000, description="Number of customers to return"),
current_user: dict = Depends(get_current_user_dep),
orders_service: OrdersService = Depends(get_orders_service),
db = Depends(get_db)
):
"""Get customers with filtering and pagination"""
try:
if active_only:
customers = await orders_service.customer_repo.get_active_customers(
db, tenant_id, skip, limit
)
else:
customers = await orders_service.customer_repo.get_multi(
db, tenant_id, skip, limit, order_by="name"
)
return [CustomerResponse.from_orm(customer) for customer in customers]
except Exception as e:
logger.error("Error getting customers", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve customers"
)
@router.get(
route_builder.build_resource_detail_route("customers", "customer_id"),
response_model=CustomerResponse
)
async def get_customer(
tenant_id: UUID = Path(...),
customer_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
orders_service: OrdersService = Depends(get_orders_service),
db = Depends(get_db)
):
"""Get customer details by ID"""
try:
customer = await orders_service.customer_repo.get(db, customer_id, tenant_id)
if not customer:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer not found"
)
return CustomerResponse.from_orm(customer)
except HTTPException:
raise
except Exception as e:
logger.error("Error getting customer",
customer_id=str(customer_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve customer"
)
@router.put(
route_builder.build_resource_detail_route("customers", "customer_id"),
response_model=CustomerResponse
)
@require_user_role(['admin', 'owner', 'member'])
async def update_customer(
customer_data: CustomerUpdate,
tenant_id: UUID = Path(...),
customer_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
orders_service: OrdersService = Depends(get_orders_service),
db = Depends(get_db)
):
"""Update customer information"""
try:
# Get existing customer
customer = await orders_service.customer_repo.get(db, customer_id, tenant_id)
if not customer:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer not found"
)
# Update customer
# Extract user ID safely for update
user_id = current_user.get("user_id")
if not user_id:
logger.error("User ID not found in current_user context for update", current_user_keys=list(current_user.keys()))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User authentication error"
)
updated_customer = await orders_service.customer_repo.update(
db,
db_obj=customer,
obj_in=customer_data.dict(exclude_unset=True),
updated_by=UUID(user_id)
)
# Commit the transaction to persist changes
await db.commit()
logger.info("Customer updated successfully",
customer_id=str(customer_id))
return CustomerResponse.from_orm(updated_customer)
except HTTPException:
raise
except Exception as e:
logger.error("Error updating customer",
customer_id=str(customer_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update customer"
)
@router.delete(
route_builder.build_resource_detail_route("customers", "customer_id"),
status_code=status.HTTP_204_NO_CONTENT
)
@require_user_role(['admin', 'owner'])
async def delete_customer(
tenant_id: UUID = Path(...),
customer_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
orders_service: OrdersService = Depends(get_orders_service),
db = Depends(get_db)
):
"""
Delete a customer (Admin+ only, GDPR-compliant soft delete)
Removes PII while maintaining referential integrity
"""
try:
customer = await orders_service.customer_repo.get(db, customer_id, tenant_id)
if not customer:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer not found"
)
# Capture customer data before deletion (for audit trail)
# Note: This is anonymized after retention period in compliance with GDPR
customer_data = {
"customer_code": customer.customer_code,
"customer_name": customer.customer_name,
"email": customer.email,
"phone": customer.phone,
"business_type": customer.business_type if hasattr(customer, 'business_type') else None
}
await orders_service.customer_repo.delete(db, customer_id, tenant_id)
# Commit the transaction to persist deletion
await db.commit()
# Log HIGH severity audit event for customer deletion (GDPR compliance)
try:
await audit_logger.log_deletion(
db_session=db,
tenant_id=str(tenant_id),
user_id=current_user["user_id"],
resource_type="customer",
resource_id=str(customer_id),
resource_data=customer_data,
description=f"Admin {current_user.get('email', 'unknown')} deleted customer {customer_data['customer_code']} (GDPR-compliant soft delete)",
endpoint=f"/customers/{customer_id}",
method="DELETE",
severity=AuditSeverity.HIGH.value
)
except Exception as audit_error:
logger.warning("Failed to log audit event", error=str(audit_error))
logger.info("Customer deleted successfully (GDPR-compliant)",
customer_id=str(customer_id),
tenant_id=str(tenant_id),
user_id=current_user["user_id"])
except HTTPException:
raise
except Exception as e:
logger.error("Error deleting customer",
customer_id=str(customer_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete customer"
)

View File

@@ -0,0 +1,455 @@
"""
Internal Demo Cloning API for Orders Service
Service-to-service endpoint for cloning order and customer data
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
import structlog
import uuid
from datetime import datetime, timezone, timedelta, date
from typing import Optional
import os
from decimal import Decimal
import json
from pathlib import Path
from app.core.database import get_db
from app.models.order import CustomerOrder, OrderItem
from app.models.customer import Customer
from shared.utils.demo_dates import adjust_date_for_demo, resolve_time_marker, get_next_workday
from app.core.config import settings
logger = structlog.get_logger()
router = APIRouter(prefix="/internal/demo", tags=["internal"])
# Base demo tenant IDs
DEMO_TENANT_PROFESSIONAL = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
def parse_date_field(date_value, session_time: datetime, field_name: str = "date") -> Optional[datetime]:
"""
Parse date field, handling both ISO strings and BASE_TS markers.
Supports:
- BASE_TS markers: "BASE_TS + 1h30m", "BASE_TS - 2d"
- ISO 8601 strings: "2025-01-15T06:00:00Z"
- None values (returns None)
Returns timezone-aware datetime or None.
"""
if not date_value:
return None
# Check if it's a BASE_TS marker
if isinstance(date_value, str) and date_value.startswith("BASE_TS"):
try:
return resolve_time_marker(date_value, session_time)
except ValueError as e:
logger.warning(
f"Invalid BASE_TS marker in {field_name}",
marker=date_value,
error=str(e)
)
return None
# Handle regular ISO date strings
try:
if isinstance(date_value, str):
original_date = datetime.fromisoformat(date_value.replace('Z', '+00:00'))
elif hasattr(date_value, 'isoformat'):
original_date = date_value
else:
logger.warning(f"Unsupported date format in {field_name}", date_value=date_value)
return None
return adjust_date_for_demo(original_date, session_time)
except (ValueError, AttributeError) as e:
logger.warning(
f"Invalid date format in {field_name}",
date_value=date_value,
error=str(e)
)
return None
def ensure_workday(target_date: datetime) -> datetime:
"""Ensure delivery date falls on a workday (Monday-Friday)"""
if target_date and target_date.weekday() >= 5: # Saturday or Sunday
return get_next_workday(target_date)
return target_date
@router.post("/clone")
async def clone_demo_data(
base_tenant_id: str,
virtual_tenant_id: str,
demo_account_type: str,
session_id: Optional[str] = None,
session_created_at: Optional[str] = None,
db: AsyncSession = Depends(get_db)
):
"""
Clone orders service data for a virtual demo tenant
Clones:
- Customers
- Customer orders with line items
- Adjusts dates to recent timeframe
Args:
base_tenant_id: Template tenant UUID to clone from
virtual_tenant_id: Target virtual tenant UUID
demo_account_type: Type of demo account
session_id: Originating session ID for tracing
Returns:
Cloning status and record counts
"""
start_time = datetime.now(timezone.utc)
# Parse session creation time for date adjustment
if session_created_at:
try:
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
except (ValueError, AttributeError):
session_time = start_time
else:
session_time = start_time
logger.info(
"Starting orders data cloning",
base_tenant_id=base_tenant_id,
virtual_tenant_id=virtual_tenant_id,
demo_account_type=demo_account_type,
session_id=session_id,
session_created_at=session_created_at
)
try:
# Validate UUIDs
base_uuid = uuid.UUID(base_tenant_id)
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Track cloning statistics
stats = {
"customers": 0,
"customer_orders": 0,
"order_line_items": 0,
"alerts_generated": 0
}
# Customer ID mapping (old -> new)
customer_id_map = {}
# Load Customers from seed data
try:
from shared.utils.seed_data_paths import get_seed_data_path
if demo_account_type == "professional":
json_file = get_seed_data_path("professional", "08-orders.json")
elif demo_account_type == "enterprise":
json_file = get_seed_data_path("enterprise", "08-orders.json")
elif demo_account_type == "enterprise_child":
json_file = get_seed_data_path("enterprise", "08-orders.json", child_id=base_tenant_id)
else:
raise ValueError(f"Invalid demo account type: {demo_account_type}")
except ImportError:
# Fallback to original path
seed_data_dir = Path(__file__).parent.parent.parent.parent / "infrastructure" / "seed-data"
if demo_account_type == "professional":
json_file = seed_data_dir / "professional" / "08-orders.json"
elif demo_account_type == "enterprise":
json_file = seed_data_dir / "enterprise" / "parent" / "08-orders.json"
elif demo_account_type == "enterprise_child":
json_file = seed_data_dir / "enterprise" / "children" / base_tenant_id / "08-orders.json"
else:
raise ValueError(f"Invalid demo account type: {demo_account_type}")
if not json_file.exists():
raise HTTPException(
status_code=404,
detail=f"Seed data file not found: {json_file}"
)
# Load JSON data
with open(json_file, 'r', encoding='utf-8') as f:
seed_data = json.load(f)
logger.info(
"Loaded orders seed data",
customers=len(seed_data.get('customers', [])),
orders=len(seed_data.get('customer_orders', []))
)
# Load Customers from seed data
for customer_data in seed_data.get('customers', []):
# Transform IDs using XOR
from shared.utils.demo_id_transformer import transform_id
try:
customer_uuid = uuid.UUID(customer_data['id'])
transformed_id = transform_id(customer_data['id'], virtual_uuid)
except ValueError as e:
logger.error("Failed to parse customer UUID",
customer_id=customer_data['id'],
error=str(e))
continue
customer_id_map[uuid.UUID(customer_data['id'])] = transformed_id
new_customer = Customer(
id=transformed_id,
tenant_id=virtual_uuid,
customer_code=customer_data.get('customer_code'),
name=customer_data.get('name'),
business_name=customer_data.get('business_name'),
customer_type=customer_data.get('customer_type'),
tax_id=customer_data.get('tax_id'),
email=customer_data.get('email'),
phone=customer_data.get('phone'),
address_line1=customer_data.get('address_line1'),
address_line2=customer_data.get('address_line2'),
city=customer_data.get('city'),
state=customer_data.get('state'),
postal_code=customer_data.get('postal_code'),
country=customer_data.get('country'),
business_license=customer_data.get('business_license'),
is_active=customer_data.get('is_active', True),
preferred_delivery_method=customer_data.get('preferred_delivery_method'),
payment_terms=customer_data.get('payment_terms'),
credit_limit=customer_data.get('credit_limit', 0.0),
discount_percentage=customer_data.get('discount_percentage', 0.0),
customer_segment=customer_data.get('customer_segment'),
priority_level=customer_data.get('priority_level'),
special_instructions=customer_data.get('special_instructions'),
delivery_preferences=customer_data.get('delivery_preferences'),
product_preferences=customer_data.get('product_preferences'),
total_orders=customer_data.get('total_orders', 0),
total_spent=customer_data.get('total_spent', 0.0),
average_order_value=customer_data.get('average_order_value', 0.0),
last_order_date=parse_date_field(
customer_data.get('last_order_date'),
session_time,
"last_order_date"
),
created_at=session_time,
updated_at=session_time
)
db.add(new_customer)
stats["customers"] += 1
# Load Customer Orders from seed data
order_id_map = {}
for order_data in seed_data.get('customer_orders', []):
# Transform IDs using XOR
from shared.utils.demo_id_transformer import transform_id
try:
order_uuid = uuid.UUID(order_data['id'])
transformed_id = transform_id(order_data['id'], virtual_uuid)
except ValueError as e:
logger.error("Failed to parse order UUID",
order_id=order_data['id'],
error=str(e))
continue
order_id_map[uuid.UUID(order_data['id'])] = transformed_id
# Map customer_id if it exists in our map
customer_id_value = order_data.get('customer_id')
if customer_id_value:
customer_id_value = customer_id_map.get(uuid.UUID(customer_id_value), uuid.UUID(customer_id_value))
# Parse date fields (supports BASE_TS markers and ISO timestamps)
adjusted_order_date = parse_date_field(
order_data.get('order_date'),
session_time,
"order_date"
) or session_time
# Handle delivery date - JSON uses 'delivery_date', database uses 'requested_delivery_date'
# Fallback to order_date + 2 hours if no delivery date is provided
delivery_date_value = order_data.get('delivery_date') or order_data.get('requested_delivery_date')
adjusted_requested_delivery = parse_date_field(
delivery_date_value,
session_time,
"requested_delivery_date"
)
# Ensure requested_delivery_date is never None - fallback to order_date + 2 hours
if adjusted_requested_delivery is None:
adjusted_requested_delivery = adjusted_order_date + timedelta(hours=2)
# Create new order from seed data
# Generate unique order number by appending tenant-specific suffix
base_order_number = order_data.get('order_number', f"ORD-{uuid.uuid4().hex[:8].upper()}")
# Add tenant-specific suffix to ensure global uniqueness
tenant_suffix = virtual_uuid.hex[:4].upper() # Use first 4 chars of tenant ID
unique_order_number = f"{base_order_number}-{tenant_suffix}"
new_order = CustomerOrder(
id=str(transformed_id),
tenant_id=virtual_uuid,
order_number=unique_order_number,
customer_id=str(customer_id_value) if customer_id_value else None,
status=order_data.get('status', 'pending'),
order_type=order_data.get('order_type', 'standard'),
priority=order_data.get('priority', 'normal'),
order_date=adjusted_order_date,
requested_delivery_date=adjusted_requested_delivery,
delivery_method=order_data.get('delivery_method'),
delivery_address=order_data.get('delivery_address'),
delivery_instructions=order_data.get('delivery_instructions'),
subtotal=order_data.get('subtotal', 0.0),
tax_amount=order_data.get('tax_amount', 0.0),
discount_amount=order_data.get('discount_amount', 0.0),
discount_percentage=order_data.get('discount_percentage', 0.0),
delivery_fee=order_data.get('delivery_fee', 0.0),
total_amount=order_data.get('total_amount', 0.0),
payment_status=order_data.get('payment_status', 'pending'),
payment_method=order_data.get('payment_method'),
payment_terms=order_data.get('payment_terms'),
special_instructions=order_data.get('special_instructions'),
order_source=order_data.get('order_source', 'manual'),
sales_channel=order_data.get('sales_channel', 'direct'),
created_at=session_time,
updated_at=session_time
)
db.add(new_order)
stats["customer_orders"] += 1
# Clone Order Items
for old_order_id, new_order_id in order_id_map.items():
result = await db.execute(
select(OrderItem).where(OrderItem.order_id == old_order_id)
)
order_items = result.scalars().all()
for item in order_items:
new_item = OrderItem(
id=uuid.uuid4(),
order_id=new_order_id,
product_id=item.product_id,
product_name=item.product_name,
product_sku=item.product_sku,
quantity=item.quantity,
unit_of_measure=item.unit_of_measure,
unit_price=item.unit_price,
line_discount=item.line_discount,
line_total=item.line_total,
status=item.status
)
db.add(new_item)
stats["order_line_items"] += 1
# Commit cloned data
await db.commit()
# NOTE: Alert generation removed - alerts are now generated automatically by the
# respective alert services which run scheduled checks at appropriate intervals.
# This eliminates duplicate alerts and provides a more realistic demo experience.
stats["alerts_generated"] = 0
total_records = stats["customers"] + stats["customer_orders"] + stats["order_line_items"]
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info(
"Orders data cloning completed",
virtual_tenant_id=virtual_tenant_id,
total_records=total_records,
stats=stats,
duration_ms=duration_ms
)
return {
"service": "orders",
"status": "completed",
"records_cloned": total_records,
"duration_ms": duration_ms,
"details": stats
}
except ValueError as e:
logger.error("Invalid UUID format", error=str(e))
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
except Exception as e:
logger.error(
"Failed to clone orders data",
error=str(e),
virtual_tenant_id=virtual_tenant_id,
exc_info=True
)
# Rollback on error
await db.rollback()
return {
"service": "orders",
"status": "failed",
"records_cloned": 0,
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
"error": str(e)
}
@router.get("/clone/health")
async def clone_health_check():
"""
Health check for internal cloning endpoint
Used by orchestrator to verify service availability
"""
return {
"service": "orders",
"clone_endpoint": "available",
"version": "2.0.0"
}
@router.delete("/tenant/{virtual_tenant_id}")
async def delete_demo_data(
virtual_tenant_id: str,
db: AsyncSession = Depends(get_db)
):
"""Delete all order data for a virtual demo tenant"""
logger.info("Deleting order data for virtual tenant", virtual_tenant_id=virtual_tenant_id)
start_time = datetime.now(timezone.utc)
try:
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Count records
order_count = await db.scalar(select(func.count(CustomerOrder.id)).where(CustomerOrder.tenant_id == virtual_uuid))
item_count = await db.scalar(select(func.count(OrderItem.id)).join(CustomerOrder).where(CustomerOrder.tenant_id == virtual_uuid))
customer_count = await db.scalar(select(func.count(Customer.id)).where(Customer.tenant_id == virtual_uuid))
# Delete in order
await db.execute(delete(OrderItem).where(OrderItem.order_id.in_(
select(CustomerOrder.id).where(CustomerOrder.tenant_id == virtual_uuid)
)))
await db.execute(delete(CustomerOrder).where(CustomerOrder.tenant_id == virtual_uuid))
await db.execute(delete(Customer).where(Customer.tenant_id == virtual_uuid))
await db.commit()
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info("Order data deleted successfully", virtual_tenant_id=virtual_tenant_id, duration_ms=duration_ms)
return {
"service": "orders",
"status": "deleted",
"virtual_tenant_id": virtual_tenant_id,
"records_deleted": {
"orders": order_count,
"items": item_count,
"customers": customer_count,
"total": order_count + item_count + customer_count
},
"duration_ms": duration_ms
}
except Exception as e:
logger.error("Failed to delete order data", error=str(e), exc_info=True)
await db.rollback()
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,237 @@
# ================================================================
# services/orders/app/api/order_operations.py
# ================================================================
"""
Order Operations API endpoints - BUSINESS logic operations
Includes status updates, demand calculation, dashboard, and business intelligence
"""
from datetime import date, datetime
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
import structlog
from shared.auth.decorators import get_current_user_dep
from shared.routing import RouteBuilder
from app.core.database import get_db
from app.services.orders_service import OrdersService
from app.schemas.order_schemas import (
OrderResponse,
OrdersDashboardSummary,
DemandRequirements
)
logger = structlog.get_logger()
# Create route builder for consistent URL structure
route_builder = RouteBuilder('orders')
router = APIRouter()
# ===== Dependency Injection =====
async def get_orders_service(db = Depends(get_db)) -> OrdersService:
"""Get orders service with dependencies"""
from app.repositories.order_repository import (
OrderRepository,
CustomerRepository,
OrderItemRepository,
OrderStatusHistoryRepository
)
from shared.clients import (
get_inventory_client,
get_production_client,
get_sales_client
)
return OrdersService(
order_repo=OrderRepository(),
customer_repo=CustomerRepository(),
order_item_repo=OrderItemRepository(),
status_history_repo=OrderStatusHistoryRepository(),
inventory_client=get_inventory_client(),
production_client=get_production_client(),
sales_client=get_sales_client()
)
# ===== Dashboard and Analytics Endpoints =====
@router.get(
route_builder.build_operations_route("dashboard-summary"),
response_model=OrdersDashboardSummary
)
async def get_dashboard_summary(
tenant_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
orders_service: OrdersService = Depends(get_orders_service),
db = Depends(get_db)
):
"""Get comprehensive dashboard summary for orders"""
try:
summary = await orders_service.get_dashboard_summary(db, tenant_id)
logger.info("Dashboard summary retrieved",
tenant_id=str(tenant_id),
total_orders=summary.total_orders_today)
return summary
except Exception as e:
logger.error("Error getting dashboard summary",
tenant_id=str(tenant_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve dashboard summary"
)
@router.get(
route_builder.build_base_route("demand-requirements"),
response_model=DemandRequirements
)
async def get_demand_requirements(
tenant_id: UUID = Path(...),
target_date: date = Query(..., description="Date for demand analysis"),
current_user: dict = Depends(get_current_user_dep),
orders_service: OrdersService = Depends(get_orders_service),
db = Depends(get_db)
):
"""Get demand requirements for production planning"""
try:
requirements = await orders_service.get_demand_requirements(db, tenant_id, target_date)
logger.info("Demand requirements calculated",
tenant_id=str(tenant_id),
target_date=str(target_date),
total_orders=requirements.total_orders)
return requirements
except Exception as e:
logger.error("Error getting demand requirements",
tenant_id=str(tenant_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to calculate demand requirements"
)
# ===== Order Status Management =====
@router.put(
route_builder.build_base_route("{order_id}/status"),
response_model=OrderResponse
)
async def update_order_status(
new_status: str,
tenant_id: UUID = Path(...),
order_id: UUID = Path(...),
reason: Optional[str] = Query(None, description="Reason for status change"),
current_user: dict = Depends(get_current_user_dep),
orders_service: OrdersService = Depends(get_orders_service),
db = Depends(get_db)
):
"""Update order status with validation and history tracking"""
try:
# Validate status
valid_statuses = [
"pending", "confirmed", "in_production", "ready",
"out_for_delivery", "delivered", "cancelled", "failed"
]
if new_status not in valid_statuses:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid status. Must be one of: {', '.join(valid_statuses)}"
)
order = await orders_service.update_order_status(
db,
order_id,
tenant_id,
new_status,
user_id=UUID(current_user["user_id"]),
reason=reason
)
if not order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Order not found"
)
logger.info("Order status updated",
order_id=str(order_id),
new_status=new_status)
return order
except HTTPException:
raise
except Exception as e:
logger.error("Error updating order status",
order_id=str(order_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update order status"
)
# ===== Business Intelligence Endpoints =====
@router.get(
route_builder.build_base_route("business-model")
)
async def detect_business_model(
tenant_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
orders_service: OrdersService = Depends(get_orders_service),
db = Depends(get_db)
):
"""Detect business model based on order patterns"""
try:
business_model = await orders_service.detect_business_model(db, tenant_id)
return {
"business_model": business_model,
"confidence": "high" if business_model else "unknown",
"detected_at": datetime.now().isoformat()
}
except Exception as e:
logger.error("Error detecting business model", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to detect business model"
)
# ===== Health and Status Endpoints =====
@router.get(
route_builder.build_base_route("status")
)
async def get_service_status(
tenant_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep)
):
"""Get orders service status"""
try:
return {
"service": "orders-service",
"status": "healthy",
"timestamp": datetime.now().isoformat(),
"tenant_id": str(tenant_id)
}
except Exception as e:
logger.error("Error getting service status", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get service status"
)

View File

@@ -0,0 +1,405 @@
# ================================================================
# services/orders/app/api/orders.py
# ================================================================
"""
Orders API endpoints - ATOMIC CRUD operations only
"""
from datetime import date
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import require_user_role
from shared.routing import RouteBuilder
from shared.security import create_audit_logger, AuditSeverity, AuditAction
from app.core.database import get_db
from app.services.orders_service import OrdersService
from app.models import AuditLog
from app.schemas.order_schemas import (
OrderCreate,
OrderUpdate,
OrderResponse
)
logger = structlog.get_logger()
audit_logger = create_audit_logger("orders-service", AuditLog)
# Create route builder for consistent URL structure
route_builder = RouteBuilder('orders')
router = APIRouter()
# ===== Dependency Injection =====
async def get_orders_service(db = Depends(get_db)) -> OrdersService:
"""Get orders service with dependencies"""
from app.repositories.order_repository import (
OrderRepository,
CustomerRepository,
OrderItemRepository,
OrderStatusHistoryRepository
)
from shared.clients import (
get_inventory_client,
get_production_client,
get_sales_client
)
return OrdersService(
order_repo=OrderRepository(),
customer_repo=CustomerRepository(),
order_item_repo=OrderItemRepository(),
status_history_repo=OrderStatusHistoryRepository(),
inventory_client=get_inventory_client(),
production_client=get_production_client(),
sales_client=get_sales_client()
)
# ===== Order CRUD Endpoints =====
@router.post(
route_builder.build_base_route("").rstrip("/"),
response_model=OrderResponse,
status_code=status.HTTP_201_CREATED
)
@require_user_role(['admin', 'owner', 'member'])
async def create_order(
order_data: OrderCreate,
tenant_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
orders_service: OrdersService = Depends(get_orders_service),
db = Depends(get_db)
):
"""Create a new customer order"""
try:
# Extract user ID safely
user_id = current_user.get("user_id")
if not user_id:
logger.error("User ID not found in current_user context", current_user_keys=list(current_user.keys()))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User authentication error"
)
order = await orders_service.create_order(
db,
order_data,
user_id=UUID(user_id)
)
# Commit the transaction to persist changes
await db.commit()
logger.info("Order created successfully",
order_id=str(order.id),
order_number=order.order_number)
return order
except ValueError as e:
logger.warning("Invalid order data", error=str(e))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
logger.error("Error creating order", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create order"
)
@router.get(
route_builder.build_base_route("").rstrip("/"),
response_model=List[OrderResponse]
)
async def get_orders(
tenant_id: UUID = Path(...),
status_filter: Optional[str] = Query(None, description="Filter by order status"),
start_date: Optional[date] = Query(None, description="Start date for date range filter"),
end_date: Optional[date] = Query(None, description="End date for date range filter"),
skip: int = Query(0, ge=0, description="Number of orders to skip"),
limit: int = Query(100, ge=1, le=1000, description="Number of orders to return"),
current_user: dict = Depends(get_current_user_dep),
orders_service: OrdersService = Depends(get_orders_service),
db = Depends(get_db)
):
"""Get orders with filtering and pagination"""
try:
# Determine which repository method to use based on filters
if status_filter:
orders = await orders_service.order_repo.get_orders_by_status(
db, tenant_id, status_filter, skip, limit
)
elif start_date and end_date:
orders = await orders_service.order_repo.get_orders_by_date_range(
db, tenant_id, start_date, end_date, skip, limit
)
else:
orders = await orders_service.order_repo.get_multi(
db, tenant_id, skip, limit, order_by="order_date", order_desc=True
)
return [OrderResponse.from_orm(order) for order in orders]
except Exception as e:
logger.error("Error getting orders", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve orders"
)
@router.get(
route_builder.build_base_route("{order_id}"),
response_model=OrderResponse
)
async def get_order(
tenant_id: UUID = Path(...),
order_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
orders_service: OrdersService = Depends(get_orders_service),
db = Depends(get_db)
):
"""Get order details with items"""
try:
order = await orders_service.get_order_with_items(db, order_id, tenant_id)
if not order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Order not found"
)
return order
except HTTPException:
raise
except Exception as e:
logger.error("Error getting order",
order_id=str(order_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve order"
)
@router.put(
route_builder.build_base_route("{order_id}"),
response_model=OrderResponse
)
@require_user_role(['admin', 'owner', 'member'])
async def update_order(
order_data: OrderUpdate,
tenant_id: UUID = Path(...),
order_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
orders_service: OrdersService = Depends(get_orders_service),
db = Depends(get_db)
):
"""Update order information"""
try:
# Get existing order
order = await orders_service.order_repo.get(db, order_id, tenant_id)
if not order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Order not found"
)
# Update order
updated_order = await orders_service.order_repo.update(
db,
db_obj=order,
obj_in=order_data.dict(exclude_unset=True),
updated_by=UUID(current_user["user_id"])
)
# Commit the transaction to persist changes
await db.commit()
logger.info("Order updated successfully",
order_id=str(order_id))
return OrderResponse.from_orm(updated_order)
except HTTPException:
raise
except Exception as e:
logger.error("Error updating order",
order_id=str(order_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update order"
)
@router.delete(
route_builder.build_base_route("{order_id}"),
status_code=status.HTTP_204_NO_CONTENT
)
@require_user_role(['admin', 'owner'])
async def delete_order(
tenant_id: UUID = Path(...),
order_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
orders_service: OrdersService = Depends(get_orders_service),
db = Depends(get_db)
):
"""Delete an order (Admin+ only, soft delete)"""
try:
order = await orders_service.order_repo.get(db, order_id, tenant_id)
if not order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Order not found"
)
# Capture order data before deletion
order_data = {
"order_number": order.order_number,
"customer_id": str(order.customer_id) if order.customer_id else None,
"order_status": order.order_status,
"total_amount": float(order.total_amount) if order.total_amount else 0.0,
"order_date": order.order_date.isoformat() if order.order_date else None
}
await orders_service.order_repo.delete(db, order_id, tenant_id)
# Commit the transaction to persist deletion
await db.commit()
# Log audit event for order deletion
try:
await audit_logger.log_deletion(
db_session=db,
tenant_id=str(tenant_id),
user_id=current_user["user_id"],
resource_type="order",
resource_id=str(order_id),
resource_data=order_data,
description=f"Admin {current_user.get('email', 'unknown')} deleted order {order_data['order_number']}",
endpoint=f"/orders/{order_id}",
method="DELETE"
)
except Exception as audit_error:
logger.warning("Failed to log audit event", error=str(audit_error))
logger.info("Order deleted successfully",
order_id=str(order_id),
tenant_id=str(tenant_id),
user_id=current_user["user_id"])
except HTTPException:
raise
except Exception as e:
logger.error("Error deleting order",
order_id=str(order_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete order"
)
# ===== Tenant Data Deletion Endpoint =====
@router.delete(
route_builder.build_base_route("tenant/{tenant_id}", include_tenant_prefix=False),
status_code=status.HTTP_200_OK
)
async def delete_tenant_data(
tenant_id: str = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Delete all order-related data for a tenant
Only accessible by internal services (called during tenant deletion)
"""
logger.info("Tenant data deletion request received",
tenant_id=tenant_id,
requesting_service=current_user.get("service", "unknown"))
# Only allow internal service calls
if current_user.get("type") != "service":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This endpoint is only accessible to internal services"
)
try:
from app.services.tenant_deletion_service import OrdersTenantDeletionService
deletion_service = OrdersTenantDeletionService(db)
result = await deletion_service.safe_delete_tenant_data(tenant_id)
return {
"message": "Tenant data deletion completed in orders-service",
"summary": result.to_dict()
}
except Exception as e:
logger.error("Tenant data deletion failed",
tenant_id=tenant_id,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete tenant data: {str(e)}"
)
@router.get(
route_builder.build_base_route("tenant/{tenant_id}/deletion-preview", include_tenant_prefix=False),
status_code=status.HTTP_200_OK
)
async def preview_tenant_data_deletion(
tenant_id: str = Path(..., description="Tenant ID"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Preview what data would be deleted for a tenant (dry-run)
Accessible by internal services and tenant admins
"""
# Allow internal services and admins
is_service = current_user.get("type") == "service"
is_admin = current_user.get("role") in ["owner", "admin"]
if not (is_service or is_admin):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions"
)
try:
from app.services.tenant_deletion_service import OrdersTenantDeletionService
deletion_service = OrdersTenantDeletionService(db)
preview = await deletion_service.get_tenant_data_preview(tenant_id)
return {
"tenant_id": tenant_id,
"service": "orders-service",
"data_counts": preview,
"total_items": sum(preview.values())
}
except Exception as e:
logger.error("Deletion preview failed",
tenant_id=tenant_id,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get deletion preview: {str(e)}"
)

View File

@@ -0,0 +1,87 @@
# ================================================================
# services/orders/app/core/config.py
# ================================================================
"""
Orders Service Configuration
"""
import os
from shared.config.base import BaseServiceSettings
class OrdersSettings(BaseServiceSettings):
"""Orders service specific settings"""
# Service Identity
APP_NAME: str = "Orders Service"
SERVICE_NAME: str = "orders-service"
VERSION: str = "1.0.0"
DESCRIPTION: str = "Customer orders and procurement planning"
# Database configuration (secure approach - build from components)
@property
def DATABASE_URL(self) -> str:
"""Build database URL from secure components"""
# Try complete URL first (for backward compatibility)
complete_url = os.getenv("ORDERS_DATABASE_URL")
if complete_url:
return complete_url
# Build from components (secure approach)
user = os.getenv("ORDERS_DB_USER", "orders_user")
password = os.getenv("ORDERS_DB_PASSWORD", "orders_pass123")
host = os.getenv("ORDERS_DB_HOST", "localhost")
port = os.getenv("ORDERS_DB_PORT", "5432")
name = os.getenv("ORDERS_DB_NAME", "orders_db")
return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{name}"
# Order Processing
ORDER_PROCESSING_ENABLED: bool = os.getenv("ORDER_PROCESSING_ENABLED", "true").lower() == "true"
AUTO_APPROVE_ORDERS: bool = os.getenv("AUTO_APPROVE_ORDERS", "false").lower() == "true"
MAX_ORDER_ITEMS: int = int(os.getenv("MAX_ORDER_ITEMS", "50"))
# Procurement Planning
PROCUREMENT_PLANNING_ENABLED: bool = os.getenv("PROCUREMENT_PLANNING_ENABLED", "true").lower() == "true"
PROCUREMENT_LEAD_TIME_DAYS: int = int(os.getenv("PROCUREMENT_LEAD_TIME_DAYS", "3"))
DEMAND_FORECAST_DAYS: int = int(os.getenv("DEMAND_FORECAST_DAYS", "14"))
SAFETY_STOCK_PERCENTAGE: float = float(os.getenv("SAFETY_STOCK_PERCENTAGE", "20.0"))
# Business Model Detection
ENABLE_BUSINESS_MODEL_DETECTION: bool = os.getenv("ENABLE_BUSINESS_MODEL_DETECTION", "true").lower() == "true"
CENTRAL_BAKERY_ORDER_THRESHOLD: int = int(os.getenv("CENTRAL_BAKERY_ORDER_THRESHOLD", "20"))
INDIVIDUAL_BAKERY_ORDER_THRESHOLD: int = int(os.getenv("INDIVIDUAL_BAKERY_ORDER_THRESHOLD", "5"))
# Customer Management
CUSTOMER_VALIDATION_ENABLED: bool = os.getenv("CUSTOMER_VALIDATION_ENABLED", "true").lower() == "true"
MAX_CUSTOMERS_PER_TENANT: int = int(os.getenv("MAX_CUSTOMERS_PER_TENANT", "10000"))
CUSTOMER_CREDIT_CHECK_ENABLED: bool = os.getenv("CUSTOMER_CREDIT_CHECK_ENABLED", "false").lower() == "true"
# Order Validation
MIN_ORDER_VALUE: float = float(os.getenv("MIN_ORDER_VALUE", "0.0"))
MAX_ORDER_VALUE: float = float(os.getenv("MAX_ORDER_VALUE", "100000.0"))
VALIDATE_PRODUCT_AVAILABILITY: bool = os.getenv("VALIDATE_PRODUCT_AVAILABILITY", "true").lower() == "true"
# Payment and Pricing
PAYMENT_VALIDATION_ENABLED: bool = os.getenv("PAYMENT_VALIDATION_ENABLED", "true").lower() == "true"
DYNAMIC_PRICING_ENABLED: bool = os.getenv("DYNAMIC_PRICING_ENABLED", "false").lower() == "true"
DISCOUNT_ENABLED: bool = os.getenv("DISCOUNT_ENABLED", "true").lower() == "true"
MAX_DISCOUNT_PERCENTAGE: float = float(os.getenv("MAX_DISCOUNT_PERCENTAGE", "50.0"))
# Delivery and Fulfillment
DELIVERY_TRACKING_ENABLED: bool = os.getenv("DELIVERY_TRACKING_ENABLED", "true").lower() == "true"
DEFAULT_DELIVERY_WINDOW_HOURS: int = int(os.getenv("DEFAULT_DELIVERY_WINDOW_HOURS", "48"))
PICKUP_ENABLED: bool = os.getenv("PICKUP_ENABLED", "true").lower() == "true"
DELIVERY_ENABLED: bool = os.getenv("DELIVERY_ENABLED", "true").lower() == "true"
# Integration Settings
PRODUCTION_SERVICE_URL: str = os.getenv("PRODUCTION_SERVICE_URL", "http://production-service:8000")
INVENTORY_SERVICE_URL: str = os.getenv("INVENTORY_SERVICE_URL", "http://inventory-service:8000")
SUPPLIERS_SERVICE_URL: str = os.getenv("SUPPLIERS_SERVICE_URL", "http://suppliers-service:8000")
SALES_SERVICE_URL: str = os.getenv("SALES_SERVICE_URL", "http://sales-service:8000")
# Global settings instance
settings = OrdersSettings()

View File

@@ -0,0 +1,90 @@
# ================================================================
# services/orders/app/core/database.py
# ================================================================
"""
Orders Service Database Configuration
"""
from sqlalchemy import create_engine, text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import sessionmaker, DeclarativeBase
import structlog
from typing import AsyncGenerator
from app.core.config import settings
logger = structlog.get_logger()
# Create async engine
async_engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
pool_recycle=3600
)
# Create async session factory
AsyncSessionLocal = async_sessionmaker(
bind=async_engine,
class_=AsyncSession,
expire_on_commit=False
)
# Base class for models
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Get database session"""
async with AsyncSessionLocal() as session:
try:
yield session
except Exception as e:
await session.rollback()
logger.error("Database session error", error=str(e))
raise
finally:
await session.close()
async def init_database():
"""Initialize database tables"""
try:
async with async_engine.begin() as conn:
# Import all models to ensure they are registered
from app.models.order import CustomerOrder, OrderItem, OrderStatusHistory
from app.models.customer import Customer, CustomerContact
# Create all tables
await conn.run_sync(Base.metadata.create_all)
logger.info("Orders database initialized successfully")
except Exception as e:
logger.error("Failed to initialize orders database", error=str(e))
raise
async def get_db_health() -> bool:
"""Check database health"""
try:
async with async_engine.begin() as conn:
await conn.execute(text("SELECT 1"))
return True
except Exception as e:
logger.error("Database health check failed", error=str(e))
return False
# Database manager instance for service_base compatibility
from shared.database.base import DatabaseManager
database_manager = DatabaseManager(
database_url=settings.DATABASE_URL,
service_name="orders-service",
pool_size=10,
max_overflow=20,
echo=settings.DEBUG
)

139
services/orders/app/main.py Normal file
View File

@@ -0,0 +1,139 @@
# ================================================================
# services/orders/app/main.py
# ================================================================
"""
Orders Service - FastAPI Application
Customer orders management service
"""
from fastapi import FastAPI, Request
from sqlalchemy import text
from app.core.config import settings
from app.core.database import database_manager
from app.api.orders import router as orders_router
from app.api.customers import router as customers_router
from app.api.order_operations import router as order_operations_router
from app.api import audit, internal_demo
from shared.service_base import StandardFastAPIService
class OrdersService(StandardFastAPIService):
"""Orders Service with standardized setup"""
expected_migration_version = "00001"
async def on_startup(self, app):
"""Custom startup logic including migration verification"""
await self.verify_migrations()
await super().on_startup(app)
async def verify_migrations(self):
"""Verify database schema matches the latest migrations."""
try:
async with self.database_manager.get_session() as session:
result = await session.execute(text("SELECT version_num FROM alembic_version"))
version = result.scalar()
if version != self.expected_migration_version:
self.logger.error(f"Migration version mismatch: expected {self.expected_migration_version}, got {version}")
raise RuntimeError(f"Migration version mismatch: expected {self.expected_migration_version}, got {version}")
self.logger.info(f"Migration verification successful: {version}")
except Exception as e:
self.logger.error(f"Migration verification failed: {e}")
raise
def __init__(self):
# Define expected database tables for health checks
orders_expected_tables = [
'customers', 'customer_contacts', 'customer_orders', 'order_items',
'order_status_history', 'audit_logs'
]
super().__init__(
service_name="orders-service",
app_name=settings.APP_NAME,
description=settings.DESCRIPTION,
version=settings.VERSION,
api_prefix="", # Empty because RouteBuilder already includes /api/v1
database_manager=database_manager,
expected_tables=orders_expected_tables
)
async def on_startup(self, app: FastAPI):
"""Custom startup logic for orders service"""
# REMOVED: Procurement scheduler service initialization
# Procurement scheduling is now handled by the Orchestrator Service
# which calls the Procurement Service's /auto-generate endpoint
pass
async def on_shutdown(self, app: FastAPI):
"""Custom shutdown logic for orders service"""
# REMOVED: Scheduler service shutdown
pass
def get_service_features(self):
"""Return orders-specific features"""
return [
"customer_management",
"order_processing",
"order_tracking"
]
# Create service instance
service = OrdersService()
# Create FastAPI app with standardized setup
app = service.create_app()
# Setup standard endpoints
service.setup_standard_endpoints()
# Include routers - organized by ATOMIC and BUSINESS operations
# IMPORTANT: Register specific routes (audit, customers) BEFORE parameterized routes (orders)
# to avoid route matching conflicts where {order_id} would match literal paths like "audit-logs"
# AUDIT: Audit log retrieval endpoints - Must be registered FIRST
service.add_router(audit.router)
# ATOMIC: Direct CRUD operations
# NOTE: Register customers_router BEFORE orders_router to ensure /customers
# matches before the parameterized /{order_id} route
service.add_router(customers_router)
service.add_router(orders_router)
# BUSINESS: Complex operations and workflows
service.add_router(order_operations_router)
# INTERNAL: Service-to-service endpoints - DEPRECATED: Replaced by script-based seed data loading
service.add_router(internal_demo.router, tags=["internal-demo"])
# REMOVED: test_procurement_scheduler endpoint
# Procurement scheduling is now triggered by the Orchestrator Service
@app.middleware("http")
async def logging_middleware(request: Request, call_next):
"""Add request logging middleware"""
import time
start_time = time.time()
response = await call_next(request)
process_time = time.time() - start_time
service.logger.info("HTTP request processed",
method=request.method,
url=str(request.url),
status_code=response.status_code,
process_time=round(process_time, 4))
return response
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=settings.DEBUG
)

View File

@@ -0,0 +1,68 @@
"""
Orders 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 .customer import Customer, CustomerContact
from .order import CustomerOrder, OrderItem, OrderStatusHistory
# Import enums
from .enums import (
CustomerType,
DeliveryMethod,
PaymentTerms,
PaymentMethod,
PaymentStatus,
CustomerSegment,
SalesChannel,
BusinessModel,
OrderType,
OrderSource,
OrderStatus,
DeliveryStatus,
ProcurementPlanType,
ProcurementStrategy,
PlanStatus,
PriorityLevel,
RequirementStatus,
RiskLevel,
)
# List all models for easier access
__all__ = [
# Models
"Customer",
"CustomerContact",
"CustomerOrder",
"OrderItem",
"OrderStatusHistory",
# Enums
"CustomerType",
"DeliveryMethod",
"PaymentTerms",
"PaymentMethod",
"PaymentStatus",
"CustomerSegment",
"SalesChannel",
"BusinessModel",
"OrderType",
"OrderSource",
"OrderStatus",
"DeliveryStatus",
"ProcurementPlanType",
"ProcurementStrategy",
"PlanStatus",
"PriorityLevel",
"RequirementStatus",
"RiskLevel",
"AuditLog",
]

View File

@@ -0,0 +1,123 @@
# ================================================================
# services/orders/app/models/customer.py
# ================================================================
"""
Customer-related database models for Orders Service
"""
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Optional, List
from sqlalchemy import Column, String, Boolean, DateTime, Numeric, Text, ForeignKey, Integer
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from shared.database.base import Base
class Customer(Base):
"""Customer model for managing customer information"""
__tablename__ = "customers"
# 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)
customer_code = Column(String(50), nullable=False, index=True) # Human-readable code
# Basic information
name = Column(String(200), nullable=False)
business_name = Column(String(200), nullable=True)
customer_type = Column(String(50), nullable=False, default="individual") # individual, business, central_bakery
# Contact information
email = Column(String(255), nullable=True)
phone = Column(String(50), nullable=True)
# Address information
address_line1 = Column(String(255), nullable=True)
address_line2 = Column(String(255), nullable=True)
city = Column(String(100), nullable=True)
state = Column(String(100), nullable=True)
postal_code = Column(String(20), nullable=True)
country = Column(String(100), nullable=False, default="US")
# Business information
tax_id = Column(String(50), nullable=True)
business_license = Column(String(100), nullable=True)
# Customer status and preferences
is_active = Column(Boolean, nullable=False, default=True)
preferred_delivery_method = Column(String(50), nullable=False, default="delivery") # delivery, pickup
payment_terms = Column(String(50), nullable=False, default="immediate") # immediate, net_30, net_60
credit_limit = Column(Numeric(10, 2), nullable=True)
discount_percentage = Column(Numeric(5, 2), nullable=False, default=Decimal("0.00"))
# Customer categorization
customer_segment = Column(String(50), nullable=False, default="regular") # vip, regular, wholesale
priority_level = Column(String(20), nullable=False, default="normal") # high, normal, low
# Preferences and special requirements
special_instructions = Column(Text, nullable=True)
delivery_preferences = Column(JSONB, nullable=True) # Time windows, special requirements
product_preferences = Column(JSONB, nullable=True) # Favorite products, allergies
# Customer metrics
total_orders = Column(Integer, nullable=False, default=0)
total_spent = Column(Numeric(12, 2), nullable=False, default=Decimal("0.00"))
average_order_value = Column(Numeric(10, 2), nullable=False, default=Decimal("0.00"))
last_order_date = Column(DateTime(timezone=True), 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)
# Relationships
contacts = relationship("CustomerContact", back_populates="customer", cascade="all, delete-orphan")
orders = relationship("CustomerOrder", back_populates="customer")
class CustomerContact(Base):
"""Additional contact persons for business customers"""
__tablename__ = "customer_contacts"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id", ondelete="CASCADE"), nullable=False)
# Contact information
name = Column(String(200), nullable=False)
title = Column(String(100), nullable=True)
department = Column(String(100), nullable=True)
# Contact details
email = Column(String(255), nullable=True)
phone = Column(String(50), nullable=True)
mobile = Column(String(50), nullable=True)
# Contact preferences
is_primary = Column(Boolean, nullable=False, default=False)
contact_for_orders = Column(Boolean, nullable=False, default=True)
contact_for_delivery = Column(Boolean, nullable=False, default=False)
contact_for_billing = Column(Boolean, nullable=False, default=False)
contact_for_support = Column(Boolean, nullable=False, default=False)
# Preferred contact methods
preferred_contact_method = Column(String(50), nullable=False, default="email") # email, phone, sms
contact_time_preferences = Column(JSONB, nullable=True) # Time windows for contact
# Notes and special instructions
notes = Column(Text, nullable=True)
# Status
is_active = Column(Boolean, nullable=False, default=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)
# Relationships
customer = relationship("Customer", back_populates="contacts")

View File

@@ -0,0 +1,162 @@
# services/orders/app/models/enums.py
"""
Enum definitions for Orders Service
Following the pattern used in the Inventory Service for better type safety and maintainability
"""
import enum
class CustomerType(enum.Enum):
"""Customer type classifications"""
INDIVIDUAL = "individual"
BUSINESS = "business"
CENTRAL_BAKERY = "central_bakery"
RETAIL = "RETAIL"
WHOLESALE = "WHOLESALE"
RESTAURANT = "RESTAURANT"
HOTEL = "HOTEL"
ENTERPRISE = "ENTERPRISE"
class DeliveryMethod(enum.Enum):
"""Order delivery methods"""
DELIVERY = "delivery"
PICKUP = "pickup"
STANDARD = "standard" # Standard delivery method
class PaymentTerms(enum.Enum):
"""Payment terms for customers and orders"""
IMMEDIATE = "immediate"
NET_15 = "net_15"
NET_30 = "net_30"
NET_60 = "net_60"
class PaymentMethod(enum.Enum):
"""Payment methods for orders"""
CASH = "cash"
CARD = "card"
CREDIT_CARD = "credit_card" # Credit card payment
CHECK = "check" # Bank check/cheque payment
BANK_TRANSFER = "bank_transfer"
ACCOUNT = "account"
class PaymentStatus(enum.Enum):
"""Payment status for orders"""
PENDING = "pending"
PARTIAL = "partial"
PAID = "paid"
FAILED = "failed"
REFUNDED = "refunded"
class CustomerSegment(enum.Enum):
"""Customer segmentation categories"""
VIP = "vip"
REGULAR = "regular"
WHOLESALE = "wholesale"
class PriorityLevel(enum.Enum):
"""Priority levels for orders and customers"""
URGENT = "urgent"
HIGH = "high"
NORMAL = "normal"
LOW = "low"
class OrderType(enum.Enum):
"""Order type classifications"""
STANDARD = "standard"
RUSH = "rush"
RECURRING = "recurring"
SPECIAL = "special"
class OrderStatus(enum.Enum):
"""Order status workflow"""
PENDING = "pending"
CONFIRMED = "confirmed"
IN_PRODUCTION = "in_production"
READY = "ready"
OUT_FOR_DELIVERY = "out_for_delivery"
DELIVERED = "delivered"
CANCELLED = "cancelled"
FAILED = "failed"
class OrderSource(enum.Enum):
"""Source of order creation"""
MANUAL = "manual"
ONLINE = "online"
PHONE = "phone"
APP = "app"
API = "api"
class SalesChannel(enum.Enum):
"""Sales channel classification"""
DIRECT = "direct"
WHOLESALE = "wholesale"
RETAIL = "retail"
class BusinessModel(enum.Enum):
"""Business model types"""
INDIVIDUAL_BAKERY = "individual_bakery"
CENTRAL_BAKERY = "central_bakery"
# Procurement-related enums
class ProcurementPlanType(enum.Enum):
"""Procurement plan types"""
REGULAR = "regular"
EMERGENCY = "emergency"
SEASONAL = "seasonal"
class ProcurementStrategy(enum.Enum):
"""Procurement strategies"""
JUST_IN_TIME = "just_in_time"
BULK = "bulk"
MIXED = "mixed"
class RiskLevel(enum.Enum):
"""Risk level classifications"""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class RequirementStatus(enum.Enum):
"""Procurement requirement status"""
PENDING = "pending"
APPROVED = "approved"
ORDERED = "ordered"
PARTIALLY_RECEIVED = "partially_received"
RECEIVED = "received"
CANCELLED = "cancelled"
class PlanStatus(enum.Enum):
"""Procurement plan status"""
DRAFT = "draft"
PENDING_APPROVAL = "pending_approval"
APPROVED = "approved"
IN_EXECUTION = "in_execution"
COMPLETED = "completed"
CANCELLED = "cancelled"
class DeliveryStatus(enum.Enum):
"""Delivery status for procurement"""
PENDING = "pending"
IN_TRANSIT = "in_transit"
DELIVERED = "delivered"
DELAYED = "delayed"
CANCELLED = "cancelled"

View File

@@ -0,0 +1,218 @@
# ================================================================
# services/orders/app/models/order.py
# ================================================================
"""
Order-related database models for Orders Service
"""
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Optional, List
from sqlalchemy import Column, String, Boolean, DateTime, Numeric, Text, ForeignKey, Integer
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from shared.database.base import Base
class CustomerOrder(Base):
"""Customer order model for tracking orders throughout their lifecycle"""
__tablename__ = "customer_orders"
# 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)
order_number = Column(String(50), nullable=False, unique=True, index=True)
# Customer information
customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id"), nullable=False, index=True)
# Order status and lifecycle
status = Column(String(50), nullable=False, default="pending", index=True)
# Status values: pending, confirmed, in_production, ready, out_for_delivery, delivered, cancelled, failed
order_type = Column(String(50), nullable=False, default="standard") # standard, rush, recurring, special
priority = Column(String(20), nullable=False, default="normal") # high, normal, low
# Order timing
order_date = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
requested_delivery_date = Column(DateTime(timezone=True), nullable=False)
confirmed_delivery_date = Column(DateTime(timezone=True), nullable=True)
actual_delivery_date = Column(DateTime(timezone=True), nullable=True)
# Delivery information
delivery_method = Column(String(50), nullable=False, default="delivery") # delivery, pickup
delivery_address = Column(JSONB, nullable=True) # Complete delivery address
delivery_instructions = Column(Text, nullable=True)
delivery_window_start = Column(DateTime(timezone=True), nullable=True)
delivery_window_end = Column(DateTime(timezone=True), nullable=True)
# Financial information
subtotal = Column(Numeric(10, 2), nullable=False, default=Decimal("0.00"))
discount_amount = Column(Numeric(10, 2), nullable=False, default=Decimal("0.00"))
discount_percentage = Column(Numeric(5, 2), nullable=False, default=Decimal("0.00"))
tax_amount = Column(Numeric(10, 2), nullable=False, default=Decimal("0.00"))
delivery_fee = Column(Numeric(10, 2), nullable=False, default=Decimal("0.00"))
total_amount = Column(Numeric(10, 2), nullable=False, default=Decimal("0.00"))
# Payment information
payment_status = Column(String(50), nullable=False, default="pending") # pending, partial, paid, failed, refunded
payment_method = Column(String(50), nullable=True) # cash, card, bank_transfer, account
payment_terms = Column(String(50), nullable=False, default="immediate")
payment_due_date = Column(DateTime(timezone=True), nullable=True)
# Special requirements and customizations
special_instructions = Column(Text, nullable=True)
custom_requirements = Column(JSONB, nullable=True) # Special dietary requirements, decorations
allergen_warnings = Column(JSONB, nullable=True) # Allergen information
# Business model detection
business_model = Column(String(50), nullable=True) # individual_bakery, central_bakery (auto-detected)
estimated_business_model = Column(String(50), nullable=True) # Based on order patterns
# Order source and channel
order_source = Column(String(50), nullable=False, default="manual") # manual, online, phone, app, api
sales_channel = Column(String(50), nullable=False, default="direct") # direct, wholesale, retail
order_origin = Column(String(100), nullable=True) # Website, app, store location
# Fulfillment tracking
production_batch_id = Column(UUID(as_uuid=True), nullable=True) # Link to production batch
fulfillment_location = Column(String(100), nullable=True) # Which location fulfills this order
estimated_preparation_time = Column(Integer, nullable=True) # Minutes
actual_preparation_time = Column(Integer, nullable=True) # Minutes
# Customer communication
customer_notified_confirmed = Column(Boolean, nullable=False, default=False)
customer_notified_ready = Column(Boolean, nullable=False, default=False)
customer_notified_delivered = Column(Boolean, nullable=False, default=False)
communication_preferences = Column(JSONB, nullable=True)
# Quality and feedback
quality_score = Column(Numeric(3, 1), nullable=True) # 1.0 to 10.0
customer_rating = Column(Integer, nullable=True) # 1-5 stars
customer_feedback = Column(Text, nullable=True)
# Cancellation and refunds
cancellation_reason = Column(String(200), nullable=True)
cancelled_at = Column(DateTime(timezone=True), nullable=True)
cancelled_by = Column(UUID(as_uuid=True), nullable=True)
refund_amount = Column(Numeric(10, 2), nullable=False, default=Decimal("0.00"))
refund_processed_at = Column(DateTime(timezone=True), 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)
# Additional metadata
order_metadata = Column(JSONB, nullable=True) # Flexible field for additional data
# Relationships
customer = relationship("Customer", back_populates="orders")
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
status_history = relationship("OrderStatusHistory", back_populates="order", cascade="all, delete-orphan")
class OrderItem(Base):
"""Individual items within a customer order"""
__tablename__ = "order_items"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
order_id = Column(UUID(as_uuid=True), ForeignKey("customer_orders.id", ondelete="CASCADE"), nullable=False)
# Product information
product_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Reference to products service
product_name = Column(String(200), nullable=False)
product_sku = Column(String(100), nullable=True)
product_category = Column(String(100), nullable=True)
# Quantity and units
quantity = Column(Numeric(10, 3), nullable=False)
unit_of_measure = Column(String(50), nullable=False, default="each")
weight = Column(Numeric(10, 3), nullable=True) # For weight-based products
# Pricing information
unit_price = Column(Numeric(10, 2), nullable=False)
line_discount = Column(Numeric(10, 2), nullable=False, default=Decimal("0.00"))
line_total = Column(Numeric(10, 2), nullable=False)
# Product specifications and customizations
product_specifications = Column(JSONB, nullable=True) # Size, flavor, decorations
customization_details = Column(Text, nullable=True)
special_instructions = Column(Text, nullable=True)
# Production requirements
recipe_id = Column(UUID(as_uuid=True), nullable=True) # Reference to recipes service
production_requirements = Column(JSONB, nullable=True) # Ingredients, equipment needed
estimated_production_time = Column(Integer, nullable=True) # Minutes
# Fulfillment tracking
status = Column(String(50), nullable=False, default="pending") # pending, in_production, ready, delivered
production_started_at = Column(DateTime(timezone=True), nullable=True)
production_completed_at = Column(DateTime(timezone=True), nullable=True)
quality_checked = Column(Boolean, nullable=False, default=False)
quality_score = Column(Numeric(3, 1), nullable=True)
# Cost tracking
ingredient_cost = Column(Numeric(10, 2), nullable=True)
labor_cost = Column(Numeric(10, 2), nullable=True)
overhead_cost = Column(Numeric(10, 2), nullable=True)
total_cost = Column(Numeric(10, 2), nullable=True)
margin = Column(Numeric(10, 2), nullable=True)
# Inventory impact
reserved_inventory = Column(Boolean, nullable=False, default=False)
inventory_allocated_at = Column(DateTime(timezone=True), 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)
# Additional metadata
customer_metadata = Column(JSONB, nullable=True)
# Relationships
order = relationship("CustomerOrder", back_populates="items")
class OrderStatusHistory(Base):
"""Track status changes and important events in order lifecycle"""
__tablename__ = "order_status_history"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
order_id = Column(UUID(as_uuid=True), ForeignKey("customer_orders.id", ondelete="CASCADE"), nullable=False)
# Status change information
from_status = Column(String(50), nullable=True)
to_status = Column(String(50), nullable=False)
change_reason = Column(String(200), nullable=True)
# Event details
event_type = Column(String(50), nullable=False, default="status_change")
# Event types: status_change, payment_received, production_started, delivery_scheduled, etc.
event_description = Column(Text, nullable=True)
event_data = Column(JSONB, nullable=True) # Additional event-specific data
# Who made the change
changed_by = Column(UUID(as_uuid=True), nullable=True)
change_source = Column(String(50), nullable=False, default="manual") # manual, automatic, system, api
# Timing
changed_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Customer communication
customer_notified = Column(Boolean, nullable=False, default=False)
notification_method = Column(String(50), nullable=True) # email, sms, phone, app
notification_sent_at = Column(DateTime(timezone=True), nullable=True)
# Additional notes
notes = Column(Text, nullable=True)
# Relationships
order = relationship("CustomerOrder", back_populates="status_history")

View File

@@ -0,0 +1,289 @@
# ================================================================
# services/orders/app/repositories/base_repository.py
# ================================================================
"""
Base repository class for Orders Service
"""
from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union
from uuid import UUID
from sqlalchemy import select, update, delete, func, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, joinedload
import structlog
from app.core.database import Base
logger = structlog.get_logger()
ModelType = TypeVar("ModelType", bound=Base)
CreateSchemaType = TypeVar("CreateSchemaType")
UpdateSchemaType = TypeVar("UpdateSchemaType")
class BaseRepository(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
"""Base repository with common CRUD operations"""
def __init__(self, model: Type[ModelType]):
self.model = model
async def get(
self,
db: AsyncSession,
id: UUID,
tenant_id: Optional[UUID] = None
) -> Optional[ModelType]:
"""Get a single record by ID with optional tenant filtering"""
try:
query = select(self.model).where(self.model.id == id)
# Add tenant filtering if tenant_id is provided and model has tenant_id field
if tenant_id and hasattr(self.model, 'tenant_id'):
query = query.where(self.model.tenant_id == tenant_id)
result = await db.execute(query)
return result.scalar_one_or_none()
except Exception as e:
logger.error("Error getting record", model=self.model.__name__, id=str(id), error=str(e))
raise
async def get_by_field(
self,
db: AsyncSession,
field_name: str,
field_value: Any,
tenant_id: Optional[UUID] = None
) -> Optional[ModelType]:
"""Get a single record by field value"""
try:
field = getattr(self.model, field_name)
query = select(self.model).where(field == field_value)
if tenant_id and hasattr(self.model, 'tenant_id'):
query = query.where(self.model.tenant_id == tenant_id)
result = await db.execute(query)
return result.scalar_one_or_none()
except Exception as e:
logger.error("Error getting record by field",
model=self.model.__name__,
field_name=field_name,
field_value=str(field_value),
error=str(e))
raise
async def get_multi(
self,
db: AsyncSession,
tenant_id: Optional[UUID] = None,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
order_by: Optional[str] = None,
order_desc: bool = False
) -> List[ModelType]:
"""Get multiple records with filtering, pagination, and sorting"""
try:
query = select(self.model)
# Add tenant filtering
if tenant_id and hasattr(self.model, 'tenant_id'):
query = query.where(self.model.tenant_id == tenant_id)
# Add additional filters
if filters:
for field_name, field_value in filters.items():
if hasattr(self.model, field_name):
field = getattr(self.model, field_name)
if isinstance(field_value, list):
query = query.where(field.in_(field_value))
else:
query = query.where(field == field_value)
# Add ordering
if order_by and hasattr(self.model, order_by):
order_field = getattr(self.model, order_by)
if order_desc:
query = query.order_by(order_field.desc())
else:
query = query.order_by(order_field)
# Add pagination
query = query.offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Error getting multiple records",
model=self.model.__name__,
error=str(e))
raise
async def count(
self,
db: AsyncSession,
tenant_id: Optional[UUID] = None,
filters: Optional[Dict[str, Any]] = None
) -> int:
"""Count records with optional filtering"""
try:
query = select(func.count()).select_from(self.model)
# Add tenant filtering
if tenant_id and hasattr(self.model, 'tenant_id'):
query = query.where(self.model.tenant_id == tenant_id)
# Add additional filters
if filters:
for field_name, field_value in filters.items():
if hasattr(self.model, field_name):
field = getattr(self.model, field_name)
if isinstance(field_value, list):
query = query.where(field.in_(field_value))
else:
query = query.where(field == field_value)
result = await db.execute(query)
return result.scalar()
except Exception as e:
logger.error("Error counting records",
model=self.model.__name__,
error=str(e))
raise
async def create(
self,
db: AsyncSession,
*,
obj_in: CreateSchemaType,
created_by: Optional[UUID] = None,
tenant_id: Optional[UUID] = None
) -> ModelType:
"""Create a new record"""
try:
# Convert schema to dict
if hasattr(obj_in, 'dict'):
obj_data = obj_in.dict()
else:
obj_data = obj_in
# Add tenant_id if the model supports it and it's provided
if tenant_id and hasattr(self.model, 'tenant_id'):
obj_data['tenant_id'] = tenant_id
# Add created_by if the model supports it
if created_by and hasattr(self.model, 'created_by'):
obj_data['created_by'] = created_by
# Create model instance
db_obj = self.model(**obj_data)
# Add to session and flush to get ID
db.add(db_obj)
await db.flush()
await db.refresh(db_obj)
logger.info("Record created",
model=self.model.__name__,
id=str(db_obj.id))
return db_obj
except Exception as e:
logger.error("Error creating record",
model=self.model.__name__,
error=str(e))
raise
async def update(
self,
db: AsyncSession,
*,
db_obj: ModelType,
obj_in: Union[UpdateSchemaType, Dict[str, Any]],
updated_by: Optional[UUID] = None
) -> ModelType:
"""Update an existing record"""
try:
# Convert schema to dict
if hasattr(obj_in, 'dict'):
update_data = obj_in.dict(exclude_unset=True)
else:
update_data = obj_in
# Add updated_by if the model supports it
if updated_by and hasattr(self.model, 'updated_by'):
update_data['updated_by'] = updated_by
# Update fields
for field, value in update_data.items():
if hasattr(db_obj, field):
setattr(db_obj, field, value)
# Flush changes
await db.flush()
await db.refresh(db_obj)
logger.info("Record updated",
model=self.model.__name__,
id=str(db_obj.id))
return db_obj
except Exception as e:
logger.error("Error updating record",
model=self.model.__name__,
id=str(db_obj.id),
error=str(e))
raise
async def delete(
self,
db: AsyncSession,
*,
id: UUID,
tenant_id: Optional[UUID] = None
) -> Optional[ModelType]:
"""Delete a record by ID"""
try:
# First get the record
db_obj = await self.get(db, id=id, tenant_id=tenant_id)
if not db_obj:
return None
# Delete the record
await db.delete(db_obj)
await db.flush()
logger.info("Record deleted",
model=self.model.__name__,
id=str(id))
return db_obj
except Exception as e:
logger.error("Error deleting record",
model=self.model.__name__,
id=str(id),
error=str(e))
raise
async def exists(
self,
db: AsyncSession,
id: UUID,
tenant_id: Optional[UUID] = None
) -> bool:
"""Check if a record exists"""
try:
query = select(func.count()).select_from(self.model).where(self.model.id == id)
if tenant_id and hasattr(self.model, 'tenant_id'):
query = query.where(self.model.tenant_id == tenant_id)
result = await db.execute(query)
count = result.scalar()
return count > 0
except Exception as e:
logger.error("Error checking record existence",
model=self.model.__name__,
id=str(id),
error=str(e))
raise

View File

@@ -0,0 +1,628 @@
# ================================================================
# services/orders/app/repositories/order_repository.py
# ================================================================
"""
Order-related repositories for Orders Service
"""
from datetime import datetime, date, timedelta
from decimal import Decimal
from typing import List, Optional, Dict, Any
from uuid import UUID
from sqlalchemy import select, func, and_, or_, case, extract
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, joinedload
import structlog
from app.models.customer import Customer
from app.models.order import CustomerOrder, OrderItem, OrderStatusHistory
from app.schemas.order_schemas import OrderCreate, OrderUpdate, OrderItemCreate, OrderItemUpdate
from app.repositories.base_repository import BaseRepository
logger = structlog.get_logger()
class CustomerRepository(BaseRepository[Customer, dict, dict]):
"""Repository for customer operations"""
def __init__(self):
super().__init__(Customer)
async def get_by_customer_code(
self,
db: AsyncSession,
customer_code: str,
tenant_id: UUID
) -> Optional[Customer]:
"""Get customer by customer code within tenant"""
try:
query = select(Customer).where(
and_(
Customer.customer_code == customer_code,
Customer.tenant_id == tenant_id
)
)
result = await db.execute(query)
return result.scalar_one_or_none()
except Exception as e:
logger.error("Error getting customer by code",
customer_code=customer_code,
error=str(e))
raise
async def get_active_customers(
self,
db: AsyncSession,
tenant_id: UUID,
skip: int = 0,
limit: int = 100
) -> List[Customer]:
"""Get active customers for a tenant"""
try:
query = select(Customer).where(
and_(
Customer.tenant_id == tenant_id,
Customer.is_active == True
)
).order_by(Customer.name).offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Error getting active customers", error=str(e))
raise
async def update_customer_metrics(
self,
db: AsyncSession,
customer_id: UUID,
order_value: Decimal,
order_date: datetime
):
"""Update customer metrics after order creation"""
try:
customer = await self.get(db, customer_id)
if customer:
customer.total_orders += 1
customer.total_spent += order_value
customer.average_order_value = customer.total_spent / customer.total_orders
customer.last_order_date = order_date
await db.flush()
logger.info("Customer metrics updated",
customer_id=str(customer_id),
new_total_spent=str(customer.total_spent))
except Exception as e:
logger.error("Error updating customer metrics",
customer_id=str(customer_id),
error=str(e))
raise
async def count_created_since(
self,
db: AsyncSession,
tenant_id: UUID,
since_date: datetime
) -> int:
"""Count customers created since a specific date"""
try:
query = select(func.count()).select_from(Customer).where(
and_(
Customer.tenant_id == tenant_id,
Customer.created_at >= since_date
)
)
result = await db.execute(query)
return result.scalar()
except Exception as e:
logger.error("Error counting customers created since date",
tenant_id=str(tenant_id),
since_date=str(since_date),
error=str(e))
raise
class OrderRepository(BaseRepository[CustomerOrder, OrderCreate, OrderUpdate]):
"""Repository for customer order operations"""
def __init__(self):
super().__init__(CustomerOrder)
async def get_multi(
self,
db: AsyncSession,
tenant_id: Optional[UUID] = None,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
order_by: Optional[str] = None,
order_desc: bool = False
) -> List[CustomerOrder]:
"""Get multiple orders with eager loading of items and customer"""
try:
query = select(self.model).options(
selectinload(CustomerOrder.items),
selectinload(CustomerOrder.customer)
)
# Apply tenant filter
if tenant_id:
query = query.where(self.model.tenant_id == tenant_id)
# Apply additional filters
if filters:
for key, value in filters.items():
if hasattr(self.model, key) and value is not None:
field = getattr(self.model, key)
if isinstance(value, list):
query = query.where(field.in_(value))
else:
query = query.where(field == value)
# Apply ordering
if order_by and hasattr(self.model, order_by):
order_column = getattr(self.model, order_by)
if order_desc:
query = query.order_by(order_column.desc())
else:
query = query.order_by(order_column)
else:
# Default ordering by order_date desc
query = query.order_by(CustomerOrder.order_date.desc())
# Apply pagination
query = query.offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Error getting multiple orders", error=str(e))
raise
async def get_with_items(
self,
db: AsyncSession,
order_id: UUID,
tenant_id: UUID
) -> Optional[CustomerOrder]:
"""Get order with all its items and customer info"""
try:
query = select(CustomerOrder).options(
selectinload(CustomerOrder.items),
selectinload(CustomerOrder.customer),
selectinload(CustomerOrder.status_history)
).where(
and_(
CustomerOrder.id == order_id,
CustomerOrder.tenant_id == tenant_id
)
)
result = await db.execute(query)
return result.scalar_one_or_none()
except Exception as e:
logger.error("Error getting order with items",
order_id=str(order_id),
error=str(e))
raise
async def get_by_order_number(
self,
db: AsyncSession,
order_number: str,
tenant_id: UUID
) -> Optional[CustomerOrder]:
"""Get order by order number within tenant"""
try:
query = select(CustomerOrder).where(
and_(
CustomerOrder.order_number == order_number,
CustomerOrder.tenant_id == tenant_id
)
)
result = await db.execute(query)
return result.scalar_one_or_none()
except Exception as e:
logger.error("Error getting order by number",
order_number=order_number,
error=str(e))
raise
async def get_orders_by_status(
self,
db: AsyncSession,
tenant_id: UUID,
status: str,
skip: int = 0,
limit: int = 100
) -> List[CustomerOrder]:
"""Get orders by status"""
try:
query = select(CustomerOrder).options(
selectinload(CustomerOrder.customer)
).where(
and_(
CustomerOrder.tenant_id == tenant_id,
CustomerOrder.status == status
)
).order_by(CustomerOrder.order_date.desc()).offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Error getting orders by status",
status=status,
error=str(e))
raise
async def get_orders_by_date_range(
self,
db: AsyncSession,
tenant_id: UUID,
start_date: date,
end_date: date,
skip: int = 0,
limit: int = 100
) -> List[CustomerOrder]:
"""Get orders within date range"""
try:
query = select(CustomerOrder).options(
selectinload(CustomerOrder.customer),
selectinload(CustomerOrder.items)
).where(
and_(
CustomerOrder.tenant_id == tenant_id,
func.date(CustomerOrder.order_date) >= start_date,
func.date(CustomerOrder.order_date) <= end_date
)
).order_by(CustomerOrder.order_date.desc()).offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Error getting orders by date range",
start_date=str(start_date),
end_date=str(end_date),
error=str(e))
raise
async def get_pending_orders_by_delivery_date(
self,
db: AsyncSession,
tenant_id: UUID,
delivery_date: date
) -> List[CustomerOrder]:
"""Get pending orders for a specific delivery date"""
try:
query = select(CustomerOrder).options(
selectinload(CustomerOrder.items),
selectinload(CustomerOrder.customer)
).where(
and_(
CustomerOrder.tenant_id == tenant_id,
CustomerOrder.status.in_(["pending", "confirmed", "in_production"]),
func.date(CustomerOrder.requested_delivery_date) == delivery_date
)
).order_by(CustomerOrder.priority.desc(), CustomerOrder.order_date)
result = await db.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Error getting pending orders by delivery date",
delivery_date=str(delivery_date),
error=str(e))
raise
async def get_dashboard_metrics(
self,
db: AsyncSession,
tenant_id: UUID
) -> Dict[str, Any]:
"""Get dashboard metrics for orders"""
try:
# Today's metrics
today = datetime.now().date()
week_start = today - timedelta(days=today.weekday())
month_start = today.replace(day=1)
# Order counts by period
orders_today = await db.execute(
select(func.count()).select_from(CustomerOrder).where(
and_(
CustomerOrder.tenant_id == tenant_id,
func.date(CustomerOrder.order_date) == today
)
)
)
orders_week = await db.execute(
select(func.count()).select_from(CustomerOrder).where(
and_(
CustomerOrder.tenant_id == tenant_id,
func.date(CustomerOrder.order_date) >= week_start
)
)
)
orders_month = await db.execute(
select(func.count()).select_from(CustomerOrder).where(
and_(
CustomerOrder.tenant_id == tenant_id,
func.date(CustomerOrder.order_date) >= month_start
)
)
)
# Revenue by period
revenue_today = await db.execute(
select(func.coalesce(func.sum(CustomerOrder.total_amount), 0)).where(
and_(
CustomerOrder.tenant_id == tenant_id,
func.date(CustomerOrder.order_date) == today,
CustomerOrder.status != "cancelled"
)
)
)
revenue_week = await db.execute(
select(func.coalesce(func.sum(CustomerOrder.total_amount), 0)).where(
and_(
CustomerOrder.tenant_id == tenant_id,
func.date(CustomerOrder.order_date) >= week_start,
CustomerOrder.status != "cancelled"
)
)
)
revenue_month = await db.execute(
select(func.coalesce(func.sum(CustomerOrder.total_amount), 0)).where(
and_(
CustomerOrder.tenant_id == tenant_id,
func.date(CustomerOrder.order_date) >= month_start,
CustomerOrder.status != "cancelled"
)
)
)
# Status breakdown
status_counts = await db.execute(
select(CustomerOrder.status, func.count()).select_from(CustomerOrder).where(
CustomerOrder.tenant_id == tenant_id
).group_by(CustomerOrder.status)
)
status_breakdown = {status: count for status, count in status_counts.fetchall()}
# Average order value
avg_order_value = await db.execute(
select(func.coalesce(func.avg(CustomerOrder.total_amount), 0)).where(
and_(
CustomerOrder.tenant_id == tenant_id,
CustomerOrder.status != "cancelled"
)
)
)
# Calculate repeat customers rate
# Count customers who have made more than one order
repeat_customers_query = await db.execute(
select(func.count()).select_from(
select(CustomerOrder.customer_id)
.where(CustomerOrder.tenant_id == tenant_id)
.group_by(CustomerOrder.customer_id)
.having(func.count(CustomerOrder.id) > 1)
.subquery()
)
)
total_customers_query = await db.execute(
select(func.count(func.distinct(CustomerOrder.customer_id))).where(
CustomerOrder.tenant_id == tenant_id
)
)
repeat_customers_count = repeat_customers_query.scalar() or 0
total_customers_count = total_customers_query.scalar() or 0
repeat_customers_rate = Decimal("0.0")
if total_customers_count > 0:
repeat_customers_rate = Decimal(str(repeat_customers_count)) / Decimal(str(total_customers_count))
repeat_customers_rate = repeat_customers_rate * Decimal("100.0") # Convert to percentage
# Calculate order fulfillment rate
total_orders_query = await db.execute(
select(func.count()).where(
and_(
CustomerOrder.tenant_id == tenant_id,
CustomerOrder.status != "cancelled"
)
)
)
fulfilled_orders_query = await db.execute(
select(func.count()).where(
and_(
CustomerOrder.tenant_id == tenant_id,
CustomerOrder.status.in_(["delivered", "completed"])
)
)
)
total_orders_count = total_orders_query.scalar() or 0
fulfilled_orders_count = fulfilled_orders_query.scalar() or 0
fulfillment_rate = Decimal("0.0")
if total_orders_count > 0:
fulfillment_rate = Decimal(str(fulfilled_orders_count)) / Decimal(str(total_orders_count))
fulfillment_rate = fulfillment_rate * Decimal("100.0") # Convert to percentage
# Calculate on-time delivery rate
on_time_delivered_query = await db.execute(
select(func.count()).where(
and_(
CustomerOrder.tenant_id == tenant_id,
CustomerOrder.status == "delivered",
CustomerOrder.actual_delivery_date <= CustomerOrder.requested_delivery_date
)
)
)
total_delivered_query = await db.execute(
select(func.count()).where(
and_(
CustomerOrder.tenant_id == tenant_id,
CustomerOrder.status == "delivered"
)
)
)
on_time_delivered_count = on_time_delivered_query.scalar() or 0
total_delivered_count = total_delivered_query.scalar() or 0
on_time_delivery_rate = Decimal("0.0")
if total_delivered_count > 0:
on_time_delivery_rate = Decimal(str(on_time_delivered_count)) / Decimal(str(total_delivered_count))
on_time_delivery_rate = on_time_delivery_rate * Decimal("100.0") # Convert to percentage
return {
"total_orders_today": orders_today.scalar(),
"total_orders_this_week": orders_week.scalar(),
"total_orders_this_month": orders_month.scalar(),
"revenue_today": revenue_today.scalar(),
"revenue_this_week": revenue_week.scalar(),
"revenue_this_month": revenue_month.scalar(),
"status_breakdown": status_breakdown,
"average_order_value": avg_order_value.scalar(),
"repeat_customers_rate": repeat_customers_rate,
"fulfillment_rate": fulfillment_rate,
"on_time_delivery_rate": on_time_delivery_rate,
"repeat_customers_count": repeat_customers_count,
"total_customers_count": total_customers_count,
"total_orders_count": total_orders_count,
"fulfilled_orders_count": fulfilled_orders_count,
"on_time_delivered_count": on_time_delivered_count,
"total_delivered_count": total_delivered_count
}
except Exception as e:
logger.error("Error getting dashboard metrics", error=str(e))
raise
async def detect_business_model(
self,
db: AsyncSession,
tenant_id: UUID,
lookback_days: int = 30
) -> Optional[str]:
"""Detect business model based on order patterns"""
try:
cutoff_date = datetime.now().date() - timedelta(days=lookback_days)
# Analyze order patterns
query = select(
func.count().label("total_orders"),
func.avg(CustomerOrder.total_amount).label("avg_order_value"),
func.count(func.distinct(CustomerOrder.customer_id)).label("unique_customers"),
func.sum(
case(
(CustomerOrder.order_type == "rush", 1),
else_=0
)
).label("rush_orders"),
func.sum(
case(
(CustomerOrder.sales_channel == "wholesale", 1),
else_=0
)
).label("wholesale_orders")
).where(
and_(
CustomerOrder.tenant_id == tenant_id,
func.date(CustomerOrder.order_date) >= cutoff_date
)
)
result = await db.execute(query)
metrics = result.fetchone()
if not metrics or metrics.total_orders == 0:
return None
# Business model detection logic
orders_per_customer = metrics.total_orders / metrics.unique_customers
wholesale_ratio = metrics.wholesale_orders / metrics.total_orders
rush_ratio = metrics.rush_orders / metrics.total_orders
if wholesale_ratio > 0.6 or orders_per_customer > 20:
return "central_bakery"
else:
return "individual_bakery"
except Exception as e:
logger.error("Error detecting business model", error=str(e))
return None
class OrderItemRepository(BaseRepository[OrderItem, OrderItemCreate, OrderItemUpdate]):
"""Repository for order item operations"""
def __init__(self):
super().__init__(OrderItem)
async def get_items_by_order(
self,
db: AsyncSession,
order_id: UUID
) -> List[OrderItem]:
"""Get all items for an order"""
try:
query = select(OrderItem).where(OrderItem.order_id == order_id)
result = await db.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Error getting order items",
order_id=str(order_id),
error=str(e))
raise
class OrderStatusHistoryRepository(BaseRepository[OrderStatusHistory, dict, dict]):
"""Repository for order status history operations"""
def __init__(self):
super().__init__(OrderStatusHistory)
async def create_status_change(
self,
db: AsyncSession,
order_id: UUID,
from_status: Optional[str],
to_status: str,
change_reason: Optional[str] = None,
changed_by: Optional[UUID] = None,
event_data: Optional[Dict[str, Any]] = None
) -> OrderStatusHistory:
"""Create a status change record"""
try:
status_history = OrderStatusHistory(
order_id=order_id,
from_status=from_status,
to_status=to_status,
change_reason=change_reason,
changed_by=changed_by,
event_data=event_data
)
db.add(status_history)
await db.flush()
await db.refresh(status_history)
logger.info("Status change recorded",
order_id=str(order_id),
from_status=from_status,
to_status=to_status)
return status_history
except Exception as e:
logger.error("Error creating status change",
order_id=str(order_id),
error=str(e))
raise

View File

@@ -0,0 +1,283 @@
# ================================================================
# services/orders/app/schemas/order_schemas.py
# ================================================================
"""
Order-related Pydantic schemas for Orders Service
"""
from datetime import datetime, date
from decimal import Decimal
from typing import Optional, List, Dict, Any
from uuid import UUID
from pydantic import BaseModel, Field, validator
from app.models.enums import (
CustomerType, DeliveryMethod, PaymentTerms, PaymentMethod, PaymentStatus,
CustomerSegment, PriorityLevel, OrderType, OrderStatus, OrderSource,
SalesChannel, BusinessModel, DeliveryStatus
)
# ===== Customer Schemas =====
class CustomerBase(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
business_name: Optional[str] = Field(None, max_length=200)
customer_type: CustomerType = Field(default=CustomerType.INDIVIDUAL)
email: Optional[str] = Field(None, max_length=255)
phone: Optional[str] = Field(None, max_length=50)
address_line1: Optional[str] = Field(None, max_length=255)
address_line2: Optional[str] = Field(None, max_length=255)
city: Optional[str] = Field(None, max_length=100)
state: Optional[str] = Field(None, max_length=100)
postal_code: Optional[str] = Field(None, max_length=20)
country: str = Field(default="US", max_length=100)
is_active: bool = Field(default=True)
preferred_delivery_method: DeliveryMethod = Field(default=DeliveryMethod.DELIVERY)
payment_terms: PaymentTerms = Field(default=PaymentTerms.IMMEDIATE)
credit_limit: Optional[Decimal] = Field(None, ge=0)
discount_percentage: Decimal = Field(default=Decimal("0.00"), ge=0, le=100)
customer_segment: CustomerSegment = Field(default=CustomerSegment.REGULAR)
priority_level: PriorityLevel = Field(default=PriorityLevel.NORMAL)
special_instructions: Optional[str] = None
delivery_preferences: Optional[Dict[str, Any]] = None
product_preferences: Optional[Dict[str, Any]] = None
class Config:
from_attributes = True
use_enum_values = True
class CustomerCreate(CustomerBase):
customer_code: str = Field(..., min_length=1, max_length=50)
class CustomerUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200)
business_name: Optional[str] = Field(None, max_length=200)
customer_type: Optional[CustomerType] = None
email: Optional[str] = Field(None, max_length=255)
phone: Optional[str] = Field(None, max_length=50)
address_line1: Optional[str] = Field(None, max_length=255)
address_line2: Optional[str] = Field(None, max_length=255)
city: Optional[str] = Field(None, max_length=100)
state: Optional[str] = Field(None, max_length=100)
postal_code: Optional[str] = Field(None, max_length=20)
country: Optional[str] = Field(None, max_length=100)
is_active: Optional[bool] = None
preferred_delivery_method: Optional[DeliveryMethod] = None
payment_terms: Optional[PaymentTerms] = None
credit_limit: Optional[Decimal] = Field(None, ge=0)
discount_percentage: Optional[Decimal] = Field(None, ge=0, le=100)
customer_segment: Optional[CustomerSegment] = None
priority_level: Optional[PriorityLevel] = None
special_instructions: Optional[str] = None
delivery_preferences: Optional[Dict[str, Any]] = None
product_preferences: Optional[Dict[str, Any]] = None
class Config:
from_attributes = True
use_enum_values = True
class CustomerResponse(CustomerBase):
id: UUID
tenant_id: UUID
customer_code: str
total_orders: int
total_spent: Decimal
average_order_value: Decimal
last_order_date: Optional[datetime]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# ===== Order Item Schemas =====
class OrderItemBase(BaseModel):
product_id: UUID
product_name: str = Field(..., min_length=1, max_length=200)
product_sku: Optional[str] = Field(None, max_length=100)
product_category: Optional[str] = Field(None, max_length=100)
quantity: Decimal = Field(..., gt=0)
unit_of_measure: str = Field(default="each", max_length=50)
weight: Optional[Decimal] = Field(None, ge=0)
unit_price: Decimal = Field(..., ge=0)
line_discount: Decimal = Field(default=Decimal("0.00"), ge=0)
product_specifications: Optional[Dict[str, Any]] = None
customization_details: Optional[str] = None
special_instructions: Optional[str] = None
recipe_id: Optional[UUID] = None
class OrderItemCreate(OrderItemBase):
pass
class OrderItemUpdate(BaseModel):
quantity: Optional[Decimal] = Field(None, gt=0)
unit_price: Optional[Decimal] = Field(None, ge=0)
line_discount: Optional[Decimal] = Field(None, ge=0)
product_specifications: Optional[Dict[str, Any]] = None
customization_details: Optional[str] = None
special_instructions: Optional[str] = None
class OrderItemResponse(OrderItemBase):
id: UUID
order_id: UUID
line_total: Decimal
status: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# ===== Order Schemas =====
class OrderBase(BaseModel):
customer_id: UUID
order_type: OrderType = Field(default=OrderType.STANDARD)
priority: PriorityLevel = Field(default=PriorityLevel.NORMAL)
requested_delivery_date: datetime
delivery_method: DeliveryMethod = Field(default=DeliveryMethod.DELIVERY)
delivery_address: Optional[Dict[str, Any]] = None
delivery_instructions: Optional[str] = None
delivery_window_start: Optional[datetime] = None
delivery_window_end: Optional[datetime] = None
discount_percentage: Decimal = Field(default=Decimal("0.00"), ge=0, le=100)
delivery_fee: Decimal = Field(default=Decimal("0.00"), ge=0)
payment_method: Optional[PaymentMethod] = None
payment_terms: PaymentTerms = Field(default=PaymentTerms.IMMEDIATE)
special_instructions: Optional[str] = None
custom_requirements: Optional[Dict[str, Any]] = None
allergen_warnings: Optional[Dict[str, Any]] = None
order_source: OrderSource = Field(default=OrderSource.MANUAL)
sales_channel: SalesChannel = Field(default=SalesChannel.DIRECT)
order_origin: Optional[str] = Field(None, max_length=100)
communication_preferences: Optional[Dict[str, Any]] = None
class Config:
from_attributes = True
use_enum_values = True
class OrderCreate(OrderBase):
tenant_id: UUID
items: List[OrderItemCreate] = Field(..., min_items=1)
class OrderUpdate(BaseModel):
status: Optional[OrderStatus] = None
priority: Optional[PriorityLevel] = None
requested_delivery_date: Optional[datetime] = None
confirmed_delivery_date: Optional[datetime] = None
delivery_method: Optional[DeliveryMethod] = None
delivery_address: Optional[Dict[str, Any]] = None
delivery_instructions: Optional[str] = None
delivery_window_start: Optional[datetime] = None
delivery_window_end: Optional[datetime] = None
payment_method: Optional[PaymentMethod] = None
payment_status: Optional[PaymentStatus] = None
special_instructions: Optional[str] = None
custom_requirements: Optional[Dict[str, Any]] = None
allergen_warnings: Optional[Dict[str, Any]] = None
class Config:
from_attributes = True
use_enum_values = True
class OrderResponse(OrderBase):
id: UUID
tenant_id: UUID
order_number: str
status: str
order_date: datetime
confirmed_delivery_date: Optional[datetime]
actual_delivery_date: Optional[datetime]
subtotal: Decimal
discount_amount: Decimal
tax_amount: Decimal
total_amount: Decimal
payment_status: str
business_model: Optional[str]
estimated_business_model: Optional[str]
production_batch_id: Optional[UUID]
quality_score: Optional[Decimal]
customer_rating: Optional[int]
created_at: datetime
updated_at: datetime
items: List[OrderItemResponse] = []
class Config:
from_attributes = True
# ===== Dashboard and Analytics Schemas =====
class OrdersDashboardSummary(BaseModel):
"""Summary data for orders dashboard"""
# Current period metrics
total_orders_today: int
total_orders_this_week: int
total_orders_this_month: int
# Revenue metrics
revenue_today: Decimal
revenue_this_week: Decimal
revenue_this_month: Decimal
# Order status breakdown
pending_orders: int
confirmed_orders: int
in_production_orders: int
ready_orders: int
delivered_orders: int
# Customer metrics
total_customers: int
new_customers_this_month: int
repeat_customers_rate: Decimal
# Performance metrics
average_order_value: Decimal
order_fulfillment_rate: Decimal
on_time_delivery_rate: Decimal
# Business model detection
business_model: Optional[str]
business_model_confidence: Optional[Decimal]
# Recent activity
recent_orders: List[OrderResponse]
high_priority_orders: List[OrderResponse]
class DemandRequirements(BaseModel):
"""Demand requirements for production planning"""
date: date
tenant_id: UUID
# Product demand breakdown
product_demands: List[Dict[str, Any]]
# Aggregate metrics
total_orders: int
total_quantity: Decimal
total_value: Decimal
# Business context
business_model: Optional[str]
rush_orders_count: int
special_requirements: List[str]
# Timing requirements
earliest_delivery: datetime
latest_delivery: datetime
average_lead_time_hours: int

View File

@@ -0,0 +1,232 @@
# services/orders/app/services/approval_rules_service.py
"""
Approval Rules Service - Smart auto-approval logic for purchase orders
Evaluates POs against configurable business rules to determine if auto-approval is appropriate
"""
from typing import Dict, List, Any, Optional, Tuple
from decimal import Decimal
from uuid import UUID
import structlog
from shared.config.base import BaseServiceSettings
from shared.utils.tenant_settings_client import TenantSettingsClient
logger = structlog.get_logger()
class ApprovalRulesService:
"""
Service for evaluating purchase orders against approval rules
Implements smart auto-approval logic based on multiple criteria
Uses tenant-specific settings from the database instead of system-level config
"""
def __init__(self, config: BaseServiceSettings, tenant_id: UUID):
self.config = config
self.tenant_id = tenant_id
# Initialize tenant settings client
tenant_service_url = getattr(config, 'TENANT_SERVICE_URL', 'http://tenant-service:8000')
self.tenant_settings_client = TenantSettingsClient(tenant_service_url=tenant_service_url)
async def evaluate_po_for_auto_approval(
self,
po_data: Dict[str, Any],
supplier_data: Optional[Dict[str, Any]] = None,
requirements_data: Optional[List[Dict[str, Any]]] = None
) -> Tuple[bool, List[str]]:
"""
Evaluate if a PO should be auto-approved using tenant-specific settings
Returns:
Tuple of (should_auto_approve, reasons)
"""
# Fetch tenant-specific procurement settings
try:
tenant_settings = await self.tenant_settings_client.get_procurement_settings(self.tenant_id)
except Exception as e:
logger.error("Failed to fetch tenant settings, using safe defaults",
tenant_id=str(self.tenant_id),
error=str(e))
# Use safe defaults if settings unavailable
tenant_settings = {
'auto_approve_enabled': False,
'auto_approve_threshold_eur': 500.0,
'auto_approve_min_supplier_score': 0.80,
'require_approval_new_suppliers': True,
'require_approval_critical_items': True
}
# Check if auto-approval is enabled for this tenant
if not tenant_settings.get('auto_approve_enabled', True):
return False, ["Auto-approval is disabled in tenant settings"]
reasons = []
should_approve = True
# Rule 1: Amount threshold check
total_amount = self._calculate_po_total(po_data)
threshold = Decimal(str(tenant_settings.get('auto_approve_threshold_eur', 500.0)))
if total_amount > threshold:
should_approve = False
reasons.append(
f"PO amount €{total_amount:.2f} exceeds threshold €{threshold:.2f}"
)
else:
reasons.append(
f"PO amount €{total_amount:.2f} within threshold €{threshold:.2f}"
)
# Rule 2: Supplier trust score check
min_supplier_score = tenant_settings.get('auto_approve_min_supplier_score', 0.80)
if supplier_data:
supplier_score = supplier_data.get('trust_score', 0.0)
is_preferred = supplier_data.get('is_preferred_supplier', False)
auto_approve_enabled = supplier_data.get('auto_approve_enabled', False)
if supplier_score < min_supplier_score:
should_approve = False
reasons.append(
f"Supplier trust score {supplier_score:.2f} below minimum {min_supplier_score:.2f}"
)
else:
reasons.append(f"Supplier trust score {supplier_score:.2f} meets minimum requirements")
if not is_preferred:
should_approve = False
reasons.append("Supplier is not marked as preferred")
else:
reasons.append("Supplier is a preferred supplier")
if not auto_approve_enabled:
should_approve = False
reasons.append("Auto-approve is disabled for this supplier")
else:
reasons.append("Auto-approve is enabled for this supplier")
elif supplier_data is None:
should_approve = False
reasons.append("No supplier data available")
# Rule 3: New supplier check
require_approval_new_suppliers = tenant_settings.get('require_approval_new_suppliers', True)
if supplier_data and require_approval_new_suppliers:
total_pos = supplier_data.get('total_pos_count', 0)
if total_pos < 5:
should_approve = False
reasons.append(f"New supplier with only {total_pos} previous orders (minimum 5 required)")
else:
reasons.append(f"Established supplier with {total_pos} previous orders")
# Rule 4: Critical/urgent items check
require_approval_critical_items = tenant_settings.get('require_approval_critical_items', True)
if requirements_data and require_approval_critical_items:
critical_count = sum(
1 for req in requirements_data
if req.get('priority') in ['critical', 'urgent', 'CRITICAL', 'URGENT']
)
if critical_count > 0:
should_approve = False
reasons.append(f"Contains {critical_count} critical/urgent items requiring manual review")
else:
reasons.append("No critical/urgent items detected")
# Rule 5: Historical approval rate check
if supplier_data:
total_pos = supplier_data.get('total_pos_count', 0)
approved_pos = supplier_data.get('approved_pos_count', 0)
if total_pos > 0:
approval_rate = approved_pos / total_pos
if approval_rate < 0.95:
should_approve = False
reasons.append(
f"Historical approval rate {approval_rate:.1%} below 95% threshold"
)
else:
reasons.append(f"High historical approval rate {approval_rate:.1%}")
# Rule 6: PO priority check
priority = po_data.get('priority', 'normal')
if priority in ['urgent', 'critical', 'URGENT', 'CRITICAL']:
should_approve = False
reasons.append(f"PO priority is '{priority}' - requires manual review")
logger.info(
"PO auto-approval evaluation completed",
should_auto_approve=should_approve,
total_amount=float(total_amount),
supplier_id=supplier_data.get('id') if supplier_data else None,
reasons_count=len(reasons),
po_data=po_data.get('id') if isinstance(po_data, dict) else str(po_data)
)
return should_approve, reasons
def _calculate_po_total(self, po_data: Dict[str, Any]) -> Decimal:
"""Calculate total PO amount including tax and shipping"""
subtotal = Decimal(str(po_data.get('subtotal', 0)))
tax = Decimal(str(po_data.get('tax_amount', 0)))
shipping = Decimal(str(po_data.get('shipping_cost', 0)))
discount = Decimal(str(po_data.get('discount_amount', 0)))
total = subtotal + tax + shipping - discount
return total
def get_approval_summary(
self,
should_approve: bool,
reasons: List[str]
) -> Dict[str, Any]:
"""
Generate a human-readable approval summary
Returns:
Dict with summary data for UI display
"""
return {
"auto_approved": should_approve,
"decision": "APPROVED" if should_approve else "REQUIRES_MANUAL_APPROVAL",
"reasons": reasons,
"reason_count": len(reasons),
"summary": self._format_summary(should_approve, reasons)
}
def _format_summary(self, should_approve: bool, reasons: List[str]) -> str:
"""Format approval decision summary"""
if should_approve:
return f"Auto-approved: {', '.join(reasons[:2])}"
else:
failing_reasons = [r for r in reasons if any(
keyword in r.lower()
for keyword in ['exceeds', 'below', 'not', 'disabled', 'new', 'critical']
)]
if failing_reasons:
return f"Manual approval required: {failing_reasons[0]}"
return "Manual approval required"
def validate_approval_override(
self,
override_reason: str,
user_role: str
) -> Tuple[bool, Optional[str]]:
"""
Validate if a user can override auto-approval decision
Returns:
Tuple of (is_valid, error_message)
"""
# Only admin/owner can override
if user_role not in ['admin', 'owner']:
return False, "Insufficient permissions to override approval rules"
# Require a reason
if not override_reason or len(override_reason.strip()) < 10:
return False, "Override reason must be at least 10 characters"
return True, None

View File

@@ -0,0 +1,490 @@
# ================================================================
# services/orders/app/services/orders_service.py
# ================================================================
"""
Orders Service - Main business logic service
"""
import uuid
from datetime import datetime, date, timedelta
from decimal import Decimal
from typing import List, Optional, Dict, Any
from uuid import UUID
import structlog
from shared.clients import (
InventoryServiceClient,
ProductionServiceClient,
SalesServiceClient
)
from shared.database.transactions import transactional
from app.core.config import settings
from app.repositories.order_repository import (
OrderRepository,
CustomerRepository,
OrderItemRepository,
OrderStatusHistoryRepository
)
from app.schemas.order_schemas import (
OrderCreate,
OrderUpdate,
OrderResponse,
CustomerCreate,
CustomerUpdate,
DemandRequirements,
OrdersDashboardSummary
)
logger = structlog.get_logger()
class OrdersService:
"""Main service for orders operations"""
def __init__(
self,
order_repo: OrderRepository,
customer_repo: CustomerRepository,
order_item_repo: OrderItemRepository,
status_history_repo: OrderStatusHistoryRepository,
inventory_client: InventoryServiceClient,
production_client: ProductionServiceClient,
sales_client: SalesServiceClient,
):
self.order_repo = order_repo
self.customer_repo = customer_repo
self.order_item_repo = order_item_repo
self.status_history_repo = status_history_repo
self.inventory_client = inventory_client
self.production_client = production_client
self.sales_client = sales_client
async def create_order(
self,
db,
order_data: OrderCreate,
user_id: Optional[UUID] = None
) -> OrderResponse:
"""Create a new customer order with comprehensive processing"""
try:
logger.info("Creating new order",
customer_id=str(order_data.customer_id),
tenant_id=str(order_data.tenant_id))
# 1. Validate customer exists
customer = await self.customer_repo.get(
db,
order_data.customer_id,
order_data.tenant_id
)
if not customer:
raise ValueError(f"Customer {order_data.customer_id} not found")
# 2. Generate order number
order_number = await self._generate_order_number(db, order_data.tenant_id)
# 3. Calculate order totals
subtotal = sum(item.quantity * item.unit_price - item.line_discount
for item in order_data.items)
discount_amount = subtotal * (order_data.discount_percentage / 100)
tax_amount = (subtotal - discount_amount) * Decimal("0.08") # Configurable tax rate
total_amount = subtotal - discount_amount + tax_amount + order_data.delivery_fee
# 4. Create order record
order_dict = order_data.dict(exclude={"items"})
order_dict.update({
"order_number": order_number,
"subtotal": subtotal,
"discount_amount": discount_amount,
"tax_amount": tax_amount,
"total_amount": total_amount,
"status": "pending"
})
order = await self.order_repo.create(db, obj_in=order_dict, created_by=user_id)
# 5. Create order items
for item_data in order_data.items:
item_dict = item_data.dict()
item_dict.update({
"order_id": order.id,
"line_total": item_data.quantity * item_data.unit_price - item_data.line_discount
})
await self.order_item_repo.create(db, obj_in=item_dict)
# 6. Create initial status history
await self.status_history_repo.create_status_change(
db=db,
order_id=order.id,
from_status=None,
to_status="pending",
change_reason="Order created",
changed_by=user_id
)
# 7. Update customer metrics
await self.customer_repo.update_customer_metrics(
db, order.customer_id, total_amount, order.order_date
)
# 8. Business model detection
business_model = await self.detect_business_model(db, order_data.tenant_id)
if business_model:
order.business_model = business_model
# 10. Integrate with production service if auto-processing is enabled
if settings.ORDER_PROCESSING_ENABLED:
await self._notify_production_service(order)
logger.info("Order created successfully",
order_id=str(order.id),
order_number=order_number,
total_amount=str(total_amount))
# Return order with items loaded
return await self.get_order_with_items(db, order.id, order_data.tenant_id)
except Exception as e:
logger.error("Error creating order", error=str(e))
raise
async def get_order_with_items(
self,
db,
order_id: UUID,
tenant_id: UUID
) -> Optional[OrderResponse]:
"""Get order with all related data"""
try:
order = await self.order_repo.get_with_items(db, order_id, tenant_id)
if not order:
return None
return OrderResponse.from_orm(order)
except Exception as e:
logger.error("Error getting order with items",
order_id=str(order_id),
error=str(e))
raise
async def update_order_status(
self,
db,
order_id: UUID,
tenant_id: UUID,
new_status: str,
user_id: Optional[UUID] = None,
reason: Optional[str] = None
) -> Optional[OrderResponse]:
"""Update order status with proper tracking"""
try:
order = await self.order_repo.get(db, order_id, tenant_id)
if not order:
return None
old_status = order.status
# Update order status
order.status = new_status
if new_status == "confirmed":
order.confirmed_delivery_date = order.requested_delivery_date
elif new_status == "delivered":
order.actual_delivery_date = datetime.now()
# Record status change
await self.status_history_repo.create_status_change(
db=db,
order_id=order_id,
from_status=old_status,
to_status=new_status,
change_reason=reason,
changed_by=user_id
)
# Customer notifications
await self._send_status_notification(order, old_status, new_status)
logger.info("Order status updated",
order_id=str(order_id),
old_status=old_status,
new_status=new_status)
return await self.get_order_with_items(db, order_id, tenant_id)
except Exception as e:
logger.error("Error updating order status",
order_id=str(order_id),
error=str(e))
raise
async def get_demand_requirements(
self,
db,
tenant_id: UUID,
target_date: date
) -> DemandRequirements:
"""Get demand requirements for production planning"""
try:
logger.info("Calculating demand requirements",
tenant_id=str(tenant_id),
target_date=str(target_date))
# Get orders for target date
orders = await self.order_repo.get_pending_orders_by_delivery_date(
db, tenant_id, target_date
)
# Aggregate product demands
product_demands = {}
total_orders = len(orders)
total_quantity = Decimal("0")
total_value = Decimal("0")
rush_orders_count = 0
special_requirements = []
earliest_delivery = None
latest_delivery = None
for order in orders:
total_value += order.total_amount
if order.order_type == "rush":
rush_orders_count += 1
if order.special_instructions:
special_requirements.append(order.special_instructions)
# Track delivery timing
if not earliest_delivery or order.requested_delivery_date < earliest_delivery:
earliest_delivery = order.requested_delivery_date
if not latest_delivery or order.requested_delivery_date > latest_delivery:
latest_delivery = order.requested_delivery_date
# Aggregate product demands
for item in order.items:
product_id = str(item.product_id)
if product_id not in product_demands:
product_demands[product_id] = {
"product_id": product_id,
"product_name": item.product_name,
"total_quantity": Decimal("0"),
"unit_of_measure": item.unit_of_measure,
"orders_count": 0,
"rush_quantity": Decimal("0"),
"special_requirements": []
}
product_demands[product_id]["total_quantity"] += item.quantity
product_demands[product_id]["orders_count"] += 1
total_quantity += item.quantity
if order.order_type == "rush":
product_demands[product_id]["rush_quantity"] += item.quantity
if item.special_instructions:
product_demands[product_id]["special_requirements"].append(
item.special_instructions
)
# Calculate average lead time
average_lead_time_hours = 24 # Default
if earliest_delivery and latest_delivery:
time_diff = latest_delivery - earliest_delivery
average_lead_time_hours = max(24, int(time_diff.total_seconds() / 3600))
# Detect business model
business_model = await self.detect_business_model(db, tenant_id)
return DemandRequirements(
date=target_date,
tenant_id=tenant_id,
product_demands=list(product_demands.values()),
total_orders=total_orders,
total_quantity=total_quantity,
total_value=total_value,
business_model=business_model,
rush_orders_count=rush_orders_count,
special_requirements=list(set(special_requirements)),
earliest_delivery=earliest_delivery or datetime.combine(target_date, datetime.min.time()),
latest_delivery=latest_delivery or datetime.combine(target_date, datetime.max.time()),
average_lead_time_hours=average_lead_time_hours
)
except Exception as e:
logger.error("Error calculating demand requirements",
tenant_id=str(tenant_id),
error=str(e))
raise
async def get_dashboard_summary(
self,
db,
tenant_id: UUID
) -> OrdersDashboardSummary:
"""Get dashboard summary data"""
try:
# Get basic metrics
metrics = await self.order_repo.get_dashboard_metrics(db, tenant_id)
# Get customer counts
total_customers = await self.customer_repo.count(
db, tenant_id, filters={"is_active": True}
)
# Get new customers this month
month_start = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
new_customers_this_month = await self.customer_repo.count_created_since(
db,
tenant_id,
month_start
)
# Get recent orders
recent_orders = await self.order_repo.get_multi(
db, tenant_id, limit=5, order_by="order_date", order_desc=True
)
# Get high priority orders
high_priority_orders = await self.order_repo.get_multi(
db,
tenant_id,
filters={"priority": "high", "status": ["pending", "confirmed", "in_production"]},
limit=10
)
# Detect business model
business_model = await self.detect_business_model(db, tenant_id)
# Calculate performance metrics from actual data
fulfillment_rate = metrics.get("fulfillment_rate", Decimal("0.0")) # Use actual calculated rate
on_time_delivery_rate = metrics.get("on_time_delivery_rate", Decimal("0.0")) # Use actual calculated rate
repeat_customers_rate = metrics.get("repeat_customers_rate", Decimal("0.0")) # Use actual calculated rate
# Use the actual calculated values from the repository
order_fulfillment_rate = metrics.get("fulfillment_rate", Decimal("0.0"))
on_time_delivery_rate_metric = metrics.get("on_time_delivery_rate", Decimal("0.0"))
repeat_customers_rate_metric = metrics.get("repeat_customers_rate", Decimal("0.0"))
return OrdersDashboardSummary(
total_orders_today=metrics["total_orders_today"],
total_orders_this_week=metrics["total_orders_this_week"],
total_orders_this_month=metrics["total_orders_this_month"],
revenue_today=metrics["revenue_today"],
revenue_this_week=metrics["revenue_this_week"],
revenue_this_month=metrics["revenue_this_month"],
pending_orders=metrics["status_breakdown"].get("pending", 0),
confirmed_orders=metrics["status_breakdown"].get("confirmed", 0),
in_production_orders=metrics["status_breakdown"].get("in_production", 0),
ready_orders=metrics["status_breakdown"].get("ready", 0),
delivered_orders=metrics["status_breakdown"].get("delivered", 0),
total_customers=total_customers,
new_customers_this_month=new_customers_this_month,
repeat_customers_rate=repeat_customers_rate_metric,
average_order_value=metrics["average_order_value"],
order_fulfillment_rate=order_fulfillment_rate,
on_time_delivery_rate=on_time_delivery_rate_metric,
business_model=business_model,
business_model_confidence=Decimal("85.0") if business_model else None,
recent_orders=[OrderResponse.from_orm(order) for order in recent_orders],
high_priority_orders=[OrderResponse.from_orm(order) for order in high_priority_orders]
)
except Exception as e:
logger.error("Error getting dashboard summary", error=str(e))
raise
async def detect_business_model(
self,
db,
tenant_id: UUID
) -> Optional[str]:
"""Detect business model based on order patterns"""
try:
if not settings.ENABLE_BUSINESS_MODEL_DETECTION:
return None
return await self.order_repo.detect_business_model(db, tenant_id)
except Exception as e:
logger.error("Error detecting business model", error=str(e))
return None
# ===== Private Helper Methods =====
async def _generate_order_number(self, db, tenant_id: UUID) -> str:
"""Generate unique order number"""
try:
# Simple format: ORD-YYYYMMDD-XXXX
today = datetime.now()
date_part = today.strftime("%Y%m%d")
# Get count of orders today for this tenant
today_start = today.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today.replace(hour=23, minute=59, second=59, microsecond=999999)
count = await self.order_repo.count(
db,
tenant_id,
filters={
"order_date": {"gte": today_start, "lte": today_end}
}
)
sequence = count + 1
return f"ORD-{date_part}-{sequence:04d}"
except Exception as e:
logger.error("Error generating order number", error=str(e))
# Fallback to UUID
return f"ORD-{uuid.uuid4().hex[:8].upper()}"
async def _notify_production_service(self, order):
"""Notify production service of new order"""
try:
if self.production_client:
await self.production_client.notify_new_order(
str(order.tenant_id),
{
"order_id": str(order.id),
"order_number": order.order_number,
"delivery_date": order.requested_delivery_date.isoformat(),
"priority": order.priority,
"items": [
{
"product_id": str(item.product_id),
"quantity": float(item.quantity),
"unit_of_measure": item.unit_of_measure
}
for item in order.items
]
}
)
except Exception as e:
logger.warning("Failed to notify production service",
order_id=str(order.id),
error=str(e))
async def _send_status_notification(self, order, old_status: str, new_status: str):
"""Send customer notification for status change"""
try:
if self.notification_client and order.customer:
message = f"Order {order.order_number} status changed from {old_status} to {new_status}"
await self.notification_client.send_notification(
tenant_id=str(order.tenant_id),
notification_type="email",
message=message,
recipient_email=order.customer.email,
subject=f"Order {order.order_number} Status Update",
priority="normal",
metadata={
"order_id": str(order.id),
"order_number": order.order_number,
"old_status": old_status,
"new_status": new_status
}
)
except Exception as e:
logger.warning("Failed to send status notification",
order_id=str(order.id),
error=str(e))

View File

@@ -0,0 +1,251 @@
# services/orders/app/services/procurement_notification_service.py
"""
Procurement Notification Service - Send alerts and notifications for procurement events using EventPublisher
Handles PO approval notifications, reminders, escalations, and summaries
"""
from typing import Dict, List, Any, Optional
from uuid import UUID
from datetime import datetime, timezone
import structlog
from shared.config.base import BaseServiceSettings
from shared.messaging import UnifiedEventPublisher
logger = structlog.get_logger()
class ProcurementNotificationService:
"""Service for sending procurement-related notifications and alerts using EventPublisher"""
def __init__(self, event_publisher: UnifiedEventPublisher):
self.publisher = event_publisher
async def send_pos_pending_approval_alert(
self,
tenant_id: UUID,
pos_data: List[Dict[str, Any]]
):
"""
Send alert when new POs are created and need approval
Groups POs and sends a summary notification
"""
try:
if not pos_data:
return
# Calculate totals
total_amount = sum(float(po.get('total_amount', 0)) for po in pos_data)
critical_count = sum(1 for po in pos_data if po.get('priority') in ['high', 'critical', 'urgent'])
# Determine severity based on amount and urgency
severity = "medium"
if critical_count > 0 or total_amount > 5000:
severity = "high"
elif total_amount > 10000:
severity = "urgent"
metadata = {
"tenant_id": str(tenant_id),
"pos_count": len(pos_data),
"total_amount": total_amount,
"critical_count": critical_count,
"pos": [
{
"po_id": po.get("po_id"),
"po_number": po.get("po_number"),
"supplier_id": po.get("supplier_id"),
"total_amount": po.get("total_amount"),
"auto_approved": po.get("auto_approved", False)
}
for po in pos_data
],
"action_required": True,
"action_url": "/app/comprar"
}
await self.publisher.publish_alert(
event_type="procurement.pos_pending_approval",
tenant_id=tenant_id,
severity=severity,
data=metadata
)
logger.info("POs pending approval alert sent",
tenant_id=str(tenant_id),
pos_count=len(pos_data),
total_amount=total_amount)
except Exception as e:
logger.error("Error sending POs pending approval alert",
tenant_id=str(tenant_id),
error=str(e))
async def send_approval_reminder(
self,
tenant_id: UUID,
po_data: Dict[str, Any],
hours_pending: int
):
"""
Send reminder for POs that haven't been approved within threshold
"""
try:
# Determine severity based on pending hours
severity = "medium" if hours_pending < 36 else "high"
metadata = {
"tenant_id": str(tenant_id),
"po_id": po_data.get("po_id"),
"po_number": po_data.get("po_number"),
"supplier_name": po_data.get("supplier_name"),
"total_amount": po_data.get("total_amount"),
"hours_pending": hours_pending,
"created_at": po_data.get("created_at"),
"action_required": True,
"action_url": f"/app/comprar?po={po_data.get('po_id')}"
}
await self.publisher.publish_alert(
event_type="procurement.approval_reminder",
tenant_id=tenant_id,
severity=severity,
data=metadata
)
logger.info("Approval reminder sent",
tenant_id=str(tenant_id),
po_id=po_data.get("po_id"),
hours_pending=hours_pending)
except Exception as e:
logger.error("Error sending approval reminder",
tenant_id=str(tenant_id),
po_id=po_data.get("po_id"),
error=str(e))
async def send_critical_po_escalation(
self,
tenant_id: UUID,
po_data: Dict[str, Any],
hours_pending: int
):
"""
Send escalation alert for critical/urgent POs not approved in time
"""
try:
metadata = {
"tenant_id": str(tenant_id),
"po_id": po_data.get("po_id"),
"po_number": po_data.get("po_number"),
"supplier_name": po_data.get("supplier_name"),
"total_amount": po_data.get("total_amount"),
"priority": po_data.get("priority"),
"required_delivery_date": po_data.get("required_delivery_date"),
"hours_pending": hours_pending,
"escalated": True,
"action_required": True,
"action_url": f"/app/comprar?po={po_data.get('po_id')}"
}
await self.publisher.publish_alert(
event_type="procurement.critical_po_escalation",
tenant_id=tenant_id,
severity="urgent",
data=metadata
)
logger.warning("Critical PO escalation sent",
tenant_id=str(tenant_id),
po_id=po_data.get("po_id"),
hours_pending=hours_pending)
except Exception as e:
logger.error("Error sending critical PO escalation",
tenant_id=str(tenant_id),
po_id=po_data.get("po_id"),
error=str(e))
async def send_auto_approval_summary(
self,
tenant_id: UUID,
summary_data: Dict[str, Any]
):
"""
Send daily summary of auto-approved POs
"""
try:
auto_approved_count = summary_data.get("auto_approved_count", 0)
total_amount = summary_data.get("total_auto_approved_amount", 0)
manual_approval_count = summary_data.get("manual_approval_count", 0)
if auto_approved_count == 0 and manual_approval_count == 0:
# No activity, skip notification
return
metadata = {
"tenant_id": str(tenant_id),
"auto_approved_count": auto_approved_count,
"total_auto_approved_amount": total_amount,
"manual_approval_count": manual_approval_count,
"summary_date": summary_data.get("date"),
"auto_approved_pos": summary_data.get("auto_approved_pos", []),
"pending_approval_pos": summary_data.get("pending_approval_pos", []),
"action_url": "/app/comprar"
}
await self.publisher.publish_notification(
event_type="procurement.auto_approval_summary",
tenant_id=tenant_id,
data=metadata
)
logger.info("Auto-approval summary sent",
tenant_id=str(tenant_id),
auto_approved_count=auto_approved_count,
manual_count=manual_approval_count)
except Exception as e:
logger.error("Error sending auto-approval summary",
tenant_id=str(tenant_id),
error=str(e))
async def send_po_approved_confirmation(
self,
tenant_id: UUID,
po_data: Dict[str, Any],
approved_by: str,
auto_approved: bool = False
):
"""
Send confirmation when a PO is approved
"""
try:
metadata = {
"tenant_id": str(tenant_id),
"po_id": po_data.get("po_id"),
"po_number": po_data.get("po_number"),
"supplier_name": po_data.get("supplier_name"),
"total_amount": po_data.get("total_amount"),
"approved_by": approved_by,
"auto_approved": auto_approved,
"approved_at": datetime.now(timezone.utc).isoformat(),
"action_url": f"/app/comprar?po={po_data.get('po_id')}"
}
await self.publisher.publish_notification(
event_type="procurement.po_approved_confirmation",
tenant_id=tenant_id,
data=metadata
)
logger.info("PO approved confirmation sent",
tenant_id=str(tenant_id),
po_id=po_data.get("po_id"),
auto_approved=auto_approved)
except Exception as e:
logger.error("Error sending PO approved confirmation",
tenant_id=str(tenant_id),
po_id=po_data.get("po_id"),
error=str(e))

View File

@@ -0,0 +1,339 @@
# services/orders/app/services/smart_procurement_calculator.py
"""
Smart Procurement Calculator
Implements multi-constraint procurement quantity optimization combining:
- AI demand forecasting
- Ingredient reorder rules (reorder_point, reorder_quantity)
- Supplier constraints (minimum_order_quantity, minimum_order_amount)
- Storage limits (max_stock_level)
- Price tier optimization
"""
import math
from decimal import Decimal
from typing import Dict, Any, List, Tuple, Optional
import structlog
logger = structlog.get_logger()
class SmartProcurementCalculator:
"""
Smart procurement quantity calculator with multi-tier constraint optimization
"""
def __init__(self, procurement_settings: Dict[str, Any]):
"""
Initialize calculator with tenant procurement settings
Args:
procurement_settings: Tenant settings dict with flags:
- use_reorder_rules: bool
- economic_rounding: bool
- respect_storage_limits: bool
- use_supplier_minimums: bool
- optimize_price_tiers: bool
"""
self.use_reorder_rules = procurement_settings.get('use_reorder_rules', True)
self.economic_rounding = procurement_settings.get('economic_rounding', True)
self.respect_storage_limits = procurement_settings.get('respect_storage_limits', True)
self.use_supplier_minimums = procurement_settings.get('use_supplier_minimums', True)
self.optimize_price_tiers = procurement_settings.get('optimize_price_tiers', True)
def calculate_procurement_quantity(
self,
ingredient: Dict[str, Any],
supplier: Optional[Dict[str, Any]],
price_list_entry: Optional[Dict[str, Any]],
ai_forecast_quantity: Decimal,
current_stock: Decimal,
safety_stock_percentage: Decimal = Decimal('20.0')
) -> Dict[str, Any]:
"""
Calculate optimal procurement quantity using smart hybrid approach
Args:
ingredient: Ingredient data with reorder_point, reorder_quantity, max_stock_level
supplier: Supplier data with minimum_order_amount
price_list_entry: Price list with minimum_order_quantity, tier_pricing
ai_forecast_quantity: AI-predicted demand quantity
current_stock: Current stock level
safety_stock_percentage: Safety stock buffer percentage
Returns:
Dict with:
- order_quantity: Final calculated quantity to order
- calculation_method: Method used (e.g., 'REORDER_POINT_TRIGGERED')
- ai_suggested_quantity: Original AI forecast
- adjusted_quantity: Final quantity after constraints
- adjustment_reason: Human-readable explanation
- warnings: List of warnings/notes
- supplier_minimum_applied: bool
- storage_limit_applied: bool
- reorder_rule_applied: bool
- price_tier_applied: Dict or None
"""
warnings = []
result = {
'ai_suggested_quantity': ai_forecast_quantity,
'supplier_minimum_applied': False,
'storage_limit_applied': False,
'reorder_rule_applied': False,
'price_tier_applied': None
}
# Extract ingredient parameters
reorder_point = Decimal(str(ingredient.get('reorder_point', 0)))
reorder_quantity = Decimal(str(ingredient.get('reorder_quantity', 0)))
low_stock_threshold = Decimal(str(ingredient.get('low_stock_threshold', 0)))
max_stock_level = Decimal(str(ingredient.get('max_stock_level') or 'Infinity'))
# Extract supplier/price list parameters
supplier_min_qty = Decimal('0')
supplier_min_amount = Decimal('0')
tier_pricing = []
if price_list_entry:
supplier_min_qty = Decimal(str(price_list_entry.get('minimum_order_quantity', 0)))
tier_pricing = price_list_entry.get('tier_pricing') or []
if supplier:
supplier_min_amount = Decimal(str(supplier.get('minimum_order_amount', 0)))
# Calculate AI-based net requirement with safety stock
safety_stock = ai_forecast_quantity * (safety_stock_percentage / Decimal('100'))
total_needed = ai_forecast_quantity + safety_stock
ai_net_requirement = max(Decimal('0'), total_needed - current_stock)
# TIER 1: Critical Safety Check (Emergency Override)
if self.use_reorder_rules and current_stock <= low_stock_threshold:
base_order = max(reorder_quantity, ai_net_requirement)
result['calculation_method'] = 'CRITICAL_STOCK_EMERGENCY'
result['reorder_rule_applied'] = True
warnings.append(f"CRITICAL: Stock ({current_stock}) below threshold ({low_stock_threshold})")
order_qty = base_order
# TIER 2: Reorder Point Triggered
elif self.use_reorder_rules and current_stock <= reorder_point:
base_order = max(reorder_quantity, ai_net_requirement)
result['calculation_method'] = 'REORDER_POINT_TRIGGERED'
result['reorder_rule_applied'] = True
warnings.append(f"Reorder point triggered: stock ({current_stock}) ≤ reorder point ({reorder_point})")
order_qty = base_order
# TIER 3: Forecast-Driven (Above reorder point, no immediate need)
elif ai_net_requirement > 0:
order_qty = ai_net_requirement
result['calculation_method'] = 'FORECAST_DRIVEN_PROACTIVE'
warnings.append(f"AI forecast suggests ordering {ai_net_requirement} units")
# TIER 4: No Order Needed
else:
result['order_quantity'] = Decimal('0')
result['adjusted_quantity'] = Decimal('0')
result['calculation_method'] = 'SUFFICIENT_STOCK'
result['adjustment_reason'] = f"Current stock ({current_stock}) is sufficient. No order needed."
result['warnings'] = warnings
return result
# Apply Economic Rounding (reorder_quantity multiples)
if self.economic_rounding and reorder_quantity > 0:
multiples = math.ceil(float(order_qty / reorder_quantity))
rounded_qty = Decimal(multiples) * reorder_quantity
if rounded_qty > order_qty:
warnings.append(f"Rounded to {multiples}× reorder quantity ({reorder_quantity}) = {rounded_qty}")
order_qty = rounded_qty
# Apply Supplier Minimum Quantity Constraint
if self.use_supplier_minimums and supplier_min_qty > 0:
if order_qty < supplier_min_qty:
warnings.append(f"Increased from {order_qty} to supplier minimum ({supplier_min_qty})")
order_qty = supplier_min_qty
result['supplier_minimum_applied'] = True
else:
# Round to multiples of minimum_order_quantity (packaging constraint)
multiples = math.ceil(float(order_qty / supplier_min_qty))
rounded_qty = Decimal(multiples) * supplier_min_qty
if rounded_qty > order_qty:
warnings.append(f"Rounded to {multiples}× supplier packaging ({supplier_min_qty}) = {rounded_qty}")
result['supplier_minimum_applied'] = True
order_qty = rounded_qty
# Apply Price Tier Optimization
if self.optimize_price_tiers and tier_pricing and price_list_entry:
unit_price = Decimal(str(price_list_entry.get('unit_price', 0)))
tier_result = self._optimize_price_tier(
order_qty,
unit_price,
tier_pricing,
current_stock,
max_stock_level
)
if tier_result['tier_applied']:
order_qty = tier_result['optimized_quantity']
result['price_tier_applied'] = tier_result['tier_info']
warnings.append(tier_result['message'])
# Apply Storage Capacity Constraint
if self.respect_storage_limits and max_stock_level != Decimal('Infinity'):
if (current_stock + order_qty) > max_stock_level:
capped_qty = max(Decimal('0'), max_stock_level - current_stock)
warnings.append(f"Capped from {order_qty} to {capped_qty} due to storage limit ({max_stock_level})")
order_qty = capped_qty
result['storage_limit_applied'] = True
result['calculation_method'] += '_STORAGE_LIMITED'
# Check supplier minimum_order_amount (total order value constraint)
if self.use_supplier_minimums and supplier_min_amount > 0 and price_list_entry:
unit_price = Decimal(str(price_list_entry.get('unit_price', 0)))
order_value = order_qty * unit_price
if order_value < supplier_min_amount:
warnings.append(
f"⚠️ Order value €{order_value:.2f} < supplier minimum €{supplier_min_amount:.2f}. "
"This item needs to be combined with other products in the same PO."
)
result['calculation_method'] += '_NEEDS_CONSOLIDATION'
# Build final result
result['order_quantity'] = order_qty
result['adjusted_quantity'] = order_qty
result['adjustment_reason'] = self._build_adjustment_reason(
ai_forecast_quantity,
ai_net_requirement,
order_qty,
warnings,
result
)
result['warnings'] = warnings
return result
def _optimize_price_tier(
self,
current_qty: Decimal,
base_unit_price: Decimal,
tier_pricing: List[Dict[str, Any]],
current_stock: Decimal,
max_stock_level: Decimal
) -> Dict[str, Any]:
"""
Optimize order quantity to capture volume discount tiers if beneficial
Args:
current_qty: Current calculated order quantity
base_unit_price: Base unit price without tiers
tier_pricing: List of tier dicts with 'quantity' and 'price'
current_stock: Current stock level
max_stock_level: Maximum storage capacity
Returns:
Dict with tier_applied (bool), optimized_quantity, tier_info, message
"""
if not tier_pricing:
return {'tier_applied': False, 'optimized_quantity': current_qty}
# Sort tiers by quantity
sorted_tiers = sorted(tier_pricing, key=lambda x: x['quantity'])
best_tier = None
best_savings = Decimal('0')
for tier in sorted_tiers:
tier_qty = Decimal(str(tier['quantity']))
tier_price = Decimal(str(tier['price']))
# Skip if tier quantity is below current quantity (already captured)
if tier_qty <= current_qty:
continue
# Skip if tier would exceed storage capacity
if self.respect_storage_limits and (current_stock + tier_qty) > max_stock_level:
continue
# Skip if tier is more than 50% above current quantity (too much excess)
if tier_qty > current_qty * Decimal('1.5'):
continue
# Calculate savings
current_cost = current_qty * base_unit_price
tier_cost = tier_qty * tier_price
savings = current_cost - tier_cost
if savings > best_savings:
best_savings = savings
best_tier = {
'quantity': tier_qty,
'price': tier_price,
'savings': savings
}
if best_tier:
return {
'tier_applied': True,
'optimized_quantity': best_tier['quantity'],
'tier_info': best_tier,
'message': (
f"Upgraded to {best_tier['quantity']} units "
f"@ €{best_tier['price']}/unit "
f"(saves €{best_tier['savings']:.2f})"
)
}
return {'tier_applied': False, 'optimized_quantity': current_qty}
def _build_adjustment_reason(
self,
ai_forecast: Decimal,
ai_net_requirement: Decimal,
final_quantity: Decimal,
warnings: List[str],
result: Dict[str, Any]
) -> str:
"""
Build human-readable explanation of quantity adjustments
Args:
ai_forecast: Original AI forecast
ai_net_requirement: AI forecast + safety stock - current stock
final_quantity: Final order quantity after all adjustments
warnings: List of warning messages
result: Calculation result dict
Returns:
Human-readable adjustment explanation
"""
parts = []
# Start with calculation method
method = result.get('calculation_method', 'UNKNOWN')
parts.append(f"Method: {method.replace('_', ' ').title()}")
# AI forecast base
parts.append(f"AI Forecast: {ai_forecast} units, Net Requirement: {ai_net_requirement} units")
# Adjustments applied
adjustments = []
if result.get('reorder_rule_applied'):
adjustments.append("reorder rules")
if result.get('supplier_minimum_applied'):
adjustments.append("supplier minimums")
if result.get('storage_limit_applied'):
adjustments.append("storage limits")
if result.get('price_tier_applied'):
adjustments.append("price tier optimization")
if adjustments:
parts.append(f"Adjustments: {', '.join(adjustments)}")
# Final quantity
parts.append(f"Final Quantity: {final_quantity} units")
# Key warnings
if warnings:
key_warnings = [w for w in warnings if '⚠️' in w or 'CRITICAL' in w or 'saves €' in w]
if key_warnings:
parts.append(f"Notes: {'; '.join(key_warnings)}")
return " | ".join(parts)

View File

@@ -0,0 +1,140 @@
"""
Orders Service - Tenant Data Deletion
Handles deletion of all order-related data for a tenant
"""
from typing import Dict
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
import structlog
from shared.services.tenant_deletion import BaseTenantDataDeletionService, TenantDataDeletionResult
from app.models.order import CustomerOrder, OrderItem, OrderStatusHistory
from app.models.customer import Customer, CustomerContact
logger = structlog.get_logger()
class OrdersTenantDeletionService(BaseTenantDataDeletionService):
"""Service for deleting all orders-related data for a tenant"""
def __init__(self, db_session: AsyncSession):
super().__init__("orders-service")
self.db = db_session
async def get_tenant_data_preview(self, tenant_id: str) -> Dict[str, int]:
"""Get counts of what would be deleted"""
try:
preview = {}
# Count orders
order_count = await self.db.scalar(
select(func.count(CustomerOrder.id)).where(CustomerOrder.tenant_id == tenant_id)
)
preview["orders"] = order_count or 0
# Count order items (will be deleted via CASCADE)
order_item_count = await self.db.scalar(
select(func.count(OrderItem.id))
.join(CustomerOrder)
.where(CustomerOrder.tenant_id == tenant_id)
)
preview["order_items"] = order_item_count or 0
# Count order status history (will be deleted via CASCADE)
status_history_count = await self.db.scalar(
select(func.count(OrderStatusHistory.id))
.join(CustomerOrder)
.where(CustomerOrder.tenant_id == tenant_id)
)
preview["order_status_history"] = status_history_count or 0
# Count customers
customer_count = await self.db.scalar(
select(func.count(Customer.id)).where(Customer.tenant_id == tenant_id)
)
preview["customers"] = customer_count or 0
# Count customer contacts (will be deleted via CASCADE)
contact_count = await self.db.scalar(
select(func.count(CustomerContact.id))
.join(Customer)
.where(Customer.tenant_id == tenant_id)
)
preview["customer_contacts"] = contact_count or 0
return preview
except Exception as e:
logger.error("Error getting deletion preview",
tenant_id=tenant_id,
error=str(e))
return {}
async def delete_tenant_data(self, tenant_id: str) -> TenantDataDeletionResult:
"""Delete all data for a tenant"""
result = TenantDataDeletionResult(tenant_id, self.service_name)
try:
# Get preview before deletion for reporting
preview = await self.get_tenant_data_preview(tenant_id)
# Delete customers (CASCADE will delete customer_contacts)
try:
customer_delete = await self.db.execute(
delete(Customer).where(Customer.tenant_id == tenant_id)
)
deleted_customers = customer_delete.rowcount
result.add_deleted_items("customers", deleted_customers)
# Customer contacts are deleted via CASCADE
result.add_deleted_items("customer_contacts", preview.get("customer_contacts", 0))
logger.info("Deleted customers for tenant",
tenant_id=tenant_id,
count=deleted_customers)
except Exception as e:
logger.error("Error deleting customers",
tenant_id=tenant_id,
error=str(e))
result.add_error(f"Customer deletion: {str(e)}")
# Delete orders (CASCADE will delete order_items and order_status_history)
try:
order_delete = await self.db.execute(
delete(CustomerOrder).where(CustomerOrder.tenant_id == tenant_id)
)
deleted_orders = order_delete.rowcount
result.add_deleted_items("orders", deleted_orders)
# Order items and status history are deleted via CASCADE
result.add_deleted_items("order_items", preview.get("order_items", 0))
result.add_deleted_items("order_status_history", preview.get("order_status_history", 0))
logger.info("Deleted orders for tenant",
tenant_id=tenant_id,
count=deleted_orders)
except Exception as e:
logger.error("Error deleting orders",
tenant_id=tenant_id,
error=str(e))
result.add_error(f"Order deletion: {str(e)}")
# Commit all deletions
await self.db.commit()
logger.info("Tenant data deletion completed",
tenant_id=tenant_id,
deleted_counts=result.deleted_counts)
except Exception as e:
logger.error("Fatal error during tenant data deletion",
tenant_id=tenant_id,
error=str(e))
await self.db.rollback()
result.add_error(f"Fatal error: {str(e)}")
return result