Add POI feature and imporve the overall backend implementation
This commit is contained in:
6
services/notification/app/consumers/__init__.py
Normal file
6
services/notification/app/consumers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Event consumers for notification service
|
||||
"""
|
||||
from .po_event_consumer import POEventConsumer
|
||||
|
||||
__all__ = ["POEventConsumer"]
|
||||
278
services/notification/app/consumers/po_event_consumer.py
Normal file
278
services/notification/app/consumers/po_event_consumer.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
297
services/notification/app/templates/po_approved_email.html
Normal file
297
services/notification/app/templates/po_approved_email.html
Normal 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>
|
||||
Reference in New Issue
Block a user