diff --git a/fusion_plating/CLAUDE.md b/fusion_plating/CLAUDE.md index 56260a1c..620e5eef 100644 --- a/fusion_plating/CLAUDE.md +++ b/fusion_plating/CLAUDE.md @@ -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 | diff --git a/fusion_plating/docs/superpowers/tests/2026-04-22-sub6-smoke.py b/fusion_plating/docs/superpowers/tests/2026-04-22-sub6-smoke.py new file mode 100644 index 00000000..94ecd780 --- /dev/null +++ b/fusion_plating/docs/superpowers/tests/2026-04-22-sub6-smoke.py @@ -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 ===') diff --git a/fusion_plating/fusion_plating_certificates/__manifest__.py b/fusion_plating/fusion_plating_certificates/__manifest__.py index c6367141..1fbb3747 100644 --- a/fusion_plating/fusion_plating_certificates/__manifest__.py +++ b/fusion_plating/fusion_plating_certificates/__manifest__.py @@ -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': """ diff --git a/fusion_plating/fusion_plating_certificates/models/res_partner.py b/fusion_plating/fusion_plating_certificates/models/res_partner.py index ec11a37f..2693a4eb 100644 --- a/fusion_plating/fusion_plating_certificates/models/res_partner.py +++ b/fusion_plating/fusion_plating_certificates/models/res_partner.py @@ -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.', + ) diff --git a/fusion_plating/fusion_plating_certificates/views/res_partner_views.xml b/fusion_plating/fusion_plating_certificates/views/res_partner_views.xml index 646ff720..28560ef7 100644 --- a/fusion_plating/fusion_plating_certificates/views/res_partner_views.xml +++ b/fusion_plating/fusion_plating_certificates/views/res_partner_views.xml @@ -37,4 +37,52 @@ + + + res.partner.form.fp.contact.routing.flags + res.partner + + 30 + + + +

+ Tick the streams this contact should receive. Leave all + blank to let the customer's company-level email handle + everything (default behaviour). Set + Global Contact for a primary account-manager + contact who wants visibility into every outbound stream. +

+ + + + + + + + + + + + + + + +
+
+
+
+ diff --git a/fusion_plating/fusion_plating_notifications/__manifest__.py b/fusion_plating/fusion_plating_notifications/__manifest__.py index 5209ab1d..d79c6f24 100644 --- a/fusion_plating/fusion_plating_notifications/__manifest__.py +++ b/fusion_plating/fusion_plating_notifications/__manifest__.py @@ -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', diff --git a/fusion_plating/fusion_plating_notifications/models/__init__.py b/fusion_plating/fusion_plating_notifications/models/__init__.py index 9d4bad49..85d97402 100644 --- a/fusion_plating/fusion_plating_notifications/models/__init__.py +++ b/fusion_plating/fusion_plating_notifications/models/__init__.py @@ -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 diff --git a/fusion_plating/fusion_plating_notifications/models/fp_delivery.py b/fusion_plating/fusion_plating_notifications/models/fp_delivery.py index f70529fe..0c16140a 100644 --- a/fusion_plating/fusion_plating_notifications/models/fp_delivery.py +++ b/fusion_plating/fusion_plating_notifications/models/fp_delivery.py @@ -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 diff --git a/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py b/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py index 41e6381c..6e1a954f 100644 --- a/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py +++ b/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py @@ -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), }) diff --git a/fusion_plating/fusion_plating_notifications/models/res_partner.py b/fusion_plating/fusion_plating_notifications/models/res_partner.py new file mode 100644 index 00000000..418cd757 --- /dev/null +++ b/fusion_plating/fusion_plating_notifications/models/res_partner.py @@ -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