""" 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 from app.services.whatsapp_service import WhatsAppService logger = structlog.get_logger() class POEventConsumer: """ Consumes purchase order events from RabbitMQ and sends notifications Sends both email and WhatsApp notifications to suppliers """ def __init__(self, email_service: EmailService, whatsapp_service: WhatsAppService = None): self.email_service = email_service self.whatsapp_service = whatsapp_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 email_success = await self.send_po_approved_email(event_data) # 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: logger.info( "PO approved email sent successfully", po_id=event_data.get('data', {}).get('po_id'), whatsapp_sent=whatsapp_success ) else: logger.error( "Failed to send PO approved email", po_id=event_data.get('data', {}).get('po_id'), whatsapp_sent=whatsapp_success ) 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 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