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:
@@ -371,7 +371,7 @@ rewrite code as new requirements surface. Each sub-project has its own design do
|
||||
| 3 | Default Process + Composer per part (reuse recipe tree) | **Shipped 2026-04-22** (commits ce07daa..f059348) | 2e, 2f |
|
||||
| 4 | Contract Review (optional, per-part, settings-driven QA roster, QA-005 1:1 PDF) | **Shipped 2026-04-22** | 2i |
|
||||
| 5 | Order-line fields (fp.serial registry, auto job#, coating-scoped thickness dropdown, revision picker) | **Shipped 2026-04-22** | 5, 6, Q2 |
|
||||
| 6 | Contact Profiles & Communication Routing (sub-contacts + per-location notification lists + global contacts) | Pending | client transcript A/B/C |
|
||||
| 6 | Contact Profiles & Communication Routing (per-contact flags + per-location routing + global contact; single resolver helper) | **Shipped 2026-04-22** | client transcript A/B/C |
|
||||
| 7 | IoT tuning (per-sensor polling interval + ingest rate-limit; entech seeded with 25 tanks / 50 sensors) | **Shipped 2026-04-22** | client transcript D |
|
||||
| 8 | Receiving / Inspection / QC flow restructure (split receiving vs inspection; racking crew inspects, not receiver) | Pending | client transcript E |
|
||||
| ∞ | First-off / last-off QC | Deferred | client transcript F |
|
||||
|
||||
128
fusion_plating/docs/superpowers/tests/2026-04-22-sub6-smoke.py
Normal file
128
fusion_plating/docs/superpowers/tests/2026-04-22-sub6-smoke.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Sub 6 smoke test — runs inside odoo-shell on entech."""
|
||||
env = env
|
||||
Partner = env['res.partner']
|
||||
|
||||
# ---- Sanity: flags + resolver exist ----------------------------------
|
||||
for f in ('x_fc_receives_certs', 'x_fc_receives_qc', 'x_fc_receives_quotes_so',
|
||||
'x_fc_receives_invoices', 'x_fc_is_global_contact'):
|
||||
assert f in Partner._fields, f'missing flag {f}'
|
||||
assert hasattr(Partner, '_fp_resolve_notification_recipients')
|
||||
print('[OK] Flags + resolver helper present')
|
||||
|
||||
# ---- Fresh company + 4 contacts --------------------------------------
|
||||
company = Partner.create({
|
||||
'name': 'Sub 6 Smoke Co.',
|
||||
'is_company': True,
|
||||
'customer_rank': 1,
|
||||
'email': 'company@acme.com',
|
||||
})
|
||||
|
||||
alice = Partner.create({
|
||||
'name': 'Alice — Certs + QC',
|
||||
'parent_id': company.id,
|
||||
'email': 'alice@acme.com',
|
||||
'x_fc_receives_certs': True,
|
||||
'x_fc_receives_qc': True,
|
||||
})
|
||||
bob = Partner.create({
|
||||
'name': 'Bob — Invoices',
|
||||
'parent_id': company.id,
|
||||
'email': 'bob@acme.com',
|
||||
'x_fc_receives_invoices': True,
|
||||
})
|
||||
carol = Partner.create({
|
||||
'name': 'Carol — Global',
|
||||
'parent_id': company.id,
|
||||
'email': 'carol@acme.com',
|
||||
'x_fc_is_global_contact': True,
|
||||
})
|
||||
dave = Partner.create({
|
||||
'name': 'Dave — no flags (silent)',
|
||||
'parent_id': company.id,
|
||||
'email': 'dave@acme.com',
|
||||
})
|
||||
print('[OK] Company + 4 contacts created')
|
||||
|
||||
# ---- Stream resolution ------------------------------------------------
|
||||
certs = set(e.lower() for e in company._fp_resolve_notification_recipients('certs'))
|
||||
assert certs == {'alice@acme.com', 'carol@acme.com'}, f'certs got {certs}'
|
||||
print(f'[OK] certs stream → {sorted(certs)}')
|
||||
|
||||
invoices = set(e.lower() for e in company._fp_resolve_notification_recipients('invoices'))
|
||||
assert invoices == {'bob@acme.com', 'carol@acme.com'}, f'invoices got {invoices}'
|
||||
print(f'[OK] invoices stream → {sorted(invoices)}')
|
||||
|
||||
qc = set(e.lower() for e in company._fp_resolve_notification_recipients('qc'))
|
||||
assert qc == {'alice@acme.com', 'carol@acme.com'}, f'qc got {qc}'
|
||||
print(f'[OK] qc stream → {sorted(qc)}')
|
||||
|
||||
quotes = set(e.lower() for e in company._fp_resolve_notification_recipients('quotes_so'))
|
||||
assert quotes == {'carol@acme.com'}, f'quotes_so got {quotes}'
|
||||
print(f'[OK] quotes_so stream → {sorted(quotes)}')
|
||||
|
||||
# ---- Delivery-location scoping ---------------------------------------
|
||||
location = Partner.create({
|
||||
'name': 'Sub 6 Smoke Warehouse',
|
||||
'parent_id': company.id,
|
||||
'type': 'delivery',
|
||||
'email': 'warehouse@acme.com',
|
||||
})
|
||||
dan = Partner.create({
|
||||
'name': 'Dan — Warehouse Certs',
|
||||
'parent_id': location.id,
|
||||
'email': 'dan@warehouse.com',
|
||||
'x_fc_receives_certs': True,
|
||||
})
|
||||
certs_loc = set(e.lower() for e in company._fp_resolve_notification_recipients(
|
||||
'certs', delivery_location=location,
|
||||
))
|
||||
assert 'dan@warehouse.com' in certs_loc
|
||||
assert 'alice@acme.com' in certs_loc
|
||||
assert 'carol@acme.com' in certs_loc
|
||||
print(f'[OK] certs+location → {sorted(certs_loc)}')
|
||||
|
||||
# ---- Backward compat: no flags → falls back to company email --------
|
||||
clean_company = Partner.create({
|
||||
'name': 'Legacy Customer',
|
||||
'is_company': True,
|
||||
'customer_rank': 1,
|
||||
'email': 'legacy@customer.com',
|
||||
})
|
||||
fallback = clean_company._fp_resolve_notification_recipients('certs')
|
||||
assert fallback == ['legacy@customer.com'], f'expected fallback, got {fallback}'
|
||||
print(f'[OK] Fallback (no contacts) → {fallback}')
|
||||
|
||||
# ---- Contact with no email gets skipped -----------------------------
|
||||
eve = Partner.create({
|
||||
'name': 'Eve — no email',
|
||||
'parent_id': company.id,
|
||||
'x_fc_receives_certs': True,
|
||||
# intentionally no email
|
||||
})
|
||||
certs2 = set(e.lower() for e in company._fp_resolve_notification_recipients('certs'))
|
||||
# Should still be alice + carol; Eve skipped
|
||||
assert certs2 == {'alice@acme.com', 'carol@acme.com'}
|
||||
print('[OK] Email-less contact silently skipped')
|
||||
|
||||
# ---- Unknown stream → fallback ---------------------------------------
|
||||
unknown = company._fp_resolve_notification_recipients('bogus_stream')
|
||||
# Should return just carol (global) since global applies to all; if no global,
|
||||
# falls back to company email. Carol is global so she should still match.
|
||||
assert 'carol@acme.com' in [e.lower() for e in unknown]
|
||||
print(f'[OK] Unknown stream with global contact → {unknown}')
|
||||
|
||||
# ---- Case-insensitive dedup ------------------------------------------
|
||||
# Add a contact with a duplicate casing
|
||||
duplicate = Partner.create({
|
||||
'name': 'Alice dup',
|
||||
'parent_id': company.id,
|
||||
'email': 'ALICE@acme.com', # different case
|
||||
'x_fc_receives_certs': True,
|
||||
})
|
||||
certs_dedup = company._fp_resolve_notification_recipients('certs')
|
||||
lowered = [e.lower() for e in certs_dedup]
|
||||
assert lowered.count('alice@acme.com') == 1, f'dedup failed, got {certs_dedup}'
|
||||
print('[OK] Case-insensitive dedup')
|
||||
|
||||
env.cr.rollback()
|
||||
print('\n=== SUB 6 SMOKE PASS — all assertions held ===')
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.3.2.0',
|
||||
'version': '19.0.4.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
|
||||
@@ -48,3 +48,42 @@ class ResPartner(models.Model):
|
||||
'thickness reading has been logged for the MO. Leave off '
|
||||
'for commercial customers.',
|
||||
)
|
||||
|
||||
# ---- Sub 6 — Per-contact communication routing -----------------------
|
||||
# These five flags live on CHILD contacts under a company partner.
|
||||
# When every contact under a company leaves them blank, the resolver
|
||||
# falls back to the company's own `email` — matching the pre-Sub-6
|
||||
# behaviour exactly. Admins opt in to per-contact routing by ticking
|
||||
# the relevant flag on each contact row.
|
||||
x_fc_receives_certs = fields.Boolean(
|
||||
string='Receives Certificates',
|
||||
default=False, tracking=True,
|
||||
help='This contact receives CoC PDFs, thickness reports, and '
|
||||
'other quality documents when a job ships.',
|
||||
)
|
||||
x_fc_receives_qc = fields.Boolean(
|
||||
string='Receives QC Alerts',
|
||||
default=False, tracking=True,
|
||||
help='This contact receives NCR, CAPA, quality-hold, and contract-'
|
||||
'review notifications.',
|
||||
)
|
||||
x_fc_receives_quotes_so = fields.Boolean(
|
||||
string='Receives Quotes & SOs',
|
||||
default=False, tracking=True,
|
||||
help='This contact receives quote sends, sale order acknowledgements, '
|
||||
'and order confirmations.',
|
||||
)
|
||||
x_fc_receives_invoices = fields.Boolean(
|
||||
string='Receives Invoices',
|
||||
default=False, tracking=True,
|
||||
help='This contact receives invoices, payment receipts, and '
|
||||
'dunning communications.',
|
||||
)
|
||||
x_fc_is_global_contact = fields.Boolean(
|
||||
string='Global Contact',
|
||||
default=False, tracking=True,
|
||||
help='Firehose. When set, this contact receives every outbound '
|
||||
'stream regardless of the per-stream flags above. Typical '
|
||||
'use: a primary account-manager contact who wants full '
|
||||
'visibility into everything the shop sends out.',
|
||||
)
|
||||
|
||||
@@ -37,4 +37,52 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Sub 6 — Per-contact routing flags on the contact's own form
|
||||
(opens when editing a child contact row). Applied to every
|
||||
res.partner form so delivery-location partners and their child
|
||||
contacts both surface the same flags. -->
|
||||
<record id="view_partner_form_fp_contact_routing_flags"
|
||||
model="ir.ui.view">
|
||||
<field name="name">res.partner.form.fp.contact.routing.flags</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||
<field name="priority">30</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Communication Routing"
|
||||
name="fp_contact_routing"
|
||||
invisible="is_company == True">
|
||||
<p class="text-muted">
|
||||
Tick the streams this contact should receive. Leave all
|
||||
blank to let the customer's company-level email handle
|
||||
everything (default behaviour). Set
|
||||
<b>Global Contact</b> for a primary account-manager
|
||||
contact who wants visibility into every outbound stream.
|
||||
</p>
|
||||
<group>
|
||||
<group>
|
||||
<field name="x_fc_is_global_contact"
|
||||
widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Per-stream subscriptions"
|
||||
invisible="x_fc_is_global_contact">
|
||||
<group>
|
||||
<field name="x_fc_receives_certs"
|
||||
widget="boolean_toggle"/>
|
||||
<field name="x_fc_receives_qc"
|
||||
widget="boolean_toggle"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_receives_quotes_so"
|
||||
widget="boolean_toggle"/>
|
||||
<field name="x_fc_receives_invoices"
|
||||
widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user