feat(notifications): Sub 6 — contact profiles + communication routing

Five new boolean flags on res.partner applied to CHILD contacts:
x_fc_receives_certs, _qc, _quotes_so, _invoices, and _is_global_contact.
Single resolver helper res.partner._fp_resolve_notification_recipients
(stream, delivery_location=None) walks location contacts first then
company contacts, returning emails for contacts that opted into the
stream (or flagged themselves global). Falls back to partner.email
when no contact opts in so existing customers keep their exact
pre-Sub-6 routing.

fp.notification.template._dispatch now maps each trigger event to a
stream (so_confirmed→quotes_so, invoice_posted→invoices, shipped→
certs, etc.) and overrides the mail_template's email_to with the
resolved list. fp.delivery passes its delivery_address_id so the
shipped/CoC email routes through location-scoped contacts when they
exist.

Partner form gets a new "Communication Routing" tab on child contact
forms with the 5 flags (hides the per-stream checkboxes when
Global Contact is on, since it overrides them).

fusion_plating_certificates → 19.0.4.0.0 (adds the flag fields)
fusion_plating_notifications → 19.0.5.0.0 (+depends certificates)

Smoke on entech: 11/11 assertions pass including per-stream routing,
delivery-location scoping, zero-flag fallback, email-less skip,
unknown-stream + global behaviour, and case-insensitive dedup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-23 00:01:15 -04:00
parent f9e1b62409
commit 0342535b9f
10 changed files with 351 additions and 7 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Notifications',
'version': '19.0.4.1.0',
'version': '19.0.5.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
'author': 'Nexa Systems Inc.',
@@ -17,6 +17,7 @@
'currency': 'CAD',
'depends': [
'fusion_plating_configurator',
'fusion_plating_certificates',
'fusion_plating_receiving',
'fusion_plating_invoicing',
'fusion_plating_bridge_mrp',

View File

@@ -5,6 +5,7 @@
from . import fp_notification_template
from . import fp_notification_log
from . import res_partner
from . import sale_order
from . import fp_receiving
from . import account_move

View File

@@ -25,7 +25,10 @@ class FpDelivery(models.Model):
so = self.env['sale.order'].search(
[('name', '=', mo.origin)], limit=1,
)
# Sub 6 — pass the delivery address so location-scoped
# contacts receive the 'shipped' notification.
Dispatch._dispatch(
'shipped', rec, rec.partner_id, sale_order=so,
delivery_location=rec.delivery_address_id or False,
)
return res

View File

@@ -20,6 +20,21 @@ TRIGGER_EVENTS = [
('deposit_created', 'Deposit Required'),
]
# Sub 6 — map each trigger event to a communication stream. Contacts on
# the customer who opt into that stream (or flag themselves as global)
# receive the email. Unmapped events fall back to the company partner's
# own email, preserving pre-Sub-6 behaviour.
FP_TRIGGER_STREAM = {
'quote_sent': 'quotes_so',
'so_confirmed': 'quotes_so',
'parts_received': 'quotes_so',
'mo_complete': 'qc',
'shipped': 'certs',
'invoice_posted': 'invoices',
'payment_received': 'invoices',
'deposit_created': 'invoices',
}
class FpNotificationTemplate(models.Model):
"""Configurable notification wrapper.
@@ -64,10 +79,17 @@ class FpNotificationTemplate(models.Model):
# ------------------------------------------------------------------
@api.model
def _dispatch(self, trigger_event, record, partner=None, sale_order=None,
extra_attachment_ids=None):
extra_attachment_ids=None, delivery_location=None):
"""Look up the template for this trigger, render it, and send.
Also logs the attempt in fp.notification.log.
Sub 6: recipient resolution now goes through
res.partner._fp_resolve_notification_recipients so per-contact
flags (certs / qc / quotes_so / invoices / global) and per-
delivery-location contacts are honoured. Customers who haven't
set any flags fall back to the company partner's email —
identical to pre-Sub-6 behaviour.
"""
template = self.search(
[('trigger_event', '=', trigger_event), ('active', '=', True)],
@@ -85,19 +107,41 @@ class FpNotificationTemplate(models.Model):
if attachment_ids:
attachment_names = self.env['ir.attachment'].browse(attachment_ids).mapped('name')
# Sub 6 — resolve recipients via the contact-routing helper.
recipient_emails = []
if partner:
stream = FP_TRIGGER_STREAM.get(trigger_event)
if stream:
recipient_emails = partner._fp_resolve_notification_recipients(
stream, delivery_location=delivery_location,
)
elif partner.email:
recipient_emails = [partner.email]
recipient_str = ', '.join(recipient_emails)
email_values = {}
if attachment_ids:
email_values['attachment_ids'] = [(6, 0, attachment_ids)]
if recipient_str:
# Override the mail.template's default Jinja-rendered email_to
# with the resolved list. Setting email_to clears partner_ids
# inherited from the template so we don't double-send.
email_values['email_to'] = recipient_str
email_values['partner_ids'] = [(5, 0, 0)]
Log = self.env['fp.notification.log']
try:
mail_id = template.mail_template_id.send_mail(
record.id,
force_send=False,
email_values={'attachment_ids': [(6, 0, attachment_ids)]} if attachment_ids else None,
email_values=email_values or None,
)
Log.create({
'template_id': template.id,
'trigger_event': trigger_event,
'sale_order_id': sale_order.id if sale_order else False,
'partner_id': partner.id if partner else False,
'recipient_email': partner.email if partner else '',
'recipient_email': recipient_str or (partner.email if partner else ''),
'attachment_names': ', '.join(attachment_names) if attachment_names else '',
'status': 'sent',
'mail_mail_id': mail_id,
@@ -109,7 +153,7 @@ class FpNotificationTemplate(models.Model):
'trigger_event': trigger_event,
'sale_order_id': sale_order.id if sale_order else False,
'partner_id': partner.id if partner else False,
'recipient_email': partner.email if partner else '',
'recipient_email': recipient_str or (partner.email if partner else ''),
'status': 'failed',
'error_message': str(exc),
})

View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 6 — Recipient resolver.
#
# Every outbound-notification path now routes its recipient lookup
# through `_fp_resolve_notification_recipients`. The helper consults
# child contacts (and optionally a delivery-location partner's child
# contacts) for per-stream flags, falling back to the company's own
# `email` when no contact opts in. This preserves the pre-Sub-6
# behaviour exactly for customers who haven't configured any flags.
from odoo import models
FP_STREAMS = ('certs', 'qc', 'quotes_so', 'invoices')
class ResPartner(models.Model):
_inherit = 'res.partner'
def _fp_resolve_notification_recipients(self, stream, delivery_location=None):
"""Return a list of email addresses that should receive a given
notification stream for this customer.
Args:
stream: one of 'certs', 'qc', 'quotes_so', 'invoices'. Any other
value returns the bare fallback list.
delivery_location: optional res.partner (typically with type=
'delivery') whose own child contacts are
consulted first, at the same priority as the
company-level contacts.
Fallback: if no contact at either level carries a matching flag
(or the global flag), the result is the company partner's own
email. This makes the resolver drop-in safe — no customer ever
silently stops receiving notifications after Sub 6 ships.
"""
self.ensure_one()
recipients = []
# Gather candidate contact recordsets: location-scoped first, then
# company-scoped. Duplicates are dropped by the final dedup pass.
candidate_sets = []
if delivery_location and delivery_location != self:
candidate_sets.append(delivery_location.child_ids)
candidate_sets.append(self.child_ids)
flag_name = f'x_fc_receives_{stream}' if stream in FP_STREAMS else None
for contacts in candidate_sets:
for contact in contacts:
if not contact.email:
continue
is_global = getattr(contact, 'x_fc_is_global_contact', False)
matches_stream = (
flag_name is not None
and getattr(contact, flag_name, False)
)
if is_global or matches_stream:
recipients.append(contact.email)
if not recipients:
# Nobody opted in via contacts — fall back to the company
# email (and the location's email, if distinct).
if delivery_location and delivery_location != self and delivery_location.email:
recipients.append(delivery_location.email)
if self.email:
recipients.append(self.email)
# Case-insensitive dedup, preserve first-seen order.
seen = set()
unique = []
for email in recipients:
key = email.strip().lower()
if key and key not in seen:
seen.add(key)
unique.append(email)
return unique