2025-11-12 15:34:10 +01:00
|
|
|
|
"""
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2025-12-05 20:07:01 +01:00
|
|
|
|
from shared.messaging import RabbitMQClient
|
2025-11-12 15:34:10 +01:00
|
|
|
|
from app.services.email_service import EmailService
|
2025-11-13 16:01:08 +01:00
|
|
|
|
from app.services.whatsapp_service import WhatsAppService
|
2025-11-12 15:34:10 +01:00
|
|
|
|
|
|
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class POEventConsumer:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Consumes purchase order events from RabbitMQ and sends notifications
|
2025-11-13 16:01:08 +01:00
|
|
|
|
Sends both email and WhatsApp notifications to suppliers
|
2025-11-12 15:34:10 +01:00
|
|
|
|
"""
|
|
|
|
|
|
|
2025-11-13 16:01:08 +01:00
|
|
|
|
def __init__(self, email_service: EmailService, whatsapp_service: WhatsAppService = None):
|
2025-11-12 15:34:10 +01:00
|
|
|
|
self.email_service = email_service
|
2025-11-13 16:01:08 +01:00
|
|
|
|
self.whatsapp_service = whatsapp_service
|
2025-11-12 15:34:10 +01:00
|
|
|
|
|
|
|
|
|
|
# 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
|
2025-11-13 16:01:08 +01:00
|
|
|
|
email_success = await self.send_po_approved_email(event_data)
|
2025-11-12 15:34:10 +01:00
|
|
|
|
|
2025-11-13 16:01:08 +01:00
|
|
|
|
# Send WhatsApp notification if service is available
|
|
|
|
|
|
whatsapp_success = False
|
|
|
|
|
|
if self.whatsapp_service:
|
|
|
|
|
|
whatsapp_success = await self.send_po_approved_whatsapp(event_data)
|
|
|
|
|
|
|
|
|
|
|
|
if email_success:
|
2025-11-12 15:34:10 +01:00
|
|
|
|
logger.info(
|
|
|
|
|
|
"PO approved email sent successfully",
|
2025-11-13 16:01:08 +01:00
|
|
|
|
po_id=event_data.get('data', {}).get('po_id'),
|
|
|
|
|
|
whatsapp_sent=whatsapp_success
|
2025-11-12 15:34:10 +01:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(
|
|
|
|
|
|
"Failed to send PO approved email",
|
2025-11-13 16:01:08 +01:00
|
|
|
|
po_id=event_data.get('data', {}).get('po_id'),
|
|
|
|
|
|
whatsapp_sent=whatsapp_success
|
2025-11-12 15:34:10 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2025-12-05 20:07:01 +01:00
|
|
|
|
async def _get_tenant_settings(self, tenant_id: str) -> Dict[str, Any]:
|
|
|
|
|
|
"""Fetch tenant settings from tenant service"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from shared.clients.tenant_client import TenantServiceClient
|
|
|
|
|
|
from shared.config.base import get_settings
|
|
|
|
|
|
|
|
|
|
|
|
config = get_settings()
|
|
|
|
|
|
tenant_client = TenantServiceClient(config)
|
|
|
|
|
|
|
|
|
|
|
|
# Get tenant details
|
|
|
|
|
|
tenant = await tenant_client.get_tenant(tenant_id)
|
|
|
|
|
|
if not tenant:
|
|
|
|
|
|
logger.warning("Could not fetch tenant details", tenant_id=tenant_id)
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
'name': tenant.get('business_name') or tenant.get('name', 'Your Bakery'),
|
|
|
|
|
|
'email': tenant.get('email', 'info@yourbakery.com'),
|
|
|
|
|
|
'phone': tenant.get('phone', '+34 XXX XXX XXX'),
|
|
|
|
|
|
'address': tenant.get('address', 'Your Bakery Address'),
|
|
|
|
|
|
'contact_person': tenant.get('contact_person', 'Bakery Manager')
|
|
|
|
|
|
}
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error("Failed to fetch tenant settings", tenant_id=tenant_id, error=str(e))
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
async def _prepare_email_context(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
2025-11-12 15:34:10 +01:00
|
|
|
|
"""
|
|
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-05 20:07:01 +01:00
|
|
|
|
# Fetch tenant settings (bakery info)
|
|
|
|
|
|
tenant_id = data.get('tenant_id')
|
|
|
|
|
|
tenant_settings = {}
|
|
|
|
|
|
if tenant_id:
|
|
|
|
|
|
tenant_settings = await self._get_tenant_settings(tenant_id)
|
|
|
|
|
|
|
|
|
|
|
|
# Add bakery info from tenant settings with fallbacks
|
|
|
|
|
|
context.update({
|
|
|
|
|
|
'bakery_name': tenant_settings.get('name', 'Your Bakery Name'),
|
|
|
|
|
|
'bakery_email': tenant_settings.get('email', 'orders@yourbakery.com'),
|
|
|
|
|
|
'bakery_phone': tenant_settings.get('phone', '+34 XXX XXX XXX'),
|
|
|
|
|
|
'bakery_address': tenant_settings.get('address', 'Your Bakery Address'),
|
|
|
|
|
|
'delivery_address': data.get('delivery_address') or tenant_settings.get('address', 'Bakery Delivery Address'),
|
|
|
|
|
|
'contact_person': data.get('contact_person') or tenant_settings.get('contact_person', 'Bakery Manager'),
|
|
|
|
|
|
'contact_phone': data.get('contact_phone') or tenant_settings.get('phone', '+34 XXX XXX XXX'),
|
|
|
|
|
|
|
|
|
|
|
|
# Payment & Delivery Terms - From PO data with fallbacks
|
|
|
|
|
|
'payment_terms': data.get('payment_terms', 'Net 30 days'),
|
|
|
|
|
|
'delivery_instructions': data.get('delivery_instructions', 'Please deliver during business hours'),
|
|
|
|
|
|
'notes': data.get('notes'),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-11-12 15:34:10 +01:00
|
|
|
|
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
|
2025-11-13 16:01:08 +01:00
|
|
|
|
|
|
|
|
|
|
async def send_po_approved_whatsapp(self, event_data: Dict[str, Any]) -> bool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Send PO approved WhatsApp notification to supplier
|
|
|
|
|
|
|
|
|
|
|
|
This sends a WhatsApp Business template message notifying the supplier
|
|
|
|
|
|
of a new purchase order. The template must be pre-approved in Meta Business Suite.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
event_data: Full event payload from RabbitMQ
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
bool: True if WhatsApp message sent successfully
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Extract data from event
|
|
|
|
|
|
data = event_data.get('data', {})
|
|
|
|
|
|
|
|
|
|
|
|
# Check for supplier phone number
|
|
|
|
|
|
supplier_phone = data.get('supplier_phone')
|
|
|
|
|
|
if not supplier_phone:
|
|
|
|
|
|
logger.debug(
|
|
|
|
|
|
"No supplier phone in event, skipping WhatsApp notification",
|
|
|
|
|
|
po_id=data.get('po_id')
|
|
|
|
|
|
)
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# Extract tenant ID for tracking
|
|
|
|
|
|
tenant_id = data.get('tenant_id')
|
|
|
|
|
|
|
|
|
|
|
|
# Prepare template parameters
|
|
|
|
|
|
# Template: "Hola {{1}}, has recibido una nueva orden de compra {{2}} por un total de {{3}}."
|
|
|
|
|
|
# Parameters: supplier_name, po_number, total_amount
|
|
|
|
|
|
template_params = [
|
|
|
|
|
|
data.get('supplier_name', 'Estimado proveedor'),
|
|
|
|
|
|
data.get('po_number', 'N/A'),
|
|
|
|
|
|
f"€{data.get('total_amount', 0):.2f}"
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
# Send WhatsApp template message
|
|
|
|
|
|
# The template must be named 'po_notification' and approved in Meta Business Suite
|
|
|
|
|
|
success = await self.whatsapp_service.send_message(
|
|
|
|
|
|
to_phone=supplier_phone,
|
|
|
|
|
|
message="", # Not used for template messages
|
|
|
|
|
|
template_name="po_notification", # Must match template name in Meta
|
|
|
|
|
|
template_params=template_params,
|
|
|
|
|
|
tenant_id=tenant_id
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if success:
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"PO approved WhatsApp sent successfully",
|
|
|
|
|
|
po_id=data.get('po_id'),
|
|
|
|
|
|
supplier_phone=supplier_phone,
|
|
|
|
|
|
template="po_notification"
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Failed to send PO approved WhatsApp",
|
|
|
|
|
|
po_id=data.get('po_id'),
|
|
|
|
|
|
supplier_phone=supplier_phone
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return success
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(
|
|
|
|
|
|
"Error sending PO approved WhatsApp",
|
|
|
|
|
|
error=str(e),
|
|
|
|
|
|
po_id=data.get('po_id'),
|
|
|
|
|
|
exc_info=True
|
|
|
|
|
|
)
|
|
|
|
|
|
return False
|