""" Marketplace B2B — service layer for bodegas, warehouse inventory and Purchase Orders (Phase 1). State machine: draft → submitted → confirmed → ready → delivered → closed ↘ rejected (terminal) Public API is grouped by concern: - Bodegas: list_bodegas, get_bodega, verify_bodega - Inventory: upload_inventory_csv, search_inventory - POs: create_po_draft, submit_po, transition_po, get_po_detail, list_pos_for_buyer, list_pos_for_seller - Notifications: notify_po_status_change (used internally by transition_po) All DB calls take a `master_conn` (psycopg2 connection to nexus_autoparts). The caller is responsible for committing and closing. """ import csv import io import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from typing import Optional # ═══════════════════════════════════════════════════════════════════════════ # STATE MACHINE # ═══════════════════════════════════════════════════════════════════════════ PO_STATUSES = ('draft', 'submitted', 'confirmed', 'rejected', 'ready', 'delivered', 'closed') # Map: current_status → {new_status: {actor_kinds}} # 'buyer' = user who created the PO; 'seller' = bodega owner/user PO_TRANSITIONS = { 'draft': {'submitted': {'buyer'}}, 'submitted': {'confirmed': {'seller'}, 'rejected': {'seller'}}, 'confirmed': {'ready': {'seller'}}, 'ready': {'delivered': {'buyer', 'seller'}}, 'delivered': {'closed': {'buyer', 'seller'}}, # terminal: rejected, closed } def _is_valid_transition(from_status: str, to_status: str, actor_kind: str) -> bool: allowed = PO_TRANSITIONS.get(from_status, {}).get(to_status) if not allowed: return False return actor_kind in allowed # ═══════════════════════════════════════════════════════════════════════════ # BODEGAS # ═══════════════════════════════════════════════════════════════════════════ def list_bodegas(master_conn, verified_only: bool = True, city: str = None) -> list[dict]: """Return all bodegas, optionally filtered.""" cur = master_conn.cursor() clauses = [] params = [] if verified_only: clauses.append("verified = TRUE") if city: clauses.append("LOWER(city) = LOWER(%s)") params.append(city) where = "WHERE " + " AND ".join(clauses) if clauses else "" cur.execute(f""" SELECT id_bodega, name, owner_name, whatsapp_phone, email, city, state, verified FROM bodegas {where} ORDER BY name """, params) rows = cur.fetchall() cur.close() return [ { 'id_bodega': r[0], 'name': r[1], 'owner_name': r[2], 'whatsapp_phone': r[3], 'email': r[4], 'city': r[5], 'state': r[6], 'verified': r[7], } for r in rows ] def get_bodega(master_conn, bodega_id: int) -> Optional[dict]: cur = master_conn.cursor() cur.execute(""" SELECT id_bodega, name, owner_name, whatsapp_phone, email, city, state, address, verified, commission_pct FROM bodegas WHERE id_bodega = %s """, (bodega_id,)) r = cur.fetchone() cur.close() if not r: return None return { 'id_bodega': r[0], 'name': r[1], 'owner_name': r[2], 'whatsapp_phone': r[3], 'email': r[4], 'city': r[5], 'state': r[6], 'address': r[7], 'verified': r[8], 'commission_pct': float(r[9] or 0), } def create_bodega(master_conn, *, name: str, whatsapp_phone: str, owner_name: str = None, email: str = None, city: str = None, state: str = None, address: str = None) -> int: """Register a new bodega (unverified by default). Admin verifies later.""" cur = master_conn.cursor() cur.execute(""" INSERT INTO bodegas (name, owner_name, whatsapp_phone, email, city, state, address) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id_bodega """, (name, owner_name, whatsapp_phone, email, city, state, address)) bodega_id = cur.fetchone()[0] cur.close() return bodega_id def verify_bodega(master_conn, bodega_id: int) -> bool: cur = master_conn.cursor() cur.execute(""" UPDATE bodegas SET verified = TRUE, verified_at = NOW() WHERE id_bodega = %s """, (bodega_id,)) ok = cur.rowcount > 0 cur.close() return ok # ═══════════════════════════════════════════════════════════════════════════ # INVENTORY — warehouse_inventory CSV upload + search # ═══════════════════════════════════════════════════════════════════════════ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict: """Bulk-upload a bodega's inventory from a CSV string. Expected columns (case-insensitive, whitespace-tolerant): part_number, stock, price Optional: min_order, warehouse_location, currency Resolution rules: - part_number matches `parts.oem_part_number` exactly (case-sensitive). - Parts not found in the master catalog are skipped and reported. - Existing rows for (bodega_id, part_id, warehouse_location) are updated via UPSERT; new rows are inserted. Returns a summary dict: {ok, inserted, updated, skipped, errors} """ reader = csv.DictReader(io.StringIO(csv_text)) # Normalize header names fieldnames = [f.strip().lower() for f in (reader.fieldnames or [])] required = {'part_number', 'stock', 'price'} missing = required - set(fieldnames) if missing: return { 'ok': False, 'error': f'Columnas faltantes en CSV: {", ".join(sorted(missing))}', 'inserted': 0, 'updated': 0, 'skipped': 0, } # Resolve bodega → its legacy user_id (warehouse_inventory still requires it) cur = master_conn.cursor() cur.execute("SELECT id_bodega FROM bodegas WHERE id_bodega = %s", (bodega_id,)) if not cur.fetchone(): cur.close() return {'ok': False, 'error': f'bodega_id {bodega_id} no existe'} inserted = 0 updated = 0 skipped = 0 errors = [] for i, row in enumerate(reader, start=2): # start=2 because row 1 is headers norm = {k.strip().lower(): (v or '').strip() for k, v in row.items()} part_number = norm.get('part_number', '') stock_str = norm.get('stock', '0') price_str = norm.get('price', '0') if not part_number: errors.append(f'Fila {i}: part_number vacio') skipped += 1 continue try: stock = int(stock_str) price = float(price_str) except ValueError: errors.append(f'Fila {i}: stock o price invalido') skipped += 1 continue # Resolve part_number → part_id cur.execute( "SELECT id_part FROM parts WHERE oem_part_number = %s LIMIT 1", (part_number,) ) row_part = cur.fetchone() if not row_part: errors.append(f'Fila {i}: part_number "{part_number}" no encontrado en catalogo') skipped += 1 continue part_id = row_part[0] # Resolve user_id from the bodega (use bodega_id as fallback if null) user_id = norm.get('user_id') or bodega_id # backward compat try: user_id = int(user_id) except (ValueError, TypeError): user_id = bodega_id location = norm.get('warehouse_location') or 'Principal' currency = (norm.get('currency') or 'MXN').upper() min_order = int(norm.get('min_order') or 1) # UPSERT on (user_id, part_id, warehouse_location) — the existing # unique constraint. Don't block if user_id FK fails. try: cur.execute(""" INSERT INTO warehouse_inventory (user_id, part_id, price, stock_quantity, min_order_quantity, warehouse_location, bodega_id, currency, updated_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW()) ON CONFLICT (user_id, part_id, warehouse_location) DO UPDATE SET price = EXCLUDED.price, stock_quantity = EXCLUDED.stock_quantity, min_order_quantity = EXCLUDED.min_order_quantity, bodega_id = EXCLUDED.bodega_id, currency = EXCLUDED.currency, updated_at = NOW() RETURNING (xmax = 0) AS inserted """, (user_id, part_id, price, stock, min_order, location, bodega_id, currency)) was_insert = cur.fetchone()[0] if was_insert: inserted += 1 else: updated += 1 except Exception as e: errors.append(f'Fila {i}: DB error: {str(e)[:100]}') skipped += 1 master_conn.rollback() # so next INSERTs can proceed continue cur.close() master_conn.commit() return { 'ok': True, 'inserted': inserted, 'updated': updated, 'skipped': skipped, 'errors': errors[:20], # cap to avoid huge responses 'total_errors': len(errors), } def search_inventory(master_conn, *, query: str = None, brand: str = None, city: str = None, limit: int = 50) -> list[dict]: """Browse warehouse_inventory filtered by query / brand / city. Returns parts WITH stock > 0 from VERIFIED bodegas only. Aggregates identical parts across bodegas so the buyer sees each part once with a list of bodegas that have it in stock. """ cur = master_conn.cursor() clauses = ["wi.stock_quantity > 0", "b.verified = TRUE"] params = [] if query: clauses.append("(p.oem_part_number ILIKE %s OR p.name_part ILIKE %s OR COALESCE(p.name_es, '') ILIKE %s)") like = f'%{query}%' params.extend([like, like, like]) if brand: # Search by vehicle brand via vehicle_parts → model_year_engine → models → brands. # Too slow for this MVP. Instead, match on aftermarket manufacturer name. clauses.append(""" EXISTS ( SELECT 1 FROM aftermarket_parts ap JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id WHERE ap.oem_part_id = p.id_part AND UPPER(m.name_manufacture) = UPPER(%s) ) """) params.append(brand) if city: clauses.append("LOWER(b.city) = LOWER(%s)") params.append(city) where_sql = " AND ".join(clauses) cur.execute(f""" SELECT p.id_part, p.oem_part_number, COALESCE(p.name_es, p.name_part) AS name, p.image_url, COUNT(DISTINCT b.id_bodega) AS bodega_count, MIN(wi.price) AS min_price, MAX(wi.price) AS max_price, SUM(wi.stock_quantity) AS total_stock, -- List of bodega names that have this part in stock ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names FROM warehouse_inventory wi JOIN bodegas b ON b.id_bodega = wi.bodega_id JOIN parts p ON p.id_part = wi.part_id WHERE {where_sql} GROUP BY p.id_part, p.oem_part_number, p.name_es, p.name_part, p.image_url ORDER BY total_stock DESC LIMIT %s """, params + [limit]) rows = cur.fetchall() cur.close() return [ { 'id_part': r[0], 'oem_part_number': r[1], 'name': r[2], 'image_url': r[3], 'bodega_count': r[4], 'min_price': float(r[5]) if r[5] is not None else None, 'max_price': float(r[6]) if r[6] is not None else None, 'total_stock_hint': 'En stock' if (r[7] or 0) > 0 else 'Consultar', 'bodega_names': r[8], # may expose; adjust if sensitive } for r in rows ] def get_bodegas_with_part(master_conn, part_id: int) -> list[dict]: """Return the list of verified bodegas that currently have a given OEM part in stock. Used when the buyer wants to pick WHICH bodega to order from. """ cur = master_conn.cursor() cur.execute(""" SELECT b.id_bodega, b.name, b.city, b.whatsapp_phone, wi.price, wi.stock_quantity, wi.min_order_quantity, wi.currency FROM warehouse_inventory wi JOIN bodegas b ON b.id_bodega = wi.bodega_id WHERE wi.part_id = %s AND wi.stock_quantity > 0 AND b.verified = TRUE ORDER BY wi.price ASC """, (part_id,)) rows = cur.fetchall() cur.close() return [ { 'id_bodega': r[0], 'name': r[1], 'city': r[2], 'whatsapp_phone': r[3], 'price': float(r[4]) if r[4] is not None else None, 'stock_hint': 'En stock', # don't expose exact quantity 'min_order': r[6] or 1, 'currency': r[7] or 'MXN', } for r in rows ] # ═══════════════════════════════════════════════════════════════════════════ # PURCHASE ORDERS # ═══════════════════════════════════════════════════════════════════════════ def create_po_draft(master_conn, *, buyer_tenant_id: int, buyer_user_id: int, buyer_display_name: str, buyer_phone: str, buyer_email: str, bodega_id: int, items: list, delivery_method: str = 'pickup', delivery_address: str = None, buyer_notes: str = None) -> int: """Create a PO in 'draft' status with its items. Args: items: list of dicts with keys: part_id, quantity, unit_price (optional) If unit_price is missing, it's pulled from warehouse_inventory. Returns the new po_id. """ if not items: raise ValueError('A PO must have at least one item') cur = master_conn.cursor() # Create header cur.execute(""" INSERT INTO purchase_orders ( buyer_tenant_id, buyer_user_id, buyer_display_name, buyer_phone, buyer_email, bodega_id, status, delivery_method, delivery_address, buyer_notes ) VALUES (%s, %s, %s, %s, %s, %s, 'draft', %s, %s, %s) RETURNING id_po """, ( buyer_tenant_id, buyer_user_id, buyer_display_name, buyer_phone, buyer_email, bodega_id, delivery_method, delivery_address, buyer_notes, )) po_id = cur.fetchone()[0] # Insert items total = 0.0 for item in items: part_id = int(item['part_id']) quantity = int(item['quantity']) if quantity < 1: continue # Lookup part info + price cur.execute(""" SELECT p.oem_part_number, COALESCE(p.name_es, p.name_part), wi.price FROM parts p LEFT JOIN warehouse_inventory wi ON wi.part_id = p.id_part AND wi.bodega_id = %s WHERE p.id_part = %s LIMIT 1 """, (bodega_id, part_id)) r = cur.fetchone() if not r: continue oem, name, db_price = r unit_price = float(item.get('unit_price') or db_price or 0) subtotal = round(unit_price * quantity, 2) total += subtotal cur.execute(""" INSERT INTO purchase_order_items (po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) """, (po_id, part_id, oem, name, quantity, unit_price, subtotal, item.get('notes'))) # Update header total cur.execute("UPDATE purchase_orders SET total_amount = %s WHERE id_po = %s", (round(total, 2), po_id)) # Log initial status cur.execute(""" INSERT INTO po_status_history (po_id, from_status, to_status, actor_user_id, actor_kind, note) VALUES (%s, NULL, 'draft', %s, 'buyer', 'PO creado') """, (po_id, buyer_user_id)) cur.close() master_conn.commit() return po_id def transition_po(master_conn, *, po_id: int, new_status: str, actor_user_id: int, actor_kind: str, note: str = None) -> dict: """Transition a PO to a new status with full validation and notification. Returns: {ok, from_status, to_status, notified} or {ok: False, error} """ if new_status not in PO_STATUSES: return {'ok': False, 'error': f'Invalid status: {new_status}'} cur = master_conn.cursor() cur.execute("SELECT status FROM purchase_orders WHERE id_po = %s FOR UPDATE", (po_id,)) row = cur.fetchone() if not row: cur.close() return {'ok': False, 'error': f'PO {po_id} not found'} from_status = row[0] if not _is_valid_transition(from_status, new_status, actor_kind): cur.close() return { 'ok': False, 'error': f'Transition {from_status}→{new_status} not allowed for {actor_kind}', } # Timestamp columns per state ts_field = { 'submitted': 'submitted_at', 'confirmed': 'confirmed_at', 'ready': 'ready_at', 'delivered': 'delivered_at', 'closed': 'closed_at', }.get(new_status) if ts_field: cur.execute( f"UPDATE purchase_orders SET status = %s, {ts_field} = NOW() WHERE id_po = %s", (new_status, po_id), ) else: cur.execute("UPDATE purchase_orders SET status = %s WHERE id_po = %s", (new_status, po_id)) # Log history row cur.execute(""" INSERT INTO po_status_history (po_id, from_status, to_status, actor_user_id, actor_kind, note) VALUES (%s, %s, %s, %s, %s, %s) """, (po_id, from_status, new_status, actor_user_id, actor_kind, note)) cur.close() master_conn.commit() # Fire notifications — non-blocking (failures logged, not raised) notified = [] try: notified = notify_po_status_change(master_conn, po_id, new_status) except Exception as e: print(f'[marketplace] notification failed for PO {po_id}: {e}') return { 'ok': True, 'from_status': from_status, 'to_status': new_status, 'notified': notified, } def get_po_detail(master_conn, po_id: int) -> Optional[dict]: cur = master_conn.cursor() cur.execute(""" SELECT po.id_po, po.buyer_tenant_id, po.buyer_user_id, po.buyer_display_name, po.buyer_phone, po.buyer_email, po.bodega_id, b.name AS bodega_name, b.whatsapp_phone AS bodega_phone, b.email AS bodega_email, po.status, po.total_amount, po.currency, po.buyer_notes, po.seller_notes, po.delivery_method, po.delivery_address, po.created_at, po.submitted_at, po.confirmed_at, po.ready_at, po.delivered_at, po.closed_at FROM purchase_orders po JOIN bodegas b ON b.id_bodega = po.bodega_id WHERE po.id_po = %s """, (po_id,)) r = cur.fetchone() if not r: cur.close() return None po = { 'id_po': r[0], 'buyer_tenant_id': r[1], 'buyer_user_id': r[2], 'buyer_display_name': r[3], 'buyer_phone': r[4], 'buyer_email': r[5], 'bodega_id': r[6], 'bodega_name': r[7], 'bodega_phone': r[8], 'bodega_email': r[9], 'status': r[10], 'total_amount': float(r[11]) if r[11] is not None else 0.0, 'currency': r[12], 'buyer_notes': r[13], 'seller_notes': r[14], 'delivery_method': r[15], 'delivery_address': r[16], 'created_at': r[17].isoformat() if r[17] else None, 'submitted_at': r[18].isoformat() if r[18] else None, 'confirmed_at': r[19].isoformat() if r[19] else None, 'ready_at': r[20].isoformat() if r[20] else None, 'delivered_at': r[21].isoformat() if r[21] else None, 'closed_at': r[22].isoformat() if r[22] else None, } # Items cur.execute(""" SELECT id_po_item, part_id, oem_part_number, part_name, manufacturer, quantity, unit_price, subtotal, confirmed_qty, notes FROM purchase_order_items WHERE po_id = %s ORDER BY id_po_item """, (po_id,)) po['items'] = [ { 'id_po_item': ir[0], 'part_id': ir[1], 'oem_part_number': ir[2], 'part_name': ir[3], 'manufacturer': ir[4], 'quantity': ir[5], 'unit_price': float(ir[6]) if ir[6] is not None else 0.0, 'subtotal': float(ir[7]) if ir[7] is not None else 0.0, 'confirmed_qty': ir[8], 'notes': ir[9], } for ir in cur.fetchall() ] # Status history cur.execute(""" SELECT from_status, to_status, actor_kind, note, created_at FROM po_status_history WHERE po_id = %s ORDER BY created_at """, (po_id,)) po['history'] = [ { 'from_status': h[0], 'to_status': h[1], 'actor_kind': h[2], 'note': h[3], 'at': h[4].isoformat() if h[4] else None, } for h in cur.fetchall() ] cur.close() return po def list_pos_for_buyer(master_conn, buyer_tenant_id: int, buyer_user_id: int = None, limit: int = 50) -> list[dict]: """Return POs created by a buyer (filtered by tenant or user).""" cur = master_conn.cursor() clauses = ['po.buyer_tenant_id = %s'] params = [buyer_tenant_id] if buyer_user_id is not None: clauses.append('po.buyer_user_id = %s') params.append(buyer_user_id) where = ' AND '.join(clauses) cur.execute(f""" SELECT po.id_po, po.status, po.total_amount, po.currency, po.bodega_id, b.name AS bodega_name, po.created_at, po.submitted_at, (SELECT COUNT(*) FROM purchase_order_items WHERE po_id = po.id_po) AS item_count FROM purchase_orders po JOIN bodegas b ON b.id_bodega = po.bodega_id WHERE {where} ORDER BY po.created_at DESC LIMIT %s """, params + [limit]) rows = cur.fetchall() cur.close() return [ { 'id_po': r[0], 'status': r[1], 'total_amount': float(r[2]) if r[2] is not None else 0.0, 'currency': r[3], 'bodega_id': r[4], 'bodega_name': r[5], 'created_at': r[6].isoformat() if r[6] else None, 'submitted_at': r[7].isoformat() if r[7] else None, 'item_count': r[8], } for r in rows ] def list_pos_for_seller(master_conn, bodega_id: int, limit: int = 50) -> list[dict]: """Inbox: POs addressed to a seller (bodega).""" cur = master_conn.cursor() cur.execute(""" SELECT po.id_po, po.status, po.total_amount, po.currency, po.buyer_tenant_id, po.buyer_display_name, po.buyer_phone, po.created_at, po.submitted_at, (SELECT COUNT(*) FROM purchase_order_items WHERE po_id = po.id_po) AS item_count FROM purchase_orders po WHERE po.bodega_id = %s AND po.status != 'draft' ORDER BY CASE po.status WHEN 'submitted' THEN 1 WHEN 'confirmed' THEN 2 WHEN 'ready' THEN 3 ELSE 4 END, po.submitted_at DESC LIMIT %s """, (bodega_id, limit)) rows = cur.fetchall() cur.close() return [ { 'id_po': r[0], 'status': r[1], 'total_amount': float(r[2]) if r[2] is not None else 0.0, 'currency': r[3], 'buyer_tenant_id': r[4], 'buyer_display_name': r[5], 'buyer_phone': r[6], 'created_at': r[7].isoformat() if r[7] else None, 'submitted_at': r[8].isoformat() if r[8] else None, 'item_count': r[9], } for r in rows ] # ═══════════════════════════════════════════════════════════════════════════ # NOTIFICATIONS — WhatsApp + Email # ═══════════════════════════════════════════════════════════════════════════ # Per-status message templates. Each is a (subject, body) tuple. # The body is plain text — same text goes to WA and email, with an optional # HTML wrapper for email. _PO_MESSAGE_TEMPLATES = { 'submitted': ( 'Nuevo pedido Nexus #{po_id}', 'Tienes un nuevo pedido en Nexus Marketplace.\n\n' 'Pedido: #{po_id}\n' 'Comprador: {buyer_display_name}\n' 'Total: ${total_amount:,.2f} {currency}\n' 'Items: {item_count}\n\n' 'Entra al marketplace para confirmar o rechazar.' ), 'confirmed': ( 'Pedido #{po_id} confirmado por {bodega_name}', 'Tu pedido fue confirmado.\n\n' 'Pedido: #{po_id}\n' 'Bodega: {bodega_name}\n' 'Total: ${total_amount:,.2f} {currency}\n\n' 'Te avisaremos cuando este listo para recoger / entregar.' ), 'rejected': ( 'Pedido #{po_id} rechazado', 'Tu pedido fue rechazado por {bodega_name}.\n\n' 'Pedido: #{po_id}\n' 'Puedes intentar con otra bodega en el marketplace.' ), 'ready': ( 'Pedido #{po_id} listo', 'Tu pedido esta listo.\n\n' 'Pedido: #{po_id}\n' 'Bodega: {bodega_name}\n' 'Metodo: {delivery_method}\n\n' 'Pasa a recogerlo o espera la entrega.' ), 'delivered': ( 'Pedido #{po_id} entregado', 'El pedido #{po_id} fue marcado como entregado.\n' 'Gracias por usar Nexus Marketplace.' ), 'closed': ( 'Pedido #{po_id} cerrado', 'El pedido #{po_id} fue cerrado.' ), } def notify_po_status_change(master_conn, po_id: int, new_status: str) -> list[str]: """Send WhatsApp + email notification about a PO status change. Returns a list of channel names that were successfully notified (e.g. ['whatsapp', 'email']). Failures are logged but not raised. """ template = _PO_MESSAGE_TEMPLATES.get(new_status) if not template: return [] # no message defined for this status po = get_po_detail(master_conn, po_id) if not po: return [] # Resolve context variables for the template ctx = { 'po_id': po_id, 'buyer_display_name': po.get('buyer_display_name') or 'Cliente', 'bodega_name': po.get('bodega_name') or 'Bodega', 'total_amount': po.get('total_amount') or 0, 'currency': po.get('currency') or 'MXN', 'delivery_method': po.get('delivery_method') or 'pickup', 'item_count': len(po.get('items') or []), } subject_tpl, body_tpl = template try: subject = subject_tpl.format(**ctx) body = body_tpl.format(**ctx) except (KeyError, ValueError) as e: print(f'[marketplace] template format error for {new_status}: {e}') return [] # Decide the recipient based on who should be notified for this status # - submitted → notify seller (new order arrived) # - confirmed/rejected/ready → notify buyer (status update) # - delivered → notify both (handled as 2 sends) # - closed → notify buyer recipients = [] if new_status == 'submitted': recipients = [{ 'kind': 'seller', 'phone': po.get('bodega_phone'), 'email': po.get('bodega_email'), }] elif new_status in ('confirmed', 'rejected', 'ready', 'closed'): recipients = [{ 'kind': 'buyer', 'phone': po.get('buyer_phone'), 'email': po.get('buyer_email'), }] elif new_status == 'delivered': recipients = [ {'kind': 'buyer', 'phone': po.get('buyer_phone'), 'email': po.get('buyer_email')}, {'kind': 'seller', 'phone': po.get('bodega_phone'), 'email': po.get('bodega_email')}, ] channels_used = [] for recipient in recipients: # WhatsApp if recipient.get('phone'): try: from services import whatsapp_service result = whatsapp_service.send_message(recipient['phone'], body) if result and not result.get('error'): channels_used.append(f"whatsapp:{recipient['kind']}") except Exception as e: print(f'[marketplace] WA send failed: {e}') # Email if recipient.get('email'): try: sent = _send_email(recipient['email'], subject, body) if sent: channels_used.append(f"email:{recipient['kind']}") except Exception as e: print(f'[marketplace] email send failed: {e}') return channels_used def _send_email(to_email: str, subject: str, body_text: str) -> bool: """Send a plain-text email via SMTP (config in pos/config.py). Returns True if the mail was actually sent, False if SMTP is not configured (silent no-op so dev environments don't crash). """ import config if not config.SMTP_USER or not config.SMTP_PASS: print('[marketplace] SMTP not configured — skipping email') return False msg = MIMEMultipart('alternative') msg['From'] = config.SMTP_FROM msg['To'] = to_email msg['Subject'] = subject msg.attach(MIMEText(body_text, 'plain', 'utf-8')) with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT, timeout=15) as server: server.starttls() server.login(config.SMTP_USER, config.SMTP_PASS) server.send_message(msg) print(f'[marketplace] email sent to {to_email}: {subject}') return True