docs(notifications): Sub 6 design spec — contact profiles + routing

Five per-contact boolean flags (certs / qc / quotes-so / invoices /
global), native Odoo delivery-location child contacts reused for
per-location routing, and a single resolver on res.partner that the
dispatcher + all mail-send sites call. Fallback to self.email keeps
existing customers bit-identical when no flags are set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-22 23:54:59 -04:00
parent a7fd39d6f3
commit f9e1b62409

View File

@@ -0,0 +1,287 @@
# 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_<stream>` 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
<field name="x_fc_receives_certs" widget="boolean_toggle" optional="show"/>
<field name="x_fc_receives_qc" widget="boolean_toggle" optional="show"/>
<field name="x_fc_receives_quotes_so" widget="boolean_toggle" optional="show"/>
<field name="x_fc_receives_invoices" widget="boolean_toggle" optional="show"/>
<field name="x_fc_is_global_contact" widget="boolean_toggle" optional="show"/>
```
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.*