Add more services
This commit is contained in:
36
services/orders/Dockerfile
Normal file
36
services/orders/Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
# Orders Service Dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY services/orders/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy shared modules
|
||||
COPY shared/ ./shared/
|
||||
|
||||
# Copy application code
|
||||
COPY services/orders/app/ ./app/
|
||||
|
||||
# Create logs directory
|
||||
RUN mkdir -p logs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
248
services/orders/README.md
Normal file
248
services/orders/README.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# Orders Service
|
||||
|
||||
Customer orders and procurement planning service for the bakery management system.
|
||||
|
||||
## Overview
|
||||
|
||||
The Orders Service handles all order-related operations including:
|
||||
|
||||
- **Customer Management**: Complete customer lifecycle and relationship management
|
||||
- **Order Processing**: End-to-end order management from creation to fulfillment
|
||||
- **Procurement Planning**: Automated procurement requirement calculation and planning
|
||||
- **Business Intelligence**: Order pattern analysis and business model detection
|
||||
- **Dashboard Analytics**: Comprehensive reporting and metrics for order operations
|
||||
|
||||
## Features
|
||||
|
||||
### Core Capabilities
|
||||
- Customer registration and management with detailed profiles
|
||||
- Order creation, tracking, and status management
|
||||
- Automated demand requirements calculation for production planning
|
||||
- Procurement planning with supplier coordination
|
||||
- Business model detection (individual bakery vs central bakery)
|
||||
- Comprehensive dashboard with real-time metrics
|
||||
- Integration with production, inventory, suppliers, and sales services
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### Dashboard & Analytics
|
||||
- `GET /api/v1/tenants/{tenant_id}/orders/dashboard-summary` - Comprehensive dashboard data
|
||||
- `GET /api/v1/tenants/{tenant_id}/orders/demand-requirements` - Demand analysis for production
|
||||
- `GET /api/v1/tenants/{tenant_id}/orders/business-model` - Business model detection
|
||||
|
||||
#### Order Management
|
||||
- `POST /api/v1/tenants/{tenant_id}/orders` - Create new customer order
|
||||
- `GET /api/v1/tenants/{tenant_id}/orders` - List orders with filtering and pagination
|
||||
- `GET /api/v1/tenants/{tenant_id}/orders/{order_id}` - Get order details with items
|
||||
- `PUT /api/v1/tenants/{tenant_id}/orders/{order_id}/status` - Update order status
|
||||
|
||||
#### Customer Management
|
||||
- `POST /api/v1/tenants/{tenant_id}/customers` - Create new customer
|
||||
- `GET /api/v1/tenants/{tenant_id}/customers` - List customers with filtering
|
||||
- `GET /api/v1/tenants/{tenant_id}/customers/{customer_id}` - Get customer details
|
||||
|
||||
#### Health & Status
|
||||
- `GET /api/v1/tenants/{tenant_id}/orders/status` - Service status information
|
||||
|
||||
## Service Integration
|
||||
|
||||
### Shared Clients Used
|
||||
- **InventoryServiceClient**: Stock levels, product availability validation
|
||||
- **ProductionServiceClient**: Production notifications, capacity planning
|
||||
- **SalesServiceClient**: Historical sales data for demand forecasting
|
||||
- **NotificationServiceClient**: Customer notifications and alerts
|
||||
|
||||
### Authentication
|
||||
Uses shared authentication patterns with tenant isolation:
|
||||
- JWT token validation
|
||||
- Tenant access verification
|
||||
- User permission checks
|
||||
|
||||
## Configuration
|
||||
|
||||
Key configuration options in `app/core/config.py`:
|
||||
|
||||
### Order Processing
|
||||
- `ORDER_PROCESSING_ENABLED`: Enable automatic order processing (default: true)
|
||||
- `AUTO_APPROVE_ORDERS`: Automatically approve orders (default: false)
|
||||
- `MAX_ORDER_ITEMS`: Maximum items per order (default: 50)
|
||||
|
||||
### Procurement Planning
|
||||
- `PROCUREMENT_PLANNING_ENABLED`: Enable procurement planning (default: true)
|
||||
- `PROCUREMENT_LEAD_TIME_DAYS`: Standard procurement lead time (default: 3)
|
||||
- `DEMAND_FORECAST_DAYS`: Days for demand forecasting (default: 14)
|
||||
- `SAFETY_STOCK_PERCENTAGE`: Safety stock buffer (default: 20%)
|
||||
|
||||
### Business Model Detection
|
||||
- `ENABLE_BUSINESS_MODEL_DETECTION`: Enable automatic detection (default: true)
|
||||
- `CENTRAL_BAKERY_ORDER_THRESHOLD`: Order threshold for central bakery (default: 20)
|
||||
- `INDIVIDUAL_BAKERY_ORDER_THRESHOLD`: Order threshold for individual bakery (default: 5)
|
||||
|
||||
### Customer Management
|
||||
- `CUSTOMER_VALIDATION_ENABLED`: Enable customer validation (default: true)
|
||||
- `MAX_CUSTOMERS_PER_TENANT`: Maximum customers per tenant (default: 10000)
|
||||
- `CUSTOMER_CREDIT_CHECK_ENABLED`: Enable credit checking (default: false)
|
||||
|
||||
### Order Validation
|
||||
- `MIN_ORDER_VALUE`: Minimum order value (default: 0.0)
|
||||
- `MAX_ORDER_VALUE`: Maximum order value (default: 100000.0)
|
||||
- `VALIDATE_PRODUCT_AVAILABILITY`: Check product availability (default: true)
|
||||
|
||||
### Alert Thresholds
|
||||
- `HIGH_VALUE_ORDER_THRESHOLD`: High-value order alert (default: 5000.0)
|
||||
- `LARGE_QUANTITY_ORDER_THRESHOLD`: Large quantity alert (default: 100)
|
||||
- `RUSH_ORDER_HOURS_THRESHOLD`: Rush order time threshold (default: 24)
|
||||
- `PROCUREMENT_SHORTAGE_THRESHOLD`: Procurement shortage alert (default: 90%)
|
||||
|
||||
### Payment and Pricing
|
||||
- `PAYMENT_VALIDATION_ENABLED`: Enable payment validation (default: true)
|
||||
- `DYNAMIC_PRICING_ENABLED`: Enable dynamic pricing (default: false)
|
||||
- `DISCOUNT_ENABLED`: Enable discounts (default: true)
|
||||
- `MAX_DISCOUNT_PERCENTAGE`: Maximum discount allowed (default: 50%)
|
||||
|
||||
### Delivery and Fulfillment
|
||||
- `DELIVERY_TRACKING_ENABLED`: Enable delivery tracking (default: true)
|
||||
- `DEFAULT_DELIVERY_WINDOW_HOURS`: Default delivery window (default: 48)
|
||||
- `PICKUP_ENABLED`: Enable pickup orders (default: true)
|
||||
- `DELIVERY_ENABLED`: Enable delivery orders (default: true)
|
||||
|
||||
## Database Models
|
||||
|
||||
### Customer
|
||||
- Complete customer profile with contact information
|
||||
- Business type classification (individual, business, central_bakery)
|
||||
- Payment terms and credit management
|
||||
- Order history and metrics tracking
|
||||
- Delivery preferences and special requirements
|
||||
|
||||
### CustomerOrder
|
||||
- Comprehensive order tracking from creation to delivery
|
||||
- Status management with full audit trail
|
||||
- Financial calculations including discounts and taxes
|
||||
- Delivery scheduling and fulfillment tracking
|
||||
- Business model detection and categorization
|
||||
- Customer communication preferences
|
||||
|
||||
### OrderItem
|
||||
- Detailed line item tracking with product specifications
|
||||
- Customization and special instruction support
|
||||
- Production requirement integration
|
||||
- Cost tracking and margin analysis
|
||||
- Quality control integration
|
||||
|
||||
### OrderStatusHistory
|
||||
- Complete audit trail of order status changes
|
||||
- Event tracking with detailed context
|
||||
- User attribution and change reasons
|
||||
- Customer notification tracking
|
||||
|
||||
### ProcurementPlan
|
||||
- Master procurement planning with business model context
|
||||
- Supplier diversification and risk assessment
|
||||
- Performance tracking and cost analysis
|
||||
- Integration with demand forecasting
|
||||
|
||||
### ProcurementRequirement
|
||||
- Detailed procurement requirements per product/ingredient
|
||||
- Current inventory level integration
|
||||
- Supplier preference and lead time management
|
||||
- Quality specifications and special requirements
|
||||
|
||||
### OrderAlert
|
||||
- Comprehensive alert system for order issues
|
||||
- Multiple severity levels with appropriate routing
|
||||
- Business impact assessment
|
||||
- Resolution tracking and performance metrics
|
||||
|
||||
## Business Logic
|
||||
|
||||
### Order Processing Flow
|
||||
1. **Order Creation**: Validate customer, calculate totals, create order record
|
||||
2. **Item Processing**: Create order items with specifications and requirements
|
||||
3. **Status Tracking**: Maintain complete audit trail of status changes
|
||||
4. **Customer Metrics**: Update customer statistics and relationship data
|
||||
5. **Business Model Detection**: Analyze patterns to determine bakery type
|
||||
6. **Alert Generation**: Check for high-value, rush, or large orders
|
||||
7. **Service Integration**: Notify production and inventory services
|
||||
|
||||
### Procurement Planning
|
||||
1. **Demand Analysis**: Aggregate orders by delivery date and products
|
||||
2. **Inventory Integration**: Check current stock levels and reservations
|
||||
3. **Requirement Calculation**: Calculate net procurement needs with safety buffer
|
||||
4. **Supplier Coordination**: Match requirements with preferred suppliers
|
||||
5. **Lead Time Planning**: Account for supplier lead times and delivery windows
|
||||
6. **Risk Assessment**: Evaluate supply risks and backup options
|
||||
|
||||
### Business Model Detection
|
||||
- **Individual Bakery**: Low order volume, direct customer sales, standard products
|
||||
- **Central Bakery**: High volume, wholesale operations, bulk orders
|
||||
- **Detection Factors**: Order frequency, quantity, customer types, sales channels
|
||||
|
||||
## Alert System
|
||||
|
||||
### Alert Types
|
||||
- **High Value Orders**: Orders exceeding configured thresholds
|
||||
- **Rush Orders**: Orders with tight delivery requirements
|
||||
- **Large Quantity Orders**: Orders with unusually high item counts
|
||||
- **Payment Issues**: Payment validation failures or credit problems
|
||||
- **Procurement Shortages**: Insufficient inventory for order fulfillment
|
||||
- **Customer Issues**: New customers, credit limit exceedances, special requirements
|
||||
|
||||
### Severity Levels
|
||||
- **Critical**: WhatsApp + Email + Dashboard + SMS
|
||||
- **High**: WhatsApp + Email + Dashboard
|
||||
- **Medium**: Email + Dashboard
|
||||
- **Low**: Dashboard only
|
||||
|
||||
## Development
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Set up database
|
||||
# Configure ORDERS_DATABASE_URL environment variable
|
||||
|
||||
# Run migrations
|
||||
alembic upgrade head
|
||||
|
||||
# Start service
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run tests
|
||||
pytest
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=app
|
||||
```
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
# Build image
|
||||
docker build -t orders-service .
|
||||
|
||||
# Run container
|
||||
docker run -p 8000:8000 orders-service
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
The service is designed for containerized deployment with:
|
||||
- Health checks at `/health`
|
||||
- Structured logging
|
||||
- Metrics collection
|
||||
- Database migrations
|
||||
- Service discovery integration
|
||||
|
||||
## Architecture
|
||||
|
||||
Follows Domain-Driven Microservices Architecture:
|
||||
- Clean separation of concerns
|
||||
- Repository pattern for data access
|
||||
- Service layer for business logic
|
||||
- API layer for external interface
|
||||
- Shared infrastructure for cross-cutting concerns
|
||||
519
services/orders/app/api/orders.py
Normal file
519
services/orders/app/api/orders.py
Normal file
@@ -0,0 +1,519 @@
|
||||
# ================================================================
|
||||
# services/orders/app/api/orders.py
|
||||
# ================================================================
|
||||
"""
|
||||
Orders API endpoints for Orders Service
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
from fastapi.responses import JSONResponse
|
||||
import structlog
|
||||
|
||||
from shared.auth.decorators import get_current_user_dep, get_current_tenant_id_dep
|
||||
from app.core.database import get_db
|
||||
from app.services.orders_service import OrdersService
|
||||
from app.schemas.order_schemas import (
|
||||
OrderCreate,
|
||||
OrderUpdate,
|
||||
OrderResponse,
|
||||
CustomerCreate,
|
||||
CustomerUpdate,
|
||||
CustomerResponse,
|
||||
OrdersDashboardSummary,
|
||||
DemandRequirements,
|
||||
ProcurementPlanningData
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
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_service_client,
|
||||
get_production_service_client,
|
||||
get_sales_service_client,
|
||||
get_notification_service_client
|
||||
)
|
||||
|
||||
return OrdersService(
|
||||
order_repo=OrderRepository(),
|
||||
customer_repo=CustomerRepository(),
|
||||
order_item_repo=OrderItemRepository(),
|
||||
status_history_repo=OrderStatusHistoryRepository(),
|
||||
inventory_client=get_inventory_service_client(),
|
||||
production_client=get_production_service_client(),
|
||||
sales_client=get_sales_service_client(),
|
||||
notification_client=get_notification_service_client()
|
||||
)
|
||||
|
||||
|
||||
# ===== Dashboard and Analytics Endpoints =====
|
||||
|
||||
@router.get("/tenants/{tenant_id}/orders/dashboard-summary", response_model=OrdersDashboardSummary)
|
||||
async def get_dashboard_summary(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
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:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
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("/tenants/{tenant_id}/orders/demand-requirements", response_model=DemandRequirements)
|
||||
async def get_demand_requirements(
|
||||
tenant_id: UUID = Path(...),
|
||||
target_date: date = Query(..., description="Date for demand analysis"),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
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:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
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 Management Endpoints =====
|
||||
|
||||
@router.post("/tenants/{tenant_id}/orders", response_model=OrderResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_order(
|
||||
order_data: OrderCreate,
|
||||
tenant_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
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:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
# Ensure tenant_id matches
|
||||
order_data.tenant_id = tenant_id
|
||||
|
||||
order = await orders_service.create_order(
|
||||
db,
|
||||
order_data,
|
||||
user_id=UUID(current_user["sub"])
|
||||
)
|
||||
|
||||
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("/tenants/{tenant_id}/orders/{order_id}", response_model=OrderResponse)
|
||||
async def get_order(
|
||||
tenant_id: UUID = Path(...),
|
||||
order_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
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:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
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.get("/tenants/{tenant_id}/orders", 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_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
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:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
# 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.put("/tenants/{tenant_id}/orders/{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_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
orders_service: OrdersService = Depends(get_orders_service),
|
||||
db = Depends(get_db)
|
||||
):
|
||||
"""Update order status"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
# 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["sub"]),
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
# ===== Customer Management Endpoints =====
|
||||
|
||||
@router.post("/tenants/{tenant_id}/customers", response_model=CustomerResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_customer(
|
||||
customer_data: CustomerCreate,
|
||||
tenant_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
orders_service: OrdersService = Depends(get_orders_service),
|
||||
db = Depends(get_db)
|
||||
):
|
||||
"""Create a new customer"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
# Ensure tenant_id matches
|
||||
customer_data.tenant_id = tenant_id
|
||||
|
||||
# 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"
|
||||
)
|
||||
|
||||
customer = await orders_service.customer_repo.create(
|
||||
db,
|
||||
obj_in=customer_data.dict(),
|
||||
created_by=UUID(current_user["sub"])
|
||||
)
|
||||
|
||||
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("/tenants/{tenant_id}/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_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
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 str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
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("/tenants/{tenant_id}/customers/{customer_id}", response_model=CustomerResponse)
|
||||
async def get_customer(
|
||||
tenant_id: UUID = Path(...),
|
||||
customer_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
orders_service: OrdersService = Depends(get_orders_service),
|
||||
db = Depends(get_db)
|
||||
):
|
||||
"""Get customer details"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
# ===== Business Intelligence Endpoints =====
|
||||
|
||||
@router.get("/tenants/{tenant_id}/orders/business-model")
|
||||
async def detect_business_model(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
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:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
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("/tenants/{tenant_id}/orders/status")
|
||||
async def get_service_status(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep)
|
||||
):
|
||||
"""Get orders service status"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
77
services/orders/app/core/config.py
Normal file
77
services/orders/app/core/config.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# ================================================================
|
||||
# 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
|
||||
DATABASE_URL: str = os.getenv("ORDERS_DATABASE_URL",
|
||||
"postgresql+asyncpg://orders_user:orders_pass123@orders-db:5432/orders_db")
|
||||
|
||||
# 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"
|
||||
|
||||
# Alert Thresholds
|
||||
HIGH_VALUE_ORDER_THRESHOLD: float = float(os.getenv("HIGH_VALUE_ORDER_THRESHOLD", "5000.0"))
|
||||
LARGE_QUANTITY_ORDER_THRESHOLD: int = int(os.getenv("LARGE_QUANTITY_ORDER_THRESHOLD", "100"))
|
||||
RUSH_ORDER_HOURS_THRESHOLD: int = int(os.getenv("RUSH_ORDER_HOURS_THRESHOLD", "24"))
|
||||
PROCUREMENT_SHORTAGE_THRESHOLD: float = float(os.getenv("PROCUREMENT_SHORTAGE_THRESHOLD", "90.0"))
|
||||
|
||||
# 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()
|
||||
80
services/orders/app/core/database.py
Normal file
80
services/orders/app/core/database.py
Normal file
@@ -0,0 +1,80 @@
|
||||
# ================================================================
|
||||
# services/orders/app/core/database.py
|
||||
# ================================================================
|
||||
"""
|
||||
Orders Service Database Configuration
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
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
|
||||
from app.models.procurement import ProcurementPlan, ProcurementRequirement
|
||||
from app.models.alerts import OrderAlert
|
||||
|
||||
# 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("SELECT 1")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Database health check failed", error=str(e))
|
||||
return False
|
||||
124
services/orders/app/main.py
Normal file
124
services/orders/app/main.py
Normal file
@@ -0,0 +1,124 @@
|
||||
# ================================================================
|
||||
# services/orders/app/main.py
|
||||
# ================================================================
|
||||
"""
|
||||
Orders Service - FastAPI Application
|
||||
Customer orders and procurement planning service
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import structlog
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import init_database, get_db_health
|
||||
from app.api.orders import router as orders_router
|
||||
|
||||
# Configure logging
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Manage application lifespan events"""
|
||||
# Startup
|
||||
try:
|
||||
await init_database()
|
||||
logger.info("Orders service started successfully")
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize orders service", error=str(e))
|
||||
raise
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Orders service shutting down")
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
description=settings.DESCRIPTION,
|
||||
version=settings.VERSION,
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Configure based on environment
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(orders_router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
try:
|
||||
db_healthy = await get_db_health()
|
||||
|
||||
health_status = {
|
||||
"status": "healthy" if db_healthy else "unhealthy",
|
||||
"service": settings.SERVICE_NAME,
|
||||
"version": settings.VERSION,
|
||||
"database": "connected" if db_healthy else "disconnected"
|
||||
}
|
||||
|
||||
if not db_healthy:
|
||||
health_status["status"] = "unhealthy"
|
||||
|
||||
return health_status
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Health check failed", error=str(e))
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"service": settings.SERVICE_NAME,
|
||||
"version": settings.VERSION,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"service": settings.APP_NAME,
|
||||
"version": settings.VERSION,
|
||||
"description": settings.DESCRIPTION,
|
||||
"status": "running"
|
||||
}
|
||||
|
||||
|
||||
@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
|
||||
|
||||
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
|
||||
)
|
||||
144
services/orders/app/models/alerts.py
Normal file
144
services/orders/app/models/alerts.py
Normal file
@@ -0,0 +1,144 @@
|
||||
# ================================================================
|
||||
# services/orders/app/models/alerts.py
|
||||
# ================================================================
|
||||
"""
|
||||
Alert system database models for Orders Service
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Numeric, Text, Integer
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class OrderAlert(Base):
|
||||
"""Alert system for orders and procurement issues"""
|
||||
__tablename__ = "order_alerts"
|
||||
|
||||
# Primary identification
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
alert_code = Column(String(50), nullable=False, index=True)
|
||||
|
||||
# Alert categorization
|
||||
alert_type = Column(String(50), nullable=False, index=True)
|
||||
# Alert types: order_issue, procurement_shortage, payment_problem, delivery_delay,
|
||||
# quality_concern, high_value_order, rush_order, customer_issue, supplier_problem
|
||||
|
||||
severity = Column(String(20), nullable=False, default="medium", index=True)
|
||||
# Severity levels: critical, high, medium, low
|
||||
|
||||
category = Column(String(50), nullable=False, index=True)
|
||||
# Categories: operational, financial, quality, customer, supplier, compliance
|
||||
|
||||
# Alert source and context
|
||||
source_entity_type = Column(String(50), nullable=False) # order, customer, procurement_plan, etc.
|
||||
source_entity_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
source_entity_reference = Column(String(100), nullable=True) # Human-readable reference
|
||||
|
||||
# Alert content
|
||||
title = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=False)
|
||||
detailed_message = Column(Text, nullable=True)
|
||||
|
||||
# Alert conditions and triggers
|
||||
trigger_condition = Column(String(200), nullable=True)
|
||||
threshold_value = Column(Numeric(15, 4), nullable=True)
|
||||
actual_value = Column(Numeric(15, 4), nullable=True)
|
||||
variance = Column(Numeric(15, 4), nullable=True)
|
||||
|
||||
# Context data
|
||||
alert_data = Column(JSONB, nullable=True) # Additional context-specific data
|
||||
business_impact = Column(Text, nullable=True)
|
||||
customer_impact = Column(Text, nullable=True)
|
||||
financial_impact = Column(Numeric(12, 2), nullable=True)
|
||||
|
||||
# Alert status and lifecycle
|
||||
status = Column(String(50), nullable=False, default="active", index=True)
|
||||
# Status values: active, acknowledged, in_progress, resolved, dismissed, expired
|
||||
|
||||
alert_state = Column(String(50), nullable=False, default="new") # new, escalated, recurring
|
||||
|
||||
# Resolution and follow-up
|
||||
resolution_action = Column(String(200), nullable=True)
|
||||
resolution_notes = Column(Text, nullable=True)
|
||||
resolution_cost = Column(Numeric(10, 2), nullable=True)
|
||||
|
||||
# Timing and escalation
|
||||
first_occurred_at = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
last_occurred_at = Column(DateTime(timezone=True), nullable=False)
|
||||
acknowledged_at = Column(DateTime(timezone=True), nullable=True)
|
||||
resolved_at = Column(DateTime(timezone=True), nullable=True)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Occurrence tracking
|
||||
occurrence_count = Column(Integer, nullable=False, default=1)
|
||||
is_recurring = Column(Boolean, nullable=False, default=False)
|
||||
recurrence_pattern = Column(String(100), nullable=True)
|
||||
|
||||
# Responsibility and assignment
|
||||
assigned_to = Column(UUID(as_uuid=True), nullable=True)
|
||||
assigned_role = Column(String(50), nullable=True) # orders_manager, procurement_manager, etc.
|
||||
escalated_to = Column(UUID(as_uuid=True), nullable=True)
|
||||
escalation_level = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Notification tracking
|
||||
notification_sent = Column(Boolean, nullable=False, default=False)
|
||||
notification_methods = Column(JSONB, nullable=True) # [email, sms, whatsapp, dashboard]
|
||||
notification_recipients = Column(JSONB, nullable=True) # List of recipients
|
||||
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Customer communication
|
||||
customer_notified = Column(Boolean, nullable=False, default=False)
|
||||
customer_notification_method = Column(String(50), nullable=True)
|
||||
customer_message = Column(Text, nullable=True)
|
||||
|
||||
# Recommended actions
|
||||
recommended_actions = Column(JSONB, nullable=True) # List of suggested actions
|
||||
automated_actions_taken = Column(JSONB, nullable=True) # Actions performed automatically
|
||||
manual_actions_required = Column(JSONB, nullable=True) # Actions requiring human intervention
|
||||
|
||||
# Priority and urgency
|
||||
priority_score = Column(Integer, nullable=False, default=50) # 1-100 scale
|
||||
urgency = Column(String(20), nullable=False, default="normal") # immediate, urgent, normal, low
|
||||
business_priority = Column(String(20), nullable=False, default="normal")
|
||||
|
||||
# Related entities
|
||||
related_orders = Column(JSONB, nullable=True) # Related order IDs
|
||||
related_customers = Column(JSONB, nullable=True) # Related customer IDs
|
||||
related_suppliers = Column(JSONB, nullable=True) # Related supplier IDs
|
||||
related_alerts = Column(JSONB, nullable=True) # Related alert IDs
|
||||
|
||||
# Performance tracking
|
||||
detection_time = Column(DateTime(timezone=True), nullable=True) # When issue was detected
|
||||
response_time_minutes = Column(Integer, nullable=True) # Time to acknowledge
|
||||
resolution_time_minutes = Column(Integer, nullable=True) # Time to resolve
|
||||
|
||||
# Quality and feedback
|
||||
alert_accuracy = Column(Boolean, nullable=True) # Was this a valid alert?
|
||||
false_positive = Column(Boolean, nullable=False, default=False)
|
||||
feedback_notes = Column(Text, nullable=True)
|
||||
|
||||
# Compliance and audit
|
||||
compliance_related = Column(Boolean, nullable=False, default=False)
|
||||
audit_trail = Column(JSONB, nullable=True) # Changes and actions taken
|
||||
regulatory_impact = Column(String(200), nullable=True)
|
||||
|
||||
# Integration and external systems
|
||||
external_system_reference = Column(String(100), nullable=True)
|
||||
external_ticket_number = Column(String(50), nullable=True)
|
||||
erp_reference = Column(String(100), 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
|
||||
alert_metadata = Column(JSONB, nullable=True)
|
||||
123
services/orders/app/models/customer.py
Normal file
123
services/orders/app/models/customer.py
Normal 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 app.core.database 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")
|
||||
218
services/orders/app/models/order.py
Normal file
218
services/orders/app/models/order.py
Normal 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 app.core.database 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")
|
||||
217
services/orders/app/models/procurement.py
Normal file
217
services/orders/app/models/procurement.py
Normal file
@@ -0,0 +1,217 @@
|
||||
# ================================================================
|
||||
# services/orders/app/models/procurement.py
|
||||
# ================================================================
|
||||
"""
|
||||
Procurement planning database models for Orders Service
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Date, Numeric, Text, Integer, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ProcurementPlan(Base):
|
||||
"""Master procurement plan for coordinating supply needs across orders and production"""
|
||||
__tablename__ = "procurement_plans"
|
||||
|
||||
# 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)
|
||||
plan_number = Column(String(50), nullable=False, unique=True, index=True)
|
||||
|
||||
# Plan scope and timing
|
||||
plan_date = Column(Date, nullable=False, index=True)
|
||||
plan_period_start = Column(Date, nullable=False)
|
||||
plan_period_end = Column(Date, nullable=False)
|
||||
planning_horizon_days = Column(Integer, nullable=False, default=14)
|
||||
|
||||
# Plan status and lifecycle
|
||||
status = Column(String(50), nullable=False, default="draft", index=True)
|
||||
# Status values: draft, pending_approval, approved, in_execution, completed, cancelled
|
||||
|
||||
plan_type = Column(String(50), nullable=False, default="regular") # regular, emergency, seasonal
|
||||
priority = Column(String(20), nullable=False, default="normal") # high, normal, low
|
||||
|
||||
# Business model context
|
||||
business_model = Column(String(50), nullable=True) # individual_bakery, central_bakery
|
||||
procurement_strategy = Column(String(50), nullable=False, default="just_in_time") # just_in_time, bulk, mixed
|
||||
|
||||
# Plan totals and summary
|
||||
total_requirements = Column(Integer, nullable=False, default=0)
|
||||
total_estimated_cost = Column(Numeric(12, 2), nullable=False, default=Decimal("0.00"))
|
||||
total_approved_cost = Column(Numeric(12, 2), nullable=False, default=Decimal("0.00"))
|
||||
cost_variance = Column(Numeric(12, 2), nullable=False, default=Decimal("0.00"))
|
||||
|
||||
# Demand analysis
|
||||
total_demand_orders = Column(Integer, nullable=False, default=0)
|
||||
total_demand_quantity = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
|
||||
total_production_requirements = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
|
||||
safety_stock_buffer = Column(Numeric(5, 2), nullable=False, default=Decimal("20.00")) # Percentage
|
||||
|
||||
# Supplier coordination
|
||||
primary_suppliers_count = Column(Integer, nullable=False, default=0)
|
||||
backup_suppliers_count = Column(Integer, nullable=False, default=0)
|
||||
supplier_diversification_score = Column(Numeric(3, 1), nullable=True) # 1.0 to 10.0
|
||||
|
||||
# Risk assessment
|
||||
supply_risk_level = Column(String(20), nullable=False, default="low") # low, medium, high, critical
|
||||
demand_forecast_confidence = Column(Numeric(3, 1), nullable=True) # 1.0 to 10.0
|
||||
seasonality_adjustment = Column(Numeric(5, 2), nullable=False, default=Decimal("0.00"))
|
||||
|
||||
# Execution tracking
|
||||
approved_at = Column(DateTime(timezone=True), nullable=True)
|
||||
approved_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
execution_started_at = Column(DateTime(timezone=True), nullable=True)
|
||||
execution_completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Performance metrics
|
||||
fulfillment_rate = Column(Numeric(5, 2), nullable=True) # Percentage
|
||||
on_time_delivery_rate = Column(Numeric(5, 2), nullable=True) # Percentage
|
||||
cost_accuracy = Column(Numeric(5, 2), nullable=True) # Percentage
|
||||
quality_score = Column(Numeric(3, 1), nullable=True) # 1.0 to 10.0
|
||||
|
||||
# Integration data
|
||||
source_orders = Column(JSONB, nullable=True) # Orders that drove this plan
|
||||
production_schedules = Column(JSONB, nullable=True) # Associated production schedules
|
||||
inventory_snapshots = Column(JSONB, nullable=True) # Inventory levels at planning time
|
||||
|
||||
# Communication and collaboration
|
||||
stakeholder_notifications = Column(JSONB, nullable=True) # Who was notified and when
|
||||
approval_workflow = Column(JSONB, nullable=True) # Approval chain and status
|
||||
|
||||
# Special considerations
|
||||
special_requirements = Column(Text, nullable=True)
|
||||
seasonal_adjustments = Column(JSONB, nullable=True)
|
||||
emergency_provisions = Column(JSONB, nullable=True)
|
||||
|
||||
# External references
|
||||
erp_reference = Column(String(100), nullable=True)
|
||||
supplier_portal_reference = Column(String(100), 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
|
||||
plan_metadata = Column(JSONB, nullable=True)
|
||||
|
||||
# Relationships
|
||||
requirements = relationship("ProcurementRequirement", back_populates="plan", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ProcurementRequirement(Base):
|
||||
"""Individual procurement requirements within a procurement plan"""
|
||||
__tablename__ = "procurement_requirements"
|
||||
|
||||
# Primary identification
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
plan_id = Column(UUID(as_uuid=True), ForeignKey("procurement_plans.id", ondelete="CASCADE"), nullable=False)
|
||||
requirement_number = Column(String(50), nullable=False, index=True)
|
||||
|
||||
# Product/ingredient information
|
||||
product_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Reference to products/ingredients
|
||||
product_name = Column(String(200), nullable=False)
|
||||
product_sku = Column(String(100), nullable=True)
|
||||
product_category = Column(String(100), nullable=True)
|
||||
product_type = Column(String(50), nullable=False, default="ingredient") # ingredient, packaging, supplies
|
||||
|
||||
# Requirement details
|
||||
required_quantity = Column(Numeric(12, 3), nullable=False)
|
||||
unit_of_measure = Column(String(50), nullable=False)
|
||||
safety_stock_quantity = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
|
||||
total_quantity_needed = Column(Numeric(12, 3), nullable=False)
|
||||
|
||||
# Current inventory situation
|
||||
current_stock_level = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
|
||||
reserved_stock = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
|
||||
available_stock = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
|
||||
net_requirement = Column(Numeric(12, 3), nullable=False)
|
||||
|
||||
# Demand breakdown
|
||||
order_demand = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
|
||||
production_demand = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
|
||||
forecast_demand = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
|
||||
buffer_demand = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
|
||||
|
||||
# Supplier information
|
||||
preferred_supplier_id = Column(UUID(as_uuid=True), nullable=True)
|
||||
backup_supplier_id = Column(UUID(as_uuid=True), nullable=True)
|
||||
supplier_name = Column(String(200), nullable=True)
|
||||
supplier_lead_time_days = Column(Integer, nullable=True)
|
||||
minimum_order_quantity = Column(Numeric(12, 3), nullable=True)
|
||||
|
||||
# Pricing and cost
|
||||
estimated_unit_cost = Column(Numeric(10, 4), nullable=True)
|
||||
estimated_total_cost = Column(Numeric(12, 2), nullable=True)
|
||||
last_purchase_cost = Column(Numeric(10, 4), nullable=True)
|
||||
cost_variance = Column(Numeric(10, 2), nullable=False, default=Decimal("0.00"))
|
||||
|
||||
# Timing requirements
|
||||
required_by_date = Column(Date, nullable=False)
|
||||
lead_time_buffer_days = Column(Integer, nullable=False, default=1)
|
||||
suggested_order_date = Column(Date, nullable=False)
|
||||
latest_order_date = Column(Date, nullable=False)
|
||||
|
||||
# Quality and specifications
|
||||
quality_specifications = Column(JSONB, nullable=True)
|
||||
special_requirements = Column(Text, nullable=True)
|
||||
storage_requirements = Column(String(200), nullable=True)
|
||||
shelf_life_days = Column(Integer, nullable=True)
|
||||
|
||||
# Requirement status
|
||||
status = Column(String(50), nullable=False, default="pending")
|
||||
# Status values: pending, approved, ordered, partially_received, received, cancelled
|
||||
|
||||
priority = Column(String(20), nullable=False, default="normal") # critical, high, normal, low
|
||||
risk_level = Column(String(20), nullable=False, default="low") # low, medium, high, critical
|
||||
|
||||
# Purchase order tracking
|
||||
purchase_order_id = Column(UUID(as_uuid=True), nullable=True)
|
||||
purchase_order_number = Column(String(50), nullable=True)
|
||||
ordered_quantity = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
|
||||
ordered_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Delivery tracking
|
||||
expected_delivery_date = Column(Date, nullable=True)
|
||||
actual_delivery_date = Column(Date, nullable=True)
|
||||
received_quantity = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
|
||||
delivery_status = Column(String(50), nullable=False, default="pending")
|
||||
|
||||
# Performance tracking
|
||||
fulfillment_rate = Column(Numeric(5, 2), nullable=True) # Percentage
|
||||
on_time_delivery = Column(Boolean, nullable=True)
|
||||
quality_rating = Column(Numeric(3, 1), nullable=True) # 1.0 to 10.0
|
||||
|
||||
# Source traceability
|
||||
source_orders = Column(JSONB, nullable=True) # Orders that contributed to this requirement
|
||||
source_production_batches = Column(JSONB, nullable=True) # Production batches needing this
|
||||
demand_analysis = Column(JSONB, nullable=True) # Detailed demand breakdown
|
||||
|
||||
# Approval and authorization
|
||||
approved_quantity = Column(Numeric(12, 3), nullable=True)
|
||||
approved_cost = Column(Numeric(12, 2), nullable=True)
|
||||
approved_at = Column(DateTime(timezone=True), nullable=True)
|
||||
approved_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
|
||||
# Notes and communication
|
||||
procurement_notes = Column(Text, nullable=True)
|
||||
supplier_communication = Column(JSONB, 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
|
||||
requirement_metadata = Column(JSONB, nullable=True)
|
||||
|
||||
# Relationships
|
||||
plan = relationship("ProcurementPlan", back_populates="requirements")
|
||||
284
services/orders/app/repositories/base_repository.py
Normal file
284
services/orders/app/repositories/base_repository.py
Normal file
@@ -0,0 +1,284 @@
|
||||
# ================================================================
|
||||
# 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
|
||||
) -> 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 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
|
||||
464
services/orders/app/repositories/order_repository.py
Normal file
464
services/orders/app/repositories/order_repository.py
Normal file
@@ -0,0 +1,464 @@
|
||||
# ================================================================
|
||||
# services/orders/app/repositories/order_repository.py
|
||||
# ================================================================
|
||||
"""
|
||||
Order-related repositories for Orders Service
|
||||
"""
|
||||
|
||||
from datetime import datetime, date
|
||||
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
|
||||
|
||||
|
||||
class OrderRepository(BaseRepository[CustomerOrder, OrderCreate, OrderUpdate]):
|
||||
"""Repository for customer order operations"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(CustomerOrder)
|
||||
|
||||
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"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
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
|
||||
367
services/orders/app/schemas/order_schemas.py
Normal file
367
services/orders/app/schemas/order_schemas.py
Normal file
@@ -0,0 +1,367 @@
|
||||
# ================================================================
|
||||
# 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
|
||||
|
||||
|
||||
# ===== 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: str = Field(default="individual", pattern="^(individual|business|central_bakery)$")
|
||||
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: str = Field(default="delivery", pattern="^(delivery|pickup)$")
|
||||
payment_terms: str = Field(default="immediate", pattern="^(immediate|net_30|net_60)$")
|
||||
credit_limit: Optional[Decimal] = Field(None, ge=0)
|
||||
discount_percentage: Decimal = Field(default=Decimal("0.00"), ge=0, le=100)
|
||||
customer_segment: str = Field(default="regular", pattern="^(vip|regular|wholesale)$")
|
||||
priority_level: str = Field(default="normal", pattern="^(high|normal|low)$")
|
||||
special_instructions: Optional[str] = None
|
||||
delivery_preferences: Optional[Dict[str, Any]] = None
|
||||
product_preferences: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class CustomerCreate(CustomerBase):
|
||||
customer_code: str = Field(..., min_length=1, max_length=50)
|
||||
tenant_id: UUID
|
||||
|
||||
|
||||
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[str] = Field(None, pattern="^(individual|business|central_bakery)$")
|
||||
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[str] = Field(None, pattern="^(delivery|pickup)$")
|
||||
payment_terms: Optional[str] = Field(None, pattern="^(immediate|net_30|net_60)$")
|
||||
credit_limit: Optional[Decimal] = Field(None, ge=0)
|
||||
discount_percentage: Optional[Decimal] = Field(None, ge=0, le=100)
|
||||
customer_segment: Optional[str] = Field(None, pattern="^(vip|regular|wholesale)$")
|
||||
priority_level: Optional[str] = Field(None, pattern="^(high|normal|low)$")
|
||||
special_instructions: Optional[str] = None
|
||||
delivery_preferences: Optional[Dict[str, Any]] = None
|
||||
product_preferences: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
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: str = Field(default="standard", pattern="^(standard|rush|recurring|special)$")
|
||||
priority: str = Field(default="normal", pattern="^(high|normal|low)$")
|
||||
requested_delivery_date: datetime
|
||||
delivery_method: str = Field(default="delivery", pattern="^(delivery|pickup)$")
|
||||
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[str] = Field(None, pattern="^(cash|card|bank_transfer|account)$")
|
||||
payment_terms: str = Field(default="immediate", pattern="^(immediate|net_30|net_60)$")
|
||||
special_instructions: Optional[str] = None
|
||||
custom_requirements: Optional[Dict[str, Any]] = None
|
||||
allergen_warnings: Optional[Dict[str, Any]] = None
|
||||
order_source: str = Field(default="manual", pattern="^(manual|online|phone|app|api)$")
|
||||
sales_channel: str = Field(default="direct", pattern="^(direct|wholesale|retail)$")
|
||||
order_origin: Optional[str] = Field(None, max_length=100)
|
||||
communication_preferences: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class OrderCreate(OrderBase):
|
||||
tenant_id: UUID
|
||||
items: List[OrderItemCreate] = Field(..., min_items=1)
|
||||
|
||||
|
||||
class OrderUpdate(BaseModel):
|
||||
status: Optional[str] = Field(None, pattern="^(pending|confirmed|in_production|ready|out_for_delivery|delivered|cancelled|failed)$")
|
||||
priority: Optional[str] = Field(None, pattern="^(high|normal|low)$")
|
||||
requested_delivery_date: Optional[datetime] = None
|
||||
confirmed_delivery_date: Optional[datetime] = None
|
||||
delivery_method: Optional[str] = Field(None, pattern="^(delivery|pickup)$")
|
||||
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[str] = Field(None, pattern="^(cash|card|bank_transfer|account)$")
|
||||
payment_status: Optional[str] = Field(None, pattern="^(pending|partial|paid|failed|refunded)$")
|
||||
special_instructions: Optional[str] = None
|
||||
custom_requirements: Optional[Dict[str, Any]] = None
|
||||
allergen_warnings: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ===== Procurement Schemas =====
|
||||
|
||||
class ProcurementRequirementBase(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)
|
||||
product_type: str = Field(default="ingredient", pattern="^(ingredient|packaging|supplies)$")
|
||||
required_quantity: Decimal = Field(..., gt=0)
|
||||
unit_of_measure: str = Field(..., min_length=1, max_length=50)
|
||||
safety_stock_quantity: Decimal = Field(default=Decimal("0.000"), ge=0)
|
||||
required_by_date: date
|
||||
priority: str = Field(default="normal", pattern="^(critical|high|normal|low)$")
|
||||
preferred_supplier_id: Optional[UUID] = None
|
||||
quality_specifications: Optional[Dict[str, Any]] = None
|
||||
special_requirements: Optional[str] = None
|
||||
storage_requirements: Optional[str] = Field(None, max_length=200)
|
||||
|
||||
|
||||
class ProcurementRequirementCreate(ProcurementRequirementBase):
|
||||
pass
|
||||
|
||||
|
||||
class ProcurementRequirementResponse(ProcurementRequirementBase):
|
||||
id: UUID
|
||||
plan_id: UUID
|
||||
requirement_number: str
|
||||
total_quantity_needed: Decimal
|
||||
current_stock_level: Decimal
|
||||
available_stock: Decimal
|
||||
net_requirement: Decimal
|
||||
order_demand: Decimal
|
||||
production_demand: Decimal
|
||||
forecast_demand: Decimal
|
||||
status: str
|
||||
estimated_unit_cost: Optional[Decimal]
|
||||
estimated_total_cost: Optional[Decimal]
|
||||
supplier_name: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProcurementPlanBase(BaseModel):
|
||||
plan_date: date
|
||||
plan_period_start: date
|
||||
plan_period_end: date
|
||||
planning_horizon_days: int = Field(default=14, ge=1, le=365)
|
||||
plan_type: str = Field(default="regular", pattern="^(regular|emergency|seasonal)$")
|
||||
priority: str = Field(default="normal", pattern="^(high|normal|low)$")
|
||||
business_model: Optional[str] = Field(None, pattern="^(individual_bakery|central_bakery)$")
|
||||
procurement_strategy: str = Field(default="just_in_time", pattern="^(just_in_time|bulk|mixed)$")
|
||||
safety_stock_buffer: Decimal = Field(default=Decimal("20.00"), ge=0, le=100)
|
||||
special_requirements: Optional[str] = None
|
||||
|
||||
|
||||
class ProcurementPlanCreate(ProcurementPlanBase):
|
||||
tenant_id: UUID
|
||||
requirements: List[ProcurementRequirementCreate] = Field(..., min_items=1)
|
||||
|
||||
|
||||
class ProcurementPlanResponse(ProcurementPlanBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
plan_number: str
|
||||
status: str
|
||||
total_requirements: int
|
||||
total_estimated_cost: Decimal
|
||||
total_approved_cost: Decimal
|
||||
total_demand_orders: int
|
||||
supply_risk_level: str
|
||||
approved_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
requirements: List[ProcurementRequirementResponse] = []
|
||||
|
||||
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
|
||||
|
||||
|
||||
class ProcurementPlanningData(BaseModel):
|
||||
"""Data for procurement planning decisions"""
|
||||
planning_date: date
|
||||
planning_horizon_days: int
|
||||
|
||||
# Demand forecast
|
||||
demand_forecast: List[Dict[str, Any]]
|
||||
|
||||
# Current inventory status
|
||||
inventory_levels: Dict[str, Any]
|
||||
|
||||
# Supplier information
|
||||
supplier_performance: Dict[str, Any]
|
||||
|
||||
# Risk factors
|
||||
supply_risks: List[str]
|
||||
demand_volatility: Decimal
|
||||
|
||||
# Recommendations
|
||||
recommended_purchases: List[Dict[str, Any]]
|
||||
critical_shortages: List[Dict[str, Any]]
|
||||
546
services/orders/app/services/orders_service.py
Normal file
546
services/orders/app/services/orders_service.py
Normal file
@@ -0,0 +1,546 @@
|
||||
# ================================================================
|
||||
# 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.notifications.alert_integration import AlertIntegration
|
||||
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,
|
||||
alert_integration: AlertIntegration
|
||||
):
|
||||
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
|
||||
self.alert_integration = alert_integration
|
||||
|
||||
@transactional
|
||||
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
|
||||
|
||||
# 9. Check for high-value or rush orders for alerts
|
||||
await self._check_order_alerts(db, order, order_data.tenant_id)
|
||||
|
||||
# 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
|
||||
|
||||
@transactional
|
||||
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(
|
||||
db,
|
||||
tenant_id,
|
||||
filters={"created_at": {"gte": 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
|
||||
fulfillment_rate = Decimal("95.0") # Calculate from actual data
|
||||
on_time_delivery_rate = Decimal("92.0") # Calculate from actual data
|
||||
repeat_customers_rate = Decimal("65.0") # Calculate from actual data
|
||||
|
||||
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,
|
||||
average_order_value=metrics["average_order_value"],
|
||||
order_fulfillment_rate=fulfillment_rate,
|
||||
on_time_delivery_rate=on_time_delivery_rate,
|
||||
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 _check_order_alerts(self, db, order, tenant_id: UUID):
|
||||
"""Check for conditions that require alerts"""
|
||||
try:
|
||||
alerts = []
|
||||
|
||||
# High-value order alert
|
||||
if order.total_amount > settings.HIGH_VALUE_ORDER_THRESHOLD:
|
||||
alerts.append({
|
||||
"type": "high_value_order",
|
||||
"severity": "medium",
|
||||
"message": f"High-value order created: ${order.total_amount}"
|
||||
})
|
||||
|
||||
# Rush order alert
|
||||
if order.order_type == "rush":
|
||||
time_to_delivery = order.requested_delivery_date - order.order_date
|
||||
if time_to_delivery.total_seconds() < settings.RUSH_ORDER_HOURS_THRESHOLD * 3600:
|
||||
alerts.append({
|
||||
"type": "rush_order",
|
||||
"severity": "high",
|
||||
"message": f"Rush order with tight deadline: {order.order_number}"
|
||||
})
|
||||
|
||||
# Large quantity alert
|
||||
total_items = sum(item.quantity for item in order.items)
|
||||
if total_items > settings.LARGE_QUANTITY_ORDER_THRESHOLD:
|
||||
alerts.append({
|
||||
"type": "large_quantity_order",
|
||||
"severity": "medium",
|
||||
"message": f"Large quantity order: {total_items} items"
|
||||
})
|
||||
|
||||
# Send alerts if any
|
||||
for alert in alerts:
|
||||
await self._send_alert(tenant_id, order.id, alert)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error checking order alerts",
|
||||
order_id=str(order.id),
|
||||
error=str(e))
|
||||
|
||||
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(
|
||||
str(order.tenant_id),
|
||||
{
|
||||
"recipient": order.customer.email,
|
||||
"message": message,
|
||||
"type": "order_status_update",
|
||||
"order_id": str(order.id)
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to send status notification",
|
||||
order_id=str(order.id),
|
||||
error=str(e))
|
||||
|
||||
async def _send_alert(self, tenant_id: UUID, order_id: UUID, alert: Dict[str, Any]):
|
||||
"""Send alert notification"""
|
||||
try:
|
||||
if self.notification_client:
|
||||
await self.notification_client.send_alert(
|
||||
str(tenant_id),
|
||||
{
|
||||
"alert_type": alert["type"],
|
||||
"severity": alert["severity"],
|
||||
"message": alert["message"],
|
||||
"source_entity_id": str(order_id),
|
||||
"source_entity_type": "order"
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to send alert",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
30
services/orders/requirements.txt
Normal file
30
services/orders/requirements.txt
Normal file
@@ -0,0 +1,30 @@
|
||||
# Orders Service Dependencies
|
||||
# FastAPI and web framework
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
|
||||
# Database
|
||||
sqlalchemy==2.0.23
|
||||
asyncpg==0.29.0
|
||||
alembic==1.13.1
|
||||
|
||||
# HTTP clients
|
||||
httpx==0.25.2
|
||||
|
||||
# Logging and monitoring
|
||||
structlog==23.2.0
|
||||
|
||||
# Date and time utilities
|
||||
python-dateutil==2.8.2
|
||||
|
||||
# Validation and utilities
|
||||
email-validator==2.1.0
|
||||
|
||||
# Authentication
|
||||
python-jose[cryptography]==3.3.0
|
||||
|
||||
# Development dependencies (optional)
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
Reference in New Issue
Block a user