Add POI feature and imporve the overall backend implementation

This commit is contained in:
Urtzi Alfaro
2025-11-12 15:34:10 +01:00
parent e8096cd979
commit 5783c7ed05
173 changed files with 16862 additions and 9078 deletions

View File

@@ -15,6 +15,13 @@ The **Notification Service** handles multi-channel communication with bakery own
- **Multi-Recipient** - Send to individuals or groups
- **Delivery Tracking** - Monitor delivery status per channel
### Purchase Order Event Processing (🆕)
- **Automatic Supplier Notifications** - Sends professional HTML emails to suppliers when POs are approved
- **Event-Driven Architecture** - Consumes procurement events from RabbitMQ
- **Template Rendering** - Uses Jinja2 templates for professional, branded emails
- **Fire-and-Forget Processing** - Non-blocking event consumption for high throughput
- **Email Template Features** - Responsive design, gradient headers, line item tables, mobile-optimized
### Email Capabilities
- **HTML Templates** - Professional branded emails
- **Plain Text Fallback** - Ensure compatibility
@@ -799,11 +806,18 @@ async def send_smart_notification(
```
### Consumed Events
**From Procurement Service (🆕)**
- **Purchase Order Approved** (`po.approved`) - Automatically sends professional email notification to supplier with PO details, line items, and delivery requirements
- **Purchase Order Rejected** (`po.rejected`) - Notifies stakeholders when a PO is rejected with reason
- **Purchase Order Sent** (`po.sent_to_supplier`) - Confirms PO dispatch to supplier
- **Delivery Received** (`delivery.received`) - Notifies relevant parties when deliveries are received
**From Other Services**
- **From Alert Processor**: Alert events trigger notifications
- **From Orchestrator**: Daily summaries, scheduled reports
- **From Orders**: Order confirmations, delivery updates
- **From Production**: Quality issue alerts, batch completion
- **From Procurement**: Stockout warnings, purchase order confirmations
## Custom Metrics (Prometheus)
@@ -915,7 +929,8 @@ python main.py
- **Auth Service** - User information
- **PostgreSQL** - Notification history
- **Redis** - Template caching
- **RabbitMQ** - Alert consumption
- **RabbitMQ** - Alert consumption and procurement event processing (🆕)
- **Procurement Service** - Purchase order events (🆕)
### Dependents
- **Alert Processor** - Sends alerts via notifications
@@ -923,6 +938,7 @@ python main.py
- **Orchestrator** - Daily summaries and reports
- **All Services** - Critical alerts routing
- **Frontend Dashboard** - Notification preferences UI
- **Suppliers** - Receive PO notifications via email (🆕)
## Business Value for VUE Madrid

View File

@@ -0,0 +1,6 @@
"""
Event consumers for notification service
"""
from .po_event_consumer import POEventConsumer
__all__ = ["POEventConsumer"]

View File

@@ -0,0 +1,278 @@
"""
Purchase Order Event Consumer
Listens for PO events and sends email notifications to suppliers
"""
import json
import structlog
from pathlib import Path
from typing import Dict, Any
from jinja2 import Environment, FileSystemLoader
from datetime import datetime
from shared.messaging.rabbitmq import RabbitMQClient
from app.services.email_service import EmailService
logger = structlog.get_logger()
class POEventConsumer:
"""
Consumes purchase order events from RabbitMQ and sends notifications
"""
def __init__(self, email_service: EmailService):
self.email_service = email_service
# Setup Jinja2 template environment
template_dir = Path(__file__).parent.parent / 'templates'
self.jinja_env = Environment(
loader=FileSystemLoader(str(template_dir)),
autoescape=True
)
async def consume_po_approved_event(
self,
rabbitmq_client: RabbitMQClient
):
"""
Start consuming PO approved events from RabbitMQ
"""
async def process_message(message):
"""Process a single PO approved event message"""
try:
async with message.process():
# Parse event data
event_data = json.loads(message.body.decode())
logger.info(
"Received PO approved event",
event_id=event_data.get('event_id'),
po_id=event_data.get('data', {}).get('po_id')
)
# Send notification email
success = await self.send_po_approved_email(event_data)
if success:
logger.info(
"PO approved email sent successfully",
po_id=event_data.get('data', {}).get('po_id')
)
else:
logger.error(
"Failed to send PO approved email",
po_id=event_data.get('data', {}).get('po_id')
)
except Exception as e:
logger.error(
"Error processing PO approved event",
error=str(e),
exc_info=True
)
# Start consuming events
await rabbitmq_client.consume_events(
exchange_name="procurement.events",
queue_name="notification.po.approved",
routing_key="po.approved",
callback=process_message
)
logger.info("Started consuming PO approved events")
async def send_po_approved_email(self, event_data: Dict[str, Any]) -> bool:
"""
Send PO approved email to supplier
Args:
event_data: Full event payload from RabbitMQ
Returns:
bool: True if email sent successfully
"""
try:
# Extract data from event
data = event_data.get('data', {})
# Required fields
supplier_email = data.get('supplier_email')
if not supplier_email:
logger.warning(
"No supplier email in event, skipping notification",
po_id=data.get('po_id')
)
return False
# Prepare template context
context = self._prepare_email_context(data)
# Render HTML email from template
template = self.jinja_env.get_template('po_approved_email.html')
html_content = template.render(**context)
# Prepare plain text version (fallback)
text_content = self._generate_text_email(context)
# Send email
subject = f"New Purchase Order #{data.get('po_number', 'N/A')}"
success = await self.email_service.send_email(
to_email=supplier_email,
subject=subject,
text_content=text_content,
html_content=html_content,
from_name=context.get('bakery_name', 'Bakery Management System'),
reply_to=context.get('bakery_email')
)
return success
except Exception as e:
logger.error(
"Error sending PO approved email",
error=str(e),
po_id=data.get('po_id'),
exc_info=True
)
return False
def _prepare_email_context(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
Prepare context data for email template
Args:
data: Event data from RabbitMQ
Returns:
Dict with all template variables
"""
# Extract items and format them
items = data.get('items', [])
formatted_items = []
for item in items:
formatted_items.append({
'product_name': item.get('product_name', 'N/A'),
'ordered_quantity': f"{item.get('ordered_quantity', 0):.2f}",
'unit_of_measure': item.get('unit_of_measure', 'units'),
'unit_price': f"{item.get('unit_price', 0):.2f}",
'line_total': f"{item.get('line_total', 0):.2f}"
})
# Determine currency symbol
currency = data.get('currency', 'EUR')
currency_symbol = '' if currency == 'EUR' else '$'
# Format dates
order_date = self._format_datetime(data.get('approved_at'))
required_delivery_date = self._format_date(data.get('required_delivery_date'))
# Build context
context = {
# PO Details
'po_number': data.get('po_number', 'N/A'),
'order_date': order_date,
'required_delivery_date': required_delivery_date or 'To be confirmed',
'total_amount': f"{data.get('total_amount', 0):.2f}",
'currency': currency,
'currency_symbol': currency_symbol,
# Supplier Info
'supplier_name': data.get('supplier_name', 'Valued Supplier'),
# Items
'items': formatted_items,
# Bakery Info (these should come from tenant settings, defaulting for now)
'bakery_name': 'Your Bakery Name', # TODO: Fetch from tenant settings
'bakery_email': 'orders@yourbakery.com', # TODO: Fetch from tenant settings
'bakery_phone': '+34 XXX XXX XXX', # TODO: Fetch from tenant settings
'bakery_address': 'Your Bakery Address', # TODO: Fetch from tenant settings
'delivery_address': 'Bakery Delivery Address', # TODO: Fetch from PO/tenant
'contact_person': 'Bakery Manager', # TODO: Fetch from tenant settings
'contact_phone': '+34 XXX XXX XXX', # TODO: Fetch from tenant settings
# Payment & Delivery Terms
'payment_terms': 'Net 30 days', # TODO: Fetch from supplier/tenant settings
'delivery_instructions': 'Please deliver to main entrance between 7-9 AM', # TODO: Fetch from PO
'notes': None, # TODO: Extract from PO notes if available
}
return context
def _generate_text_email(self, context: Dict[str, Any]) -> str:
"""
Generate plain text version of email
Args:
context: Template context
Returns:
Plain text email content
"""
items_text = "\n".join([
f" - {item['product_name']}: {item['ordered_quantity']} {item['unit_of_measure']} "
f"× {context['currency_symbol']}{item['unit_price']} = {context['currency_symbol']}{item['line_total']}"
for item in context['items']
])
text = f"""
New Purchase Order #{context['po_number']}
Dear {context['supplier_name']},
We would like to place the following purchase order:
ORDER DETAILS:
- PO Number: {context['po_number']}
- Order Date: {context['order_date']}
- Required Delivery: {context['required_delivery_date']}
- Delivery Address: {context['delivery_address']}
ORDER ITEMS:
{items_text}
TOTAL AMOUNT: {context['currency_symbol']}{context['total_amount']} {context['currency']}
PAYMENT & DELIVERY:
- Payment Terms: {context['payment_terms']}
- Delivery Instructions: {context['delivery_instructions']}
- Contact Person: {context['contact_person']}
- Phone: {context['contact_phone']}
Please confirm receipt of this order by replying to this email.
Thank you for your continued partnership.
Best regards,
{context['bakery_name']}
{context['bakery_address']}
Phone: {context['bakery_phone']}
Email: {context['bakery_email']}
---
This is an automated email from your Bakery Management System.
"""
return text.strip()
def _format_datetime(self, iso_datetime: str) -> str:
"""Format ISO datetime string to readable format"""
if not iso_datetime:
return 'N/A'
try:
dt = datetime.fromisoformat(iso_datetime.replace('Z', '+00:00'))
return dt.strftime('%B %d, %Y at %H:%M')
except Exception:
return iso_datetime
def _format_date(self, iso_date: str) -> str:
"""Format ISO date string to readable format"""
if not iso_date:
return None
try:
if 'T' in iso_date:
dt = datetime.fromisoformat(iso_date.replace('Z', '+00:00'))
else:
dt = datetime.fromisoformat(iso_date)
return dt.strftime('%B %d, %Y')
except Exception:
return iso_date

View File

@@ -19,7 +19,9 @@ from app.services.sse_service import SSEService
from app.services.notification_orchestrator import NotificationOrchestrator
from app.services.email_service import EmailService
from app.services.whatsapp_service import WhatsAppService
from app.consumers.po_event_consumer import POEventConsumer
from shared.service_base import StandardFastAPIService
import asyncio
class NotificationService(StandardFastAPIService):
@@ -52,6 +54,8 @@ class NotificationService(StandardFastAPIService):
self.orchestrator = None
self.email_service = None
self.whatsapp_service = None
self.po_consumer = None
self.po_consumer_task = None
# Define custom metrics for notification service
notification_custom_metrics = {
@@ -190,8 +194,32 @@ class NotificationService(StandardFastAPIService):
app.state.email_service = self.email_service
app.state.whatsapp_service = self.whatsapp_service
# Initialize and start PO event consumer
self.po_consumer = POEventConsumer(self.email_service)
# Start consuming PO approved events in background
# Use the global notification_publisher from messaging module
from app.services.messaging import notification_publisher
if notification_publisher and notification_publisher.connected:
self.po_consumer_task = asyncio.create_task(
self.po_consumer.consume_po_approved_event(notification_publisher)
)
self.logger.info("PO event consumer started successfully")
else:
self.logger.warning("RabbitMQ not connected, PO event consumer not started")
app.state.po_consumer = self.po_consumer
async def on_shutdown(self, app: FastAPI):
"""Custom shutdown logic for notification service"""
# Cancel PO consumer task
if self.po_consumer_task and not self.po_consumer_task.done():
self.po_consumer_task.cancel()
try:
await self.po_consumer_task
except asyncio.CancelledError:
self.logger.info("PO event consumer task cancelled")
# Shutdown SSE service
if self.sse_service:
await self.sse_service.shutdown()

View File

@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Purchase Order - {{po_number}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333333;
background-color: #f5f5f5;
margin: 0;
padding: 0;
}
.email-container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.header {
background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 100%);
color: #ffffff;
padding: 30px 20px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.header p {
margin: 8px 0 0 0;
font-size: 14px;
opacity: 0.9;
}
.content {
padding: 30px 20px;
}
.greeting {
font-size: 16px;
margin-bottom: 20px;
}
.info-box {
background-color: #F9FAFB;
border-left: 4px solid #4F46E5;
padding: 16px;
margin: 20px 0;
border-radius: 4px;
}
.info-box h3 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
color: #6B7280;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #E5E7EB;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-weight: 500;
color: #6B7280;
}
.info-value {
font-weight: 600;
color: #111827;
}
.items-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
.items-table thead {
background-color: #F3F4F6;
}
.items-table th {
padding: 12px;
text-align: left;
font-size: 13px;
font-weight: 600;
color: #6B7280;
text-transform: uppercase;
border-bottom: 2px solid #E5E7EB;
}
.items-table td {
padding: 12px;
border-bottom: 1px solid #E5E7EB;
font-size: 14px;
}
.items-table tbody tr:last-child td {
border-bottom: none;
}
.items-table tbody tr:hover {
background-color: #F9FAFB;
}
.text-right {
text-align: right;
}
.total-row {
background-color: #F3F4F6;
font-weight: 600;
font-size: 16px;
}
.total-row td {
padding: 16px 12px;
border-top: 2px solid #4F46E5;
}
.cta-button {
display: inline-block;
background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 100%);
color: #ffffff;
text-decoration: none;
padding: 14px 28px;
border-radius: 6px;
font-weight: 600;
font-size: 14px;
margin: 20px 0;
text-align: center;
box-shadow: 0 4px 6px rgba(79, 70, 229, 0.2);
}
.cta-button:hover {
opacity: 0.9;
}
.notes {
background-color: #FEF3C7;
border-left: 4px solid #F59E0B;
padding: 16px;
margin: 20px 0;
border-radius: 4px;
}
.notes h4 {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 600;
color: #92400E;
}
.notes p {
margin: 0;
font-size: 14px;
color: #78350F;
}
.footer {
background-color: #F9FAFB;
padding: 20px;
text-align: center;
font-size: 12px;
color: #6B7280;
border-top: 1px solid #E5E7EB;
}
.footer p {
margin: 4px 0;
}
.footer a {
color: #4F46E5;
text-decoration: none;
}
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.content {
padding: 20px 16px;
}
.items-table th,
.items-table td {
padding: 8px;
font-size: 12px;
}
}
</style>
</head>
<body>
<div class="email-container">
<!-- Header -->
<div class="header">
<h1>📦 New Purchase Order</h1>
<p>Order #{{po_number}}</p>
</div>
<!-- Content -->
<div class="content">
<div class="greeting">
<p>Dear {{supplier_name}},</p>
<p>We would like to place the following purchase order:</p>
</div>
<!-- Order Information -->
<div class="info-box">
<h3>Order Details</h3>
<div class="info-row">
<span class="info-label">PO Number:</span>
<span class="info-value">{{po_number}}</span>
</div>
<div class="info-row">
<span class="info-label">Order Date:</span>
<span class="info-value">{{order_date}}</span>
</div>
<div class="info-row">
<span class="info-label">Required Delivery:</span>
<span class="info-value">{{required_delivery_date}}</span>
</div>
<div class="info-row">
<span class="info-label">Delivery Address:</span>
<span class="info-value">{{delivery_address}}</span>
</div>
</div>
<!-- Order Items -->
<table class="items-table">
<thead>
<tr>
<th>Product</th>
<th class="text-right">Quantity</th>
<th class="text-right">Unit Price</th>
<th class="text-right">Total</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{item.product_name}}</td>
<td class="text-right">{{item.ordered_quantity}} {{item.unit_of_measure}}</td>
<td class="text-right">{{currency_symbol}}{{item.unit_price}}</td>
<td class="text-right">{{currency_symbol}}{{item.line_total}}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="3" class="text-right">Total Amount:</td>
<td class="text-right">{{currency_symbol}}{{total_amount}}</td>
</tr>
</tfoot>
</table>
<!-- Call to Action -->
<div style="text-align: center;">
<p style="margin-bottom: 10px;">Please confirm receipt of this order:</p>
<a href="mailto:{{bakery_email}}?subject=RE: PO {{po_number}} - Confirmation" class="cta-button">
Confirm Order
</a>
</div>
<!-- Important Notes -->
{% if notes %}
<div class="notes">
<h4>⚠️ Important Notes</h4>
<p>{{notes}}</p>
</div>
{% endif %}
<!-- Payment & Delivery Instructions -->
<div class="info-box" style="margin-top: 30px;">
<h3>Payment & Delivery</h3>
<p style="margin: 0; font-size: 14px; color: #6B7280;">
• Payment Terms: {{payment_terms}}<br>
• Delivery Instructions: {{delivery_instructions}}<br>
• Contact Person: {{contact_person}}<br>
• Phone: {{contact_phone}}
</p>
</div>
<!-- Footer Message -->
<p style="margin-top: 30px; font-size: 14px; color: #6B7280;">
Thank you for your continued partnership. If you have any questions about this order,
please don't hesitate to contact us.
</p>
<p style="font-size: 14px; color: #6B7280;">
Best regards,<br>
<strong>{{bakery_name}}</strong>
</p>
</div>
<!-- Footer -->
<div class="footer">
<p><strong>{{bakery_name}}</strong></p>
<p>{{bakery_address}}</p>
<p>Phone: {{bakery_phone}} | Email: <a href="mailto:{{bakery_email}}">{{bakery_email}}</a></p>
<p style="margin-top: 16px; font-size: 11px; color: #9CA3AF;">
This is an automated email. Please do not reply directly to this message.
</p>
</div>
</div>
</body>
</html>