# Sub 6 — Contact Profiles & Communication Routing **Date:** 2026-04-22 **Module scope:** `fusion_plating_notifications` (owns the recipient resolver + dispatcher wiring), `fusion_plating_certificates` (owns the contact-level permission fields on `res.partner`). **Status:** Design approved; implementing in this session. **Predecessor context:** Fine-Tuning Initiative, entry in `fusion_plating/CLAUDE.md` (Sub 6 preview from client transcripts A/B/C). Builds on Sub 2's `_fp_resolve_cert_requirement`. --- ## 1. Scope Three pieces, shipped together: 1. **Per-contact permission flags** — child contacts under `res.partner` gain five booleans that control which communication streams they receive: - `x_fc_receives_certs` — CoCs, thickness reports - `x_fc_receives_qc` — NCR / CAPA / quality hold / contract review - `x_fc_receives_quotes_so` — quote sends, SO acks, order confirmations - `x_fc_receives_invoices` — invoices, payment receipts, dunning - `x_fc_is_global_contact` — firehose; every stream 2. **Multiple delivery locations** via Odoo native `child_ids` with `type='delivery'`. Each delivery-location partner can carry its own child contacts with the five flags scoped to that location. 3. **Recipient resolver** — single helper `res.partner._fp_resolve_notification_recipients` that every dispatch path uses. ### Out of scope - Splitting the partner-level `x_fc_send_coc` / `x_fc_send_thickness_report` flags into per- contact versions. Those stay as the "generate?" decision; the new flags handle "who receives?". Orthogonal. - Rewriting `_fp_resolve_cert_requirement`. It keeps returning generation booleans; the new recipient resolver sits alongside it. - Portal user access — contact flags are purely for outbound email routing, not portal login permissions. - Reworking Odoo's native `type` selection on `res.partner` (contact / invoice / delivery / other). Sub 6 adds data alongside; doesn't touch the selection. --- ## 2. Data Model ### 2.1 `res.partner` additions — in `fusion_plating_certificates/models/res_partner.py` ```python x_fc_receives_certs = fields.Boolean( string='Receives Certificates (CoC, thickness)', default=False, tracking=True, help='Contact receives certificate PDFs and quality documents when a job ships.', ) x_fc_receives_qc = fields.Boolean( string='Receives QC Alerts', default=False, tracking=True, help='Contact receives NCR / CAPA / quality hold / contract review notifications.', ) x_fc_receives_quotes_so = fields.Boolean( string='Receives Quotes & Sales Orders', default=False, tracking=True, help='Contact receives quotes, sale order acknowledgements, and order confirmations.', ) x_fc_receives_invoices = fields.Boolean( string='Receives Invoices', default=False, tracking=True, help='Contact receives invoices, payment receipts, and dunning communications.', ) x_fc_is_global_contact = fields.Boolean( string='Global Contact (receives everything)', default=False, tracking=True, help='When set, this contact receives every outbound stream regardless of the ' 'per-stream flags above. Typical use: a primary account-manager contact ' 'at the customer who wants full visibility.', ) ``` All five default `False` so existing customers see no routing change until the admin explicitly ticks a flag. ### 2.2 No new models Delivery locations already supported via Odoo's native `res.partner.type = 'delivery'` child partners. No custom "location" model needed. --- ## 3. Recipient Resolver ### 3.1 Helper ```python # res.partner (in fusion_plating_notifications) _FP_STREAMS = ('certs', 'qc', 'quotes_so', 'invoices') def _fp_resolve_notification_recipients(self, stream, delivery_location=None): """Return a list of email addresses for the given stream. Args: stream: one of 'certs', 'qc', 'quotes_so', 'invoices' delivery_location: optional res.partner with type='delivery'; if given, its child contacts are consulted FIRST with a higher priority than the company-level contacts. Fallback behaviour (critical for backward compatibility): If neither the company nor the optional location has any child contacts flagged for this stream (or as global), fall back to `self.email`. This preserves the pre-Sub-6 behaviour where every dispatch used the partner's top-level email. """ ``` ### 3.2 Selection logic For each contact, in both the `delivery_location.child_ids` and the company's own `child_ids`: - **Skip** contacts with no `email`. - **Include** if `x_fc_is_global_contact` OR `x_fc_receives_` is True. Dedup emails (case-insensitive) before returning. If zero contacts matched across BOTH levels, fall back to `self.email` (and the delivery- location's email if provided). Never return an empty list without trying fallbacks. ### 3.3 Stream mapping (event → stream) Wired in `fp.notification.template._dispatch`: | Trigger event | Stream | |---|---| | `so_confirmed`, `quote_sent`, `order_acknowledged` | `quotes_so` | | `invoice_posted`, `payment_received`, `dunning_sent` | `invoices` | | `shipped`, `coc_issued`, `thickness_report_sent` | `certs` | | `ncr_opened`, `capa_opened`, `quality_hold_opened`, `contract_review_signed` | `qc` | Unknown event → fall back to `self.email` (preserves current behaviour). --- ## 4. Integration Points ### 4.1 `fp.notification.template._dispatch` (or equivalent) Replace every `partner.email` recipient lookup with: ```python recipients = partner._fp_resolve_notification_recipients( stream=_FP_EVENT_TO_STREAM.get(event, None), delivery_location=context.get('delivery_location'), ) ``` ### 4.2 Cert-email send (on MO done) In `fusion_plating_bridge_mrp/models/mrp_production.py`, the `_fp_generate_cert_pdf` path posts an email via `mail.mail`. Update its recipient: ```python to_emails = partner._fp_resolve_notification_recipients( 'certs', delivery_location=delivery.delivery_address_id, ) ``` ### 4.3 Delivery shipped email (fp.delivery.action_mark_delivered) Same pattern — stream `'certs'` + the delivery's own address as `delivery_location`. ### 4.4 `_fp_resolve_cert_requirement` — **unchanged**. Sub 2's cert resolver is about REQUIREMENT ("should we generate a CoC?"), not recipient ("who gets the email?"). Keeping it untouched avoids breaking downstream callers. The recipient decision is handled by the new resolver in parallel. --- ## 5. Views ### 5.1 Partner form — extend the Contacts tab Add five boolean columns to the child-contact inline list: ```xml ``` Inherit on `base.view_partner_form` targeting the `child_ids` sub-tree. ### 5.2 Company partner form — "Notification Routing" block On the main partner form (when `is_company=True`), show a read-only summary block under the existing "Plating Documents" tab: - Effective recipient count per stream (computed live from child contacts) - A short help note: "Leave all contacts empty to keep the current 'email the company partner' behaviour." ### 5.3 Delivery-location partner form Same Contacts tab inherit applies — delivery-location partners are `res.partner` records, so the same view inherit surfaces the flag columns automatically. --- ## 6. Defensive Measures 1. **Fallback to `self.email`** — if every level returns nothing, the resolver still returns the company's email. No customer ever silently stops receiving notifications after upgrade. 2. **Case-insensitive dedup** — prevents double-emailing a contact who appears at both the location and company level. 3. **Stream name registry** — `_FP_STREAMS` constant at the top of the partner module. Adding a future stream means one edit + one event-map addition. 4. **Email must be truthy** — contacts without an email are silently skipped, not errors. 5. **No changes to cert resolver** — Sub 2's helper stays the single source of truth for "what gets generated". Sub 6's resolver owns "who receives what gets generated". --- ## 7. Testing ### 7.1 Recipient resolver smoke - Partner with no child contacts, `email='acct@customer.com'` → resolver returns `['acct@customer.com']` for every stream. - Partner with 3 contacts: Alice (certs+qc), Bob (invoices), Carol (global) → - `certs` → {alice, carol} - `invoices` → {bob, carol} - `quotes_so` → {carol} - Delivery-location partner with its own child contact Dan (certs) → - `certs` with location → {dan, alice, carol} - `certs` without location → {alice, carol} ### 7.2 Dispatcher integration - Post an invoice → assert `fp.notification.log` records list Bob + Carol, not the company partner. - Mark a delivery shipped → assert CoC email went to Dan + Alice + Carol. ### 7.3 Backward compatibility - Upgrade a DB with zero contact flags set → every trigger event still delivers to `partner.email`. No regression. --- ## 8. File Manifest ``` fusion_plating_certificates/ ├── __manifest__.py (version bump) ├── models/ │ └── res_partner.py (+5 boolean flags) └── views/ └── res_partner_views.xml (inherit Contacts tab, add columns) fusion_plating_notifications/ ├── __manifest__.py (version bump) ├── models/ │ ├── __init__.py (+ res_partner) │ ├── res_partner.py NEW — resolver helper │ ├── fp_notification_template.py (dispatcher wires resolver) │ ├── account_move.py (pass stream='invoices') │ ├── account_payment.py (pass stream='invoices') │ └── fp_delivery.py (pass stream='certs' + location) └── views/ └── res_partner_views.xml NEW — routing summary block ``` Rough LOC: ~250 Python, ~100 XML. --- ## 9. Rollout Update order on entech: 1. `fusion_plating_certificates` (new flag fields) 2. `fusion_plating_notifications` (resolver + dispatcher wiring) Post-deploy verification: - Open any customer → Contacts tab shows 5 new columns - Create a child contact with `x_fc_receives_invoices=True`, post an invoice → log shows the child's email - Clear all flags on that customer → next post goes back to company `.email` --- *End of spec.*